 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 9 Review Part 2: EmojiArt Drag and Drop Multithreading

singularis7 2021. 9. 25. 00:20
반응형

EmojiArt

  • MVVM 
  • fileprivate
  • enum with associated values
  • Drag and Drop

우와~ 기대된다!!!!


Model

  • 새로운 EmojiArt 프로젝트를 생성한 후에 EmojiArtModel 모델을 생성한다.

  • 일반적으로 모델에 Model이라는 이름을 함께 사용하지 않는다.
  • 이번의 경우 지난 분기 수업에 있어서 앱의 이름과 동일한 구조체가 있을때 Preview가 혼동되는 문제가 있었다.
/// UI Independent representation of EmojiArtDocument
struct EmojiArtModel {
    /// Custom type background
    var background: Background
    
    /// Another custom type of array
    var emojis = [Emoji]()
    
    struct Emoji {
        /// emoji is immutable
        let text: String
        
        /// Use int because of emphasizeing not CGFloat (UI Independent)
        var x: Int
        var y: Int
        var size: Int
    }
    
    /// Background has three diffrent things.
    enum Background {
        case blank       // blank
        case url         // HTTP...
        case imageData   // JPEG, TIFF...
    }
}
  • EmojiArtDocument의 UI 독립적인 표현이다.
  • Background와 Emoji 모두 사용자 지정 type이다.
  • emoji는 변경할 수 없는 text와 Int type의 좌표 및 크기 값을 갖고있다.
  • CGFloat을 사용하지 않은 이유는 모델이 UI Independent 하다는 점을 강조하기위해서다.
  • Background는 3가지 상태로 표현될 수 있기 때문에 enum으로 정의되었다.
  • blank는 아무것도 없는 상태, url은 http를 사용하여 자료를 다운로드 받는 상태, imageData는 실제 이미지 데이터를 의미한다.
private var uniqueEmojiId = 0

mutating func addEmoji(_ text: String, at location: (x: Int, y: Int), size: Int) {
    uniqueEmojiId += 1
    emojis.append(Emoji(text: text, x: location.x, y: location.y, size: size, id: uniqueEmojiId))
}
  • addEmoji Document에 Emoji를 추가하는 기능을 구현한다.
  • Emoji 구조체는 SwiftUI에서 ForEach 되야 하기 때문에 Identifiable 프로토콜을 conform해야 한다.
  • 따라서 각 emoji를 식별할 수 있는 id값이 추가된 것을 확인할 수 있다.
  • 문제가 있다면 EmojiArtModel에서 emojis 배열이 private가 아니어서 다른 이가 기존 이모지와 충돌하는 id로 수정할 수 있다.
  • 만약 private으로 설정하면 UI에서 emoji의 위치값 혹은 크기를 변경할 수 없기 때문이다. 어떻게 해결할 수 있을까?
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
}
  • fileprivate 접근 제어자를 사용하여 문제를 해결할 수 있다.
  • 이 생성자는 현재 코드가 담겨있는 파일에서만 사용가능하다.
  • 새로운 생성자를 선언했기 때문에 자동으로 제공되는 생성자는 없어지며 현재 수정자 외에는 아무도 이모지를 생성할 수 없다.
// UI Independent representation of EmojiArtDocument
struct EmojiArtModel {
    /// Custom type background
    var background: Background
    /// Another custom type of array
    var emojis = [Emoji]()
    ...
    /// No one can use EmojiArtModel free initializer
    init() { }
    ...
}
  • EmojiArtModel의 생성자를 통해 배경이나 이모지를 설정할 수 없도록 해야한다.
  • 따라서 아무 역할도 하지 않는 생성자를 정의하여 자동으로 만들어진 생성자를 덮어쓰기한다.
extension EmojiArtModel {
    /// Background has three diffrent things.
    enum Background {
        case blank            // blank
        case url(URL)         // HTTP...
        case imageData(Data)  // JPEG, TIFF...
    }
    
