개발을 시작하는 이야기

[SwiftUI] Lecture 6: Protocols Shapes 본문

개발 이야기/Swift

[SwiftUI] Lecture 6: Protocols Shapes

Teiresias 2022. 4. 18. 18:23

강의 보기 : Youtube :: Stanford 

 

Protocol

특정 기능을 수행에 필요한 필수적인 속성이나 메서드를 정의한다. 

protocol Moveable {
	func move(by: Int)
    var hasMoved: Bool { get }
    var distanceFromStart: Int { get set }
}

struct PortableThing: Moveable {
	func move(by: Int) {
    	print("\(by)만큼 움직입니다")
    }
    var hasMoved
    var distanceFromStart
}

프로토콜은 또 다른 프로토콜을 따를 수 있다.

protocol Moveable {
    var hasMoved: Bool { get }
}

protocol Messagable: Moveable {
	//...
}

프로토콜에서도 제너릭을 쓸 수 있다.

대신 프로토콜에서 제너릭을 사용하기 위해서는 < >이 아닌 associated type 키워드를 통해 선언한다.

protocol Identifiable {
	associatedtype ID where ID: Hashable
    var id: ID { get }
}

//Or moew simply

protocol Identifiable {
	associatedtype ID: Hashable
    var id: ID { get }
}

@ViewBuilder

View를 리턴하는 함수를 @ViewBuilder로 만들어준다.

init(items: [Item], aspectRatio: CGFloat, @ViewBuilder content: @escaping (Item) -> ItemView) {
    ...
}

@ViewBuilder 
func someViewBuider() -> some View { }

@escaping

커스텀 init을 통해 인자로 함수를 받을 때, 인자는 기본적으로 value type이므로 해당 함수가 init 밖에서 쓰인다면 메모리 할당을 통해 힙에 저장해야 하기 때문에 escaping으로 표기해줘야 한다.

struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable{
    ...
    let content: (Item) -> ItemView
   	
    init(items: [Item], aspectRatio: CGFloat, @ViewBuilder content: @escaping (Item) -> ItemView) { 
    //여기서 content를 @escaping으로 선언함
        ...
        self.content = content
    }
        
    var body: some View {
        GeometryReader { geometry in
            VStack {
                ...
                LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
                    ForEach(items) { item in
                        content(item).aspectRatio(aspectRatio, contentMode: .fit)// 여기서 content 클로저가 사용됨
                    }
                }
                ...
            }
        }
    }
}

Shape

Path()는 이차원 그림을 그리는 데 필요한 다양한 함수를 갖고 있다.

func path(in rect: CGRect) -> Path {
	return a Path
}

원을 그릴때 주의점은 0도가 시계의 12시 방향이 아닌 3시 방향을 기준으로 시작하게 된다.(x축이 기준이 되기 때문인 듯) 그래서 12시 방향을 기준으로 할 때는 -90을 해주면 된다.

Pie(startAngle: Angle(degrees: 0 - 90), endAngle: Angle(degrees: 110 - 90))

Pie

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
    }
}

Path() 에서 오랜만에 사용하는 sin과 cos

 

View

 

import SwiftUI

struct EmojiMemoryGameView: View {
    @ObservedObject var game: EmojiMemoryGame
    
    var body: some View {
        AspectVGrid(items: game.cards, aspectRatio: 2/3, content: { card in
            if card.isMatched && !card.isFaceUp {
                Rectangle().opacity(0)
            } else {
                CardView(card: card)
                    .padding(4)
                    .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)
                    Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: 110-90))
                        .padding(5)
                        .opacity(0.5)
                    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 = 10
        static let lineWidth: CGFloat = 3
        static let fontScale: CGFloat = 0.7
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let game = EmojiMemoryGame()
        game.choose(game.cards.first!)
        return EmojiMemoryGameView(game: game)
    }
}

 

body의 ScrollView, LazyVGrid를 struct AsectVGrid로 상속해서 사용했다. 

Preview에서 기존에 일반 모드와 다크모드를 사용하던것을 첫 카드만 뒤집힌 일반View로 변경해서 사용한다. 

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) { item in
                        content(item).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))
    }
}

struct를 사용하여 상속하는건 예제로 보고 있으면 이해도 되고, 왜 이렇게 하는게 좋은가는 알겠지만, 실제로 내가 적용하려면 아직은 기능을 십분 활용을 못한채 제한적으로 밖에 사용하지 못한다. 더 많이 사용해보고 익숙해져야겠다.