개발을 시작하는 이야기

[SwiftUI] Lecture 5: Properties Layout @ViewBuilder 본문

개발 이야기/Swift

[SwiftUI] Lecture 5: Properties Layout @ViewBuilder

Teiresias 2022. 4. 14. 18:52

강의 보기 : Youtube :: Stanford 

 

@State, @Binding

@State는 단어 그대로 현재 상태를 나타내는 속성으로, 뷰의 어떤 값을 저장하는 데 사용된다. 현재 뷰 UI의 특정 상태를 저장하기 위해 만들어진 것이기 때문에 보통 Private로 지정하여 사용한다. @State 속성의 프로퍼티 값은 재 할당을 하더라도 변경되지 않는데 @Binding 변수를 통해서 변경이 가능하다. @Binding은 단어 그대로 구속력 있는, 묶여 있다는 뜻으로, @State 속성으로 선언된 프로퍼티와 묶여 있다고 생각하면 된다. 

 

@State는 주가 되는 뷰에 선언을 해주고, 선언된 프로퍼티를 다른 뷰에서 사용하기 위해서는 @Binding을 사용한다. 그리고 사용 시에는 $를 앞에 붙여주어 Binding 변수임을 나타내 준다. 

Struct ContentView: View {
	@State private var test = 0
    var body: some View {
    	BindView(test: $test)
    }
}

Struct BindView: View {
	@Binding var test: Int
    var body: some View {
    	Stepper("test: \(test)", value: $test)
    }
}

Property Observers

단어 그대로 프로퍼티 값의 변화를 관찰하고 이에 응답한다. 새로운 값이 현재의 값과 동일하더라도 속성의 값이 설정(Set) 될 때마다 호출된다. lazy 저장 프로퍼티를 제외하고, 정의된 저장 프로퍼티에 옵저버를 추가할 수 있다. 또한 하위 클래스 내의 프로퍼티를 재정의하여 상속된 프로퍼티 (저장 프로퍼티, 연산 프로퍼티 어느 것이든)에도 프로퍼티 옵저버를 추가할 수 있다. 프로퍼티 옵저버는 총 두 가지 옵션이 있는데, willSetdidSet 이 있다.

willSet은 값이 저장되기 직전에 호출된다. newValue변경 값을 가져올 수 있다.

didSet은 값이 저장된 직후에 호출된다. oldValue변경 전 값을 가져올 수 있다. 

var name: String = "Unknown" {
	willSet {
    	print("현재이름 = \(name), 변경후 이름 = \(newValue)")
    }
    didSet {
    	print("현재 이름 = \(name), 변경전 이름 = \(oldValue)")
    }
}

위에서 저장 프로퍼티에만 옵저버를 추가할 수 있다고 했지만 연산 프로퍼티에도 옵저버를 추가할 수 있다. 다만 조건이 있는데, 부모 클래스의 연산 프로퍼티를 오버라이딩 할 경우에 추가할 수 있다. 

struct Person {
    // 연산 프로퍼티는 이렇게 다른 저장 프로퍼티를 꼭 필요로 한다.
    var name: String = "Unknown"
    
    // 값을 저장하는 것이 아니므로, 타입 추론 불가. 따라서 타입 명시 필수!
    var selfIntroduce: String {
        // 접근자 getter (다른 프로퍼티의 값을 얻거나 연산하여 리턴할 때 사용)
        // => 어떤 저장 프로퍼티의 값을 연산하여 반환할 것인지 return 구문 필수
        get {
            return "내 이름은 \(name)야??"
        }
        
        // 설정자 setter (다른 저장 프로퍼티에 값을 저장할 때 사용)
        set {
            self.name = "내 이름은 " + newValue + "야!!"
        }
    }
}

Layout

GeometryReader

View를 구성할때 VStack, HStack, ZStack들로만 적절히 섞어 사용해도 레이아웃을 구성할 수 있지만, 그 이상으로 하위 뷰들의 위치나 모양을 직접 설정해주어야 하는 경우가 있다. 그럴 때 사용해주는 Container View 가 바로 GeometryReader이다. 

ChildView는 별 설정이 없다면 ParentView가 제안해준 위치를 사용한다. 하지만 ParentView가 제안한 설정이 마음에 들지 않는다면 ChildView가 자신의 위치, 모양 등을 설정할 수 있다. 이때, ChildView가 ParentView가 제안한 위치를 활용하여 재설정할 때 사용하는 것이 GeometryReader이다.


오늘은 기능을 더하기보다는 코드를 간결하게 작성하는 것에 주력했다.

