SwiftUI로 코로나 현황 앱 만들기 Part 5 - WorldwideView

Sat, 5 Mar 2022 00:00:00 +0000

튜토리얼 Part 5에서는 SwiftUI의 View 프로토콜과 다양한 뷰를 활용하여 WorldwideView를 만든다. 이 과정에서 NavigationView와 정적인 List도 다룬다.

2022-04-24: @MainActor가 사용된 @StateObject 이니셜라이징 관련 사항 수정

앞서 Part 4에서는 MVVM 디자인 패턴과 그 패턴에 따라 WorldwideView에서 쓰일 뷰 모델 WorldwideViewModel을 만들었다. Part 4까지의 파일은 여기에서 다운로드할 수 있다.

이번 Part 5에서는 WorldwideView를 만들어 봄으로써 SwiftUI의 뷰가 어떤 식으로 구성되는지 알아볼 것이다.

SwiftUI View Protocol

SwiftUI의 View는 프로토콜이다. 이 프로토콜을 따름으로써 커스텀 뷰를 만들 수 있게 되는데, 프로토콜을 따르기 위해서 필요로 하는 것은 앞서 Part 4에서도 잠깐 언급했듯이 var body: some View라는 computed property 하나이다.

이 컴퓨티드 프로퍼티 안에 우리가 원하는 뷰의 모습을 설명하듯이(declarative) 작성해서 넣으면 SwiftUI가 알아서 그 뷰를 렌더링 해 준다. 우선은 간단한 것부터 시작해 보자.

Image

뷰는 말 그대로 유저가 화면을 통해서 보게 되는 것이다. SwiftUI에서는 뷰를 개발자가 자유롭게 구성할 수 있도록 기본적인 몇 가지 뷰를 제공하는데 그중 하나가 Image이다. Image 타입은 말 그대로 이미지를 화면에 렌더링 할 때 사용하는 뷰이다. 화면이 너무 글자와 숫자로만 들어차 있으면 답답한 느낌이 들 수 있으므로 적당히 시각적인 요소를 이용하는 것이 좋다.

우선은 화면에 사용할 이미지를 준비하자. 우리가 사용할 이미지는 여기서 다운로드한다. 이 이미지는 Unsplash라는 사이트에서 가져왔다. Unsplash는 라이선스 없이 사용할 수 있는 많은 이미지를 많이 제공해 주는 사이트이다.. 다운로드한 이미지 이름을 covid.jpg로 변경하자.

이 이미지 파일을 앱 내에서 사용하기 위해서는 Assets에 집어넣어야 한다. Assets에 파일을 넣을 때에는 해당 파일을 빨간색으로 표시된 왼쪽 패널에 끌어다 넣으면 된다.

이미지 파일을 제대로 넣었다면 아래 스크린샷처럼 표시된다.

이제 이 이미지를 실제로 화면에 표시해 보자. WorldwideView.swift 파일로 가서 else 블록에 Text 부분을 삭제하고 아래와 같이 작성한다.

//  WorldwideView.swift

import SwiftUI

struct WorldwideView: View {
    @StateObject private var viewModel: WorldwideViewModel

    init() {
        _viewModel = StateObject(wrappedValue: WorldwideViewModel())
    }

    var body: some View {
        if viewModel.isLoading {
            ProgressView()
        } else {
            Image("covid")
        }
    }
}

그리고 command + option + P 단축키로 프리뷰를 실행해 보니 프리뷰에서는 ProgressView만 보여준다.

이 뷰가 처음 시작할 때 WorldwideViewModel이 이니셜라이징 되면서 isLoading 프로퍼티를 true로 바꾸게 된다. 그 결과 ProgressView가 화면에 나타나게 되고, 단순 프리뷰이기 때문에 그 상태에서 정지된 채 계속해서 ProgressView만 보이게 된 것이다. 스크린샷에서 빨간색으로 표시한 버튼을 눌러 프리뷰에서 앱을 실행하는 방법도 있지만, UI를 변경할 때마다 매번 네트워킹을 거치는 것은 아무래도 비효율적이다. 우선은 임시방편으로 if-else 부분을 지우고 아래와 같이 작성하자.

//  WorldwideView.swift

import SwiftUI

struct WorldwideView: View {
    @StateObject private var viewModel: WorldwideViewModel

    init() {
        _viewModel = StateObject(wrappedValue: WorldwideViewModel())
    }

    var body: some View {
        Image("covid")
    }
}

이제 프리뷰는 제대로 작동한다. 하지만 이미지가 뭔가 이상하다.

View Modifiers

SwiftUI의 Image는 기본적으로 이미지가 원래 가지고 있는 픽셀 사이즈 그대로 화면에 보여준다. 이 이미지는 가로로 6000 픽셀 크기인데 프리뷰의 iPhone 13 Pro Max의 가로 1284 픽셀에 보여주다 보니 6000 픽셀 중 1284 크기에 해당하는 화면만 보이고 있는 것이다. 이미지 크기를 조절하려면 view modifier를 사용한다.

View Modifier는 SwiftUI View를 조절할 때 사용하는 것이다. SwiftUI에서 기본적으로 제공하는 것만 하더라도 다양한 뷰 모디파이어가 있고, 자신에게 필요한 커스텀 모디파이어를 만들 수도 있다. 뷰 모디파이어는 해당 View 아래에 . 형태로 적용한다. 우선 아래와 같이 작성하자.

// WorldwideView.swift

var body: some View {
    Image("covid")
        .resizable()
}

여전히 이상하다. .resizable() 모디파이어는 이름 그대로 이미지의 사이즈를 조절할 수 있게 해 준다. 이미지 크기를 바꿔 줄 때에는 반드시 .resizable() 모디파이어를 먼저 사용해 주어야 한다. 이 모디파이어에 의해 크기가 변경된 이미지는 가로 1284, 세로 2778 픽셀 크기의 iPhone 13 Pro Max 화면을 꽉 채우게 되는데, 다만 원래의 이미지 크기를 단순히 화면 사이즈로 바꾸어 버리기 때문에 그 과정에서 이미지가 일그러져 보이게 되는 것이다. 이미지의 원래 비율을 유지하려면 아래와 같이 뷰 모디파이어를 하나 더 추가해 주면 된다.

