 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 2 Review Part 2: Learning more about SwiftUI

singularis7 2021. 8. 9. 21:06
반응형

지난 강좌에 이어 계속해서 UI 를 발전시키는 시간이다. 앞에서 해온 것과 마찬가지로 본래 코드의 문제점을 찾고 계속 개선해 보는 시간을 가져본다.

카드마다 서로 다른 이모티콘을 띄우는 방법은 없을까?

고민을 해보았다. 간단하게 구조체에 카드에 들어갈 content 변수를 추가한 후에 CardView 인스턴스를 생성할 때마다 서로 다른 content 값으로 초기화 해주면 되겠네! 라고 생각하고 위처럼 코드를 작성해 보았다. 하지만 이 방식 또한 문제점이 있다. 만약 카드가 단 4장이 아니라 수백장이었다면 하나 하나 content 값을 적어서 초기화 해주어야 하는가? 해보지 않아도 아주 귀찮아지는 방식이라는 것이 예측되는 바이다.

우리에겐 이 코드를 개선할 수 있는 비장의 자료구조가 존재한다. 가장 기본적이기도 하지만 가장 많이 사용되는 Collection 인 Array 를 사용하는 것이다. 간잔하게 Swift 에서 배열을 사용하는 예시를 살펴보자!

var emojis: Array<String> = ["🥇", "🥈", "🥉", "🏅"]
var emojis: [String] = ["🥇", "🥈", "🥉", "🏅"]
var emojis = ["🥇", "🥈", "🥉", "🏅"]
Generics
Generic code enables you to write flexible, reusable functions and types that can work with any type, subject to requirements that you define. 
For example, Swift’s Array and Dictionary types are both generic collections. You can create an array that holds 
Int values, or an array that holds String values, or indeed an array for any other type that can be created in Swift. 

Swift 에서 배열을 사용할 때 Int, String 등 다양한 종류의 자료형을 담을 수 있는 이유는 배열이 Generic 개념을 활용하여 정의 되었기 때문이다. 이게 정말 좋은게 여러가지 타입에 대응하는 클래스 나 코드를 정의해줄 필요가 없으니 코딩 노동시간을 확연하게 줄일 수 있다는 점이다. Swift 에서는 위와 같은 방식으로 사용하지만 Type Inference 기능으로 Swift가 알아서 타입 추론을 해주기 때문에 마지막 코드와 같이 적어도 잘 동작한다.

우리의 목표는 UI 를 정의하는데 발생하는 반복적인 작업을 컴퓨터에게 시키는 것이었으니 반복 논리만 존재하면 귀찮은 일을 개선할 수 있다. 불행이도 UI 사용함에 있어서 기존 방식의 For loop 은 사용할 수 없지만 SwiftUI 에서는 배열에 존재하는 값에 기반하여 UI View 를 생성해주는 함수 ForEach 를 제공한다.

ForEach
A structure that computes views on demand from an underlying collection of identified data.

View를 생성해주는 ForEach 함수를 활용하여 emojis 배열의 String 값을 content 로 갖는 View 를 동적으로 생성하였으나 애석하게도 오류가 뜨고 말았다. 오류는 다음과 같다.

'ForEach' requires that 'String' confirms to 'identifiable'

그렇다면 "식별할 수 있다" 라는 것은 무슨말일까? 모든 구조체는 id 와 같이 각 객체를 구분지을 수 있는 요소를 변수로 갖고 있을 때 식별 가능하다고 표현한다. 그렇다면 ForEach 가 Uniquely Identifiable 한 타입을 요구하는 이유는 무엇일까?

예를 들어 emoji 배열을 다시 정렬하거나 새로운 이모지를 추가 혹은 제거해야 하는 경우가 있을 것이다. 그럴 경우 배열의 변동사항에 맞추어 View 또한 조정해야 할 것인데 배열의 모든 항목을 고유하게 식별할 수 있어야만 각 배열값에 대한 변동사항을 그에 대응하는 View 에 반영시킬 수 있을 것이다.

이 상황 (String 이 identifiable 하지 않아서 View 를 생성할 수 없음) 을 해결할 수 있는 방법은 ForEach 함수의 입력으로 id 파라미터를  추가해준 후에 "\.self" 를 넘겨주는 것이다.

