THIS IS ELLIE

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

개발/SwiftUI

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

Ellie Kim 2021. 2. 21. 16:45

Color vs UIColor

- Color
ShapeStyle에 Color를 사용할 수 있다.
ex) .foregroundColor(Color.green)
View에 Color를 사용할 수 있다.
ex) Color.white
하지만 API는 제한적이다.

-
UIColor
시스템 관련 색상을 포함해 더 많은 색이 있다. 
원하는 UIColor가 있으면 Color(uiColor:)를 사용하면 된다.

Image vs UIImage
- Image
주로 View역할을 한다.
(jpeg또는 gif등)을 포함하는 타입의 변수는 아니다.(그건 UIImage의 역할)
Asset에 있는 이미지는 Image(name: String)으로 접근할 수 있다.
시스템 이미지를 사용하기 위해서는 Image(systenName:)으로 사용할 수 있다.
.imageScale() view modifier로 시스템 이미지의 크기를 제어할 수도 있다.

- UIImage
실제로 이미지를 생성/조작하고 변수에 저장하는 타입이다.
Image(uiImage: )를 사용해 이미지를 보여줄 수 있다.


오늘의 데모 키워드
- Emoji MVVM
- ScrollView
- fileprivate
- Drag and Drop
- UIImage
- Multithreading
MVVM을 재검토하고 EmojiArt를 만들어 본다.

모델

import Foundation

struct EmojiArt {
    var backgroundURL: URL?
    var emojis = [Emoji]()
    
    struct Emoji: Identifiable {
        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
        }
    }
    
    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))
    }
}

UI에 대한 작업이 없으므로 Foundation프레임워크를 그대로 사용한다.
fileprivate 접근 제어자를 사용해 파일 내에서만 초기화 가능하게 만들었다.
원래는 왼쪽 상단이 0,0인데 0,0을 센터로 만들어주기 위해서 x, y 좌표를 받아 처리한다.
EmojiArt모델 내에 Emoji구조체를 만들고 구조체들을 배열로 생성해 뷰모델에서 접근 가능하게 한다.

 

뷰모델

import SwiftUI

class EmojiArtDocument: ObservableObject {
    
    static let palette: String = "👩‍💻🙋‍♀️🙇‍♀️💃🏄‍♀️🧘‍♂️"
    
    @Published private var emojiArt: EmojiArt = EmojiArt()
    
    @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)) }
}

뷰모델 EmojiArtDocument클래스에 ObservableObject프로토콜을 채택한다.
뷰에서 옵저빙이 필요한 emojiArt프로퍼티와 backgroundImage프로퍼티는 @Published키워드를 붙여준다.

backgrounImage는 뷰에서 접근하지만 set은 못하도록 private(set)으로 제약을 준다.
실제 이미지를 가지고 있어야 하기 때문에 Image가 아닌 UIImage타입을 사용했다.
(Image뷰는 이미지를 가지고 있지 않음)

아래 Intent함수들은 뷰에서 호출하는 함수들이다.
이모지를 추가하고, 움직이고, 스케일을 조절할 수 있다.
이미지를 받아오는 작업에 DispatchQueue를 사용했다.
백그라운드로 갔다가 UI작업은 다시 메인 스레드에서 실행되어야 하기 때문에 DispatchQeue.main.async를 호출해준다.
알맞은 image가 backgroundImage로 설정되어야 하므로 if url == self.emojiArt.backgroundURL 조건문을 추가해준다.


import SwiftUI

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(
                    Group {
                        if self.document.backgroundImage != nil {
                            Image(uiImage: self.document.backgroundImage!)
                        }
                    }
                )
                    .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)
                        return self.drop(providers: providers, at: location)
                    }
                ForEach(self.document.emojis) { emoji in
                    Text(emoji.text)
                        .font(self.font(for: emoji))
                        .position(self.position(for: emoji, in: geometry.size))
                }
            }
        }
    }
    
    private func font(for emoji: EmojiArt.Emoji) -> Font {
        Font.system(size: emoji.fontSize)
    }
    
    private func position(for emoji: EmojiArt.Emoji, in size: CGSize) -> CGPoint {
        CGPoint(x: emoji.location.x + size.width/2,
                y: emoji.location.y + size.height/2)
    }
    
    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
}

document는 뷰모델인 EmojiArtDocument타입 프로퍼티다.
뷰모델을 구독하고 있기 위해 @ObservedObject키워드를 붙여준다.
이미지와 이모지를 드래그 앤 드롭하면 백그라운드 이미지 뷰에 표시되도록 한다.

추가로 스탠퍼드에서 제공해준 코드

더보기
import SwiftUI

extension Collection where Element: Identifiable {
    func firstIndex(matching element: Element) -> Self.Index? {
        firstIndex(where: { $0.id == element.id })
    }
    // note that contains(matching:) is different than contains()
    // this version uses the Identifiable-ness of its elements
    // to see whether a member of the Collection has the same identity
    func contains(matching element: Element) -> Bool {
        self.contains(where: { $0.id == element.id })
    }
}

extension Data {
    // just a simple converter from a Data to a String
    var utf8: String? { String(data: self, encoding: .utf8 ) }
}

extension URL {
    var imageURL: URL {
        // check to see if there is an embedded imgurl reference
        for query in query?.components(separatedBy: "&") ?? [] {
            let queryComponents = query.components(separatedBy: "=")
            if queryComponents.count == 2 {
                if queryComponents[0] == "imgurl", let url = URL(string: queryComponents[1].removingPercentEncoding ?? "") {
                    return url
                }
            }
        }
        // this snippet supports the demo in Lecture 14
        // see storeInFilesystem below
        if isFileURL {
            var url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
            url = url?.appendingPathComponent(self.lastPathComponent)
            if url != nil {
                return url!
            }
        }
        return self.baseURL ?? self
    }
}