// WorldwideView.swift

var body: some View {
    Image("covid")
        .resizable()
        .scaledToFit()
}

이제 무언가 제대로 되고 있는 느낌이다. .scaledToFit() 모디파이어는 이미지의 원래 비율을 유지하면서 화면에 핏하게 채워준다. 이 이미지의 경우 화면에 비해 가로가 더 긴 이미지이기 때문에 가로를 꽉 채우고 세로는 비워 두게 된다. 이미지 조절하는 모디파이어 중 .scaledToFill() 모디파이어도 있는데 이건 이미지 비율을 유지하면서 화면을 가득 채우는 것이다. 직접 실험해 보기 바란다.

이제 이미지가 제대로 표시되고 있지만 여백 없이 화면을 너무 채우고 있어서 조금 갑갑한 느낌이 든다. 여백을 조금 주기 위해서 아래와 같이 모디파이어를 하나 더 추가하자.

// WorldwideView.swift

var body: some View {
    Image("covid")
        .resizable()
        .scaledToFit()
        .padding()
}

훨씬 낫다. .padding() 모디파이어가 하는 일은 Image 오브젝트가 화면에서 차지하는 공간은 그대로 두면서 안쪽으로 여백을 만들어 주는 것이다. () 안에 다양한 파라미터 값을 주어서 위, 아래, 왼쪽, 오른쪽, 수직, 수평 등 패딩을 주고 싶은 범위를 한정한다든지, 아니면 여백의 크기를 조절한다든지 할 수 있다.

이제 모든 게 다 좋아 보인다. 다만 이미지 모서리가 너무 뾰족하여 날카롭게 느껴지는 게 흠이다. 이걸 바꾸기 위해 아래와 같이 뷰 모디파이어를 하나 더 추가해 보자.

// WorldwideView.swift

var body: some View {
    Image("covid")
        .resizable()
        .scaledToFit()
        .padding()
        .cornerRadius(10)
}

이상하다. 아무 일도 일어나지 않는다. 왜 그럴까? 모서리를 둥글게 만드는 것은 저 뷰 모디파이어가 확실하다. 이름만 봐도 딱 알 수 있다.

문제는 순서에 있다. 조금 전에도 이미지의 사이즈를 조절 하려면 .resizable() 모디파이어를 먼저 사용하여야 한다고 했다. 뷰 모디파이어에 있어서 순서는 매우 중요하다. 위 코드를 보면 패딩을 주고 난 다음에 코너를 둥글게 한다. 패딩으로 인해 실제 눈에 보여지는 이미지는 Image 오브젝트가 차지하고 있는 범위보다 작은 범위에서 그려지고 있는데, .cornerRadius()는 Image 오브젝트 자체에 적용되고 있어서 실제 적용은 되었지만 하얀색의 여백으로 되어 있는 부분에 적용되고 있기 때문에 눈에 보이지 않는 것이다. 우리가 원하는 대로 사진 이미지 자체의 모서리를 둥글게 만들려고 한다면 .padding()과 .cornerRadius(10)의 순서를 서로 바꾸어 주면 된다.

// WorldwideView.swift

var body: some View {
    Image("covid")
        .resizable()
        .scaledToFit()
        .cornerRadius(10)
        .padding()
}

이제 모든 것이 완벽하다. 이제 이 아래에 실제 데이터를 보여줄 차례다. 아래쪽에 보여주면 되니까 자연스럽게 Image 아래에 조금 전 지웠던 Text를 다시 추가하자.

// WorldwideView.swift

var body: some View {
    Image("covid")
        .resizable()
        .scaledToFit()
        .cornerRadius(10)
        .padding()

    Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
}

코드를 다 작성하고 프리뷰를 보면 프리뷰가 두 개가 되고, 프리뷰 별로 이미지 하나 텍스트 하나를 보여주는 이상한 사태가 벌어져 있다. body 프로퍼티는 컴퓨티드 프로퍼티라서 하나의 뷰만 리턴할 수 있는데 우리는 두 개의 뷰를 작성한 것이다. 그래서 각각으로 프리뷰가 나타난 것이다. 그렇다면 이미지 아래에 뷰를 더 추가하고 싶다면 어떻게 해야 할까?

VStack, HStack, ZStack

어느 한 뷰와 나란히 양옆이나 위아래에 뷰를 추가하고 싶을 때 사용하는 것이 VStack과 HStack이다. V는 vertical, H는 horizontal을 떠올리면 되겠다. ZStack의 경우 z 축, 즉 화면의 앞과 뒤 선후를 주고 싶을 때 사용하는 것으로 예를 들면 이미지 위에 글자를 넣는 다든지 할 때 사용한다. 지금 우리는 이미지 아래에 글자를 넣고 싶은 것이므로 Image와 Text 코드 모두를 VStack 블록으로 감싸면 된다.

// WorldwideView.swift

var body: some View {
    VStack {
        Image("covid")
            .resizable()
            .scaledToFit()
            .cornerRadius(10)
            .padding()

        Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
    }
}

이제 계속해서 아래에 텍스트를 추가하자.

// WorldwideView.swift

var body: some View {
    VStack {
        Image("covid")
            .resizable()
            .scaledToFit()
            .cornerRadius(10)
            .padding()

        Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
        Text("Today Cases: \(viewModel.worldwide?.todayCases ?? 0)")
        Text("Deaths: \(viewModel.worldwide?.deaths ?? 0)")
        Text("Today Deaths: \(viewModel.worldwide?.todayDeaths ?? 0)")
        Text("Recovered: \(viewModel.worldwide?.recovered ?? 0)")
        Text("Today Recovered: \(viewModel.worldwide?.todayRecovered ?? 0)")
        Text("Active: \(viewModel.worldwide?.active ?? 0)")
        Text("World Population: \(viewModel.worldwide?.population ?? 0)")
        Text("Affected Countries: \(viewModel.worldwide?.affectedCountries ?? 0)")
    }
}

