SwiftUI Lecture5-6 정리

이번 강의에서는 주로 layout에 대해서 배웠다. 먼저 layout이 그려지는 방식은 다음을 따른다.

  1. 가능한 공간이 주어진다.
  2. 각 내부 view들 별로 본인의 사이즈를 결정한다.
  3. 컨테이너 뷰에서 각 내부 view들에게 공간을 할당한다.
  4. 컨테이너 뷰 역시 다른 컨테이너 뷰 속에 들어있을 수 있기 때문에 1-3 반복

이때 각 view별로 공간을 할당하는 방식이 조금씩 다르다. 이를 알아보도록 하자.

1. HStack & VStack

least felxible view를 먼저 공간 할당을 하는 방식으로 동작한다. 그 후 가능한 공간을 줄인다. 예시로 flexible 정도를 나타내면 다음과 같다.

생각해보면 RoundedRectangle의 경우 남아있는 모든 공간을 차지한다는 것을 기억할 것이다. 만약 very flexible view가 포함될 경우 그 stack은 마찬가지로 very flexible이 된다.

.layoutPriority(value)를 변경해주면 priority를 임의로 부여할 수 있다. 예를들어 Text View가 여러개 있을 때 특정 Text의 공간을 먼저 확보하도록 할 때 사용될 수 있다. 기본적으로 모두 0으로 설정되어있다.

2. LazyHStack & LazyVStack

visible 하지 않은 view는 draw하지 않는 lazy stack이다. 달리 얘기하자면 화면 상에 드러나지 않은 view들은 불러오지 않고 있다가 화면에서 등장하면 그 때 불러와서 view를 그리는 방식이다. 기존의 HStack과 VStack은 모든 원소를 불러왔지만 lazy는 늦게 불러온다는 점에서 차이가 있다.

view를 모두 화면에 띄우는 것이 아니기 때문에 일정하게 각각의 크기를 조정해줘야한다. 따라서 not flexible하다.

그 외에도 LazyHGrid, LazyVGrid는 최대한 적게 공간을 차지하도록 되어있다는 것을 기억하자. List & Form & OutlineGroup 이라는 것도 존재하는데 really smart VStack이라고 불린다. 이는 추후에 다시 다룰 예정이다.

3. GeometryReader View

우리는 View를 작성할 때 그 공간의 Size에 맞게 폰트 크기라던지 여러 설정들을 변경하고 싶을 수 있다. 그럴 때 사용하는 것이 GeometryReader View이다.

size에 대한 정보를 포함하고 있는 geometry라는 변수를 사용해서 그 size 정보를 이용해서 여러 요소들을 설정해준다. GeometryReader{} 라는 view 속에서 사용을 하면 된다. .size는 CGSize 타입으로 widthheight 정보를 포함하고 있다. 그 외에도 safe area를 알려주는 역할도 수행하는데 이는 노치와 같이 그리면 잘리는 영역을 빼고 그릴 수 있도록 도와준다.

이를 활용하여 우리의 CardView를 다시 그리면 다음과 같다. font 함수에서 geometry size 변수를 활용한 것을 볼 수 있다. 중간의 Pie 함수는 Pie모양 도형을 그려주는 함수로 추후에 다룰 예정이니 가볍게 이해하고 넘어가자.

struct CardView: View {
    let card: EmojiMemoryGame.Card
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
                if card.isFaceUp {
                    shape.fill().foregroundColor(.white)
                    shape.strokeBorder(lineWidth: DrawingConstants.lineWidth)
                    Pie(startAngle: Angle(degrees: 270), endAngle: Angle(degrees: 20)).padding(5).opacity(0.5)
                    Text(card.content).font(font(in:geometry.size))
                } else {
                    shape.fill()
                }
            }
        }
    }
    
    private func font(in size: CGSize) -> Font {
        Font.system(size: min(size.width, size.height) * DrawingConstants.fontScale)
    }
    
    private struct DrawingConstants {
        static let cornerRadius: CGFloat = 10
        static let lineWidth: CGFloat = 3
        static let fontScale: CGFloat = 0.7
    }
}

여기서 한가지 포인트는 cornerRadius와 같은 특정 상수값들을 따로 빼서 DrawingConstants로 선언해서 사용하는 것을 확인할 수 있다. 이렇게 해주는 이유는 사용하는 기기 환경 등에서 따라서 값을 변경해야할 때 일괄적으로 처리하기 위함이다.

4. @ViewBuiler

만약 특정 상황에서는 카드 View를 보여주고싶고 특정 상황에서는 카드를 안보이게 하고싶다면 어떻게 해야할까? 가장 먼저 떠오르는 것이 if else를 활용하는 것이다. 하지만 실제로 if else를 body 내부에 표현하면 오류가 난다.

이 때 사용하는 것이 @ViewBuilder 속성이다. 이는 내부에 여러 View들이 나열될 수 있다는 것을 나타내주는 것으로 어떤 함수 앞에 이를 표시하면 해당 함수 내에서는 여러 View들의 list를 나타낼 수 있다.