let hw = "Hello World"
print(hw) 	// Hello World
print(hw.self) 	// Hello World

self는 실제로 구조체 그 자체를 의미하는 변수이기도 하다. 즉 구조체가 담고 있는 내용을 기반으로 뷰를 식별하겠다는 의미이다. 이 방식은 문제점이 존재한다. Xcode에서 등장하는 오류는 피할 수 있겠지만 카드뷰에 담기는 contents에 중복이 발생할 경우 동일한 뷰로 식별되기 때문에 예를 들어 아래와 같은 상황을 지켜보자!

struct ContentView: View {
    var emojis = ["🥇", "🥈", "🥉", "🥇"]
    
    var body: some View {
        HStack {
            ForEach(emojis, id: \.self, content: { emoji in
                CardView(content: emoji)
            })
        }
        .padding()
        .foregroundColor(.red)
    }
}

 emojis 배열에 중복된 String 값이 존재한다. 이 상황에서 첫번째 카드를 탭하면 어떤 결과가 나오게 될까? 우리는 카드에 들어가는 contents 그 자체를 식별자로 사용했으니 0번째 카드와 3번째 카드는 어떠한 명령을 주던 동시에 동일하게 동작하게 된다. 즉 0번 카드를 뒤집으면 3번 카드도 동시에 동일하게 뒤집히는 결과가 발생한다는 것이다. 추후에 카드를 관할하는 로직을 구성할 때 이 아이디어를 기억해두어야 할 것 같은 느낌이 든다.

ForEach 의 장점이 동적으로 View 를 생성해 주는 것이었으니 이 장점을 살려서 카드를 추가하고 제거하는 버튼을 구현해보자!

코드를 약간 수정해 보았다. ForEach 함수 내에 emojis[0..<emojiCount] 와 같이 수정하면 emojis 배열 전체 중에서 인덱스 안에 들어온 범위 만큼만 읽어 오겠다는 의미이다. Swift 에서의 Array Slice 도 python 의 Array slice 와 비슷한 느낌의 문법인 것 같다. 

Swift 문법의 범위 표기법인 0..<emojiCount 는 수학적으로 [0, emojiCount) 구간과 동일하며 Swift 문법의 범위 표기법인 0...emojiCount 는 수학적으로 [0, emojiCount] 와 동일하다. 전자는 고전 프로그래밍 언어에서 배열의 인덱스를 사용하는 논리와 비슷한 것 같다고 생각하고 후자는 인간 수학적 범위에 가까운 방식이라는 생각이 들었다. 난 어쨋든 전자가 더 맘에 들고 익숙하다.

slice 를 활용하는 이유는 마지막에 해당하는 범위만 변수로 두고 버튼으로 변수값을 컨트롤할 수 있도록 구성한다면 버튼으로 View 를 컨트롤 해보고자 하는 우리의 목표에 한걸음더 다가갈 수 있을 것이다.

논리는 만들었으니 버튼은 어떻게 만들 것인가? 버튼도 다른 View 와 마찬가지로 View 처럼 동작하도록 설계 되었기 때문에 버튼과 Combiner 를 적절히 조합하면 UI 적으로 버튼을 배치시킬 수 있을 것이다.

하단부에 버튼을 추가하기 위해 새로운 Combiner 가 등장했다. VStack 은 HStack 과 반대로 세로 방향으로 View 를 결합시켜준다. 위 코드의 경우 2가지 예시로 활용해 보았다. 첫번째로 카드뷰 뭉치와 버튼을 세로 방향으로 결합시키기 위해서 사용했으며 두번째로 버튼 내부에 텍스트 배치를 세로로 수행하기 위하여 VStack 을 활용해보았다. 그리고 버튼의 동작까지 구현된 모습은 다음과 같다.

struct ContentView: View {
    var emojis = ["🥇", "🥈", "🥉", "🎖", "✈️", "🚀", "🧸", "🪄", "⏰", "📱", "⌚️", "💻", "🖥", "⚽️", "🏀", "🏈", "🥎", "⚾️", "🏓", "🤿", "🥊", "🎗", "🚁", "🚦"]
    @State var emojiCount = 3
    
