THIS IS ELLIE

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

개발/SwiftUI

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

Ellie Kim 2021. 2. 28. 13:26

앱을 유지할 수 있는 여러 가지 방법
- FileManager
- CoreData
- CloudKit
- UserDefault

오늘 사용할 유저디폴트를 살펴본다.
유저 디폴트를 사용하기 위해서 인스턴스가 필요하다.

let defualts = UserDefaults.standard

데이터 저장하는 방법

defaults.set(object, forKey: “SomeKey”) 

여기서 object는 property list여야 한다.
(Bool, Int, Double, String 등)

데이터 추출하는 방법

let i: Int = defaults.integer(forKey: “MyInteger”)
let d: Data? = defaults.data(forKey: “MyData”)

let a = array(forKey: “MyArray”)

하지만 마지막 줄과 같이 배열 추출에는 Array<Any>로 리턴될 것이다.
리턴된 Array Element를 as로 타입 캐스팅을 해야 한다.
이렇게 하기 귀찮으면 codable을 사용해 data로 넣는 방법이 있다.

제스처
탭, 클릭, 스와이프 세미한 제스처에 대한 상호 작용을 정의할 수 있다.
아래는 제스처 구조체 종류이다.

https://developer.apple.com/documentation/swiftui/gestures

유저에 대해서 제스처 인풋을 받고 처리를 한다.
콜백에서는 뷰의 상태를  업데이트하거나 특정 작업을 수행한다.

SwiftUI에서 Gesture modifiers는 3가지 방식으로 업데이트를 받을 수 있다.
- updating(_:body:)
- onChanged(_:)
- onEnded(_:)

Discrete Gesture
더블 탭을 사용하려면 아래와 같이 사용하면 된다.

핸들링 필요하면 아래와 같이 사용하면 된다. 

var theGesture: some Gesture {
    return TapGesture(count: 2)
    .onEnded { /do something
    }
} 

쉬운 방법 easy to use 

myView.onTapGesture(count: Int) { /do something }
myView.onLongPressGesture(...) { /do something }

Non-Discrete Gesture
DragGesture, MagnificationGesture, RotationGesture, LongPressGesture
이는 argument를 준다. (discrete랑 다른 점) 

value는 드래그 제스처가 끝났을 때의 상태를 알려준다. 

var theGesture: some Gesture {
    return TapGesture(count: 2)
        .onEnded { value in
            /do something
        }
}

핸들링하는 방법 여기서 타입은 어떤 타입이든 가능하다.

@GestureState var myGestureState: MyGestureStateType = <starting value>
var theGesture: some Gesture {
    DragGesture(...)
        .updating($myGestureState) { value, myGestureState, transaction in
            myGestureState = /usally something related to value
        }
        .onEnded { value in
            /do something
        }
}

.updating에서는 손가락이 움직이면 클로저를 호출한다.
@GestureState 변수에는 $가 앞에 붙어야 하는 것 잊지 마라.

myGestureState argument는 클로저에 필수적이다.
(여기서 @GestureState를 변경할 수 있는 유일한 곳) 
제스처가 액티브할 때만 가능하니깐 클로저 내부에서만 상태를 변경할 수 있다.

@GestureState에 대한 변경이 필요 없다면 .onChanged로 처리해라.

var theGesture: some Gesture {
    DragGesture(...)
        .onChanged { value in
            / do something with value
        }
        .onEnded { value in
            /do something
        }
}

손가락 위치가 크게 상관없을 때 사용하면 좋음.

제스처가 일어날 때 정보를 얻으려면 @GestureState
.updating을 제스처에 추가하고 .updating에서 value를 사용해서 처리해라. 
.onEnded로 스테이트를 변경해 상황에 맞는 처리를 해준다.


EmojiArt 키워드
- JSON/Codable
- UserDefaults
- Optional Image
- Animatable Font Size
- Gesture

 

모델

struct EmojiArt: Codable {
    var backgroundURL: URL?
    var emojis = [Emoji]()
    
    struct Emoji: Identifiable, Codable, Hashable {
        let text: String
        var x: Int
        var y: Int
        var size: Int
        let id: Int
        
        fileprivate init(text: String, x: Int, y: Int, size: Int, id: Int) {
            self.text = text
            self.x = x
            self.y = y
            self.size = size
            self.id = id
        }
    }
    
    var json: Data? {
        return try? JSONEncoder().encode(self)
    }
    