제대로 작동은 되고 있는데 미적으로 별로 마음에 들지 않는다. UI란 건 역시 아름다워야 한다. 내가 디자인에는 재능이 없다 하더라도 적어도 지금보다는 더 낫게 만들 수는 있다.

List

SwiftUI에서 리스트 형태의 데이터를 보여주는데 적합한 것이 바로 List 오브젝트이다. UIKit 시절의 UITableView나 UICollectionView 같은 것이라고 보면 되겠다. 사용법은 간단하다. Text 오브젝트들을 모두 List 블록 안에 집어넣자.

// WorldwideView.swift

var body: some View {
    VStack {
        Image("covid")
            .resizable()
            .scaledToFit()
            .cornerRadius(10)
            .padding()

        List {
            Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
            Text("Today Cases: \(viewModel.worldwide?.todayCases ?? 0)")
            Text("Deaths: \(viewModel.worldwide?.deaths ?? 0)")
            Text("Today Deaths: \(viewModel.worldwide?.todayDeaths ?? 0)")
            Text("Recovered: \(viewModel.worldwide?.recovered ?? 0)")
            Text("Today Recovered: \(viewModel.worldwide?.todayRecovered ?? 0)")
            Text("Active: \(viewModel.worldwide?.active ?? 0)")
            Text("World Population: \(viewModel.worldwide?.population ?? 0)")
            Text("Affected Countries: \(viewModel.worldwide?.affectedCountries ?? 0)")
        }
    }
}

List Styles

위 스크린샷과 같이 리스트 부분은 굉장히 그럴싸하게 바뀌었다. 다만, 이미지가 있는 부분과 리스트가 있는 부분의 배경 색깔이 조금 다른 문제가 생겼다. 이 부분은 리스트의 스타일이 insetGrouped로 설정되어 있어서 그런 것인데, insetGrouped 리스트 스타일에서는 배경을 자동으로 만들어 준다. 리스트 스타일을 한 번 다른 것으로 바꾸어 보자.

// WorldwideView.swift

List {
    ...
}
.listStyle(.plain)

나는 Swift의 .으로 시작하는 신택스(syntax)를 굉장히 좋아한다. 직관적이고, 자동완성(auto-completion) 기능이 잘 작동하기 때문이다. 괄호 안에 .을 찍으면 다른 옵션들이 자동완성으로 나타난다. 여러 가지를 직접 시도해 보길 바란다. .plain으로 바꾸었더니 배경에 일관성은 느껴지지만, 왠지 이전이 더 예뻤던 것 같다. 그래서 이미지도 리스트의 일부로 편입시키고, 그룹을 지어서(결국 inset grouped이다) 더 괜찮게 바꿔보자.

// WorldwideView.swift

var body: some View {
    List {
        Image("covid")
            .resizable()
            .scaledToFit()
            .cornerRadius(10)
            .padding()

        Section {
            Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
            Text("Today Cases: \(viewModel.worldwide?.todayCases ?? 0)")
            Text("Deaths: \(viewModel.worldwide?.deaths ?? 0)")
            Text("Today Deaths: \(viewModel.worldwide?.todayDeaths ?? 0)")
            Text("Recovered: \(viewModel.worldwide?.recovered ?? 0)")
            Text("Today Recovered: \(viewModel.worldwide?.todayRecovered ?? 0)")
            Text("Active: \(viewModel.worldwide?.active ?? 0)")
            Text("World Population: \(viewModel.worldwide?.population ?? 0)")
            Text("Affected Countries: \(viewModel.worldwide?.affectedCountries ?? 0)")
        } header: {
            Text("Worldwide Yesterday")
        }
    }
    .listStyle(.insetGrouped)
 }

전체를 List 블록으로 감쌌고, 그 안에 텍스트가 들어가는 부분은 Section으로 분리하여 그룹이 지어지게 만들었다. Section은 header와 footer를 추가할 수 있는데, header는 말 그대로 섹션의 위쪽에, footer는 섹션의 아래쪽에 들어가는 뷰이다. 현재는 header만을 사용했다. 이렇게 작성하면 아래와 같이 구현된다.

List Rows

이제 리스트의 한 줄(열) 한 줄을 조금 더 멋지게 바꾸어 보자.

// WorldwideView.swift

var body: some View {
    List {
        ...

        Section {
            HStack {
                Text("Cases")
                    .bold()

                Spacer()

                Text("\(viewModel.worldwide?.cases ?? 123456)")
                    .foregroundColor(.secondary)
            }

            ...

        } header: {
            Text("Worldwide Yesterday")
        }
    }
    .listStyle(.insetGrouped)
 }

우선 제일 첫 줄만 바꾸어 보았다. Spacer()는 둘 사이에 최대한 여백을 주는 타입이다. 이렇게 바꾸고 나니 이제 좀 전문적인 느낌이 든다. 근데 이제 겨우 한 줄 바꿨는데 나머지 8 줄을 더 바꾸려니 막노동 같다. 전문적인 것과는 거리가 멀어진다.

