THIS IS ELLIE

스탠포드 SwiftUI강의 복습하기 Lecture9 본문

개발/SwiftUI

스탠포드 SwiftUI강의 복습하기 Lecture9

Ellie Kim 2021. 2. 28. 23:00

Property Wrappers 

@something은 다 프로퍼티 래퍼이다.
각 Property Wrapper는 구조체이며 이는 'template'동작을 캡슐화한다.

예를 들어
@State는 변수가 힙에 살도록 하고
@Published는 변경이 있을 때 publish 하도록 하고
@ObservedObject는 published 변경이 감지되면 뷰를 다시 그리도록 하고
Property Wrapper는 이 구조체들을 생성하고 사용하기 쉽게 해준다.

Property Wrapper 안 쓰면 아래처럼 사용해야 한다.

https://cs193p.sites.stanford.edu/sites/g/files/sbiybj16636/files/media/file/l9_0.pdf

Property Wrapper를 사용하기 위해 구조체를 만들고 그에 맞게 구현해줘야 한다.
또한 Property Wrapper안에 또 다른 변수가 존재하는데 projectedValue이다.
이 projectedValue값에 접근하려면 $를 사용해야 한다.

@Published에서 wrappedValue가 설정(변경)되면 무엇을 할까?
publisher인 projectValue를 통해 변경 사항을 publish 한다.
또한 ObservableObject에서 objectWillChange.send()를 호출한다.


@State
바인딩 $ 힙에 바인딩 역할을 한다.
뷰가 변경되면 무효화된다.

@ObservedObject
바인딩 $WrappedValue에 바인딩한다 이는 뷰모델이다.
WrappedValue가 objectWillChange.send()를 수행할 때 View를 무효화한다.

@Binding
바인딩 $ 스스로에게 바인딩한다.
바인딩된 값이 변경되면 뷰가 무효화된다.

바인딩은 TextField에서 텍스트 가져오기, Picker에서 선택하거나 Toggle 된 상태, NavigationView에서 선택된 아이템 찾기 등에서 많이 사용된다.
하지만 바인딩은 단일 소스를 갖는 사실을 알아야 한다.

@State 또는 @ObservedObject는 다른 뷰와 공유한다.

struct MyView: View {
    @State var myString = “Hello”
    var body: View {
        OtherView(sharedText: $myString)
    }
}
struct OtherView: View {
    @Binding var sharedText: String
    var body: View {
        Text(sharedText)
    }
}

OtherView body는 myView에서 문자열은 항상 MyView에 있는 myString값이고
Other Viewers sharedText는 MyView의 myString에 바인딩된다.
상수 값에 바인딩이 가능하다.

OtherView(sharedText: .constant(“Howdy”))

연산 바인딩도 가능하다. (Binding(get:, set:))

@EnvironmentObject 
@ObservedObject와 동일하지만 뷰에게 다른 방법으로 전달한다.

let myView = MyView().environmentObject(theViewModel)
let myView = MyView(viewModel: theViewModel)

// 뷰 내부
@EnvironmentObject var viewModel: ViewModelClass
@ObservedObject var viewModel: ViewModelClass

다른점은 EnvironmentObject 는 모든 뷰의 body에 표시된다. (모달 표시되는 뷰 제외)
따라서 뷰가 동일한 뷰모델을 공유할 때 가끔 사용된다.
모달로 띄울 때 @EnvironmentObject를 사용하는 것이 좋다.
각 ObservableObject타입의 뷰에 각 @EnvironmentObject를 사용할 수 있다.
WrappedValue는 .environmentObject()를 통해 ObservableObject를 얻을 때 View로 보낸다.

@Environment
()를 사용해서 다른 변수에게 값을 넘겨준다.

 @Environment(\.colorScheme) var colorScheme

위와 같이 컬러 스킴을 keyPath로 전달할 수 있다.
EnvironmentValues 구조체에 저장된다.


뷰모델

import SwiftUI
import Combine

class EmojiArtDocument: ObservableObject {
    
    static let palette: String = "👩‍💻🙋‍♀️🙇‍♀️💃🏄‍♀️🧘‍♂️"
    
    @Published private var emojiArt: EmojiArt
    
    private static let untitled = "EmojiArtDocument.Untitled"
    private var autosaveCancellable: AnyCancellable?
    