extension GeometryProxy {
    // converts from some other coordinate space to the proxy's own
    func convert(_ point: CGPoint, from coordinateSpace: CoordinateSpace) -> CGPoint {
        let frame = self.frame(in: coordinateSpace)
        return CGPoint(x: point.x-frame.origin.x, y: point.y-frame.origin.y)
    }
}

// simplifies the drag/drop portion of the demo
// you might be able to grok this
// but it does use a generic function
// and also is doing multithreaded stuff here
// and also is bridging to Objective-C-based API
// so kind of too much to talk about during lecture at this point in the game!

extension Array where Element == NSItemProvider {
    func loadObjects<T>(ofType theType: T.Type, firstOnly: Bool = false, using load: @escaping (T) -> Void) -> Bool where T: NSItemProviderReading {
        if let provider = self.first(where: { $0.canLoadObject(ofClass: theType) }) {
            provider.loadObject(ofClass: theType) { object, error in
                if let value = object as? T {
                    DispatchQueue.main.async {
                        load(value)
                    }
                }
            }
            return true
        }
        return false
    }
    func loadObjects<T>(ofType theType: T.Type, firstOnly: Bool = false, using load: @escaping (T) -> Void) -> Bool where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading {
        if let provider = self.first(where: { $0.canLoadObject(ofClass: theType) }) {
            let _ = provider.loadObject(ofClass: theType) { object, error in
                if let value = object {
                    DispatchQueue.main.async {
                        load(value)
                    }
                }
            }
            return true
        }
        return false
    }
    func loadFirstObject<T>(ofType theType: T.Type, using load: @escaping (T) -> Void) -> Bool where T: NSItemProviderReading {
        self.loadObjects(ofType: theType, firstOnly: true, using: load)
    }
    func loadFirstObject<T>(ofType theType: T.Type, using load: @escaping (T) -> Void) -> Bool where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading {
        self.loadObjects(ofType: theType, firstOnly: true, using: load)
    }
}

extension String {
    // returns ourself without any duplicate Characters
    // not very efficient, so only for use on small-ish Strings
    func uniqued() -> String {
        var uniqued = ""
        for ch in self {
            if !uniqued.contains(ch) {
                uniqued.append(ch)
            }
        }
        return uniqued
    }
}

// it cleans up our code to be able to do more "math" on points and sizes

extension CGPoint {
    static func -(lhs: Self, rhs: Self) -> CGSize {
        CGSize(width: lhs.x - rhs.x, height: lhs.y - rhs.y)
    }
    static func +(lhs: Self, rhs: CGSize) -> CGPoint {
        CGPoint(x: lhs.x + rhs.width, y: lhs.y + rhs.height)
    }
    static func -(lhs: Self, rhs: CGSize) -> CGPoint {
        CGPoint(x: lhs.x - rhs.width, y: lhs.y - rhs.height)
    }
    static func *(lhs: Self, rhs: CGFloat) -> CGPoint {
        CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
    }
    static func /(lhs: Self, rhs: CGFloat) -> CGPoint {
        CGPoint(x: lhs.x / rhs, y: lhs.y / rhs)
    }
}

extension CGSize {
    static func +(lhs: Self, rhs: Self) -> CGSize {
        CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
    }
    static func -(lhs: Self, rhs: Self) -> CGSize {
        CGSize(width: lhs.width - rhs.width, height: lhs.height - rhs.height)
    }
    static func *(lhs: Self, rhs: CGFloat) -> CGSize {
        CGSize(width: lhs.width * rhs, height: lhs.height * rhs)
    }
    static func /(lhs: Self, rhs: CGFloat) -> CGSize {
        CGSize(width: lhs.width/rhs, height: lhs.height/rhs)
    }
}

extension String
{
    // returns ourself but with numbers appended to the end
    // if necessary to make ourself unique with respect to those other Strings
    func uniqued<StringCollection>(withRespectTo otherStrings: StringCollection) -> String
        where StringCollection: Collection, StringCollection.Element == String {
        var unique = self
        while otherStrings.contains(unique) {
            unique = unique.incremented
        }
        return unique
    }
    
    // if a number is at the end of this String
    // this increments that number
    // otherwise, it appends the number 1
    var incremented: String  {
        let prefix = String(self.reversed().drop(while: { $0.isNumber }).reversed())
        if let number = Int(self.dropFirst(prefix.count)) {
            return "\(prefix)\(number+1)"
        } else {
            return "\(self) 1"
        }
    }
}

extension UIImage {
    // Lecture 14 support
    // stores ourself as jpeg in a file in the filesystem
    // in the Application Support directory in our sandbox
    // with the given name (or a unique name if no name provided)
    // and returns the URL to it
    // care must be taken if you hold on to a URL like this persistently
    // because your Application Support directory's URL
    // can change between instances of your application
    // (see some hackery in imageURL above to account for this)
    // if you wanted to hold on to a URL like this in the real world
    // (i.e. not in demo-ware)
    // you'd probably just hold onto the end part of the URL
    // (i.e. not including the Application Support directory's URL)
    // and then always prepend Application Support's URL upon use of the URL fragment
    // this function might also want to add a parameter for the compression quality
    // (currently it is best-quality compression)
    func storeInFilesystem(name: String = "\(Date().timeIntervalSince1970)") -> URL? {
        var url = try? FileManager.default.url(
            for: .applicationSupportDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: true
        )
        url = url?.appendingPathComponent(name)
        if url != nil {
            do {
                try self.jpegData(compressionQuality: 1.0)?.write(to: url!)
            } catch {
                url = nil
            }
        }
        return url
    }
}

 

실행결과

 

resource:
cs193p.sites.stanford.edu/
 

 

반응형