DRY(Don't Repeat Yourself)

소프트웨어 개발에서 DRY라는 원칙이 있다. Don't Repeat Yourself, 같은 것을 반복하지 말라는 것이다. 코딩을 하는데 동일한 것이 계속 반복된다면, 그때는 그 반복되는 부분을 일반화된 별개의 타입으로 떼어낼(abstract) 때가 된 것이다.

SwiftUI에서는 어떤 뷰의 각 파트를 별개의 또 다른 뷰로, 즉 구성품으로 하여 전체를 구성할 수 있도록 되어 있다. 여기서 반복되는 부분은 HStack 부분이므로 이 부분을 별개의 타입으로 만들자. Worldwide 그룹 안에 command + N 단축키로 WorldwideListRow.swift 이름으로 새로운 SwiftUI View 파일을 추가하자. 이제 HStack 블록 전체를 잘라내서 WorldwideListRow.swift 파일의 body 블록 안에 붙여 넣자. 그러면 뷰 모델을 찾을 수 없다고 에러가 발생할 것이다. 새로운 프로퍼티를 추가하고, 다음과 같이 작성하자.

// WorldwideListRow.swift

import SwiftUI

struct WorldwideListRow: View {
    let title: String
    let number: Int

    var body: some View {
        HStack {
            Text(title)
                .bold()

            Spacer()

            Text("\(number)")
                .foregroundColor(.secondary)
        }
    }
}

이렇게 수정하면 프리뷰 코드 쪽에서 에러가 발생한다. 이제 WorldwideListRow를 이니셜라이징하려면, title이라는 String과 number라는 Int 값이 필요한데 프리뷰 코드에서 이니셜라이징하면서 이를 제공해 주지 않아서 발생하는 에러이다. 프리뷰 코드는 순전히 프리뷰 용도이므로 임의의 값을 주면 된다.

// WorldwideListRow.swift
...

struct WorldwideListRow_Previews: PreviewProvider {
    static var previews: some View {
        WorldwideListRow(title: "Cases", number: 123456)
    }
}

이제 이렇게 만든 타입을 WorldwideView 내에서 사용할 차례다. WorldwideView.swift 파일로 가서 아래와 같이 수정하자.

// WorldwideView.swift

Section {
    WorldwideListRow(title: "Cases", number: viewModel.worldwide?.cases ?? 0)
    WorldwideListRow(title: "Today Cases", number: viewModel.worldwide?.todayCases ?? 0)
    WorldwideListRow(title: "Deaths", number: viewModel.worldwide?.deaths ?? 0)
    WorldwideListRow(title: "Today Deaths", number: viewModel.worldwide?.todayDeaths ?? 0)
    WorldwideListRow(title: "Recovered", number: viewModel.worldwide?.recovered ?? 0)
    WorldwideListRow(title: "Today Recovered", number: viewModel.worldwide?.todayRecovered ?? 0)
    WorldwideListRow(title: "Active", number: viewModel.worldwide?.active ?? 0)
    WorldwideListRow(title: "World Population", number: viewModel.worldwide?.population ?? 0)
    WorldwideListRow(title: "Affected Countries", number: viewModel.worldwide?.affectedCountries ?? 0)
} header: {
    ...
}

여전히 막노동인 것처럼 느껴질 것 같다. 부분적으로는 막노동이 맞지만 이전과 비교하면 많은 것이 달라졌다. 만약 9 줄의 모습을 모두 다르게 바꾸려고 한다면, 이전 같으면 9 줄 모두의 코드를 수정해 주어야 하지만 이제는 WorldwideListRow 한 군데에서만 수정하면 9 줄 모두가 바뀌게 된다. 이것만으로도 엄청난 차이이다.

이제 대략적인 모습이 완성되었으니 다시 ProgressView를 돌려놓자.

// WorldwideView.swift

var body: some View {
    if viewModel.isLoading {
        ProgressView()
    } else {
        List {
            Image("covid")
                .resizable()
                .scaledToFit()
                .cornerRadius(10)
                .padding()

            Section {
                WorldwideListRow(title: "Cases", number: viewModel.worldwide?.cases ?? 0)
                WorldwideListRow(title: "Today Cases", number: viewModel.worldwide?.todayCases ?? 0)
                WorldwideListRow(title: "Deaths", number: viewModel.worldwide?.deaths ?? 0)
                WorldwideListRow(title: "Today Deaths", number: viewModel.worldwide?.todayDeaths ?? 0)
                WorldwideListRow(title: "Recovered", number: viewModel.worldwide?.recovered ?? 0)
                WorldwideListRow(title: "Today Recovered", number: viewModel.worldwide?.todayRecovered ?? 0)
                WorldwideListRow(title: "Active", number: viewModel.worldwide?.active ?? 0)
                WorldwideListRow(title: "World Population", number: viewModel.worldwide?.population ?? 0)
                WorldwideListRow(title: "Affected Countries", number: viewModel.worldwide?.affectedCountries ?? 0)
            } header: {
                Text("Worldwide Yesterday")
            }
        }
        .listStyle(.insetGrouped)
    }
}

이렇게 보니 또 복잡하게 느껴진다. List 블록도 별개의 타입으로 만들 때가 되었다. WorldwideListView.swift라는 이름으로 새로운 SwiftUI View 파일을 추가하고 List 블록을 잘라내서 붙여 넣자. 과정은 앞서와 동일하다.

// WorldwideListView.swift

import SwiftUI

struct WorldwideListView: View {
    let cases: Int
    let todayCases: Int
    let deaths: Int
    let todayDeaths: Int
    let recovered: Int
    let todayRecovered: Int
    let active: Int
    let population: Int
    let affectedCountries: Int

    var body: some View {
        List {
            Image("covid")
                .resizable()
                .scaledToFit()
                .cornerRadius(10)
                .padding()

            Section {
                WorldwideListRow(title: "Cases", number: cases)
                WorldwideListRow(title: "Today Cases", number: todayCases)
                WorldwideListRow(title: "Deaths", number: deaths)
                WorldwideListRow(title: "Today Deaths", number: todayDeaths)
                WorldwideListRow(title: "Recovered", number: recovered)
                WorldwideListRow(title: "Today Recovered", number: todayRecovered)
                WorldwideListRow(title: "Active", number: active)
                WorldwideListRow(title: "World Population", number: population)
                WorldwideListRow(title: "Affected Countries", number: affectedCountries)
            } header: {
                Text("Worldwide Yesterday")
            }
        }
        .listStyle(.insetGrouped)
    }
}

struct WorldwideListView_Previews: PreviewProvider {
    static var previews: some View {
        WorldwideListView(
            cases: 0,
            todayCases: 0,
            deaths: 0,
            todayDeaths: 0,
            recovered: 0,
            todayRecovered: 0,
            active: 0,
            population: 0,
            affectedCountries: 0
        )
    }
}

이제 다시 WorldwideView.swift 파일로 가서 이를 사용하기 전에 잠시 생각해 볼 것이 있다.

Error

지금까지는 에러가 발생하면 콘솔에 프린트하는 것이 다였다. 하지만 콘솔은 개발 단계에서 디버깅으로 개발자만 볼 수 있는 것일 뿐, 실제로 사용하는 유저는 이를 보지 못한다. 우리가 WorldwideViewModel 타입에서 사용하는 코드 중 에러가 발생할 가능성이 있는 부분이 있기에 에러가 발생한다면 앱이 제대로 작동 중인지 아닌지, 왜 그런지, 에러가 발생했다면 어떤 에러인지를 유저에게 인지시켜 주어야 좋은 사용자 경험을 줄 수 있다.

WorldwideViewModel.swift 파일로 가서 아래와 같이 수정하자.

// WorldwideViewModel.swift

import Foundation

@MainActor
class WorldwideViewModel: ObservableObject {
    @Published private(set) var isLoading = false
    @Published private(set) var worldwide: Worldwide? = nil
    @Published private(set) var error: Error?

    init() {
        fetch()
    }

    func fetch() {
        isLoading = true
        worldwide = nil
        error = nil

        let networkManager = NetworkManager()

        Task {
            let result = await networkManager.download(from: .worldwide)

            isLoading = false

            switch result {
            case .success(let data):
                worldwide = data
            case .failure(let error):
                self.error = error
            }
        }
    }
}

Error? 타입인 @Publsihed가 붙은 error 프로퍼티를 정의했고, fetch() 메서드가 시작될 땐 nil을 할당하고, 만약 에러가 발생한다면 그 발생한 에러를 할당하도록 코드를 수정한 것이다. self.error = error에서 self는 error라는 이름이 두 개가 있기 때문에 어느 것인지 구별해 주기 위해 WorldwideViewModel에 속한 error라는 것을 지칭하기 위해 self 키워드가 붙었다. 그리고 한 가지 수정사항이 fetch() 메서드를 시작할 때에 worldwide = nil 또한 추가하였다. 이는 나중에 이 메서드를 반복해서 실행하게 될 때를 대비한 것이다.

이제 이 에러를 활용하기 위해 WorldwideView.swift 파일로 가서 아래와 같이 수정하자.

// WorldwideView.swift

struct WorldwideView: View {
    @StateObject private var viewModel: WorldwideViewModel

    init() {
        _viewModel = StateObject(wrappedValue: WorldwideViewModel())
    }

    var body: some View {
        if viewModel.isLoading {
            ProgressView()
        } else {
            if let worldwide = viewModel.worldwide {
                WorldwideListView(
                    cases: worldwide.cases,
                    todayCases: worldwide.todayCases,
                    deaths: worldwide.deaths,
                    todayDeaths: worldwide.todayDeaths,
                    recovered: worldwide.recovered,
                    todayRecovered: worldwide.todayRecovered,
                    active: worldwide.active,
                    population: worldwide.population,
                    affectedCountries: worldwide.affectedCountries
                )
            } else {
                if let error = viewModel.error {
                    Text(error.localizedDescription)
                        .foregroundColor(.red)
                        .multilineTextAlignment(.center)
                }
            }
        }
    }
}

Unwrap

if let이라는 구문은 옵셔널을 언랩하는 구문이다. 언랩이라는 것은 옵셔널에 값이 있는지 없는지 확인하는 과정으로, 만약 옵셔널이 nil이 아니라 값이 있다면 let 뒤에 붙은 상수(constant), 이 경우 worldwide에 할당하라는 의미이다. 만약 옵셔널이 값이 없고 nil이라면 else 블록 코드가 실행된다.

이렇게 작성하는 이유는 우리가 지금까지 계속 반복한 ?? 0, 즉 nil-coalescing보다 더 안전한 방법이기 때문이다. Nil-coalescing을 사용하였을 때 만약 값이 없어서 0이 된다면 유저로서는 그 진짜 값이 0인 것인지 옵셔널이기 때문인지 알 길이 없다. 따라서 옵셔널은 가급적 언랩한 후에 사용하는 것이 좋다.

위에서 작성한 전체 control flow를 자세히 보자. 먼저 isLoading이 true라면 ProgressView를 보여주고, 그렇지 않다면, 첫 번째 else 블록이 실행된다. 첫 번째 else 블록의 의미는, viewModel.worldwide를 업랩했을 때 값이 있다면 WorldwideListView를 보여주고, 그렇지 않다면, 그리고 viewModel.error를 업랩했을 때 값이 있다면 에러 메시지를 화면에 보여 주는 것이다.

Control flow를 짤 때에는 빠짐없이 모든 경우의 수를 다 다루었는지 논리적으로 잘 생각해 보아야 한다. 우리가 WorldwideViewModel에서 짠 코드를 생각해 보면, worldwide에 값이 없는 때는 에러가 발생했을 때뿐이다. 따라서 if let error ... 구문에서는 else를 다루지 않아도 안전하다고 하겠다.

이제 앱을 실행해 보면, 처음에 로딩 중일 때 로딩 화면이 나타났다가 데이터를 성공적으로 다운로드하고 나면 우리가 지금까지 만든 뷰가 모습을 드러내는 것을 확인할 수 있을 것이다. 에러가 발생한 경우에도 제대로 작동하는지를 확인하려면 인터넷(와이파이) 연결을 끊고 앱을 실행해 보면 된다.

아주 잘 작동한다.

NavigationView

그런데 만약 에러가 발생했다면 그다음은 어떻게 해야 할까? 위처럼 인터넷 연결 문제라면 제대로 연결되었을 때 다시 시도해 볼 수 있는 버튼이 필요할 것 같다. 이 버튼을 놓을 이상적인 곳이 나는 NavigationBar라고 생각한다.

NavigationView는 하위 계층으로 화면을 계속해서 이동하고, 상 하위 계층 간 이동이 용이한, 말 그대로 화면 간 이동에 좋은 뷰이다. 이 화면에서는 다른 화면으로 이동할 일은 없겠지만, 내비게이션을 떠나 멋진 제목 바(내비게이션 바)가 생기는 것만으로도 충분히 사용할 가치가 있다고 생각한다. 아이폰의 설정 앱이 이 NavigationView가 잘 사용된 예이다. NavigationView의 사용은 VStack 등과 같이 NavigationView 블록으로 감싸주면 된다.

// WorldwideView.swift

var body: some View {
    NavigationView {
        if viewModel.isLoading {
            ProgressView()
        } else {
            if let worldwide = viewModel.worldwide {
                WorldwideListView(
                    cases: worldwide.cases,
                    todayCases: worldwide.todayCases,
                    deaths: worldwide.deaths,
                    todayDeaths: worldwide.todayDeaths,
                    recovered: worldwide.recovered,
                    todayRecovered: worldwide.todayRecovered,
                    active: worldwide.active,
                    population: worldwide.population,
                    affectedCountries: worldwide.affectedCountries
                )
            } else {
                if let error = viewModel.error {
                    Text(error.localizedDescription)
                        .foregroundColor(.red)
                        .multilineTextAlignment(.center)
                }
            }
        }
    }
}

이 코드만으로도 내비게이션 뷰는 분명히 생겼지만 화면 상으로는 별 변화를 느끼기 힘들다. 내비게이션 바에 제목을 달아주기 위해서는 내비게이션 뷰 안에 있는 뷰에 모디파이어를 붙여 주어야 하는데, 우리의 경우 WorldwideListView와 Text 두 군데에 필요한 상황이다. 이런 경우 두 군데 다 붙여 주는 반복하기보다는 그 전체 뷰를 하나로 묶어 주는 Group을 사용하면 된다.

// WorldwideView.swift

var body: some View {
    NavigationView {
        if viewModel.isLoading {
            ProgressView()
        } else {
            Group {
                if let worldwide = viewModel.worldwide {
                    WorldwideListView(
                        cases: worldwide.cases,
                        todayCases: worldwide.todayCases,
                        deaths: worldwide.deaths,
                        todayDeaths: worldwide.todayDeaths,
                        recovered: worldwide.recovered,
                        todayRecovered: worldwide.todayRecovered,
                        active: worldwide.active,
                        population: worldwide.population,
                        affectedCountries: worldwide.affectedCountries
                    )
                } else {
                    if let error = viewModel.error {
                        Text(error.localizedDescription)
                            .foregroundColor(.red)
                            .multilineTextAlignment(.center)
                    }
                }
            }
            .navigationTitle("COVID Numbers")
        }
    }
}

.navigationTitle()이 바로 내비게이션 바에 제목을 붙여 주는 모디파이어다. 이제 정말 이 뷰가 완성되어 가는 것이 보인다.

이제 네비게이션 바에 버튼을 달아 주려면 .navigationBarTitle("COVID Numbers")아래에 코드를 추가해 주면 된다.

// WorldwideView.swift

...
.navigationTitle("COVID Numbers")
.toolbar {
    Button {
        viewModel.fetch()
    } label: {
        Label("Refresh", systemImage: "arrow.clockwise")
    }
}

.toolbar는 내비게이션 바에 버튼을 넣을 수 있는 모디파이어다. Button은 Image나 Text처럼 SwiftUI에서 기본적으로 제공되는 버튼 타입이다. Button은 그 버튼을 눌렀을 때 실행되는 action과 그 버튼의 모습을 나타내는 label로 주로 구성한다. 이 경우 action에는 WorldwideViewModel.fetch() 메서드를 호출하는 것이며, label에는 Label 타입을 사용하였다. 이제 앱을 실행해 보면 오른쪽 위에 리프레시 버튼이 생겼고 잘 작동하는 것을 확인할 수 있다.

SF Symbols

위에서 사용한 Label 또한 SwiftUI에서 기본적으로 제공하는 타입으로 아이콘과 텍스트를 나란히 놓을 수 있는 기능을 제공한다. 내비게이션 바에서는 Label을 사용하더라도 아이콘만 표시되는데, 그래도 Image를 사용하지 않고 Label을 사용하는 것은 시각장애인 기능을 켰을 때 Refresh라는 글을 읽어 주기 때문이다.

Label에서 systemImage: 파라미터에 보면 "arrow.clockwise"라고 적었는데 이건 도대체 어디서 온 것일까. 애플에서는 앱을 만들 때에 다양한 아이콘을 사용할 수 있도록 제공하고 있는데 그것이 바로 SF Symbols 앱이다. SF Symbols 앱은 여기서 다운로드할 수 있다.

사용법은 간단하다. SF Symbols 앱에서 원하는 아이콘을 찾은 다음 그 아이콘 이름을 복사하여 Label의 systemImage: 파라미터에 넣거나, Image(systemName:)에 건네주면 SwiftUI가 알아서 아이콘을 렌더링 한다.

DateFormatter

우리가 Part 1에서 만든 Worldwide 데이터 모델에서 아직까지 사용하지 않은 프로퍼티가 하나 있다. let updated: Int가 그것이다. 이걸 맨 마지막으로 미룬 이유는 Swift에서 날짜와 시간을 다루는 것이 꽤나 까다롭기 때문이다. 앱이란 것이 어느 한 국가에서만 사용하는 것이 아니다 보니 언어가 다르고, 날짜 표기법이 다르고, 달력 자체가 다른 등 다양한 변화가 생겨나기 때문이다. 우리가 보기엔 같은 영어를 사용하는 미국과 영국을 당장 비교해 보더라도 날짜 표기법에 있어서는 미국은 월-일-년 순이고, 영국의 경우는 일-월-년 순으로 날짜를 표기한다. 또 줄여서 쓸지, 정확하게 모두 다 표기할지의 문제도 있다. 시간 또한 군대처럼 24시간 표기를 할지, 12시간으로 표시할지 문제가 있다. 이런 복잡한 날짜와 시간을 Swift에서 쉽게 다룰 수 있게 도와주는 타입이 있는데 바로 DateFormatter 클래스이다.

우선은 updated 프로퍼티를 사용하기 위해서 WorldwideListView에 프로퍼티를 추가하자. 프리뷰 코드에도 동일한 프로퍼티가 필요하니 거기에도 추가한다.

//  WorldwideListView.swift

struct WorldwideListView: View {
    ...
    let updated: Int

    ...
}

struct WorldwideListView_Previews: PreviewProvider {
    static var previews: some View {
        WorldwideListView(
            ...
            updated: 0
        )
    }
}

그리고 마지막으로 WorldwideView에서 WorldwideListView를 놓은 곳에도 추가하면 에러가 모두 사라진다.

//  WorldwideView.swift

struct WorldwideView: View {
    @StateObject private var viewModel: WorldwideViewModel

    init() {
        _viewModel = StateObject(wrappedValue: WorldwideViewModel())
    }

    var body: some View {
        NavigationView {
            if viewModel.isLoading {
                ProgressView()
            } else {
                Group {
                    if let worldwide = viewModel.worldwide {
                        WorldwideListView(
                            ...
                            updated: worldwide.updated
                        )

        ...

    }
}

이제 이 정보를 뷰에 표시해 보자. 데이터가 업데이트된 시간은 데이터 자체는 아니므로, WorldwideListView의 리스트 내에 섹션 footer로 위치시키는 게 타당할 것으로 보인다. 다시 WorldwideListView.swift 파일로 가서 다음과 같이 footer를 추가하자.

// WorldwideListView.swift
    ...
} header: {
    Text("Worldwide Yesterday")
} footer: {
    HStack {
        Spacer()
        Text("Last updated")
        Text("\(updated)")
    }
}

이제 앱을 실행해 보면 리스트 footer 화면에 이런 식으로 표시된다. 시간과 관련되어 있으므로 정확히 같은 숫자는 나오지 않을 것이다.

Last updated 1,645,918,053,912

그런데 시간이 나올 줄 알았더니 그냥 긴 숫자가 나왔다. 도대체 이게 무슨 의미일까? API 문서를 찾아보아도 API를 어떻게 호출하는지, 쿼리가 어떤 게 있는지만 나오고 데이터가 의미하는 바에 대해서는 언급이 없다. 어쨌거나 저 숫자가 시간을 의미하는 것이라면, Date 오브젝트를 통해 앱에서 사용하는 시간으로 변환할 수 있을 것이다. WorldwideListView.swift 파일 body 프로퍼티 아래에 메서드를 하나 추가하자. 그리고 프리뷰에서 사용하기 위해 프리뷰 코드 updated: 0의 0을 위 숫자로 바꿔주자.

//  WorldwideListView.swift

struct WorldwideListView: View {
    ...

    func convertToDateString() -> String {

    }
}

struct WorldwideListView_Previews: PreviewProvider {
    static var previews: some View {
        WorldwideListView(
            ...
            updated: 1_645_918_053_912
        )
    }
}

코드에서는 숫자가 길어지면 자릿수를 구별하기 위해 콤마를 사용하지 못한다. 대신에 _를 콤마처럼 사용할 수 있다. 자동완성 기능을 통해 Date 오브젝트의 이니셜라이저를 보니 몇 가지 옵션이 있다.

(timeInterval:since:)라는 것은 어떤 특정한 시각으로부터 몇 초가 흘렀는지를 통해 시간으르 표현하는 것이다. (timeIntervalSince1970:)은 1970년 1월 1일 0시로부터, (timeIntervalSinceReferenceDate:)는 2001년 1월 1일 0시부터이다. 다행히도 옵션이 몇 가지 안되어서 시간이 많이 걸리지는 않을 것 같다. 우선은 2001년이 마음에 드니 이걸로 시도해 보자.

let date = Date(timeintervalSinceReferenceDate: updated) // Error

저기까지 작성했더니 에러가 발생한다.

Cannot convert value of type 'Int' to expected argument type 'TimeInterval' (aka 'Double')

Replace 'updated' with 'TimeInterval(update)'

우리는 Worldwide 모델을 정의할 때 update 프로퍼티를 Int로 정의했는데 Date 오브젝트에 사용하려면 TimeInterval 타입을 건네야 한다는 뜻이다. 그리고 TimeInterval은 Double과 동일하다고 한다. Xcode가 제시하는 해법대로 TimeInterval(updated)를 사용해도 되겠지만 코드의 복잡성 해소를 위해 처음부터 TimeInterval 타입으로 모델을 정의하자.

//  Worldwide.swift

struct Worldwide: Decodable {
    let updated: TimeInterval
    ...
}

WorldwideListView에서도 Int가 사용되고 있으므로 여기도 수정해 준다.

//  WorldwideListView.swift

struct WorldwideListView: View {
    ...
    let updated: TimeInterval

    ...
}

이제 계속해서 메서드를 작성하자.

// WorldwideListView.swift

func convertToDateString() -> String {
    let date = Date(timeIntervalSinceReferenceDate: updated)
    let formatter = DateFormatter()
    formatter.locale = .autoupdatingCurrent
    formatter.dateStyle = .medium
    formatter.timeStyle = .medium

    let string = formatter.string(from: date)
    return string
}

DateFormatter를 사용해서 로케일, 날짜 및 시간 표시 스타일을 설정하고, 그러고 나서 Date 타입을 String 타입으로 변환했다. dateStyle과 timeStyle에 대해서는 각자가 다른 옵션들을 시험해 보길 바란다. 이제 이 메서드에서 리턴하는 String 값을 뷰에서 사용하면 된다.

// WorldwideListView.swift
    ...
} footer: {
    HStack {
        Spacer()
        Text("Last updated")
        Text(convertToDateString())
    }
}

이렇게 수정하고 프리뷰를 보니 날짜가 이렇게 표시된다. 각자가 쓰고 있는 기기의 타임존 설정에 따라 표시 형식은 달라질 수 있다.

Last updated Jan 25, 54158 at 8:25:12 PM

그런데 뭔가가 이상하다. 54158년이라니. (timeIntervalSinceReferenceDate:)가 아니었나 보다. (timeIntervalSince1970:)으로 바꾸어 본다.

Last updated Jan 25, 54127 at 8:25:12 PM

54127년 아까랑 30년 차이밖에 안 난다. 당연하다. 2000년과 1970년은 30년 차이니까. 단위 자체가 다른듯하다. 우리가 쓰는 것은 초 단위인데, 저 데이터는 0.1초, 혹은 0.01초 단위인지도 모르겠다. 10으로도 나누어 보고, 100으로도 나누어 보다가 1000에서 드디어 찾았다. 오늘과 비슷한 날짜가 나왔다.

let date = Date(timeIntervalSince1970: updated / 1000)

// Last updated Jan 25, 2022 at 8:27:33 PM

1970년 1월 1일 0시가 기점은 맞았고, 0.001초 단위였던 것이다. 이제 날짜가 제대로 표시되기는 하지만 마음에 들지는 않는다. 언제 업데이트되었는지 정확한 시간보다는 몇 분 전, 몇 시간 전 이런 게 더 적절하게 정보를 주는 것이 아닐까 하는 생각이 든다. 다행히도 그렇게 어렵지 않게 바꿀 수 있다. DateFormatter 대신에 RelativeDateTimeFormatter를 사용하면 된다. 메서드를 아래와 같이 수정하자.

// WorldwideListView.swift

func convertToDateString() -> String {
    let date = Date(timeIntervalSince1970: updated / 1000)
    let formatter = RelativeDateTimeFormatter()
    formatter.locale = .autoupdatingCurrent
    formatter.dateTimeStyle = .named

    let string = formatter.localizedString(for: date, relativeTo: Date())
    return string
}

마찬가지로 dateTimeStyle은 각자가 옵션을 실험해 보자. 아무 파라미터도 없는 Date() 이니셜라이징은 이니셜라이징 되는 현재 날짜와 시각을 의미한다. 따라서 데이터에서 제시한 시각이 지금 현재와 얼마나 차이 나는지를 String 값으로 반환하는 메서드가 된다. 이제 이렇게 표시된다.

Last updated 47 minutes ago

우리가 원하는 바로 그 모습이다.

Refactoring

그런데 날짜를 시간으로 바꾸는 메서드가 뷰 코드에 있으니 별로 마음에 들지 않는다. 다른 곳에서도 써야 할지도 모를 기능이기도 하고 말이다. 그래서 리팩토링할 때가 온 것이다. Extensions라는 새로운 그룹을 만들고, 그 안에 TimeInterval+String.swift이라는 이름으로 새로운 Swift 파일을 생성하고 아래와 같이 작성하자.

//  TimeInterval+String.swift

import Foundation

extension TimeInterval {
    func relativeTimeString() -> String {
        let date = Date(timeIntervalSince1970: self / 1000)
        let formatter = RelativeDateTimeFormatter()
        formatter.locale = .autoupdatingCurrent
        formatter.dateTimeStyle = .named

        let string = formatter.localizedString(for: date, relativeTo: Date())
        return string
    }
}

코드 자체는 아까와 동일하다. 다만, 적절한 이름으로 바꾸었고, TimeInterval 타입에 직접 사용될 코드이기 때문에 updated 대신에 self 키워드가 들어갔다. 이제 WorldwideListView.swift로 가서 convertToDateString() 메서드는 아예 삭제하고, footer의 코드를 아래와 같이 수정하자.

// WorldwideListView.swift

    ...
} footer: {
    HStack {
        Spacer()
        Text("Last updated")
        Text(updated.relativeTimeString())
    }
}

깔끔하게 바뀐 데다가 이제 다른 데에서도 사용할 수 있게 되었다. 이런 것이 바로 리팩토링의 힘이다.

여기까지 해서 WorldwideView는 완성되었으므로, Part 5 또한 마무리 짓는다. 지금까지 진행된 파일은 여기에서 다운로드할 수 있다.

Part 5에서는 지금까지...

Image, HStack, VStack 등과 같은 SwiftUI에서 기본 제공하는 뷰 타입과 view modifier를 어떻게 사용하는지

List 타입을 통해 리스트형 데이터를 어떻게 표시하고, 뷰를 파트 단위로 어떻게 구성하는지

Optional 타입을 unwrap하는 것과 이를 SwiftUI 뷰에서 어떻게 사용하는지

NavigationView와 내비게이션 타이틀, 버튼을 어떻게 구성하는지

DateFormatter로 날짜와 시간을 어떻게 표시하는지

진행하였다. 이제 다음 Part 6에서는 각 국가를 표시하는 화면을 만들어 보면서 지금까지 사용한 정적인 List에서 동적인 데이터를 표시하는 List로 어떻게 변화하는지, 네트워킹 코드 재사용을 위해 Swift Generic을 활용하는 법 등이 진행될 것이다.