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

SwiftUI로 코로나 현황 앱 만들기 Part 7 - CountriesView List Row

May 8, 2022

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

Part 6에서는 CountriesView를 만들면서 같은 데이터 형식이 반복되는 상황에서 리스트에 이를 동적 표시하고, 그 과정에서 제네릭을 활용하여 NetworkManager가 다루는 데이터 모델을 추상화하여 여러 데이터 모델에도 사용이 가능하도록 변경하였다. Part 6까지의 코드는 여기에서 다운로드할 수 있다.

이번 파트에서는 Part 6에서 만든 리스트의 줄(row) UI를 새롭게 꾸며볼 예정이다. 이 과정에서 네트워크를 통해 데이터를 다운로드해 이미지로 표시하는 방법도 함께 다룰 것이다. 그리고 마지막으로 리스트에 있는 많은 국가를 유저가 일일이 스크롤 하여 찾지 않더라도 검색하여 찾을 수 있도록 검색 기능을 추가해 보겠다.

CountriesListRow

리스트의 줄 UI를 별도의 뷰로 정의하여 재사용하는 것은 Part 5에서 WorldwideView에서 WorldwideListViewWorldwideListRow를 만든 것과 동일한 방법이다. 우선 Countries 그룹 안에 CountriesListRow.swift 이름으로 SwiftUI 파일을 생성하고 아래와 같이 작성한다.

//  CountriesListRow.swift

import SwiftUI

struct CountriesListRow: View {
    let name: String
    let continent: String
    let population: Int
    let url: String

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: URL(string: url)) { image in
                image
                    .resizable()
                    .scaledToFill()
            } placeholder: {
                Color.secondary.opacity(0.5)
            }
            .frame(width: 70, height: 50)
            .cornerRadius(8)
            .shadow(radius: 4)

            VStack(alignment: .leading, spacing: 8) {
                Text(name)
                    .font(.headline)

                Label(continent, systemImage: "mappin.and.ellipse")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
            .padding(.leading, 8)

            Spacer()
        }
        .padding()
    }
}

그리고 프리뷰 코드도 아래와 같이 수정한다.

// CountriesListRow.swift

struct CountriesListRow_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            CountriesListRow(
                name: "S. Korea",
                continent: "Asia",
                population: 51_343_185,
                url: "https://disease.sh/assets/img/flags/kr.png"
            )

            CountriesListRow(
                name: "S. Korea",
                continent: "Asia",
                population: 51_343_185,
                url: "https://disease.sh/assets/img/flags/kr.png"
            )
            .preferredColorScheme(.dark)
        }
        .previewLayout(.sizeThatFits)
    }
}

일단 프리뷰 코드부터 살펴보자. 지금까지는 프리뷰 화면을 기기의 전체 화면을 사용하였는데 이번 CountriesListRow처럼 화면에서 작은 부분을 차지하는 프리뷰는 굳이 화면 전체를 쓰기보다는 그 크기에 맞게 프리뷰를 사용하면 공간 활용성이 더 높아진다. .previewLayout(.sizeThatFits) 부분이 바로 프리뷰를 그 타입의 프레임 크기만큼만 나타나도록 하는 모디파이어다. .sizeThatFits 부분은 .을 통해서 다른 옵션들을 확인할 수 있고 그 부분은 여러분에게 맡긴다.

preferredColorScheme(.dark)는 프리뷰 화면을 다크 모드로 보여준다. 요즈음은 앱이나 웹사이트를 만들 때 라이트 모드와 다크 모드 모두 지원해야 하는 것은 기본이기 때문에 다크 모드일 때 앱이 어떻게 보일지도 신경 써서 만들 필요가 있다. 일일이 시뮬레이터를 켜고 설정을 바꿔가면서 확인하기보다는 이 모디파이어를 사용하면 프리뷰를 통해 간편하게 확인할 수 있다. 위 코드에서는 라이트 모드와 다크 모드 모두를 보여주기 위해서 Group으로 묶어서 두 개의 프리뷰를 만들고, 그중 하나는 다크 모드를 적용한 것이다.