    init() {
        emojiArt = EmojiArt(json: UserDefaults.standard.data(forKey: EmojiArtDocument.untitled)) ?? EmojiArt()
        autosaveCancellable = $emojiArt.sink { emojiArt in
            UserDefaults.standard.data(forKey: EmojiArtDocument.untitled)
        }
        fetchBackgroundImageData()
    }
    
    @Published private(set) var backgroundImage: UIImage?
    
    var emojis: [EmojiArt.Emoji] { emojiArt.emojis }
    
    // MARK: - Intent(s)
    
    func addEmoji(_ emoji: String, at location: CGPoint, size: CGFloat) {
        emojiArt.addEmoji(emoji, x: Int(location.x), y: Int(location.y), size: Int(size))
    }
    
    func moveEmoji(_ emoji: EmojiArt.Emoji, by offset: CGSize) {
        if let index = emojiArt.emojis.firstIndex(matching: emoji) {
            emojiArt.emojis[index].x += Int(offset.width)
            emojiArt.emojis[index].y += Int(offset.height)
        }
    }
    
    func scaleEmoji(_ emoji: EmojiArt.Emoji, by scale: CGFloat) {
        if let index = emojiArt.emojis.firstIndex(matching: emoji) {
            emojiArt.emojis[index].size = Int((CGFloat(emojiArt.emojis[index].size) * scale).rounded(.toNearestOrEven))
        }
    }
    
    var backgroundURL: URL? {
        get {
            emojiArt.backgroundURL
        }
        set {
            emojiArt.backgroundURL = newValue?.imageURL
            fetchBackgroundImageData()
        }
    }

    private var fetchImageCancellable: AnyCancellable?
    
    private func fetchBackgroundImageData() {
        backgroundImage = nil
        if let url = self.emojiArt.backgroundURL {
            fetchImageCancellable?.cancel()
            fetchImageCancellable = URLSession.shared.dataTaskPublisher(for: url)
                .map { data, urlResponse in UIImage(data: data) }
                .receive(on: DispatchQueue.main)
                .replaceError(with: nil)
                .assign(to: \EmojiArtDocument.backgroundImage, on: self)
        }
    }
}

struct EmojiArtDocumentView: View {
    @ObservedObject var document: EmojiArtDocument
    @State private var chosenPalette: String = ""
    
    var body: some View {
        VStack {
            HStack {
                PaletteChooser(document: document, chosenPalette: $chosenPalette)
                ScrollView(.horizontal) {
                    HStack {
                        ForEach(chosenPalette.map { String($0) }, id: \.self) { emoji in
                            Text(emoji)
                                .font(Font.system(size: defaultEmojiSize))
                                .onDrag { return NSItemProvider(object: emoji as NSString) }
                        }
                    }
                }
            }
            GeometryReader { geometry in
                ZStack{
                    Color.white.overlay(
                        OptionalImage(uiImage: self.document.backgroundImage)
                            .scaleEffect(self.zoomScale)
                            .offset(self.panOffset)
                    )
                    .gesture(self.doubleTapToZoom(in: geometry.size))
                    if self.isLoading {
                        Image(systemName: "hourglass").imageScale(.large).spinnig()
                    } else {
                        ForEach(self.document.emojis) { emoji in
                            Text(emoji.text)
                                .font(animatableWithSize: emoji.fontSize * zoomScale)
                                .position(self.position(for: emoji, in: geometry.size))
                        }
                    }
                }
                .clipped()
                .gesture(self.panGesture())
                .gesture(self.zoomGesture())
                .edgesIgnoringSafeArea([.horizontal, .bottom])
                .onReceive(self.document.$backgroundImage) { image in
                    self.zoomToFit(image, in: geometry.size)
                }
                .onDrop(of: ["public.image","public.text"], isTargeted: nil) { providers, location in
                    
                    var location = geometry.convert(location, from: .global)
                    location = CGPoint(x: location.x - geometry.size.width/2,
                                       y: location.y - geometry.size.height/2)
                    location = CGPoint(x: location.x - self.panOffset.width,
                                       y: location.y - self.panOffset.height)
                    location = CGPoint(x: location.x / self.zoomScale,
                                       y: location.y / self.zoomScale)
                    return self.drop(providers: providers, at: location)
                }
            }
        }
    }
    var isLoading: Bool {
        document.backgroundURL != nil && document.backgroundImage == nil
    }
    
