반응형
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에 도입시켰다!
반응형
' Apple > Stanford iOS Programming (SwiftUI)' 카테고리의 다른 글
Lecture 9 Review Part 1: EmojiArt Drag and Drop Multithreading (0) | 2021.09.24 |
---|---|
Lecture 7 Review Part 2: ViewModifier Animation (0) | 2021.09.09 |
Lecture 7 Review Part 1: ViewModifier Animation (0) | 2021.09.04 |
Lecture 6 Review Part 3: Protocols Shapes (0) | 2021.08.26 |
Lecture 6 Review Part 2: Protocols Shapes (0) | 2021.08.26 |