본문 바로가기
Development/Toy Projects

[프로젝트] XKCD Comic Viewer 만들기 👀

by Kyunghoon Kim 2020. 8. 20.

안녕하세요 이번 포스트에서는 랜들 먼로(Randall Munroe)가 연재하는 웹 코믹에 대한 Viewer를 제작해보려고 합니다. XKCD는 과학 웹 만화입니다. 특징으로는 막대 인간 그림체를 이용하고 있고, 풍자적인 내용과 수학, 공학적인 개그가 주를 이룹니다. XKCD 웹 코믹 사이트는 아래에 남겨두도록 하겠습니다. 관심 있으신 분들은 한 번씩 들려보시길 바랍니다. 🎈

 

 

출처 :  https://xkcd.com

 

https://xkcd.com

 

Dependency

This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License. This means you're free to copy and share these comics (but not to sell them). More details.

xkcd.com

 

 


 

1. UI(XAML) 구성

 본 코드 작성에 앞서 UI부터 먼저 구성해 보도록 하겠습니다. UI는 Viewer를 만드는데 필요한 요소를 제외하고는 따로 추가하지 않았습니다. 전체 UI 구조를 보고 바로 만들어 볼게요.

 

UI 구조

먼저 Grid의 영역을 구분해 주기 위해서 Colum을 나누어 주었습니다. Grid.Column="0"과 Grid.Column"2"는 이전화와 다음화로 이동하기 위한 버튼을 배치해 두었습니다. 그리고 메인 영역이 되는 Grid.Colum="1"에는 사용자가 보고자 하는 만화로 이동하기 위한 검색창과 이동 버튼을 배치하였고 그 아래에는 실제 만화 이미지 그리고 그 아래에는 첫 화와 마지막화로 이동할 수 있는 버튼과 현재 웹툰 페이지를 나타내기 위한 번호를 배치하였습니다.

 

여기서 Grid.Column="1"(메인 영역)에 대해서는 다시 Row를 나누고 Colum을 적절히 나누어 배치하였습니다. 그러면 먼저 영역에 대한 구분을 먼저 작성해 보도록 하겠습니다.


<UI 영역 나누기 작업>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*"/>
            <ColumnDefinition Width="8*"/>
            <ColumnDefinition Width="1*"/>
        </Grid.ColumnDefinitions>
 
        <Grid Grid.Column="0">
 
        </Grid>
        
        <Grid Grid.Column="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="0.5*"/>
                <RowDefinition Height="9*"/>
                <RowDefinition Height="0.5*"/>
            </Grid.RowDefinitions>
 
            <Grid Grid.Row="0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="8*"/>
                    <ColumnDefinition Width="2*"/>
                </Grid.ColumnDefinitions>
 
                <Grid Grid.Column="0">
 
                </Grid>
 
                <Grid Grid.Column="1">
 
                </Grid>
            </Grid>
            
            <Grid Grid.Row="1">
 
            </Grid>
 
            <Grid Grid.Row="2">
 
            </Grid>
        </Grid>
 
        <Grid Grid.Column="2">
 
        </Grid>
    </Grid>
cs

 

이렇게 작성해주시면 아래의 화면과 같이 UI가 분리되어있는 모습을 볼 수 있습니다. UI구성의 나머지는 필요한

기능에 맞는 컨트롤(Control)들을 채워 주시면 됩니다. 이제 나눈 영역에 필요한 컨트롤들을 한번 채워보겠습니다.

 

분리된 UI 영역

 


