 Apple Lover Developer & Artist

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

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 6 Review Part 2: Protocols Shapes

singularis7 2021. 8. 26. 00:36
반응형

이전 강좌에서 배운 사항을 실제로 Demo를 통해 이해해보는 시간이다. 가장 좋은 방법은 SwiftUI에서 사용되는 ScrollView나 LazyVGrid의 인자로 View가 들어가는 원리에 대해 파악해보는 것이다.

구현 목표화면 크기에 맞추기 위해 모든 카드를 올바른 크기로 유지하는 View Combiner를 정의하는 것이다.

새로 구현할 Combiner가 이미 구현되었다고 가정하고 사용하고 있다. 주석처리한 코드의 역할을 수행하는 Combiner이며 아직 정의되지 않았기 때문에 오류가 뜨고 있음을 확인할 수 있다. 지금바로 새로운 AspectVGrid를 만들어보도록 하자!

AspectVGrid라는 새로운 Swift 파일을 생성하였다. 우측에 정의한 것처럼 이 View Combiner를 사용할 때 필요할 것으로 기대되는 자료를 담기 위해 var body를 제외한 3개의 프로퍼티를 추가하였다.

  • items : Memorize 게임에서 카드를 담기 위해 사용하는 배열이다. 실제로 어떤 타입이 올지 모르기 때문에 제네릭으로 정의되었다.
  • aspectRatio : 카드의 종횡비를 결정하기 위해 사용된다.
  • content : View Combiner 내부에 그려질 View 함수를 받는 용도로 사용된다. View마다 하나의 카드 정보를 받아서 View를 만들어야 하기 때문에 ItemView 제네릭을 새롭게 정의했으며 View 프로토콜을 만족해야 한다는 제약사항을 추가해줬다.

위와 같이 정의하면 body 부분에서 "Hello World"만 렌더링하고 있다는 점만 제외하면 실제로 동작하는 코드가 만들어진다. 카드 개수에 반응하여 컬럼의 수를 조절하도록 다음과 같이 프로그래밍 하였다.

private func adaptiveGridItem(width: CGFloat) -> GridItem {
    var gridItem = GridItem(.adaptive(minimum: width))
    gridItem.spacing = 0
    return gridItem
}
    
// Spacing이 존재하지 않음을 가정한 코드이다.
private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
    var columnCount = 1
    var rowCount = itemCount
    
    repeat {
        let itemWidth = size.width / CGFloat(columnCount)
        let itemHeight = itemWidth / itemAspectRatio
        
        if CGFloat(rowCount) * itemHeight < size.height {
            break
        }
            
        columnCount += 1
        rowCount = (itemCount + (columnCount - 1)) / columnCount
        
    } while columnCount < itemCount
        
    if columnCount > itemCount {
        columnCount = itemCount
    }
        
    return floor(size.width / CGFloat(columnCount))
}

LazyVGrid 레이아웃을 사용하면 각 카드 사이에 Default Spacing 이 포함된다. 컬럼의 수를 계산해주는 위 코드는 여백이 없을때 Geometry Reader로 계산되는 공간의 크기를 전제로 설계되어있기 때문에 다음과 같은 코드가 탄생하였다.

var body: some View {
    GeometryReader { geometry in
        let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
        LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
            ForEach(items) { item in
                content(item).aspectRatio(aspectRatio, contentMode: .fit)
            }
        } 
    }
}

코드는 다음과 같이 실행된다.

코딩에서 있어서 아주 좋은 습관 하나를 소개해주셨다!

var body: some View {
    GeometryReader { geometry in
        VStack {
            let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
            LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
                ForEach(items) { item in
                    content(item).aspectRatio(aspectRatio, contentMode: .fit)
                }
            }
            Spacer()
        }
    }
}

위 UI 코드에서 VStack과 Spacer가 없었다면 GeometryReader와 LazyVGrid만 존재했을 것이다. GeometryReader는 아주 Flexible한 View여서 자기가 확보할 수 있는 공간을 적극적으로 점유하려할텐데 LazyVGrid는 Combiner 내부 컨텐츠의 크기에 맞추려는 경향이 있어서 덜 Flexible한 View이다. 