    var body: some View {
        VStack {
            HStack {
                ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
                    CardView(content: emoji)
                }
            }
            HStack {
                addButton
                Spacer()
                removeButton
            }
        }
        .padding()
        .foregroundColor(.red)
    }
    
    func IncreaseView() {
        emojiCount = min(emojiCount+1, emojis.count)
    }
    
    func DecreaseView() {
        emojiCount = max(emojiCount-1, 0)
    }
    
    var removeButton: some View {
        Button(action: DecreaseView, label: {
            VStack {
                Text("Remove")
                Text("Card")
            }
        })
    }
    
    var addButton: some View {
        Button(action: IncreaseView, label: {
            VStack {
                Text("Add")
                Text("Card")
            }
        })
    }
}

 

잠시 개발과 다른 측면에서 어플리케이션을 바라볼 필요도 있다. 우리가 사용하는 앱들중에는 하단에 navigation 바가 존재하던 메뉴가 존제하던 우리가 만들고 있는 앱 처럼 "카드를 추가합니다" 혹은 "카드를 제거합니다" 라고 글로 쓰는 경우가 많지 않다. 일단 뭔가 복잡하다는 느낌이 강하게 든다. 대신에 위와 같은 단순한 동작들은 그림을 활용해보는게 좋은 방법일 수 도 있다. 

훌륭한 디자이너와 함께 직접 그림을 그릴 수도 있지만 Apple 이 이미 만들어 둔 SF-Symboles 을 활용해보는 것도 방법이 될 수 있다. Apple 이 사전에 만들어둔 무수히 많은 Symbole 이미지를 키워드에 기반하여 활용할 수 있으며 Apple Developer 사이트에서 이미지 검색 엔진 프로그램을 다운로드 받을 수 있다. Homebrew cask 로 받을 수 있다면 이를 활용하는 것도 방법일 수 있다.

SF-Symboles 앱에서 우리가 필요로 하는 더하기 심볼을 검색해보자!

이런 식으로 키워드에 기반하여 원하는 symbole 을 찾았다면 잠시 symbole 파일의 이름을 기억해두자!

외부 이미지를 활용할 경우 지난 시간에 살펴봤던 것 처럼 Xcode 프로젝트에 이미지 Asset 을 추가해야 사용할 수 있지만 SF-Symbole 의 경우 시스템에서 기본적으로 제공하는 이미지 셋이기 때문에 Image(SystemName: "...") 을 통해 손쉽게 불러올 수 있다.

사족을 하나 추가해보고자 한다. Spacer 를 사용하면 View 와 View 사이에 여백을 만들어주는데, 파라미터를 활용하여 여백의 정도를 수동으로 지정해 줄 수도 있지만 기본값 그대로를 활용할 수도 있다. 기본값이 주는 이점이 존재하는데 이게 참 재미있다. 앞서 SwiftUI는 아이폰 뿐만 아니라 iPad, Apple Watch 등에서도 UI 개발 도구로서 활용된다고 배웠다. 기본값 Space 를 활용하면 각 장치에 알맞게 알아서 여백을 지정해주는 기능이 있다고 한다. 심지어 지금 만들고 있는 View 조차도 바로 Apple Watch 에 도입시킬 수 있을 정도이다. 

우리의 앱의 문제점은 여기서 끝나지 않는다. 사용자 관점에거 버튼이 "빨강색" 으로 되었있다면 뭔가 부정적인 의미로 받아들일 가능성이 높다. 예를들면 신호등 색깔을 생각해볼 수도 있을 것이다 빨간색은 뭔가 경고를 하거나 동작을 제한하는 느낌이 강하게 든다. 

한편 또 생각해볼 수 있다. 나는 버튼 색을 지정해준적이 없는데 빨강색으로 나오는 것일까? 이에 대한 답변으로는 오히려 색을 지정해 주지 않았기 때문에 빨간색으로 등장한 것이라는 답변을 해주고 싶다.

버튼이 속한 상위 Combiner 를 살펴보자! body 를 양파껍질 벗겨내듯 분해해보면 최상위 combiner 인 VStack 에 foregroundColor 값을 Color.red 로 지정해주었기 때문에 내부에 있는 다른 모든 View 들이 이 속성을 이어 받은 것이다. 문제점을 해결하기 위해 foegroundColor(.red) 코드를 CardView 가 위치한 뷰 Combiner 로 옮겨주면 iOS 에서 공식적으로 지원하는 컨트롤 버튼 색상인 파란색으로 돌아오게 될 것이다.

