The Hobbyist Programmer
Posts about Swift, Python, and life, by Hyeonmin

SwiftUI로 코로나 현황 앱 만들기 Part 8 - CountryDetailView

July 4, 2022

이 글에 사용된 버전: Xcode 13.4, Swift 5.6

Part 7에서는 국가 리스트의 각 줄을 별도의 타입인 CountriesListRow로 만들어서 표시하였다. Part 7까지의 코드는 여기에서 찾을 수 있다.

이번 파트에서는 앞 파트에서 만든 리스트의 줄을 터치하여 각 국가별 디테일한 정보를 제공하는 화면으로 이동하는 기능을 추가하고 그리고 국가 디테일 화면을 만들어 볼 것이다.

NavigationLink

내비게이션 뷰의 가장 큰 기능은 내비게이션 바를 통해서 여러 화면 간 왔다 갔다 할 수 있는, 즉 말 그대로 내비게이션 기능이다. 이 내비게이션 기능을 이용하여 이동할 수 있는 화면들의 집합을 내비게이션 스택이라고 한다. SwiftUI에서는, 다른 대부분의 기능들이 그러했듯, 내비게이션 기능도 간단하게 구현할 수 있다.

내비게이션 기능 구현을 위해서는 탭 제스처를 받는 뷰가 해당하는 부분 코드를 NavigationLink 블록으로 감싸기만 하면 된다. 우리 앱에서는 국가 리스트의 줄에 해당하는 CountriesListRow를 터치하여 이동할 것이므로 이 뷰를 NavigationLink 블록 안으로 넣으면 된다. CountriesView.swift 파일로 가서 ForEach 블록 안을 아래와 같이 수정한다.

struct CountriesView: View {
    ...

    List {
        ForEach(viewModel.searchedResults) { country in
            NavigationLink {
                Text(country.country)
            } label: {
                CountriesListRow(
                    name: country.country,
                    continent: country.continent,
                    population: country.population,
                    url: country.countryInfo.flag
                )
            }
        }
    }
    .listStyle(.plain)
    .searchable(text: $viewModel.searchTerm)

    ...
}

NavigationLink의 첫 번째 블록은 이동하고자 하는 곳, 즉 destination에 해당하고, 두 번째 블록은 내비게이션 링크 버튼이 어떻게 보일지에 해당하는 레이블이 되며, 여기서는 CountriesListRow가 된다. 목적지 뷰는 아직 만들지 않았기에 우선은 화면에 나라 이름이 표시되도록 Text(country.country)를 건넸다.

이제 앱을 실행하면 리스트 각 줄 끝에 더 이동할 곳이 있다는 표시로 > 마크가 보인다. 줄을 터치하면 새로운 화면으로 이동하고, 내비게이션 바에 되돌아오는 버튼을 터치하면 두 화면 간 이동이 가능한 것을 확인할 수 있다.

Navigation Link

CountryDetailView

이제 각 나라의 디테일이 표시되는 화면을 만들 차례이다. 먼저 CountryDetail이라는 새로운 그룹(폴더)을 만들고 그 안에 CountryDetailView.swift라는 새로운 SwiftUI View 파일을 생성한다. 그런 다음 생성한 파일을 다음과 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country

    var body: some View {
        Text(country.country)
    }
}

이 뷰가 이니셜라이징 될 때 받을 파라미터로 Country를 설정했다. 프리뷰 코드에서 에러가 발생할 텐데 이 에러를 해결하기 위해서 Country의 더미 데이터 인스턴스를 하나 생성하도록 하자. Country.swift 파일로 가서 파일 끝에다가 아래 익스텐션을 추가한다.

// Country.swift
...

extension Country {
    static var example: Country {
        Country(
            updated: 1653276487775,
            country: "S. Korea",
            countryInfo: CountryInfo(
                iso2: "KR",
                iso3: "KOR",
                lat: 37,
                long: 127.5,
                flag: "https://disease.sh/assets/img/flags/kr.png"),
            cases: 17967672,
            todayCases: 9975,
            deaths: 23987,
            todayDeaths: 22,
            recovered: 17414103,
            todayRecovered: 42223,
            population: 51352317,
            continent: "Asia"
        )
    }
}

이제 CountryDetailView.swift 파일로 가서 프리뷰 코드를 아래와 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView_Previews: PreviewProvider {
    static var previews: some View {
        CountryDetailView(country: .example)
    }
}