Preview Layout

이제 본래의 코드를 확인하자. 뷰 코드에서는 우리가 그 뷰에서 사용할 데이터를 프로퍼티로 받고, 뷰 내에서는 VStackHStack의 조합을 통해 적절히 화면에 배치한 것이다. 그리고 제일 상위 HStack에서는 UI를 왼쪽으로 정렬하기 위해 끝에 Spacer()를 활용하였다.

뷰 코드는 대부분 이제 익숙할 것으로 생각이 드는데 다만, 우리가 처음 사용하는 AsyncImage에 대해서는 따로 설명이 필요해 보인다.

AsyncImage

iOS15 이전에는 SwiftUI(뿐만 아니라 UIKit에서도)에는 URL을 통해 이미지를 렌더링 하는 타입이 존재하지 않았다. 그래서 그런 타입이 필요하다면 커스터마이징을 통해 직접 만들어야 했는데, 네트워킹과 상태 관리를 해줘야 해서 꽤나 어려운 작업이었다. 물론 Kingfisher와 같은 외부 라이브러리를 통해 해결하는 방법도 있었다.

하지만 iOS15로 오면서 SwiftUI에 드디어 그런 작업을 해주는 기본 타입이 생겼는데 그것이 바로 AsyncImage이다. AsyncImage의 기본적인 형태는 아래와 같다.

AsyncImage(url: URL(string: "https://www.example.com/example.png"))

이전에 네트워킹 편에서 URL(string:)은 옵셔널인 URL?로 이니셜라이징 된다는 것을 보여준 적이 있는데 이 AsyncImage 타입은 다행히도 옵셔널 타입인 URL?로 이니셜라이징이 가능하다. 그래서 언랩과 같은 복잡한 과정이 생략되고 이렇게 간단한 형태로 목적을 달성할 수 있게 되었다.

이제 우리 코드에서 사용한 AsyncImage를 자세히 보자.

AsyncImage(url: URL(string: url)) { image in
    image
        .resizable()
        .scaledToFill()
} placeholder: {
    Color.secondary.opacity(0.5)
}
.frame(width: 70, height: 50)
.cornerRadius(8)
.shadow(radius: 4)

AsyncImageImage와 달리 여기에 직접적으로 .resizable()과 같은 모디파이어를 사용할 수 없기 때문에 뷰모디파이어를 적용하려면 AsyncImage(url:scale:content:placeholder:)를 사용하여 content 클로저에서 반환하는 Image 인스턴스에다가 적용해야 한다. 그래서 위와 같은 형태가 되었고, 이미지가 로딩되기 전까지 사용할 플레이스홀더도 간단한 형태로 사용하였다. 다만, scale은 여기서 사용하지 않기에 생략하였고 생략하는 경우 기본값은 1이다.

이 외에 어떤 부분이 적용 가능한지는 공식문서 등을 참조하여 여러분이 직접 시도해 보기 보란다.

이제 이렇게 만든 CountriesListRow를 리스트에서 사용하도록 하자. CountriesView.swift 파일로 가서 List 부분을 아래와 같이 수정한다.

List {
    ForEach(viewModel.countries) { country in
        CountriesListRow(
            name: country.country,
            continent: country.continent,
            population: country.population,
            url: country.countryInfo.flag
        )
    }
}
.listStyle(.plain)

이제 앱을 실행해 보면 리스트의 각 줄이 국가에 맞게 잘 표시되는 것을 확인할 수 있다.

List Row

검색 기능 구현하기

이번 파트에서 마지막으로 할 것은 나라 이름으로 리스트를 검색하는 기능을 구현하는 것이다. WorldwideView에서와는 달리 CountriesView의 리스트에는 데이터 개수가 200개가 넘기 때문에 유저가 검색 없이 원하는 데이터를 찾으려고 한다면 굉장히 많은 시간이 소요될 수 있다. 따라서 이런 경우에는 좋은 사용자 경험을 위해 검색 기능이 필수적으로 요구된다고 하겠다.