Model

import Foundation

struct MemoryGame<CardContent> where CardContent: Equatable {
    private(set) var cards: Array<Card>
    
    private var indexOfTheOneAndIOnlyFaceUpCatd: Int? {
        get { cards.indices.filter ({ cards[$0].isFaceUp }).oneAndOnly }
        set { cards.indices.forEach { cards[$0].isFaceUp = ($0 == newValue) } }
    }
    
    mutating func choose(_ card: Card) {
        if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }),
           !cards[chosenIndex].isFaceUp,
           !cards[chosenIndex].isMatched
        {
           if let potentialMatchIndex = indexOfTheOneAndIOnlyFaceUpCatd {
               if cards[chosenIndex].content == cards[potentialMatchIndex].content {
                   cards[chosenIndex].isMatched = true
                   cards[potentialMatchIndex].isMatched = true
               }
               cards[chosenIndex].isFaceUp = true
           } else {
               indexOfTheOneAndIOnlyFaceUpCatd = chosenIndex
            }
        }
    }
    
    init(numberOfPairsOfCards: Int, createCardContent: (Int) -> CardContent) {
        cards = []
        // add numberOfPairsOfCards x 2 cards to cards array
        for pairIndex in 0..<numberOfPairsOfCards {
            let content: CardContent = createCardContent(pairIndex)
            cards.append(Card(content: content, id: pairIndex*2))
            cards.append(Card(content: content, id: pairIndex*2+1))
        }
    }
    
    struct Card: Identifiable {
        var isFaceUp = false
        var isMatched = false
        let content: CardContent
        let id: Int
    }
}

extension Array {
    var oneAndOnly: Element? {
        if count == 1 {
            return first
        } else {
            return nil
        }
    }
}

ViewModel

import SwiftUI

class EmojiMemoryGame: ObservableObject {
    typealias Card = MemoryGame<String>.Card
    
    private static let emojis = ["💡", "📋", "🖥", "😺", "🗺", "😱", "🙈", "🤔", "📪", "👨‍🏫", "📱", "🎉", "📄", "💁", "📞", "👨‍💻", "⚒", "🙋", "🤵‍♂️", "😀", "😃", "😄", "😁"]
    
    private static func createMemoryGame() -> MemoryGame<String> {
        MemoryGame<String>(numberOfPairsOfCards: 9) { pairIndex in
            emojis[pairIndex]
        }
    }
        
    @Published private var model: MemoryGame<String> = createMemoryGame()
    
    var cards: Array<Card> {
        model.cards
    }
    
    //MARK: - Intent(s)
    
    func choose(_ card: Card) {
        model.choose(card)
    }
}

View

import SwiftUI

struct EmojiMemoryGameView: View {
    @ObservedObject var game: EmojiMemoryGame
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 60))]) {
                ForEach(game.cards) { card in
                    CardView(card: card)
                        .aspectRatio(2/3, contentMode: .fit)
                        .onTapGesture {
                            game.choose(card)
                        }
                }
            }
        }
        .foregroundColor(/*@START_MENU_TOKEN@*/.red/*@END_MENU_TOKEN@*/)
        .padding(.horizontal)
    }
}

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)
                    Text(card.content)
                        .font(font(in: geometry.size))
                } else if card.isMatched {
                    shape.opacity(0)
                } 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 = 20
        static let lineWidth: CGFloat = 3
        static let fontScale: CGFloat = 0.8
    }
}

 

commend키를 활용하여 Rename으로 해당 함수를 참조하는 모든 명령어를 한 번에 바꾸는 것은 무척 좋은 꿀팁이었다.

 

변수를 설정할 때 타입을 지정해주는 것이 코드를 보다 안정적으로 유지해주기 때문에 타입을 선언해주는 것이 좋다고 들었던 것 같은데 스탠퍼드 선생님은 반복할 필요가 없다고 대부분 쳐내버리고 꼭 필요한 몇 가지의 타입만 선언해주었다. 이거는 작업하는 코딩 컨벤션에 따라 달라지는 것 일수도 있겠지만 한번 찾아보고 정석적인 부분이 있다면 그쪽으로 맞춰 습관화를 들이는 게 좋겠다.

 


 

코드 단순화 작업을 하는걸 보고 있으니 부족한 점을 많이 느꼈다. 이 부분을 공부해서 출시 앱에 적용하는 것을 단기 목표 삼아 진행해봐야겠다.

 

참고자료 : Access Control, @state, @Binding, GeometryReader