이렇게 하면 에러가 사라진 것을 확인할 수 있다. 이제 새롭게 만든 이 뷰로 이동할 수 있도록 앞에서 만든 NavigationLink 코드의 첫 번째 블록을 수정한다.

// CountriesView.swift

struct CountriesView: View {

...

    List {
        ForEach(viewModel.searchedResults) { country in
            NavigationLink {
                CountryDetailView(country: country)
            } label: {
                CountriesListRow(
                    name: country.country,
                    continent: country.continent,
                    population: country.population,
                    url: country.countryInfo.flag
                )
            }
        }
    }
    
...

이제 앱이 이전과 동일하게 작동하지만 이동하는 뷰는 우리가 별도로 만든 뷰로 이동하게 됐다.

MapKit

Swift에서 앱을 만들 때 애플 지도를 사용할 수 있도록 프레임워크를 제공하고 있는데 그 프레임워크가 바로 MapKit이다. 이번에 우리가 만들 뷰는 한 나라의 디테일한 정보를 보여주는 뷰이므로, 그 나라가 위치한 지도를 보여주는 것도 의미가 있을 것으로 보인다. 마침 API에서 CountryInfo 부분에 위도(latitude)와 경도(longitude)도 제공하고 있기도 하니, MapKit을 이용해서 지도를 표시해 보자.

지도를 만들 때 건네주어야 하는 파라미터가 복잡한 편이어서 지도 뷰를 별도의 타입으로 정의하는 것이 더 나을 것으로 보인다. CountryMapView.swift라는 이름으로 새로운 SwiftUI View 파일을 만들고 다음과 같이 작성하자.

// CountryMapView.swift

import SwiftUI
import MapKit

struct CountryMapView: View {
    let country: Country

    @State private var region: MKCoordinateRegion

    init(country: Country) {
        self.country = country

        let meters: Double = 1_500_000
        let coordinate = CLLocationCoordinate2D(
            latitude: country.countryInfo.lat,
            longitude: country.countryInfo.long
        )

        _region = State(
            initialValue: MKCoordinateRegion(
                center: coordinate,
                latitudinalMeters: meters,
                longitudinalMeters: meters
            )
        )
    }

    var body: some View {
        Map(coordinateRegion: $region)
    }
}

struct CountryMapView_Previews: PreviewProvider {
    static var previews: some View {
        CountryMapView(country: .example)
            .frame(height: 200)
    }
}

SwiftUI에서 지도를 추가하는 것은 매우 간단하다. MapKit을 import하고(위쪽에 MapKit을 import 하는 것을 잊지 말자), Map 뷰를 생성하기만 하면 된다. 다만 이 과정에서 MapMKCoordinateRegion 타입의 파라미터를 받는데 바인딩 형태로 받기 때문에 @State 가 붙은 프로퍼티가 필요하다. MKCoordinateRegion은 이니셜라이저에서 CLLocationCoordinate2D 타입의 파라미터를 받아야 하는데, 이 타입을 이니셜라이징 하려면 latitudelongitude 값을 알아야 한다. 문제는 이 맵 뷰 타입이 이니셜라이징 되기 전까지는 해당 값을 모르기 때문에, 지금까지와 같이 @State 프로퍼티에 초깃값을 부여하는 형태로 할 수가 없다는 것이다. 그래서 이 문제를 해결하기 위해 커스텀 이니셜라이저를 만들고, 그 안에서 초깃값을 부여하였다.

이렇게 만든 맵 뷰는 MKCoordinateRegion에서 설정한 위도와 경도에 해당하는 지역을 지도 한가운데 표시하며 화면에 나타난다. latitudalMeterslongitudalMeters는 지도가 보여주는 남북 간 그리고 동서 간 거리를 나타내는 것으로 적당한 크기로 보여주기 위해서 1500km(1,500,000미터) 값을 부여했다. 이 값이 너무 크면 작은 나라는 너무 작게 보일 테고, 너무 작으면 큰 나라는 일부만 보이게 된다. 만약 API에서 면적이나 영토 크기에 대한 정보도 제공해 준다면 그 값을 기반으로 저 값을 조정할 수도 있겠지만 해당 정보는 API에서 제공하지 않기 때문에 임의의 값을 사용할 수밖에 없다. 1,500km 정도라면 중국이나 러시아와 같은 큰 나라는 한눈에 들어오지 않겠지만 대부분의 나라를 커버할 수 있을 것으로 생각된다.

이제 우리가 만든 맵 뷰가 잘 작동하는지 보려면 프리뷰를 통해 확인하면 된다. 맵 뷰는 프리뷰를 실행해야만 지도가 제대로 나타난다.

MapView

지도는 잘 나타나는데 조금 밋밋한 느낌이 있다. 그리고 여러 국가가 섞여 있는 지역이라면 우리가 확인 중인 나라가 정확히 어디에 위치해 있는지 파악하기 힘들 수도 있다. 이럴 때 사용할 수 있는 것이 어노테이션(annotation)이다.

어노테이션은 간단히 말해서 지도에 표시를 하는 것이다. 핀으로 표시한다든지, 혹은 노트가 될 수도 있다. 애플 맵에서 사용하는 MapMarker, MapPin 등의 타입이 이에 해당하고, 커스텀으로 만들 수도 있는데 여기서는 그냥 풍선 모양의 MapMarker를 사용하겠다.

MapMarker를 사용하기 위해서는 Map 타입의 annotationItems 파라미터에 어레이 형태로 Identifiable 프로토콜을 채택한 타입을 건네주어야 한다. 파라미터를 어레이 형태로 받는 건 여러 장소를 동시에 표시할 수도 있기 때문이다.

일단 annotationItems를 위해 여기에 사용할 별도의 타입을 하나 만들자. CountryMapView.swift에서 CountryMapView와 프리뷰 코드 사이에 아래와 같이 익스텐션을 작성한다. 이 타입은 이 뷰 외에 다른 곳에서는 사용하지 않을 것이므로, 타입 안에 타입을 정의하는 Nested Type 형태로 정의하였다.

// CountryMapView.swift

...

extension CountryMapView {
    struct Location: Identifiable {
        let id = UUID()
        let coordinate: CLLocationCoordinate2D
    }
}

이 타입을 CountryMapView 타입 내에서 사용하기 위해 프로퍼티를 하나 새로 생성한다.

// CountryMapView.swift

struct CountryMapView: View {
    ...