이를 활용해서 우리의 EmojiMemoryGameView를 다시 표현하면 다음과 같다. AspectVGird라는 View를 이용해 CardView를 각각 그리려고 하는데 카드가 매치가 되었다면 해당 카드를 그리지 않고 안보이게 하도록 구현하였다. 이 때 cardView 함수 위에 @ViewBuilder 속성을 활용하여 if else 구문과 함께 여러 view들을 나열한 것을 확인하자.

struct EmojiMemoryGameView: View {
    @ObservedObject var game: EmojiMemoryGame
    var body: some View {
        AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
            cardView(for: card)
        }
        .foregroundColor(.red)
        .padding(.horizontal)
    }
    
    @ViewBuilder
    private func cardView(for card: EmojiMemoryGame.Card) -> some View {
        if card.isMatched && !card.isFaceUp {
            Rectangle().opacity(0)
        } else {
            CardView(card: card)
                .padding(4)
                .onTapGesture {
                    game.choose(card)
                }
        }
    }
}

이 때 AspectVGrid라는 것은 ScrollView를 쓰지 않으면 lazyVGrid를 그릴 때 카드의 갯수에 따라 화면 밖으로 잘리는 경우가 생길 것이다. 이를 방지하기 위해서 전체 화면에 모든 카드를 띄울 수 있는 grid의 width를 계산해서 그려주는 View 구조체를 만든 것이다.

5. AspectVGrid View

이를 코드로 나타내면 다음과 같다. 유의할 것은 Card라는 아이템을 여기서는 인풋으로 주지만 card를 그리는 것이 아닌 다른 것을 그릴 수도 있기 때문에 Item 과 ItemView라는 Generic Type을 활용한 것을 확인하자. 이 때 각각의 Item은 Identifiable해야하고 ItemView는 하나의 View여야 한다고 속성을 부여해주어야한다. 이제 우리는 item들의 Array와 그리는 비율, 각각의 item들을 View로 바꾸어주는 함수를 인풋으로 받아서 VGrid View를 그릴 것이다.

이 때 init함수에서 content라는 함수를 단순히 self.content = content하게 되면 에러가 발생한다. @escaping 속성을 부여해주지 않으면 에러가 발생하는데 인풋으로 받은 함수를 struct 내부에서 사용하고 싶다면 부여해줘야한다.

그 외에는 코드를 읽어보면 간단히 이해할 수 있다. 유의할 점으로는 Grid간의 spacing을 가로 세로 모두 0으로 설정해주어 격자 각각의 크기를 구하는데 spacing을 고려하지 않아도 되는 편의성을 주었다는 점이다. 대신 그 공간에 들어가는 Card View자체에 padding 속성을 부여해주어 카드간의 간격을 설정해주었다.

import SwiftUI

struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
    var items: [Item]
    var aspectRatio: CGFloat
    var content: (Item) -> ItemView
    
    init(items: [Item], aspectRatio: CGFloat, @ViewBuilder content: @escaping (Item)  -> ItemView) {
        self.items = items
        self.aspectRatio = aspectRatio
        self.content = content
    }
    
    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) {
                        content($0).aspectRatio(aspectRatio, contentMode: .fit)
                    }
                }
                Spacer(minLength: 0)
            }
        }
    }
    
    private func adaptiveGridItem(width: CGFloat) -> GridItem {
        var gridItem = GridItem(.adaptive(minimum: width))
        gridItem.spacing = 0
        return gridItem
    }
    
    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))
    }
}

6. Pie

View Struct를 직접 정의했듯이 Pie 라는 도형을 그리는 Struct도 직접 구현할 수 있다. 도형의 구조체는 속성이 Shape로 View에서는 body variable을 정의해줘야 했듯이 Shape 구조체는 path라는 함수를 구현해줘야한다.

path함수는 CGRect를 변수로 받아 Path라는 경로를 return하는 함수로 CGRect는 도형을 그리기 쉽도록 주어진 영역의 좌표 정보들을 저장해놓은 변수이다. midX, midY와 같은 정보를 통해 중심값을 찾을 수도 있고 width와 height를 알수도 있다.

Path()라는 p 변수에 addline과 같은 함수를 통해 도형을 그려주자. 유의할 점으로는 addArc를 할 때 clockwise의 inverse를 사용하는 것을 알 수 있다. 우리는 실제로 반시계방향으로 그림을 그리고 싶기 때문에 clockwise 변수를 false로 선언해주었다. 그러나 arc를 그릴 때는 clockwise: true를 사용하게 되는 것인데 실제 그림을 그릴 때는 y축이 아래에서 위로 뻗어나가는 것이 아닌 위에서 아래로 뻗어나가는 축을 기준으로 그림을 그린다. 따라서 우리가 평소에 생각하는 반시계 방향은 그림을 그리는 평면에서는 시계방향이 되는 것이다.

import SwiftUI

struct Pie: Shape {
    var startAngle: Angle
    var endAngle: Angle
    var clockwise: Bool = false
    
    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height)/2
        let start = CGPoint(
            x: center.x + radius * CGFloat(cos(startAngle.radians)),
            y: center.y + radius * CGFloat(sin(startAngle.radians))
        )
        var p = Path()
        p.move(to: center)
        p.addLine(to: start)
        p.addArc(
            center: center,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: !clockwise
        )
        p.addLine(to: center)
        return p
    }
}