카드의 개수에 상관없이 LazyVGrid View가 화면 상단에 붙어서 카드를 렌더링 할 수 있도록 코드에 명시를 해주면 코드도 더 명확해지고 코드로 다른 사용자와 소통하기 더 좋아질 것이다. VStack과 Spacer() 코드가 바로 그 역할을 수행하고 있다.

다음은 ViewBuilder에 관하여 살펴볼 것이다. 강의 5에서도 살펴보았지만 ViewBuilder는 프로토콜이 아니어서 ~처럼 동작하거나 제약과 이득을 보는 개념이 아니다. 생긴 것 그대로 @ViewBuilder이기 때문에 컴파일러가 함수를 ViewBuilder로 해석하도록 하기위해 추가된 키워드이다. list of view는 어디에 사용되어야 할까? 예시를 한번 만들어보자!

이전에는 CardView 하나만 AspectVGrid의 content로 들어갔지만 위와 같이 복잡한 조건문이 들어가자 바로 오류를 출력하고 있다. 사유는 content 파라미터에 들어가있는 코드가 ViewBuilder 함수가 아니기 때문이다. 어떻게 내부에 들어간 함수가 ViewBuilder로 해석될 수 있도록 처리할 수 있을까? 

이 문제를 해결하기위해 AspectVGrid 구조체에 생성자를 정의해보았다. 파라미터로는 AspectVGrid가 갖고 있는 프로퍼티를 설정할 수 있도록 작성되었다. content 클로져를 받는 부분에서 "escaping 클로져를 넣어줘야 할 곳에 non-escaping 클로져를 넣어주고 있어!"라고 오류를 내보이고 있다! 이게 무슨 의미일까? 

init의 파라미터로 전달된 content 클로져는 내부에 정의된 context를 탈출한다. 왜냐하면 struct의 content property에 클로져가 할당되고 추후에 var body를 구성할 때 content 클로져가 호출되기 때문이다. 

위와 같은 식으로 동작하는 경우 해당 클로져를 받는 파라미터 앞에 @escaping 키워드를 붙여줘야 한다.

왜? 이렇게 동작하는지 의문을 가져볼 수 있다. 

  1. 생성자를 호출하는 사람들이 자신이 넘겨주는 클로져가 View를 만들때 사용된다는 것을 알게 하기 위해서이다.
  2. 컴파일러가 알고싶어한다. 만약 당신이 파라미터로 넘어온 클로져를 유지하지 않을때 escape하는 경우 이 함수를 inline 처리할 수 있게 되며 바로 실행시키면 되기 때문에 별도의 메모리를 할당할 필요가 없어진다.

이전에 배웠지만 class는 struct 혹은 enum (value type)과 다르게 reference type으로 동작하며 특정 메모리 공간을 가리키고 있으며 function type을 갖는 closure 또한 마찬가지로 reference type이기 때문에 메모리의 heap 공간에 존재하며 reference type을 담고 있는 변수는 heap 공간의 해당 메모리를 가리키고 있는 포인터이다.

따라서 컴파일러가 메모리에 새로운 공간을 생성하지 않고 inline 하기 위해서는 escape 되는지 알아야하기 때문에 @escaping 키워드를 명시하게 되는 것이다. 

앞서서 단일 View가 아닌 조건문이 포함된 복잡한 함수가 content로 넘어가면서 오류가 발생했었다. 이에 대하여 컴파일러에게 ViewBuilder로 인식하도록 설명해주는 방법은 다음과 같이 @Viewbuilder 를 파라미터 앞에 붙여주는 것이다.

@ViewBuilder에 대한 또다른 용도를 소개해본다. 이렇게 보니 AspectVGrid의 body 부분 코드가 참 더러운 것 같다. 내부에 있는 View 클로져를 외부에 별도의 함수로 정의해보자!

아래의 cardView 함수에서 @ViewBuilder 키워드를 빼버리면 함수 내부에 return 문도 존재하지 않으며 컴파일러가 View 처럼 생각하지 않기 때문에 무수한 오류가 나를 반겨준다.... 다시말해 함수 내부에 구현된 내용을 Viewbuilder 로 이해하도록 컴파일러에게 설명하여 별도의 오류가 생기지 않도록 만들어주는 역할을 한다. 하지만 paul hegarty 교수는 후자와 같은 방법을 선호하지 않는다고 한다.

반응형