    // 추후에 코드가 추가될 예정이기 때문에 분리했다.
}
  • EmojiArtModel에서 Background에 코드가 추가될 예정이기 때문에 EmojiArtModel.Background 별도의 파일로 분리했다.
  • blank의 경우 아무런 자료가 필요 없지만 나머지의 경우 associated value로 URL 혹은 이미지 데이터를 갖고 있어야 한다.

  • URL은 원격 서버의 item, 로컬 파일의 path와 같은 자료의 위치를 식별하는 값이다.
  • http://, file:// 이런식으로 사용된다. 

  • Data는 기본 구조체로 Bool, String, Int와 같은 범주에서 생각해볼 수 있다.
  • 단순한 byte buffer를 갖는 value type이다.
  • 따라서 jpeg 바이트를 담기에 아주 적절하다!
var url: URL? {
    switch self {
    case .url(let url): return url
    default: return nil
    }
}

var imageData: Data? {
    switch self {
    case .imageData(let data): return data
    default: return nil
    }
}
  • url 혹은 image 데이터 값을 제공해주는 편의기능의 var을 선언할 수 있다.
  • 만약 background가 url 혹은 data를 갖고 있다면 해당 값을 반환한다.
  • 아무런 값을 갖고있지않다면 nil을 반환하여 optional 로 정의한다.
  • 꼭 필요한 기능은 아니지만 다른이가 url이 필요할 때 값이 있는지 없는지에 대응할 수 있도록 도와준다.
tips!
Emoji를 Set 컬렉션에 담는 경우 identifiable 뿐만 아니라 hashable도 만족시켜야한다. Hashable 프로토콜만 추가함으로써  구조체의 모든 프로퍼티가 hashable 하다면 Hashable하게 동작하게 되는 것이 Swift의 강력한 기능이다.

View Model

  • EmojiArtDocument 파일을 만들 것이다.
  • EmojiArt 예술 작품은 본질적으로 Document이다.
  • Mac에서는 파일 메뉴에서 문서를 열고 닫는 등의 모든 작업을 수행할 수 있다.
  • (물론 iOS에서는 위와 같은 방식으로 동작하지는 않는다)
  • 하지만 사람들이 가지며 열 수 있는 문서, 이름을 지정하는 등의 모든 것이 있다.
class EmojiArtDocument: ObservableObject {
    @Published private(set) var emojiArt: EmojiArtModel
    
    init() {
        emojiArt = EmojiArtModel()
    }
    
    // MARK: - Convinient functions
    var emojis: [EmojiArtModel.Emoji] { emojiArt.emojis }
    var background: EmojiArtModel.Background { emojiArt.background }
    
    // MARK: - Intent(s)
    func setBackground(_ background: EmojiArtModel.Background) {
        emojiArt.background = background
    }
    
    func addEmoji(_ emoji: String, at location: (x: Int, y: Int), size: CGFloat) {
        emojiArt.addEmoji(emoji, at: location, size: Int(size))
    }
    
    func moveEmoji(_ emoji: EmojiArtModel.Emoji, by offset: CGSize) {
        if let index = emojiArt.emojis.index(matching: emoji) {
            emojiArt.emojis[index].x += Int(offset.width)
            emojiArt.emojis[index].y += Int(offset.height)
        }
    }
    
    func scaleEmojis(_ emoji: EmojiArtModel.Emoji, by scale: CGFloat) {
        if let index = emojiArt.emojis.index(matching: emoji) {
            emojiArt.emojis[index].size = Int((CGFloat(emojiArt.emojis[index].size) * scale).rounded(.toNearestOrAwayFromZero))
        }
    }
}
  • ViewModel은 class로 선언되며 ObservableObject를 충족시켜야한다.
  • @Published로 model을 선언해주어 자료의 변동사항이 있을때마다 공표하도록 설정해두었다.
  • Model의 접근 지정자를 private(set)으로 해주어 model에 접근할 수 있지만 수정은 불가능하다.
  • 추후의 강좌에서 init을 활용할 예정이기 때문에 지금은 model을 생성하는 간단한 코드만 담겼다.
  • 사용자가 emoji나 background에 편리하게 접근할 수 있도록 computed variable을 선언해주었다.
  • 사용자는 배경을 설정, 이모지 추가, 이모지의 위치 및 크기 변경의 intent를 가질 수 있기에 해당 함수를 선언해준다.

  • 이모지의 위치나 크기를 변경하려면 model의 emoji 배열에서 해당 이모지를 찾아 직접 수정해줘야 한다.
  • 따라서 emoji 배열에서 수정하려는 이모지에 대한 인덱스 값이 필요하다.
  • argument로 넘어온 이모지는 복사본이기 때문에 수정해도 아무런 효력을 발휘하지 못한다.
  • index(matching:)은 편의를 위해 직접 작성된 메서드이기 때문에 위와 같은 오류가 발생하고 있다.