    init?(json: Data?) {
        if json != nil, let newEmojiArt = try? JSONDecoder().decode(EmojiArt.self, from: json!) {
            self = newEmojiArt
        } else {
            return nil
        }
    }
    
    init() {}
    
    private var uniqueEmojiId = 0
    
    mutating func addEmoji(_ text: String, x: Int, y: Int, size: Int) {
        uniqueEmojiId += 1
        emojis.append(Emoji(text: text,
                            x: x,
                            y: y,
                            size: size,
                            id: uniqueEmojiId))
    }
}

json변수가 추가되었다.
EmojiArt에 인코딩 디코딩이 가능하도록 Codable을 붙여주고
변수 emojis 타입이 [Emoji]니 Emoji한테도 Codable을 붙여준다.
초기화 init은 두 가지로 초기화 실패 시 빈 EmojiArt()를 생성해준다.

 

뷰모델

class EmojiArtDocument: ObservableObject {
    
    static let palette: String = "👩‍💻🙋‍♀️🙇‍♀️💃🏄‍♀️🧘‍♂️"
    
    private var emojiArt: EmojiArt {
        willSet {
            objectWillChange.send()
        }
        didSet {
            UserDefaults.standard.set(emojiArt.json, forKey: EmojiArtDocument.untitled)
        }
    }
    private static let untitled = "EmojiArtDocument.Untitled"
    init() {
        emojiArt = EmojiArt(json: UserDefaults.standard.data(forKey: EmojiArtDocument.untitled)) ?? EmojiArt()
        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))
        }
    }
    
    func setBackgroundURL(_ url: URL?) {
        emojiArt.backgroundURL = url?.imageURL
        fetchBackgroundImageData()
    }
    
    private func fetchBackgroundImageData() {
        backgroundImage = nil
        if let url = self.emojiArt.backgroundURL {
            DispatchQueue.global(qos: .userInitiated).async {
                if let imageData = try? Data(contentsOf: url) {
                    DispatchQueue.main.async {
                        if url == self.emojiArt.backgroundURL {
                            self.backgroundImage = UIImage(data: imageData)
                        }
                    }
                }
            }
        }
    }
}

extension EmojiArt.Emoji {
    var fontSize: CGFloat { CGFloat(self.size) }
    var location: CGPoint { CGPoint(x: CGFloat(x), y: CGFloat(y)) }
}

emojiArt변수에 @Published를 제거하고 변수를 옵저빙 하다가 objectWillChange.send()를 호출해주거나 유저 디폴트에 저장한다.
유저 디폴트에 저장된 값의 여부에 따른 초기화를 해준다.

 

struct EmojiArtDocumentView: View {
    @ObservedObject var document: EmojiArtDocument
    
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(EmojiArtDocument.palette.map { String($0) }, id: \.self) { emoji in
                    Text(emoji)
                        .font(Font.system(size: defaultEmojiSize))
                        .onDrag { return NSItemProvider(object: emoji as NSString) }
                }
            }
        }
        .padding(.horizontal)
        GeometryReader { geometry in
            ZStack{
                Color.white.overlay(
                    OptionalImage(uiImage: self.document.backgroundImage)
                        .scaleEffect(self.zoomScale)
                        .offset(self.panOffset)
                )
                .gesture(self.doubleTapToZoom(in: geometry.size))
                .gesture(self.zoomGesture())
                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])
            .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)
            }
        }
    }
    
    @State private var steadyStatezoomScale: CGFloat = 1.0
    @GestureState private var gestureZoomScale: CGFloat = 1.0
    
    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.setBackgroundURL(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
}

핀치 제스처와, 더블 탭 제스처가 추가되었다.

 

스탠포드에서 제공해주는 애니메이션 관련 코드

더보기

아래 코드는
ViewModifier는 주어진 크기의 폰트로 시스템 폰트를 설정한다. (weight, design도)
크기 변경에 애니메이션 효과를 적용할 수 있다.

struct AnimatableSystemFontModifier: AnimatableModifier {
    var size: CGFloat
    var weight: Font.Weight = .regular
    var design: Font.Design = .default
    
    func body(content: Content) -> some View {
        content.font(Font.system(size: size, weight: weight, design: design))
    }
    
    var animatableData: CGFloat {
        get { size }
        set { size = newValue }
    }
}

extension View {
    func font(animatableWithSize size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> some View {
        self.modifier(AnimatableSystemFontModifier(size: size, weight: weight, design: design))
    }
}

 

resource:
cs193p.sites.stanford.edu/

반응형