<전체 UI(XAML) 소스코드>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*"/>
            <ColumnDefinition Width="8*"/>
            <ColumnDefinition Width="1*"/>
        </Grid.ColumnDefinitions>
 
        <Grid Grid.Column="0">
            <Button x:Name="btnPreviousImage" Padding="15" Margin="15" 
                    Height="60" Width="60" HorizontalAlignment="Center"
                    FocusVisualStyle="{x:Null}"
                    Click="btnPreviousImage_Click" Style="{StaticResource btnRoundCorner}">&lt;</Button>
        </Grid>
        
        <Grid Grid.Column="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="0.5*"/>
                <RowDefinition Height="9*"/>
                <RowDefinition Height="0.5*"/>
            </Grid.RowDefinitions>
 
            <Grid Grid.Row="0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="8*"/>
                    <ColumnDefinition Width="2*"/>
                </Grid.ColumnDefinitions>
 
                <Grid Grid.Column="0">
                    <Border BorderBrush="Black" BorderThickness="0,0,0,1" Width="405"
                        HorizontalAlignment="Right">
                        <TextBox x:Name="tbSearchComic" 
                             Style="{StaticResource btnHint}"
                             Tag="보고싶은 화의 페이지 번호를 입력해 주세요."/>
                    </Border>
                </Grid>
 
                <Grid Grid.Column="1">
                    <Button Click="btnSpecificComicImageSearch_Click"
                        FocusVisualStyle="{x:Null}" VerticalAlignment="Center"
                        Style="{StaticResource removeBtnHighLightStyle}">이동</Button>
                </Grid>
            </Grid>
            
            <Grid Grid.Row="1">
                <Image x:Name="imgComic" Margin="5"/>
            </Grid>
 
            <Grid Grid.Row="2">
                <Grid.Resources>
                    <Style TargetType="Button">
                        <Setter Property="FontFamily" Value="나눔스퀘어_ac"/>
                        <Setter Property="VerticalAlignment" Value="Center"/>
                        <Setter Property="FocusVisualStyle" Value="{x:Null}"/>
                        <Setter Property="Background" Value="Transparent"/>
                        <Setter Property="BorderBrush" Value="Transparent"/>
                    </Style>
                </Grid.Resources>
                <Button x:Name="btnStartImage" Style="{StaticResource removeBtnHighLightStyle}"
                        HorizontalAlignment="Left" Margin="40,0,0,0"
                        Click="btnStartImage_Click">첫 화</Button>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
                    <TextBlock x:Name="tbComicPageNum"/>
                    <TextBlock Text=" / " xml:space="preserve"/>
                    <TextBlock x:Name="tbMaxComicPageNum"/>
                </StackPanel>
                <Button x:Name="btnEndImage" Style="{StaticResource removeBtnHighLightStyle}"
                        HorizontalAlignment="Right" Margin="0,0,40,0"
                        Click="btnEndImage_Click">마지막 화</Button>
            </Grid>
        </Grid>
 
        <Grid Grid.Column="2">
            <Button x:Name="btnNextImage" Padding="15" Margin="15"
                    Height="60" Width="60" HorizontalAlignment="Center"
                    FocusVisualStyle="{x:Null}"
                    Click="btnNextImage_Click" Style="{StaticResource btnRoundCorner}">&gt;</Button>
        </Grid>
    </Grid>
cs

 


2. ApiHelper.cs 🎃

UI구성을 마쳤으니 이제 필요한 소스코드를 작성해 보겠습니다. 사용한 클래스는 ApiHelper.cs, ComicModel.cs, ComicProcesser.cs가 있고 MainWindow의 비하인드 코드까지 짜 보도록 할게요. 먼저 ApiHelper.cs부터 봅시다. 참고로 사용한 NugetPackage는 Microsoft.AspNet.WebApi.Client와 Newtonsoft.json입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System.Net.Http;
using System.Net.Http.Headers;
 
namespace XKCD_Viewer.XKCD
{
    public static class ApiHelper
    {
        public static HttpClient ApiClient { get; set; }
 
        public static void InitializeClient()
        {
            ApiClient = new HttpClient();
            ApiClient.DefaultRequestHeaders.Accept.Clear();
            ApiClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        }
    }
}
cs

 

위의 코드를 보면 MSDN에도 명시되어 있듯이 "URI로 식별되는 리소스에서 HTTP 요청을 보내고, HTTP 응답을 받기 위한 기본 클래스"라고 되어있습니다. 즉 URI를 식별해서 요청을 보내어 응답을 받기 위해 사용합니다. 그리고 InitializeClient()메서드를 통해 초기화를 해주고, Json 형식으로 받기 위해 Header에 application/json을 추가해 줍니다.

 