    let locations: [Location]

    @State private var region: MKCoordinateRegion

    ...
}

이제 이니셜라이저를 다음과 같이 수정한다.

// CountryMapView.swift

struct CountryMapView: View {
    ...

    init(country: Country) {
        self.country = country

        let meters: Double = 1_500_000
        let coordinate = CLLocationCoordinate2D(
            latitude: country.countryInfo.lat,
            longitude: country.countryInfo.long
        )

        locations = [Location(coordinate: coordinate)]

        _region = State(
            initialValue: MKCoordinateRegion(
                center: coordinate,
                latitudinalMeters: meters,
                longitudinalMeters: meters
            )
        )
    }

    ...
}

마지막으로 body 안을 아래와 같이 수정한다.

// CountryMapView.swift

struct CountryMapView: View {

    ...

    var body: some View {
        Map(coordinateRegion: $region, annotationItems: locations) { location in
            MapMarker(coordinate: location.coordinate)
        }
    }
}

이제 마커가 제대로 표시되는 것을 확인할 수 있다.

MapMarker

GeometryReader

이제 앞에서 만든 지도뷰를 가지고 CountryDetailView의 헤더 부분을 만들어보자. 헤더 부분은 그 전체를 지도가 차지할 텐데, 이 지도를 화면의 약 1/4 크기 정도로만 배치하려고 한다. 하지만 아이폰SE의 크기와 13 프로 맥스의 크기가 각각 다른데, 어떻게 하면 각기 다른 화면 크기에서도 동일하게 높이가 1/4이 되도록 할 수 있을까? 이 경우 사용할 수 있는 것이 GeometryReader 타입이다.

GeometryReader는 그 안에 뷰를 배치할 수 있는 컨테이너 뷰에 해당하는데, GeometryReader가 속해 있는 뷰, 즉 parent 뷰에서의 좌표와 GeometryView의 사이즈 데이터를 제공한다. 우리는 좌표 값은 사용하지 않고 크기 정보만 이용할 예정이고, 이 크기 정보를 이용해서 지도가 화면에서 세로로 1/4 크기만 차지하도록 할 수 있다. 이제 CountryDetailView.swift 파일로 가서 아래와 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    CountryMapView(country: country)
                        .frame(height: geometry.size.height / 4)
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

CountryDetailView가 전체 화면을 차지할 것이기 때문에 body 프로퍼티 안을 GeometryReader로 시작하여 전체 화면의 크기를 알 수 있도록 하였다. 화면 내의 정보가 화면 크기를 초과할 수도 있기 때문에 스크롤이 가능하도록 ScrollView도 포함하고, 마지막에 CountryMapView.frame(height:) 모디파이어로 GeometryReader가 제공하는 높이 값의 1/4 값을 적용하였다. 이렇게 함으로써 지도가 화면의 1/4 높이로 표시되는 것을 확인할 수 있다.

.navigationBarTitleDisplayMode(.inline) 부분은 내비게이션 바 제목 부분이 크게 나오지 않고 작게 나오도록 조절하는 코드이다. 애플에서는 내비게이션 바 제목을 활용할 때 첫 화면은 큰 제목으로 그 안에서 이동해 가는 화면들은 연속성을 위해 특별한 경우가 아니면 작은 제목을 활용하도록 권하고 있다.

이제 이 지도 위에 국기가 표시되도록 꾸며보자. VStack 안을 아래와 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    ZStack(alignment: .bottomTrailing) {
                        CountryMapView(country: country)

                        AsyncImage(url: URL(string: country.countryInfo.flag)) { phase in
                            switch phase {
                            case .empty:
                                ProgressView()
                            case .success(let image):
                                image
                                    .resizable()
                                    .scaledToFit()
                            default:
                                Image(systemName: "flag")
                            }
                        }
                        .frame(maxWidth: geometry.size.width / 5)
                        .padding(24)
                        .shadow(radius: 4)
                    }
                    .frame(height: geometry.size.height / 4)
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

지도 위에 겹쳐서 국기가 표시되도록 ZStack을 사용했다. AsyncImage는 앞에서 사용한 것과 조금 다른 방식으로 사용하였는데, AsyncImage의 너비를 화면 사이즈의 1/5만 차지하도록 GeometryReader를 활용한 것을 확인해 보라. 마지막으로 그림자 효과를 조금 줘서 국기가 마치 지도 위에 떠 있는 듯한 분위기를 연출하였다.

이제 ZStack 아래에 국가명 등을 표시하도록 아래와 같이 HStack을 하나 추가하여 헤더 부분을 마무리하자.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    ZStack(alignment: .bottomTrailing) {
                        ...
                    }
                    .frame(height: geometry.size.height / 4)