    @State private var steadyStatezoomScale: CGFloat = 1.0
    @GestureState private var gestureZoomScale: CGFloat = 1.0
    // any type
    
    private var zoomScale: CGFloat {
        steadyStatezoomScale * gestureZoomScale
    }
    private func zoomGesture() -> some Gesture {
        MagnificationGesture()
            .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, trasaction in
                gestureZoomScale = latestGestureScale
            }
            .onEnded{ finalGestureScale in
                self.steadyStatezoomScale *= finalGestureScale
            }
    }
    
    @State private var steadyStatePanOffset: CGSize = .zero
    @GestureState private var gesturePanOffset: CGSize = .zero
    
    private var panOffset: CGSize {
        (steadyStatePanOffset + gesturePanOffset) * zoomScale
    }
    
    private func panGesture() -> some Gesture {
        DragGesture()
            .updating($gesturePanOffset) { latestDragGestureValue, gesturePanOffset, transaction in
                gesturePanOffset = latestDragGestureValue.translation / self.zoomScale
            }
            .onEnded { finalDragGestureValue in
                self.steadyStatePanOffset = self.steadyStatePanOffset + (finalDragGestureValue.translation / self.zoomScale)
            }
    }
    
    
    
    private func doubleTapToZoom(in size: CGSize) -> some Gesture {
        TapGesture(count: 2)
            .onEnded {
                withAnimation {
                    self.zoomToFit(self.document.backgroundImage, in: size)
                }
            }
    }
    
    private func zoomToFit(_ image: UIImage?, in size: CGSize) {
        if let image = image, image.size.height > 0 {
            let hZoom = size.width / image.size.width
            let vZoom = size.height / image.size.height
            self.steadyStatePanOffset = .zero
            self.steadyStatezoomScale = min(hZoom, vZoom)
        }
    }
    
    private func position(for emoji: EmojiArt.Emoji, in size: CGSize) -> CGPoint {
        var location = emoji.location
        location = CGPoint(x: location.x * zoomScale,
                           y: location.y * zoomScale)
        location = CGPoint(x: emoji.location.x + size.width/2,
                y: emoji.location.y + size.height/2)
        location = CGPoint(x: location.x + panOffset.width,
                           y: location.y + panOffset.height)
        return location
    }
    
    private func drop(providers: [NSItemProvider], at location: CGPoint) -> Bool {
        var found = providers.loadFirstObject(ofType: URL.self) { url in
            print("dropped \(url)")
            self.document.backgroundURL = url
        }
        if !found {
            found = providers.loadObjects(ofType: String.self) { string in
                self.document.addEmoji(string, at: location, size: self.defaultEmojiSize)
            }
        }
        return found
    }
    
    private let defaultEmojiSize: CGFloat = 40
}

팔레트 선택할 수 있는 코드 추가

struct PaletteChooser: View {
    @ObservedObject var document: EmojiArtDocument
    @Binding var chosenPalette: String
        
    var body: some View {
        HStack {
            Stepper(onIncrement: {
                self.chosenPalette = self.document.palette(after: self.chosenPalette)
            }, onDecrement: {
                self.chosenPalette = self.document.palette(after: self.chosenPalette)

            }, label: { EmptyView() })
            Text(self.document.paletteNames[self.chosenPalette] ?? "")
        }
        .fixedSize(horizontal: true, vertical: false)
    }
}

struct PaletteChooser_Previews: PreviewProvider {
    static var previews: some View {
        PaletteChooser(document: EmojiArtDocument(), chosenPalette: Binding.constant(""))
    }
}

이번에 전체적으로 Property Wrappers에 대한 설명이 주를 이뤘고 코드적으로 보완된 것은 
인터넷에서 backgroundImage를 로드할 때 더 나은 피드백은 제공 해주는 것
.onReceive를 통해 backgroundImage가 변경될 때 자동으로 확대/축소하는 것
팔레트 선택하는 코드가 추가된 것.

resource:
cs193p.sites.stanford.edu/

반응형

'개발 > SwiftUI' 카테고리의 다른 글

SwiftUI - Image  (0) 2022.07.09
SwiftUI - Spacer  (2) 2022.03.27
스탠포드 SwiftUI강의 복습하기 Lecture8  (0) 2021.02.28
스탠포드 SwiftUI강의 복습하기 Lecture7  (0) 2021.02.21
스탠포드 SwiftUI강의 복습하기 Lecture6  (0) 2021.02.14