3. ComicModel.cs 🔔

ComicModel 클래스는 말 그대로 요청을 보내고 응답을 받을 때 주는 정보를 담기위한 용도로 사용합니다.

1
2
3
4
5
6
7
8
namespace XKCD_Viewer.XKCD
{
    public class ComicModel
    {
        public int Num { get; set; }
        public string Img { get; set; }
    }
}
cs

4. ComicProcessor.cs 📚

ComicProcess 클래스에서는 만화 정보를 가져오기 위한 LoadComic() 메서드를 제작합니다. 만화 페이지를 인자로 받고 URL을 통해 요청을 보내고 받은 Response를 통해 위에서 만든 ComicModel 형식으로 값을 받아들입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System;
using System.Net.Http;
using System.Threading.Tasks;
 
namespace XKCD_Viewer.XKCD
{
    public class ComicProcessor
    {
        public static async Task<ComicModel> LoadComic(int comicNumber = 0)
        {
            string url = "";
 
            if (comicNumber > 0)
            {
                url = $"https://xkcd.com/{ comicNumber }/info.0.json";
            }
            else
            {
                url = "https://xkcd.com/info.0.json";
            }
 
            using (HttpResponseMessage response = await ApiHelper.ApiClient.GetAsync(url))
            {
                if (response.IsSuccessStatusCode)
                {
                    ComicModel comic = await response.Content.ReadAsAsync<ComicModel>();
                    return comic;
                }
                else
                {
                    throw new Exception(response.ReasonPhrase);
                }
            }
        }
    }
}
 
cs

5. MainWindow.xaml.cs 🙆‍♂️

여기까지 따라오셨다면 거의 제작이 완료되었습니다. 마지막으로 MainWindow.xaml.cs에서 제일 처음 UI를 구성할 때 배치한 컨트롤(Control)과 방금 만든 여러 클래스의 조화만 이루면 Comic-Viewer제작이 완료됩니다. 그럼 작성해 보도록 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using XKCD_Viewer.XKCD;
 
namespace XKCD_Viewer
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private int maxNumber = 0;
        private int currentNumber = 0;
 
        public MainWindow()
        {
            InitializeComponent();
            ApiHelper.InitializeClient();
        }
 
        private async void Window_Loaded(object sender, RoutedEventArgs e)
        {
            tbSearchComic.TextDecorations = TextDecorations.Underline;
            await LoadComicImage();
        }
 
        private async Task LoadComicImage(int imageNumber = 0)
        {
            var comic = await ComicProcessor.LoadComic(imageNumber);
            tbComicPageNum.Text = Convert.ToString(comic.Num);
 
            if(imageNumber == 0)
            {
                maxNumber = comic.Num;
                tbMaxComicPageNum.Text = Convert.ToString(maxNumber);
            }
 
            currentNumber = comic.Num;
 
            var uriSource = new Uri(comic.Img, UriKind.Absolute);
            imgComic.Source = new BitmapImage(uriSource);
        }
 
        #region Previous & Next
        private async void btnPreviousImage_Click(object sender, RoutedEventArgs e)
        {
            if (currentNumber > 1)
            {
                currentNumber -= 1;
                btnNextImage.IsEnabled = true;
                await LoadComicImage(currentNumber);
 
                if (currentNumber == 1)
                {
                    btnPreviousImage.IsEnabled = false;
                }
            }
            else
            {
                MessageBox.Show("이미 첫화 페이지 입니다.");
            }
        }
 
        private async void btnNextImage_Click(object sender, RoutedEventArgs e)
        {
            if (currentNumber < maxNumber)
            {
                currentNumber += 1;
                btnPreviousImage.IsEnabled = true;
                await LoadComicImage(currentNumber);
 
                if(currentNumber == maxNumber)
                {
                    btnNextImage.IsEnabled = false;
                }
            }
            else
            {
                MessageBox.Show("다음화가 존재하지 않습니다.");
            }
        }
        #endregion
 
        #region Start & End
        private async void btnStartImage_Click(object sender, RoutedEventArgs e)
        {
            if(currentNumber == 0)
            {
                MessageBox.Show("이미 첫 화 페이지 입니다.");
            }
 
            var comic = await ComicProcessor.LoadComic(614);
            tbComicPageNum.Text = Convert.ToString(0);
            currentNumber = 0;
 
            var uriSource = new Uri(comic.Img, UriKind.Absolute);
            imgComic.Source = new BitmapImage(uriSource);
        }
 
        private async void btnEndImage_Click(object sender, RoutedEventArgs e)
        {
            if(currentNumber == maxNumber)
            {
                MessageBox.Show("다음화가 존재하지 않습니다.");
            }
 
            var comic = await ComicProcessor.LoadComic(maxNumber);
            tbComicPageNum.Text = Convert.ToString(maxNumber);
            currentNumber = maxNumber;
 
            var uriSource = new Uri(comic.Img, UriKind.Absolute);
            imgComic.Source = new BitmapImage(uriSource);
        }
        #endregion
 
        private async void btnSpecificComicImageSearch_Click(object sender, RoutedEventArgs e)
        {
            if(tbSearchComic.Text.Length > 0)
            {
                try
                {
                    int specificPageNum = Convert.ToInt32(tbSearchComic.Text);
                    if (tbSearchComic.Text != null && tbSearchComic.Text.Length > 0 && specificPageNum > 0 && specificPageNum < maxNumber)
                    {
                        var comic = await ComicProcessor.LoadComic(specificPageNum);
                        tbComicPageNum.Text = tbSearchComic.Text;
                        currentNumber = specificPageNum;
 
                        var uriSource = new Uri(comic.Img, UriKind.Absolute);
                        imgComic.Source = new BitmapImage(uriSource);
                    }
                }
                catch (Exception error)
                {
                    Debug.WriteLine(error.Message);
                }
                finally
                {
                    tbSearchComic.Text = string.Empty;
                }
            }
            else
            {
                MessageBox.Show("보고싶은 화의 번호를 입력해 주세요!");
            }
        }
    }
}
 