이번엔 카드뷰에 집중해보자 본래 카드는 저렇게 길죽하게 늘어지지 않는다. 카드를 정말 카드 처럼 보여주고 싶다면 어떻게 layout을 구성하여 카드를 배치해야 하는 것인가? 해결책으로 그리드 를 활용해보는 방법이 있을 수 있다. 

LazyVGrid
A container view that arranges its child views in a grid that grows vertically, creating items only as needed.

그리드 시스템인데 뷰가 가로 방향으로 쌓여가는 방식의 그리드가 바로 LazyVGrid 이다. columns 파라미터를 통해 그리드를 깔아줄 열의 개수만큼 GridItem() 배열을 넘겨주면 열의 개수를 지정할 수 있다.

특정 열에 고정값을 주어서 각 열의 크기를 다르게 조절할 수 있다. 이 경우 GridItem 의 기본값이 flexible 이기 때문에 나머지 열은 자동으로 조절된다.

그리드를 활용해도 여전히 카드의 모습은 아니다. HStack 을 사용했을 때에는 카드가 Hstack 에서 사용할 수 있는 모든 공간을 가득 채워서 길쭉하게 늘어지는 형태를 보였다. LazyVGrid 는 가로 방향으로는 지정한 컬럼 만큼 나눠서 한가득 채우지만 세로 방향으로는 가능한 작게 만든다. 도대체 카드를 카드처럼 만드는 방법은 무엇일까?

Swift UI 에서 View 처럼 동작한다는 말을 계속 듣고있다. 이른바 View 처럼 행동하는 방법 중에는 어느 View를 보여줄 때 가로와 세로 비율을 지정해주는 기능이 존재하고 Aspect ratio 라는 개념으로 설명되고 있다. 영상의 가로 세로 비율이자 화면비라고 불리기도 한다. 사실 이 개념을 컴퓨터 그래픽스 수업에서 만난적이 있었는데 여기서 다시 만나니깐 정말 반갑다.

드디어 카드가 좀 카드 처럼 보이기 시작했지만 문제가 발생했다!!! 이놈의 문제는 끝이 없다. 카드가 증가하면서 화면의 세로 범위를 초과한 경우가 발생한 것이다. 심지어 보였던 버튼도 화면 밖으로 튕겨나가버렸다. 사실 해결하는 방법은 비교적 나와 가까운 곳에 있었다. 바로 ScrollView 를 활용하여 화면을 더 넓은 범위의 화면을 활용할 수 있도록 해주면 되는 것이다. 실제로 채팅을 하거나, 인터넷을 할 때 매번 내가 사용하고 있던 방식이기도 하다.

마지막으로 우리가 사용한 LazyViewGrid 에서 "Lazy" 하다는 의미는 무엇이었을까 공식 문서에는 아래와 같이 설명한다.

The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.

Stack 이 "lazy" 하다는 것은 스택 View 가 화면에 그려질 필요가 있기 전까지는 item을 생성하지 않는다. scroll view 를 활용했을때 메모리를 와 밀접한 연관성이 있을 것 같다. 그리고 공식 문서에는 친절하게 정리되어있었다 ㄷㄷ...

Creating Performant Scrollable Stacks
Display large numbers of repeated views efficiently with scroll views, stack views, and lazy stacks.

우리의 카드 처럼 많은 View 를 그려야 할 때 그냥 ScrollView 안에 VStack 혹은 HStack 을 추가하여 위 기능을 구현할 수 있지만 LazyStack 을 사용하여 같은 기능을 구현하면 더 효율적으로 동작할 수 있게 구현할 수 있다.

Stack views and lazy stacks have similar functionality, and they may feel interchangeable, but they each have strengths in different situations. Stack views load their child views all at once, making layout fast and reliable, because the system knows the size and shape of every subview as it loads them. Lazy stacks trade some degree of layout correctness for performance, because the system only calculates the geometry for subviews as they become visible.
When choosing the type of stack view to use, always start with a standard stack view and only switch to a lazy stack if profiling your code shows a worthwhile performance improvement.

마지막 문장으로 처음에는 표준 stack을 통해 구현하면서 Profiling 을 통해 성능을 확인해보다가 lazy stack 을 활용하였을 때 유의미한 성능 개선이 발생된다면 사용하라고 권장하고 있다.

반응형