                    HStack(alignment: .bottom) {
                        Text(country.country)
                            .font(.largeTitle)
                            .bold()

                        Spacer()

                        VStack(alignment: .trailing) {
                            Text(country.continent)
                                .font(.headline)
                            Label("\(country.population)", systemImage: "person.3")

                        }
                        .foregroundColor(.secondary)
                    }
                    .padding()
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

헤더 부분만으로도 코드가 꽤 길어져서 헤더를 별도의 타입으로 빼는 것이 좋을 것 같다. CountryDetailHeader.swift라는 이름으로 SwiftUI View 파일을 만들어서 VStack 부분 코드를 복사해서 붙여 넣은 다음 아래와 같은 모습이 되도록 일부 수정한다.

import SwiftUI

struct CountryDetailHeader: View {
    let country: Country
    let geometry: GeometryProxy

    var body: some View {
        VStack {
            ZStack(alignment: .bottomTrailing) {
                CountryMapView(country: country)

                AsyncImage(url: URL(string: country.countryInfo.flag)) { phase in
                    switch phase {
                    case .empty:
                        ProgressView()
                    case .success(let image):
                        image
                            .resizable()
                            .scaledToFit()
                    default:
                        Image(systemName: "flag")
                    }
                }
                .frame(maxWidth: geometry.size.width / 5)
                .padding(24)
                .shadow(radius: 4)
            }
            .frame(height: geometry.size.height / 4)

            HStack(alignment: .bottom) {
                Text(country.country)
                    .font(.largeTitle)
                    .bold()

                Spacer()

                VStack(alignment: .trailing) {
                    Text(country.continent)
                        .font(.headline)
                    Label("\(country.population)", systemImage: "person.3")

                }
                .foregroundColor(.secondary)
            }
            .padding()
        }
    }
}

struct CountryDetailHeader_Previews: PreviewProvider {
    static var previews: some View {
        GeometryReader { geometry in
            CountryDetailHeader(country: .example, geometry: geometry)
        }
    }
}

이제 이 타입을 활용하여 CountryDetailView를 아래와 같이 간단하게 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    CountryDetailHeader(country: country, geometry: geometry)
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

복잡한 코드를 별도의 타입으로 빼고, 그걸 이용하여 심플하게 정리하는 이 순간이 코딩을 하면서 가장 즐거운 순간 중 하나가 아닐까 싶다.

LazyVGrid

이제 특정 국가의 구체적인 정보를 어떻게 표시할 것인지가 남았다. WorldwideView에서처럼 리스트 형태로 표시할 수도 있겠지만, 몇 안 되는 화면으로 이루어진 앱에서는 같은 화면이 반복되면 좀 지루할 것 같다. 그래서 여기서는 좀 다른 방법을 사용해 보려고 한다.

리스트 형태가 아니더라도 비슷한 종류의 데이터 여러 개를 효과적으로 보여주는 방법으로 그리드 형태가 있다. 마침 SwiftUI에서 그리드 형태로 뷰를 배치할 수 있는 컨테이너 뷰가 있는데 바로 LazyVGridLazyHGrid이다. 이름에서 유추할 수 있듯이 V는 수직으로 뷰가 추가되는 형태이고, H는 수평으로 뷰가 추가된다. 아이폰 화면에서는 아무래도 수직 스크롤링이 익숙하기 때문에 여기서는 LazyVGrid를 사용하겠다. 그리고 이름에 lazy가 있는 것은 데이터가 아주 많을 때 모든 데이터를 한 번에 준비하는 것이 아니고 화면에 나타날 때쯤, 즉 필요해질 때 준비한다는 의미이며 이를 통해서 데이터가 많을 때는 디바이스의 자원을 효율적으로 활용할 수 있게 된다. 물론 우리 앱에서는 데이터 개수가 몇 개 되지 않아서 lazy로 작동하지는 않을 것으로 보인다.

이제 CountryDetailView.swift 파일로 가서 아래와 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country
    let columns = [GridItem(), GridItem()]

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    CountryDetailHeader(country: country, geometry: geometry)