cs

 

제일 먼저 MainWindow() 생성자에서 ApiHelper에 만들어 두었던 InitializeClient() 메서드를 호출해 주었습니다. 그리고 이 Window가 Loaded 될 때 LoadComicImage() 메서드를 호출하여 만화 정보를 불러올 수 있도록 하였습니다. 그리고 이 만화 이미지가 언제 응답으로 들어올지 모르기 때문에, 언제 로드될지 모르기 때문에 async/await을 사용하여 비동기 처리하였습니다.

 

버튼의 Click 이벤트에 대한 내용은 크게 어려운것이 없으므로 넘어가겠습니다. 마지막으로 특정 만화 검색 이동을 눌렀을 때의 내용을 보게 되면 TextBox에 입력한 번호를 읽어서 만화 페이지를 이동시켜주었습니다. 사실 버튼에 대한 모든 내용에 기능만 우선 구현하다 보니 중복되는 부분이 많이 있습니다. 이 부분은 여러분들이 개선해 보시는 것도 좋을 것 같습니다.

 

이제 완성된 XKCD-Comic-Viewer를 구경해 볼까요?

 

XKCD-Comic-Viewer 실행 화면

왼쪽은 첫화로 이동하였을 때의 모습이고 오른쪽 화면은 원하는 화를 보기 위해서 ex) 1200을 입력하고 이동하였을 때의 모습입니다. 


6. XKCD-Comic-Viwer 시연 영상

마지막으로 지금까지 만든 XKCD-Comic-Viewer의 시연영상을 끝으로 이번 포스트는 마치도록 하겠습니다. 이 프로그램에 대한 소스코드는 영상 하단의 제 GitHub에 올려두었으니 필요하신 분들은 참고하시길 바랍니다. 감사합니다. 🙆‍♀️🙆‍♂️

 

 

XKCD-Comic-Viwer 시연 영상

https://github.com/KyungHoon0126/XKCD-Comic-Viewer

 

KyungHoon0126/XKCD-Comic-Viewer

XKCD Comic Viewer 👀. Contribute to KyungHoon0126/XKCD-Comic-Viewer development by creating an account on GitHub.

github.com

 

댓글