extension Collection where Element: Identifiable {
    func index(matching element: Element) -> Self.Index? {
        firstIndex(where: { $0.id == element.id })
    }
}
  • 위 코드는 Protocol Extension의 예시이다. 즉, Array, Dictionary, Set, String과 같은 모든 Collection에서 쓸 수 있다.
  • Identifiable을 만족하는 Collection에서 이미 가지고 있는 값과 동일한 id를 가진 element를 찾고싶을 수 있다.
  • firstIndex(matchingn) 대신에 index(matching:)으로 사용하고자 한다.
  • 누군가 Identifiable한 컬렉션을 생성할 때 보통 오직 하나의 Identifiable 값만 존재할 것이라고 추정하기 때문이다.
  • 실제로 이와 같이 동작하라는 제약 사항은 없으며 단지 naming 선택일 뿐이다.
  • 리턴 타입이 Self.Index인 사유는 모든 Collection이 Index로 int를 사용하는 것은 아니기 때문이다.
  • 실제로 String은 String.Index 타입을 사용한다.

View

  • SwiftUI를 사용하여 View를 작성하기전에 뼈대를 잡아보자!
import SwiftUI

struct EmojiArtDocumentView: View {
    @ObservedObject var document: EmojiArtDocument
    
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}

 

  • 만약 View에서 직접 ViewModel을 초기화하는 경우 @ObservedObject 대신 다른 것을 사용해야한다. (추후에 소개한다)
import SwiftUI

@main
struct EmojiArtApp: App {
    let document = EmojiArtDocument()
    
    var body: some Scene {
        WindowGroup {
            EmojiArtDocumentView(document: document)
        }
    }
}
  • App에서도 지금은 let을 사용하지만 앞으로 여러개의 document를 생성할 수 있기 때문에 다른 것을 사용하게 될 것이다.
  • 실제 UI를 구성하기 전에 데모앱을 살펴보면서 UI가 어떻게 구성되어있는지 살펴보자!

var body: some View {
    VStack(spacing: 0) {
        documentBody
        palette
    }
}
  • UI는 그림으로 보여지는 document의 body와 emoji palete 부분으로 구성되어있다.
struct EmojiArtDocumentView: View {
    @ObservedObject var document: EmojiArtDocument
    let defaultEmojiFontSize: CGFloat = 40
	...
    var palette: some View {
        ScrollingEmojisView(emojis: testEmojis)
            .font(.system(size: defaultEmojiFontSize))
    }
    let testEmojis = "🐶👻🐱🖥📱🍇😱😅🎾🌏🫕🍉🥑🍓⚽️🏀🍌🍎👀"
}
  • 팔레트 부분의 경우 이모지를 보여줄 수 있도록 가로 방향의 ScrollView를 활용하여 구현했다.
  • String의 각 요소는 String이 아니기 때문에 String으로 변환시켜줘야 한다.
  • map은 아주 중요한 함수로 함수형 프로그래밍 뿐만 아니라 SwiftUI에서도 매우 중요한 기능을 맵핑한다.
struct ScrollingEmojisView: View {
    let emojis: String
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(emojis.map { String($0) }, id: \.self) { emoji in
                    Text(emoji)
                }
            }
        }
    }
}
  • 이제 documentBody 영역에 이모지를 그릴 수 있도록 구현해보자!
  • 이모지를 Drag and Drop 할 수 있는 기능을 구현할 것이다.