                    LazyVGrid(columns: columns) {
                        VStack(spacing: 8) {
                            Text("Cases")
                                .font(.headline)

                            Text(country.cases, format: .number)
                                .font(.title2)
                                .bold()

                            Text("+\(country.todayCases)")
                                .foregroundColor(.secondary)
                        }
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color(white: 0.95))
                        .cornerRadius(8)
                    }
                    .padding()
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

columns라는 프로퍼티를 위쪽에 추가하였는데, 이 프로퍼티가 LazyVGrid에 파라미터로 전달된다. 위에서는 GridItem이 두 개 담긴 어레이 형태로 정의하였는데 이렇게 하면 세로로 2개 열이 있는 그리드가 된다. GridItem을 기본 값으로 이니셜라이징하게 되면 .flexible()이 적용되는데, 이 외에도 다양한 크기를 부여할 수 있는데 실험은 독자 여러분에게 맡긴다. LazyVGrid 안에는 그리드가 표시할 뷰를 넣었다. 이렇게 작성하면 다음과 같이 프리뷰에 표시된다.

LazyVGrid

이제 그리드 안의 뷰를 더 작성하여야 하는데 이 부분은 코드가 반복되는 부분이기 때문에 별도의 타입으로 정의할 필요가 있다. CountryDetailGridItem.swift라는 파일 이름으로 새로운 SwiftUI View 파일을 생성한 다음 LazyVGrid 안의 VStack 부분을 복사하여 붙여 넣고, 아래와 같이 수정한다.

// CountryDetailGridItem.swift

import SwiftUI

struct CountryDetailGridItem: View {
    let title: String
    let number: Int
    let today: Int?

    var body: some View {
        VStack(spacing: 8) {
            Text(title)
                .font(.headline)

            Text(number, format: .number)
                .font(.title2)
                .bold()

            if let today = today {
                Text("+\(today)")
                    .foregroundColor(.secondary)
            } else {
                Text("No Data")
                    .foregroundColor(.clear)
            }
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(Color(white: 0.95))
        .cornerRadius(8)
    }
}

struct CountryDetailGridItem_Previews: PreviewProvider {
    static var previews: some View {
        CountryDetailGridItem(title: "Case", number: 17967672, today: 9975)
            .previewLayout(.sizeThatFits)
    }
}

여기서 한 가지 생각해 보아야 하는 점은 최종 확진자 수 외에 오늘 추가 확진자 수와 같은 데이터는 항상 존재하지는 않는다는 것이다. 그래서 이 부분은 옵셔널로 정의하고 뷰 또한 언랩을 통해서 데이터가 있을 때만 표시되도록 하였고, 만약 데이터가 없는 경우 높이를 맞추기 위해서 더미 텍스트를 투명한 색깔로 표시되도록 하였다. 이제 이 타입을 이용하여 CountryDetailView를 아래와 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country
    let columns = [GridItem(), GridItem()]

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    CountryDetailHeader(country: country, geometry: geometry)