검색 기능을 위해서는 두 가지 측면에서 구현해 주어야 하는데, 하나는 UI 부분이고 다른 하나는 검색과 일치하는지를 필터링하는 로직 부분이다. UI 부분은 iOS15로 오면서 SwiftUI에 기능이 추가되었기 때문에 의외로 간단하게 해결이 가능하기도 하고 어차피 로직 파트가 먼저 해결되어야 하기도 하기에 로직 부분을 먼저 진행하도록 하자.

검색 로직

검색 기능을 구헌 하기 위해서는 유저가 검색하는 단어를 메모리에 저장하고, 그 단어가 바뀔 때마다 UI에도 변화를 주어야 한다. 이런 기능을 위해서는 역시 @Published가 알맞을 것이다. 검색 로직도 로직이므로 뷰모델에서 기능을 구현하는 것이 적당할 것으로 보인다. CountriesViewModel.swift 파일로 가서 검색어가 저장될 @Published 프로퍼티를 아래와 같이 추가한다.

// CountriesViewModel.swift

@MainActor
class CountriesViewModel: ObservableObject {
    ...

    @Published var searchTerm = ""

    ...

다음은 searchTerm이 변경될 때마다 실제로 데이터 모델 리스트를 필터링해야 한다. Combine 프레임워크를 사용하여 조금 더 세세하게 구현할 수도 있겠지만, 이 경우 searchTerm이 바뀔 때마다 네트워킹이 유발되거나 하는 것이 아니라 단순히 아이폰 내에서 약 200개 정도의 데이터 모델을 비교하여 찾는 것에 불과하기 때문에 조금 더 간단한 방법으로 컴퓨티드 프로퍼티를 사용하여 구현해도 충분할 것으로 보인다. searchTerm 바로 아래에 아래와 같이 컴퓨티드 프로퍼티를 추가한다.

// CountriesViewModel.swift

@MainActor
class CountriesViewModel: ObservableObject {
    ...

    @Published var searchTerm = ""

    var searchedResults: [Country] {
        countries.filter {
            searchTerm.isEmpty
            || $0.country.lowercased().contains(searchTerm.lowercased())
        }
    }