var documentBody: some View {
    ZStack {
        // 배경으로 바꿀것임
        Color.yellow
        ForEach(document.emojis) { emoji in
            Text(emoji.text)
                .font(.system(size: fontSize(for: emoji)))
        }
    }
}

private func fontSize(for emoji: EmojiArtModel.Emoji) -> CGFloat {
    CGFloat(emoji.size)
}
  • Emoji는 이미 Identifiable하기 때문에 ForEach를 돌릴 수 있다.
  • 모델의 이모지를 꺼내와서 텍스트로 출력하는 원리이다.
  • 이모지의 크기를 지정하기위해 별도의 fontSize 메서드를 통해 계산하고있다.
  • 핀치 제스쳐를 사용하여 이모티콘의 크기를 변경하는 기능을 구현할 때 활용해야한다.
  • 특정 (x, y)좌표에 이모티콘을 위치시켜야 한다. 이때 사용되는 것은 position 수정자로 CGPoint 값을 통해 처리한다.
  • 모든 좌표값은 좌표계를 통해 표현된다. 원점(origin)은 어디에 위치해 있을까?
 /// To enable ForEach in SwiftUI, emoji conforms Identifiable protocol
struct Emoji: Identifiable, Hashable {
    /// emoji is immutable
    let text: String

    /// Use int because of emphasizeing not CGFloat (UI Independent)
    var x: Int // offset from the center
    var y: Int // offset from the center
    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
    }
}
  • 모든 좌표는 중심(center)을 기준으로 좌표값이 표현된다. 두가지 측면에서 이점이 있다.
  • 첫번째: 원점이 어디인지 걱정할 필요없이 배경을 중앙에 위치시킬 수 있다.
  • 두번째: Drawing 시스템의 근원과 관계없이 관리될 수 있어서 UI 독립적으로 구현할 수 있다.
  • 따라서 UI 부분에서는 모델의 좌표값을 View에서의 좌표계로 변환시켜야한다.
var documentBody: some View {
    GeometryReader { geometry in
        ZStack {
            // 배경으로 바꿀것임
            Color.yellow
            ForEach(document.emojis) { emoji in
                Text(emoji.text)
                    .font(.system(size: fontSize(for: emoji)))
                    .position(position(for: emoji, in: geometry))
            }
        }
    }
}

private func position(for emoji: EmojiArtModel.Emoji, in geometry: GeometryProxy) -> CGPoint {
    convertFromEmojiCoordinates((emoji.x, emoji.y), in: geometry)
}

private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
    let center = geometry.frame(in: .local).center
    return CGPoint(
        x: center.x + CGFloat(location.x),
        y: center.y + CGFloat(location.y)
    )
}
  • documentBody의 center를 계산하려면 GeometryReader를 사용하여 현재 공간에 대한 크기를 계산할 수 있어야 한다.
  • geometryProxy에서는 global 혹은 local 좌표계에서의 좌표값을 선택하여 가져올 수 있다.
  • 하지만 본래에는 center값을 제공해주지 않으며 Readability를 향상시키기 위해 extentsion으로 center를 붙여줬다.
  • 함수에서 간단한 좌표값을 주고 받을때 Tuple을 활용하는 예시를 잘 봐두자! 특히 Tuple에서 반드시 레이블을 지정할 필요는 없다!
extension CGRect {
    var center: CGPoint {
        CGPoint(x: midX, y: midY)
    }
}
  • ViewModel에서 Dummy 이모지 몇개를 추가해서 현재 코드를 테스트해보았다!

오! 이모티콘이 띄어졌다!

  • 다음의 목표는 대망의 Drag and Drop 기능을 구현하는 것이다.
  • Swift 세계와 Objective-C의 세계를 이어주는 좀 신기한 API 친구를 사용하기 때문이다.
  • 멋진 기능을 멋진 방법으로 SwiftUI에 도입시켰다!
반응형