                    LazyVGrid(columns: columns) {
                        CountryDetailGridItem(
                            title: "Cases",
                            number: country.cases,
                            today: country.todayCases
                        )

                        CountryDetailGridItem(
                            title: "Deaths",
                            number: country.deaths,
                            today: country.todayDeaths
                        )

                        CountryDetailGridItem(
                            title: "Recovered",
                            number: country.recovered,
                            today: country.todayRecovered
                        )
                    }
                    .padding()
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

화면으로는 이렇게 표시된다.

CountryDetail

짝이 맞지 않아서 데이터를 하나 더 추가하도록 하자. Country.swift 파일로 가서 active라는 Int 프로퍼티를 하나 추가한다.

struct Country: Decodable {
    ...
    let todayRecovered: Int
    let active: Int
    ...
}

이제 CountryDetailView로 가서 그리드 아이템을 하나 더 추가한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country
    let columns = [GridItem(), GridItem()]

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    CountryDetailHeader(country: country, geometry: geometry)

                    LazyVGrid(columns: columns) {
                        ...

                        CountryDetailGridItem(
                            title: "Active",
                            number: country.active,
                            today: nil
                        )
                    }
                    .padding()
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}

마지막으로 그리드 뷰도 별도의 타입으로 빼도록 하자. CountryDetailInfoGrid.swift라는 파일명으로 새로운 SwiftUI View 파일을 생성하고 LazyVGrid 부분을 복사해서 붙여 넣고 아래와 같이 수정한다.

// CountryDetailInfoGrid.swift

import SwiftUI

struct CountryDetailInfoGrid: View {
    let country: Country
    let columns = [GridItem(), GridItem()]

    var body: some View {
        LazyVGrid(columns: columns) {
            CountryDetailGridItem(
                title: "Cases",
                number: country.cases,
                today: country.todayCases
            )

            CountryDetailGridItem(
                title: "Deaths",
                number: country.deaths,
                today: country.todayDeaths
            )

            CountryDetailGridItem(
                title: "Recovered",
                number: country.recovered,
                today: country.todayRecovered
            )

            CountryDetailGridItem(
                title: "Active",
                number: country.active,
                today: nil
            )
        }
        .padding()
    }
}

struct CountryDetailInfoGrid_Previews: PreviewProvider {
    static var previews: some View {
        CountryDetailInfoGrid(country: .example)
            .previewLayout(.sizeThatFits)
    }
}

이제 CountryDetailView로 가서 columns 프로퍼티를 삭제하고 아래와 같이 수정한다.

// CountryDetailView.swift

struct CountryDetailView: View {
    let country: Country

    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    CountryDetailHeader(country: country, geometry: geometry)

                    CountryDetailInfoGrid(country: country)
                }
            }
        }
        .navigationTitle(country.country)
        .navigationBarTitleDisplayMode(.inline)
    }
}
CountryDetailGrid

이렇게 해서 CountryDetailView 화면이 완성되어서 Part 8도 여기에서 마친다. Part 8에서는 지금까지...

을 진행해 보았다. Part 8까지의 파일은 여기에서 찾을 수 있다.

마치며

Part 1에서 8까지 이르는 긴 여정을 통해서 간단하게나마 하나의 앱이 완성되었다. 아이팟 터치와 같은 작은 기기의 화면으로 보면 폰트 크기 조절 등 소소하게 수정해 줘야 하는 부분들이 보이는데 이 부분은 독자 여러분에게 맡기려고 한다. WWDC22에서 그래프를 그릴 수 있는 프레임워크인 Swift Charts가 발표되었는데 이번 가을에 iOS 16이 정식 버전으로 나오면 오늘 만든 화면에 그래프도 넣어볼 예정이다. 그때까지 이 프로젝트는 넣어두고 다시 새로운 포스트를 통해 돌아오도록 하겠다.