    ...

filter 메서드는 Array 타입에 적용할 수 있는 메서드로, Array의 각 데이터를 주어진 조건에 따라 필터링하여 다시 필터링 된 값만을 Array 형태로 다시 반환하는 Swift 내장 메서드이다. 여기서는 countries 프로퍼티를 주어진 조건에 따라 필터링하여 다시 searchResults 프로퍼티로 반환한다.

주어진 조건은 클로저 내에 우리가 작성하는데, 이 경우 아랫부분이 조건에 해당한다.

searchTerm.isEmpty || $0.country.lowercased().contains(searchTerm.lowercased())

조건을 적용한다는 것은 리스트 안의 개개의 데이터 모델을 조건에 일치하는지 하나하나 확인하여 조건이 true 값이면 반환되는 리스트에 포함하고, false면 반환되는 리스트에서 제외하는 것이다. 즉 조건을 적용하였을 때 모든 데이터가 true면 전체 데이터가 다 포함되고, 모두 false면 빈 리스트가 반환되는 형식이다.

필터링 로직을 더 자세히 살펴보자. ||는 OR에 해당하기 때문에 왼쪽 부분 searchTerm.isEmptysearchTerm이 아무것도 없이 빈("") 값이면 항상 true가 되기 때문에 countries 프로퍼티의 전체 리스트가 그대로 반환하게 된다. searchTerm이 빈 값이 아니라면, $0.country.lowercased().contains(searchTerm.lowercased())가 참인지 확인하게 된다. 이처럼 검색을 안 하는 상태, 즉 검색어가 비어 있을 때에는 전체 리스트를 그대로 보여 주는 것이 논리적으로 타당해 보인다.

$0countries 리스트 안에 있는 개별 Country 데이터를 지칭하기 때문에 $0.country는 결국 국가명이 된다. 이를 소문자(lowercased())로 변환하고, contains 메서드를 사용하였는데, contains 메서드는 이름 그대로 어떤 값이 포함되었는지를 확인하여 Bool 값을 반환하는 메서드이다. 따라서 국가명이 searchTerm에 있는 단어를 포함하면 true 값이 반환되는 것이다. 컴퓨터는 대문자와 소문자를 각기 다른 것으로 인식하기 때문에 비교 대상 둘 다 대문자로 바꾸어 주거나 또는 소문자로 바꾸어서 대소문자에 상관없도록(case insensitive) 해 주어야 한다. 위의 경우는 둘 다 소문자로 변환하였다.

이렇게 해서 필터링 기능은 모두 구현되었다. 이제 이렇게 필터링 한 결과를 UI에서 사용하도록 하자. CountriesView.swift 파일로 가서 List가 사용하는 데이터, 즉 ForEach에 건네주는 데이터를 아래와 같이 변경한다.

// CountriesView.swift

struct CountriesView: View {
    ...
    List {
        ForEach(viewModel.searchedResults) { country in
    ...

이대로 앱을 실행하면 이전과 아무런 변화가 없어 보인다. 필터링 기능은 구현하였지만 이 기능이 아직 UI와 연결되지 않아서 작동하지 않고 있기 때문이다. 이제 UI에 검색 기능을 구현할 차례이다.

.searchable

iOS 15 이전에는 SwiftUI에 검색창을 구현하는 기능이 따로 없어서 처음부터 다 만들었어야 했지만, iOS 15가 나오면서 내비게이션 바에 검색창을 넣는 것이 아주 간단해졌다. 우선 코드부터 작성하도록 하자. CountriesView.swift 파일로 가서 List의 마지막에 아래와 같이 코드를 작성한다.

// CountriesView.swift

struct CountriesView: View {
    ...

    List {
        ...
    }
    .listStyle(.plain)
    .searchable(text: $viewModel.searchTerm)
    
    ...

너무나 간단하게도 위와 같이 검색어를 담고 있는 프로퍼티를 바인딩으로 건네 주면서 .searchable 단 한 줄만 작성하면 내비게이션 바에 검색창이 생성된다.

UI 검색창에 단어는 searchTerm 프로퍼티와 바인딩 되어서 단어가 바뀔 때마다 searchTerm이 바뀌고, 이는 searchedResults 컴퓨티드 프로퍼티를 변경하게 되어, 최종적으로 List를 변화시키게 된다. .searchable를 전체 NavigationViewGroup에 붙이지 않고 List에 붙인 이유는 에러가 발생했을 때는 검색 기능이 필요 없기 때문이다.

여기까지 수정한 김에 마지막으로 데이터 다시 가져오는 리프레시 기능을 위해 CountriesView.swift 파일을 아래와 같이 수정하자.

struct CountriesView: View {
    ...
    Group {
        ...
    }
    .navigationTitle("Countries")
    .toolbar {
        Button {
            viewModel.fetch()
        } label: {
            Label("Refresh", systemImage: "arrow.clockwise")
        }
    }

이제 앱을 실행해 보면, 처음에는 내비게이션 바에 검색창이 숨어 있다가 살짝 밑으로 드래그하면 나타나고, 검색 기능도 제대로 작동하는 것을 확인할 수 있다.

Search

Part 7은 여기까지 해서 마치겠다. Part 7에서는 지금까지...

하였다. Part 8에서는 Part 7에서 구현한 줄을 탭 하여 새로운 화면으로 이동하는 기능과 그 새로운 화면에 각 국가별 세부 데이터를 표시하는 기능을 구현해 볼 것이다.