오늘은 드디어 앱 아이콘을 추가해봤다! 아이콘 이미지 뭘로 하지 하다가 전에 금손 친구가 만들어준 미모지를 썼다. 교수님 말씀대로 인터넷에서 대충 iOS icon maker
로 검색해서 뜨는 사이트에서 사이즈별로 아이콘을 생성한 다음, 다운받은 폴더 그대로 Assets
에 넣어줬다. 그러고 나서 EmojiArt-General-App Icons and Launch Images
에서 source
를 해당 폴더로 바꿔주면 끝!
엄청 간단한데 만족도가 생각보다 매우 높아서(xcode 상태바?에도 뜨고 시뮬레이터 돌릴 때도 괜히 귀여움...) 다음부터는 아이콘 설정부터 해줘야지 생각했다ㅋㅋㅋㅋㅋㅋ
유저들은 사실 앱을 켰을 때 마지막 종료 시(에러로 인한 강종 포함) 화면이 그대로 뜰 거라고 생각할텐데 아직 우리 앱은 종료하면 변경 사항은 저장되나 EmojiArt
와 palette
의 줌/팬 정돈나 순서 등이 초기 상태로 돌아간다. 따라서 @SceneStroage
를 써서 이러한 기능을 구현해줄 것이다.
@SceneStorage
는 Scene
별로 정보를 시스템에 저장하는데, 이 정보는 앱을 닫거나 강제종료해도 유지되므로, 다음에 다시 앱을 켰을 때 저장된 값이 있다면 이를 불러와서 해당 상태로 복구한다. 따라서 현재 우리가 원하는 복구 기능을 구현하는 데 적합한 친구!SceneStorage
에 변화가 발생하면 현재 View
가 무효화된다. 주의할 점은 저장과 복원은 시스템에서 모두 처리해주나, 저장할 수 있는 타입이 매우 한정되어 있다는 것.
저장하려는 변수 앞에 @SceneStorage("someIdtoIdentifyThisVar")
와 같이 선언해주면 바로 앱 실행 시 자동 저장/복구가 가능하다! 이제 이모지 팔레트가 날씨-동물-음식 순이고 내가 음식 팔레트로 이동한 상태에서 앱을 껐다 다시 키면 날씨 팔레트가 아니라 음식 팔레트가 뜬다.
struct PaletteChooser: View {
...
// required to identify this var in the SceneStorage
@SceneStorage("chosenPaletteIndex")
private var chosenPaletteIndex = 0
...
}
다만 SceneStorage
가 기본으로 지원하지 않는 타입의 경우 SceneStorage
가 지원하는 RawRepresentable
로 변환해야 한다. 예컨대 우리의 경우 이모지 아트를 줌/팬 한 정도도 기억하고 싶을 수 있는데, 각각 CGFloat
, CGSize
이므로 변환이 필요하다.
RawRepresentable
이란 raw value
(우리가 아는 기본 타입들) 로의 변환/재변환이 가능한 타입이다. 따라서 이러한 변환 과정을 지정해주면 해당 프로토콜에 순응하게 할 수 있다! 우리는 String
타입으로 변환해줄 것이기 때문에 아래와 같이 RawRepresentable
에 extension
을 추가해서 CGFloat
과 CGSize
를 RawRepresentable
로 만들어주면, 이제 드디어 SceneStorage
를 SteadyStateZoomOffset
, SteadyStatePanOffset
에도 적용할 수 있다.
extension RawRepresentable where Self: Codable {
public var rawValue: String {
if let json = try? JSONEncoder().encode(self), let string = String(data: json, encoding: .utf8) {
return string
} else {
return ""
}
}
public init?(rawValue: String) {
if let value = try? JSONDecoder().decode(Self.self, from: Data(rawValue.utf8)) {
self = value
} else {
return nil
}
}
}
extension CGSize: RawRepresentable { }
extension CGFloat: RawRepresentable { }
.font(size:)
로 폰트를 지정한 View
들의 경우 디바이스에서 설정한 크기가 적용되지 않는다. 위에서 보면 이모지 팔레트 이름이나 메뉴는 시스템 설정에 따라 변경되었는데 defaultEmojiFontSize
를 지정해줬던 이모지들은 사이즈가 그대로다.@ScaledMetric
을 붙이면 해당 변수의 크기를 유저의 시스템 설정 값과 일치하게 조정해준다!
struct EmojiArtDocumentView: View {
...
@ScaledMetric var defaultEmojiFontSize: CGFloat = 40
...
}
App 프로토콜
은 앱의 구조와 작동방식을 나타내며 한 앱당 오직 하나의 구조체만이 해당 프로토콜에 순응한다. 해당 구조체 앞에 @main
을 붙임으로써 App
의 entry point
임을 표시한다. App 프로토콜
은 앱을 실행하기 위해 시스템이 호출하는 main()
메서드를 내장하고 있다. App
의 body
는 Scene
인스턴스들로 구성된다.@main
struct myApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Scene
은 우리가 UI 상에 나타내려고 하는, View
체계에서 가장 상위에 있는 View
를 담고 있으며, 시스템에서 생명주기를 관리한다.
Scene
의 대표적인 3가지
WindowGroup { return aTopLevelView }
DocumentGroup(newDocument:) { config in ... return aTopLevelView }
DocumentGroup(viewing:) { config in ... return aTopLevelView}
Scene
의 content
인자는 some View
를 리턴하는 클로저인데, 유저가 새로운 창을 띄울 때마다 호출되며, 새로운 Scene
의 최상위 View
를 리턴한다.
non-document-oriented Scene-building Scene
content
로 선언한 View
구조가 해당 그룹으로부터 앱이 생성한 각 window
의 템플릿으로 사용된다. 각 window
는 독립적인 상태를 유지한다, 즉 새로운 Scene
의 각 View
구조 내부의 State
혹은 StateObject
변수들은 별도로 메모리에 저장된다. viewModelsharedInAllScenes 변수
의 경우 MyApp
에서 선언되어 inject
되고 있으므로 모든 Scene
이 공유한다. @main
struct MyApp: App {
@StateObject var viewModelharedInAllScenes = MyViewModel()
var body: some Scene {
WindowGroup {
MyView(viewModel: viewModelSharedInAllScenes) // Declare a view hierarchy here.
}
}
}
document-oriented Scene-building Scene
document Model
과 해당 document
타입을 나타낼 수 있는 View
를 사용해서 만든다. SwiftUI
는 모델을 사용해서 앱에서 해당 document
를 지원한다. document
가 생성되거나 열릴 때마다 SwiftUI
는 새로운 ViewModel
을 만든다.config
인자에는 해당 document
의 ViewModel
과 document
를 여는 데 필요한 fileURL
이 들어있으며, 해당 URL
을 통해 ViewModel
에 접근해서 새로운 Scene
을 위한 View
를 만든다EmojiArt
에서 config.document
는 EmojiArtDocument
타입!newDocument
인자는 비어있는 새로운 document
를 만드는 데 사용하는 클로저DocumentGroup
이 제대로 작동하기 위해서는ViewModel
이 ReferenceFileDocument
프로토콜에 순응해야 하며Undo
를 구현해야 한다 : Undo
를 구현하는 대신 FileDocument
프로토콜에 순응하게 할 수도 있음 (슬라이드 참조)A document is a body of information, such as pages of text, stored in a file locally or in iCloud. 출처: Apple Documentation
struct EmojiArtApp: app {
var body: some Scene {
DocumentGroup(newDocument: { EmojiArtDocument() }) { config in
EmojiArtDocumentView(document: config.document)
}
}
}
document
기반의 앱을 만들고 있으므로 어떤 타입의 파일을 열고 수정할 수 있는지 표시해야 하는데, 특히 우리처럼 custom document
를 사용하는 경우 새로운 타입을 선언해야 한다. 따라서 Uniform Type Identifiers
를 사용할 건데, extension
을 통해 커스텀 타입을 추가할 수 있다.
Uniform Type Identifiers
: 파일 혹은 데이터의 타입을 식별하는 데 사용되는 기본 타입들을 제공한다. Xcode는 앱을 빌드할 때, document
타입에 관한 정보를 앱의 information property list(이하 Info.plist)
파일에 넣고, 유저들이 앱을 설치하면 시스템에서 해당 정보를 사용해 앱이 열 수 있는 파일들을 결정한다. 따라서 Info.plist
에서 타입을 선언하면 된다.
EmojiArt - Targets: EmojiArt - Info
에서 Exported Types Identifiers
, Imported Type Identifiers
, Document Types
를 셋팅해주면 된다. 이하는 공식문서와 강의를 참고해서 정리했다.
타입을 선언할 때는 먼저 UTType
-열거나, 보내거나, 받을 데이터의 타입을 나타내는 구조체-을 선언한다. 즉, 우리의 앱이 source
가 되는 exported type
인지, 아니면 외부에서 선언된 타입을 사용하는 imported type
인지 선언한다. 커스텀 document
타입을 사용하는 경우 exported type
과 imported type
을 모두 선언해줘야 한다. 우리의 경우 emojiart
라는 커스텀 타입을 사용할 것이고 파일을 열기도하고(import
) 저장하거나 내보내기(export
)도 할 것이므로 둘 다 선언해 준다.
identifier
가 필요하기 때문에 edu.stanford.cs193p
와 같은 reverse DNS
형식으로 선언해준다. type conformance
도 선언할 수 있는데, document type
을 선언할 때는 아래의 두 identifier
에 반드시 순응해야 한다. public.data
: Finder
혹은 Files app
이 해당 document type
을 표시할 수 있도록 함 (i.e. 저장된 아이템이 해당 타입인지 식별/표시)public.content
: 유저들이 에어드랍을 통해 해당 타입을 공유할 수 있게 함.jpeg
와 같이 해당 타입을 나타낼 확장자도 지정할 수 있다! 우리의 경우 emojiart
로 지정하면 이제 모든 파일에 .emojiart
가 붙는다.UTType
을 선언한 뒤에는 위에서 선언한 두 UTI
가 우리의 앱이 소유하는 document
를 나타낸다는 것을 알려주는 Document Type
을 선언해야 한다
Viewer
가 아니고 emojiart
를 편집할 수 있으므로 Handler Rank
를 Owner
로 설정한다Info.plist
뿐만 아니라 코드에도 우리가 읽고 쓸 수 있는 UTTypes
들을 선언해줘야 한다.
먼저 우리가 Info.plist
에 선언한 타입을 extension
을 사용해서 UTType
의 static
프로퍼티로 선언해주면 해당 타입을 의미하는 UTType.emojiart
가 새로 정의된다. 이 친구를 이제 아래에서 만나볼 ReferenceFileDocument
프로토콜에 순응하도록 구현하는 과정에서 쓴다.
extension UTType {
static let emojiart = UTType(exportedAs: "edu.stanford.cs193p.emojiart")
}
파일(디스크)로부터/에 EmojiArtDocument
를 읽어오기/쓰기 위해 필요한 프로토콜로 이전에 우리가 직접 작성했던 autosaving
관련 코드를 모두 대체한다.
Model
에 변화가 발생하면 백그라운드 스레드에서 snapshot(contentType:)
이 호출되고, snapshot
을 담을 수 있는 filewrapper
가 요청된다. ObservableObject
프로토콜에 순응하므로 ViewModel
에만 적용할 수 있으며, snapshot
을 이용해 쓰기 작업을 백그라운드 스레드에서 수행한다.
- cf. FileDocument
: Model
을 디스크에/로부터 저장하고/불러오는 데 필요한 프로토콜
readableContentTypes
: document
가 열 수 있는 타입들
writeableContentTypes
: document
가 저장하거나 변환될 수 있는 타입
fileWrapper(snapshot:configuration:)
: snapshot
을 직렬화해서 파일 시스템에 저장하며, 저장 위치를 반환한다. 보통은 데이터 blob
에 불과하지만 더 복잡한 데이터를 담을 수도 있다.
- file wrappers
: 파일 시스템 상의 노드(파일, 디렉토리, 심볼릭 링크)를 나타내는 객체로 FileWrapper
클래스의 인스턴스. 종류에 따라 노드명, 컨텐츠, 도착 노드 등에 대한 정보를 담고 있다
snapshot(contentType:)
: document
의 현재 상태를 담은 Snapshot
을 생성해서 리턴한다. 이때 Snapshot
은 제너릭으로 보통 Data
타입으로, 직렬화에 사용된다.
- Snapshot
이 생성될 때까지 document
는 수정 불가하며, Snapshot
이 생성되면, document
는 다시 수정 가능해지고 Snapshot
은 write(snapshot:to:contentType:)
메서드를 이용해 직렬화한다.
init(configuration:)
: 주어진 ReadConfiguration
에 들어있는 정보( UTType
, file wrapper
)를 불러와서 self
를 초기화한다.
- ReadConfiguration
, WriteConfiguration
: 어디서/에 document
를 불러오고 저장할 지 알려준다.
먼저 우리가 불러오고 저장할 타입이 아까 위에서 지정한 UTType.emojiart
라고 선언해준다.
그리고 우리는 Snapshot
의 타입을 Data
로 선언해줄 거다!
snapshot(contentType:)
은 EmojiArtModel
을 Data
로 변환한 값을 리턴하므로 Model
을 Data
로 바꿔줘야 한다. EMojiArtModel
의 json
메서드를 사용해 모델을 Data
형태로 인코딩한다.
fileWrapper(snapshot:configuration:)
함수의 경우 FileWrapper
를 리턴해야 하는데, FileWrapper(regularFileWithContents:)
의 인자로 snapshot
을 넣어주면 해당 Data
를 담고 있는 regular-file file wrapper
를 생성한다.
required init(configuration:)
에서는 configuration
에 있는filewrapper
에 저장되어 있는 contents
를 불러온 다음 역직렬화해서 emojiArt
로 설정한다. Model
을 불러온 다음에는 배경 이미지도 꼭 불러와주기...! 실패하는 경우 에러를 던지는데 보통은 그럴 일이 없을 거라고 하셨다....
class EmojiArtDocument: ReferenceFileDocument{
// MARK: - ReferenceFileDocument
static var readableContentTypes = [UTType.emojiart]
static var writeableContentTypes = [UTType.emojiart]
required init(configuration: ReadConfiguration) throws {
if let data = configuration.file.regularFileContents {
emojiArt = try EmojiArtModel(json: data)
fetchBackgroundImageDataIfNecessary()
} else {
throw CocoaError(.fileReadCorruptFile)
}
}
func snapshot(contentType: UTType) throws -> Data {
// Model converted (possibly) to some other type, like a Data
try emojiArt.json()
}
// writing the document out to a file
func fileWrapper(snapshot: Data, configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: snapshot)
}
...
}
아까 ReferenceFileDocument
프로토콜에 따르게 하면 자동저장이 된다고 했는데, 새 파일을 생성해서 이모지를 추가한 다음 껐다 키면 다시 빈 파일이 우리를 맞이한다...아직 Undo
를 구현하지 않았기 때문인데, SwiftUI
는 undo
가 등록될 때? how do i say register...변화가 생겼음을 인식하고, 그제서야 snapshot(contentType:)
을 호출한다.
Undo
구현은 undoManager
를 사용해서 하는데, 특정 작업을 수행할 때 해당 작업에 대한 undo operation
을 undo stack
에 넣으면 해당 작업을 undoable
하게 만들 수 있다. 스택에 넣은 뒤에 undo()
메서드를 호출하면 undo
작업이 수행된다.
UndoManager
의 registerUndo(withTarget:handler:)
메서드를 사용하면 undo stack
에 handler 클로저
를 target
에 대한 undo operation
으로 넣을 수 있고, undo()
호출 시 해당 클로저가 실행된다.undo manager
가 undo/redo
작업을 수행할 수 있도록 target
은 reference type
으로 전달되어야 하며, ARC
를 피하기 위해서 unowned reference
를 갖는다. 주로 Intent 함수
들을 호출할 때 유저가 undo
하고 싶을 것이므로 보통 ViewModel
내 관련 코드가 위치하는데, Model
이 value type
인 경우 ( handler
에서 쓰일) snapshot
을 만들기 쉬워 쉽게 undo
할 수 있다
그러나 UndoManager
자체는 View
의 Environment
의 일부이므로, Intent 함수
가 호출될 때 View
로부터 넘겨받아야 한다.
undo
작업을 좀 더 쉽게 하기 위해 만든 함수인데 기본 메커니즘은 undoable
하게 만들고 싶은 작업인 doit
을 수행하기 전에 Model
을 로컬 변수에 저장한 다음(스냅샷을 저장), doit
을 수행하면서, undoManager
를 사용해서 undo operation
을 등록하는 것. 이때, undo operation
은 현재 Model
을 아까 로컬 변수에 저장한 Model
로 바꿔주는 클로저다. undo operation
을 undoablyPeform
하면 undo
를 undo
하는 것이므로 redo
가 가능해진다!operation
인자는 나중에 View
에서 menu item
이 어떤 작업을 undo
할지 표시하는 데 쓰기 위해서 받는 String
class EmojiArtDocument: ReferenceFileDocument {
...
private func undoablyPerform(operation: String, with undoManager: UndoManager? = nil, doit: () -> Void) {
let oldEmojiArt = emojiArt // snapshot our Model so we can undo back to it
doit() // do some operation
// make it undoable
undoManager?.registerUndo(withTarget: self) { myself in
// made it redo by making undo undoable...
myself.undoablyPerform(operation: operation, with: undoManager) {
myself.emojiArt = oldEmojiArt
}
}
undoManager?.setActionName(operation)
}
}
Intent 함수
들의 작업을 undo
하려고 하는 것이므로 각 Intent 함수
내부에서 작업을 수행하는 코드를 undoablyPerform(operation:with:doit:)
의 doit
인자로 넣어주면 해당 작업이 undoable
해진다!class EmojiArtDocument: ReferenceFileDocument {
func setBackground(_ background: EmojiArtModel.Background, undoManager: UndoManager?) {
undoablyPerform(operation: "Set Background", with: undoManager) {
emojiArt.background = background
}
}
func addEmoji(_ emoji: String, at location: (x: Int, y: Int), size: CGFloat, undoManager: UndoManager?) {
undoablyPerform(operation: "Add \(emoji)", with: undoManager) {
emojiArt.addEmoji(emoji, at: location, size: Int(size))
}
}
func moveEmoji(_ emoji: EmojiArtModel.Emoji, by offset: CGSize, undoManager: UndoManager?) {
if let index = emojiArt.emojis.index(matching: emoji) {
undoablyPerform(operation: "Move", with: undoManager) {
emojiArt.emojis[index].x += Int(offset.width)
emojiArt.emojis[index].y += Int(offset.height)
}
}
}
func scaleEmoji(_ emoji: EmojiArtModel.Emoji, by scale: CGFloat, undoManager: UndoManager?) {
if let index = emojiArt.emojis.index(matching: emoji) {
undoablyPerform(operation: "Scale", with: undoManager) {
emojiArt.emojis[index].size = Int((CGFloat(emojiArt.emojis[index].size) * scale).rounded(.toNearestOrAwayFromZero))
}
}
}
}
undoManager
는 View
의 Environment Value
에 해당되므로 View
에서 intent 함수
를 호출할 때 인자로 넣어줘야 한다. 따라서 아래와 같이 undoManager
를 선언해서 쓴다! struct EmojiArtDocumentView: View {
...
@Environment(\.undoManager) var undoManager
...
}
undo/redo
를 구현했으니, 유저가 실제로 해당 작업을 할 수 있도록 View
의 toolbar
에 undo/redo
버튼을 달아주면 이제 진짜 끝이다..다음 extension
과 View
를 사용했는데, UndoManager extension
은 만약 undo/redo
할 작업이 있는 경우 해당 작업의 이름이나 디폴트값을 리턴하도록 하고, UndoButton
은 작업의 이름을 인자로 받아서 undo/redo
할 작업이 있는 경우에만 버튼의 라벨로 써서 버튼을 나타낸다. struct UndoButton: View {
let undo: String?
let redo: String?
@Environment(\.undoManager) var undoManager
var body: some View {
let canUndo = undoManager?.canUndo ?? false
let canRedo = undoManager?.canRedo ?? false
if canUndo || canRedo {
Button {
if canUndo {
undoManager?.undo()
} else {
undoManager?.redo()
}
} label: {
if canUndo {
Image(systemName: "arrow.uturn.backward.circle")
} else {
Image(systemName: "arrow.uturn.forward.circle")
}
}
.contextMenu {
if canUndo {
Button {
undoManager?.undo()
} label: {
Label(undo ?? "Undo", systemImage: "arrow.uturn.backward")
}
}
if canRedo {
Button {
undoManager?.redo()
} label: {
Label(redo ?? "Redo", systemImage: "arrow.uturn.forward")
}
}
}
}
}
}
extension UndoManager {
var optionalUndoMenuItemTitle: String? {
canUndo ? undoMenuItemTitle : nil
}
var optionalRedoMenuItemTitle: String? {
canRedo ? redoMenuItemTitle : nil
}
}
struct EmojiArtDocumentView: View {
...
@Environment(\.undoManager) var undoManager
var documentBody: some View {
GeometryReader { geometry in
ZStack {
// some code
}
.toolbar {
UndoButton(
undo: undoManager?.optionalUndoMenuItemTitle,
redo: undoManager?.optionalRedoMenuItemTitle
)
}
}
}
...
}
EmojiArt
랑 비슷해 보이지만 아이패드의 Files
를 이용해서 저장된 파일들을 본 건데, Sun.emojiart
를 누르면 emojiart
가 뜨는 게 아니고 그냥 설명만 뜬다
iOS에 시스템 Files app
에서 .emojiart document
들을 열어도 된다고 알려줘야 하는데 Info.plist
에서 우클릭 - Add Row - Supports Document Browser
를 Yes
로 설정해주면 된다! 이렇게 하면 시스템 Files 앱
에서 .emojiart
파일들을 눌렀을 때 파일이 열리면서 EmojiArt
앱으로 연결된다.
사실 새 프로젝트를 생성할 때마다 자동으로 생성되는 App
파일을 들여다보지 않은 게 걸려서 완강하고 나면 꼭 공부해봐야지 했는데 역시...교수님은 계획이 다 있으셨다...초반에는 배울 게 너무 많아서 App
파일을 보고도 아 미래의 나에게 맡기자라고 생각했는데 슬슬 Swift
이제 좀 익숙해졌다 싶어서 아 이것도 이제 진짜 봐야지 싶을 때 딱 강의 주제로 나오다니 소름...ㅋㅋㅋㅋㅋㅋㅋ
Document-oriented App
이란 무엇인가에 대해서 감이 잘 오지 않았는데 이번 강의를 들으면서 일반 앱과의 차이를 알 수 있었다.
each of the Scenes(i.e. the top level View) in the documentGroup is using its own VM
protocol ReferenceFileDocument : it's all about putting ur document onto disc and getting it off the disc
Undo: the way that the document architecture knows that ur documnet has changed and can auto-save it
- the whole saving mechanism in iOS is based on auto-saving ur documnet(unlike Mac, there's no save menu)
FileDocument: sth that the Model must conform to if we don't want to implement Undo...it's about put the model in and out of disc
- init : gives ReadConfiguration which tells u the file from which u r suppose to read ur document
ReferenceFileDocument
- for reference types...
make type EmojiArtDocument...
mark Document Type as owner b/c we're not just a viewer, we actually own this type, so we're the ones who r gonna be registering it on the iOS device and all that
make reference blahblah protocol
undo/redo