ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Build an app with SwiftData (feat. WWDC 2023)
    SwiftData 2023. 6. 13. 10:51

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 WWDC 2023에서 공개된 SwiftData로 앱을 구성하는 방법에 대해 학습해보겠습니다🙋🏻

     

    우선 이번 포스팅은 WWDC 2023의 섹션을 보면서 소개하는 앱을 다운 받아 같이 진행되는 형식입니다.

    그렇기에 아래 링크를 통해 해당 프로젝트를 다운 받아서 같이 살펴보면서 흐름을 이해하는것이 좋습니다😃

    https://developer.apple.com/documentation/SwiftUI/Building-a-document-based-app-using-SwiftData

     

    Building a document-based app using SwiftData | Apple Developer Documentation

    Code along with the WWDC presenter to transform an app with SwiftData.

    developer.apple.com

     

    그럼 들어가볼까요~?🕺🏻

     

    Meet the app

    어떤 앱을 만드는걸까?

    우선 단순하게 이러한 카드들을 통해 화면 이동을 하고 해당 카드들의 상태를 변경하는 등 간단한 앱을 소개합니다.

     

    그럼 우선 Model을 SwiftData에 맞게 구성해야겠죠?

     

    SwiftData Models

    Model

    해당 카드 모델을 정의해볼께요.

    import Foundation
    import SwiftData
    
    // MARK: - 기존
    final class Card: ObservableObject {
        @Published var front: String
        @Published var back: String
        var creationDate: Date
    
        init(front: String, back: String, creationDate: Date = .now) {
            self.front = front
            self.back = back
            self.creationDate = creationDate
        }
    }
    
    extension Card: Identifiable { }
    
    extension Card: Hashable {
        static func == (lhs: Card, rhs: Card) -> Bool {
            lhs.front == rhs.front &&
            lhs.back == rhs.back &&
            lhs.creationDate == rhs.creationDate
        }
    
        func hash(into hasher: inout Hasher) {
            hasher.combine(front)
            hasher.combine(back)
            hasher.combine(creationDate)
        }
    }
    
    // MARK: - SwiftData 방식으로 변경
    @Model
    final class Card {
        var front: String
        var back: String
        var creationDate: Date
    
        init(front: String, back: String, creationDate: Date = .now) {
            self.front = front
            self.back = back
            self.creationDate = creationDate
        }
    }
    
    extension Card: Identifiable { }
    
    extension Card: Hashable {
        static func == (lhs: Card, rhs: Card) -> Bool {
            lhs.front == rhs.front &&
            lhs.back == rhs.back &&
            lhs.creationDate == rhs.creationDate
        }
    
        func hash(into hasher: inout Hasher) {
            hasher.combine(front)
            hasher.combine(back)
            hasher.combine(creationDate)
        }
    }

    기존 방식에서 우리는 익숙하듯 모델은 ObservableObject 프로토콜을 채택해야 하며 각 업데이트할 프로퍼티는 @Published 프로퍼티 래퍼를 따라야합니다.

     

    그러나 SwiftData 방식으로 변경하게 되면 @Model 매크로를 통해 선언 시 정의해주면 ObservableObject를 따르기에 더 이상 프로토콜 채택을 명시적으로 하지 않아도 됩니다.

    @Published도 마찬가지죠!

     

    아주 이렇게 간단히 모델의 변경은 끝났어요!
    이 모델을 사용하는 View를 봐볼까요?

     

    View

    // MARK: - 기존
    struct CardEditorView: View {
        @ObservedObject var card: Card
        @FocusState private var focusedField: FocusedField?
        ...
    }
    
    // MARK: - 변경
    struct CardEditorView: View {
        @Bindable var card: Card
        @FocusState private var focusedField: FocusedField?
        ...
    }

    보시면 기존 ObservableObject 모델은 뷰에서 @ObservedObject 혹은 @StateObject 프로퍼티 래퍼를 붙여 선언해줘야 합니다.

    그러나 이제는 @Bindable로 변경하는게 다입니다!

    카드의 앞 뒷면 텍스트에 직접 바인딩 되도록 설정합니다.

     

    여기서 선언을 하기 위한 두가지가 있어요.

    하나는 @Observable 매크로와 @Bindable 프로퍼티 래퍼가 있습니다.

    이 두가지로 기존보다 훨씬 적은 코드로 데이터 흐름을 쉽게 설정할 수 있습니다.

     

    @Observable과 @Bindable의 차이를 좀 더 자세히 알고 싶으시면 아래 포스팅을 참고해주세요🙋🏻

    https://green1229.tistory.com/373

     

    Discover Observation in SwiftUI (feat. WWDC 2023)

    안녕하세요. 그린입니다🍏 이번 포스팅에서는 이번 WWDC 2023에서 소개된 SwiftUI에서 데이터 플로우 변화를 가져가면서 새로 생긴 매크로와 기존 데이터 바인딩 등 방식의 변화들을 다뤄볼까해요

    green1229.tistory.com

     

    자 그럼 이제 모델 설정이 끝났다면 SwiftData에서 모델을 쿼리하고 UI 표시하기 위해 ContentView로 넘어가겠습니다🕺🏻

     

    Querying models to display in UI

    ContentView

    import SwiftUI
    
    // MARK: - 기존
    struct ContentView: View {
        @State private var cards: [Card] = SampleDeck.contents
        @State private var editing = false
        @State private var navigationPath: [Card] = []
    
        var body: some View {
            NavigationStack(path: $navigationPath) {
                CardGallery(cards: cards, editing: $editing) { card in
                    withAnimation { navigationPath.append(card) }
                } addCard: {
                    let newCard = Card(front: "Sample Front", back: "Sample Back")
                    // save card
                    withAnimation {
                        navigationPath.append(newCard)
                        editing = true
                    }
                }
                .padding()
                .toolbar { EditorToolbar(isEnabled: false, editing: $editing) }
            }
        }
    }
    
    // MARK: - 변경
    struct ContentView: View {
        @Query private var cards: [Card]
        @State private var editing = false
        @State private var navigationPath: [Card] = []
        ...
    }

    기존 cards 프로퍼티를 보면 SampleDeck 대신 SwiftData를 이용하려해요.

    즉, SwiftData 스토리지에 바인딩하기 위해서는 @State 프로퍼티 래퍼 대신 @Query로 교체되어야 합니다.

    그게 다입니다.

    UI에서 SwiftData에 의해 관리되는 모델을 표시하고 싶을때마다 @Query를 사용하면 됩니다🙌

     

    @Query

    해당 프로퍼티 래퍼는 SwiftData에서 모델을 쿼리해줍니다.

    기존 @State가 작동하는 방식과 유사하게 모델이 변경될 때마다 업데이트된 뷰를 트리거해줍니다.

    모든 뷰는 필요한 만큼 @Query 프로퍼티를 가질 수 있어요.

    즉, 정렬 및 필터링 등 편하게 프로퍼티를 구성하고 사용할 수 있습니다.

    내부적으로는 뷰의 모델 컨텍스트를 데이터 소스로 사용합니다.

     

    그럼 여기서 @Query에 모델 컨텍스트를 어떻게 제공할까요?

     

    modelContainer

    SwiftUI는 뷰의 모델 컨테이너의 편리한 설정을 위해 아래 뷰 및 씬 모디파이어를 제공해줍니다.

    .modelContainer(for: Card.self)

    즉, SwiftData를 사용하려면 모든 앱에서는 하나 이상의 ModelContainer를 설정해줘야 합니다.

    그렇게 되면 @Query가 사용할 컨텍스트를 포함해 전체 스토리지 스택을 생성해요.

    뷰에는 단일 모델 컨테이너가 있지만 뷰 계층 구조에 필요한 만큼 컨테이너를 만들고 사용할 수 있어요.

     

    import SwiftUI
    
    @main
    struct SwiftDataFlashCardSample: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
            }
            .modelContainer(for: Card.self)
        }
    }

    이렇게 최상위 앱 구조체에서 심어줘야 합니다.

     

    그럼 아래와 같이 전체 그룹 scene에서 어디든지 설정할 수 있어요.

    즉 컨테이너를 상속 받게 되는 것이죠.

     

    만약 여러개의 스토리지 스택이 필요하다면 이렇게도 설정 가능합니다.

    import SwiftUI
    
    @main
    struct SwiftDataFlashCardSample: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
            }
            .modelContainer(for: Card.self)
            
            Window("Card Designer") {
                CardDesignerView()
            }
            .modelContainer(for: Design.self)
        }
    }

    그럼 서로 다른 윈도우에 모델 컨테이너를 가져가게 되죠.

     

    만약 해당 SwiftData로 사용할때 프리뷰에서 샘플 데이터가 필요할 수 있습니다.

    import SwiftData
    
    @MainActor
    let previewContainer: ModelContainer = {
        do {
            let container = try ModelContainer(
                for: Card.self, ModelConfiguration(inMemory: true)
            )
            for card in SampleDeck.contents {
                container.mainContext.insert(object: card)
            }
            return container
        } catch {
            fatalError("Failed to create container")
        }
    }()

    이렇게 샘플 컨테이너를 구성해줍니다.

    그런 다음 프리뷰를 제공할 뷰에서 아래와 같이 모델 컨테이너를 심어줄 수 있어요.

    #Preview {
        ContentView()
            .frame(minWidth: 500, minHeight: 500)
            .modelContainer(previewContainer)
    }

     

    자 그럼 이제 실제로 카드의 변경 및 새로운 사항들을 SwiftData가 추적하고 저장을 어떻게 하는지 확인해볼까요?

     

    Creating & Updating

    뷰에서 모델 컨텍스트에 액세스할 수 있도록 SwiftUI는 새로운 environment 변수를 제공합니다.

    @Environment(\.modelContext) private var modelContext

    모델 컨테이너와 마찬가지로 각 뷰에선 단일 컨텍스트가 있지만 필요한 만큼 더 가져갈 수 있어요.

    import SwiftUI
    import SwiftData
    
    struct ContentView: View {
      @Query private var cards: [Card]
      @State private var editing = false
      @State private var navigationPath: [Card] = []
      
      // MARK: - 🙋🏻 환경변수 추가
      @Environment(\.modelContext) private var modelContext
      
      var body: some View {
        NavigationStack(path: $navigationPath) {
          CardGallery(cards: cards, editing: $editing) { card in
            withAnimation { navigationPath.append(card) }
          } addCard: {
            let newCard = Card(front: "Sample Front", back: "Sample Back")
            
            // MARK: - 🙋🏻 Save Card
            modelContext.insert(newCard)
            
            withAnimation {
              navigationPath.append(newCard)
              editing = true
            }
          }
          .padding()
          .toolbar { EditorToolbar(isEnabled: false, editing: $editing) }
        }
      }
    }

    SwiftData는 모델 컨텍스트를 자동 저장합니다.

    그렇기에 위에서 save가 아닌 insert로 변경만 해줘도 자동 저장되죠.

    즉, 저장에 대해 걱정할 필요가 없고 SwiftData 스토리지를 공유하거나 전송하기 전에 모든 변경 사항이 즉시 유지되는지 굳이 확인할 필요가 없습니다👍

     

    자 이렇게 편리하게 카드를 추가하고 앱을 종료하고 다시 실행해도 유지되어 남아있습니다😁

     

    마지막으로 알아볼것은 SwiftUI가 SwiftData기반 문서 앱 지원에 대한것입니다.

     

    Document-based apps

    실제로 SwiftUI를 통해 카드를 별도의 문서로 취급할 수 있어요.

    그리고 이 문서들은 공유 / 저장/ 복사 등도 할 수 있습니다.

     

    말이 어렵지 실제 보면 이해가 되실거에요!😄

     

    우선 App 파일을 엽니다.

    import SwiftUI
    import SwiftData
    
    @main
    struct SwiftDataFlashCardSample: App {
        var body: some Scene {
            #if os(iOS) || os(macOS)
            DocumentGroup(editing: Card.self, contentType: .flashCards) {
                ContentView()
            }
            #else
            WindowGroup {
                ContentView()
                    .modelContainer(for: Card.self)
            }
            #endif
        }
    }

    이렇게 분기를 하여 DocumentGroup에 ContentView를 심어주죠.

    여기서 editing으론 SwiftData를 통해 구현한 모델 타입을 넣어줍니다.

    그리고 중요한 contentType을 보겠습니다.

     

    SwiftData Document-based 앱은 사용자 지정 콘텐츠 유형을 선언해줘야 합니다.

    각 SwiftData 문서는 고유한 모델 셋에 빌드되기에 디스크에 고유한 표현이 있습니다.

    예를들어 모든 JPEG는 동일한 이진 구조를 가져요.

    그리고 Xcode 프로젝트에는 특정 디렉토리와 파일이 포함되어 있죠.

    사용자가 카드 데크를 열 때 카드 데크 형식과 파일 확장자를 앱과 연결하려면 OS가 필요하죠.

    이것이 콘텐츠 유형을 선언해야 하는 이유입니다.

     

    SwiftData document는 패키지이기에 SwiftData 모델의 일부 프로퍼티를 externalStorage 속성으로 가져가면 외부에 저장된 모든 항목이 문서 패키지의 일부가 됩니다.

     

    import UniformTypeIdentifiers
    
    extension UTType {
        static var flashCards = UTType(exportedAs: "com.example.flashCards")
    }

    이렇게 UTType을 확장하여 정의했는데요.

    해당 코드에는 새 콘텐츠 유형에 대한 정의가 있어 코드에서 편하게 사용할 수 있어요.

     

    이제 OS에서 새로운 콘텐츠 유형을 선언해야 합니다.

    즉, 앱에서 만든 카드 데크를 다른 문서와 구별하는데 도움이 되도록 파일 확장자를 지정해줘야 해요.

    이렇게 앱 타겟에서 Info 섹션에 Exported Type Identifiers를 커스텀하게 채워 넣습니다.

    중요한건 identifier는 위에서 정한 코드의 식별자와 동일해야 합니다.

    SwiftData document는 패키지이기에 타입이 com.apple.package를 준수하는지 확인해야 합니다.

     

    그럼 이제 선언한 콘텐츠 유형을 위 App 코드에서 볼 수 있듯이 넣어주면 됩니다.

    모델 컨테이너는 설정하지 않아도 문서 인프라가 각 문서에 대해 하나씩 설정합니다.

     

    그럼 한번 돌려볼까요?

    자 이렇게 문서로 카드 데크로 생성하고 저장할 수 있습니다.

     

    마무리

    이렇게 실제 앱에서 어떻게 SwiftData를 기본적으로 사용하는지와 확장되어 어떻게 Document로 활용할 수 있는지를 알아봤습니다!

    너무 편리한거 같아요 언제쯤 써보려나 iOS 17 미니멈 타겟...?

     

    참고 자료

    https://developer.apple.com/wwdc23/10154

     

    Build an app with SwiftData - WWDC23 - Videos - Apple Developer

    Discover how SwiftData can help you persist data in your app. Code along with us as we bring SwiftData to a multi-platform SwiftUI app...

    developer.apple.com

    'SwiftData' 카테고리의 다른 글

    Model your schema with SwiftData (feat. WWDC 2023)  (11) 2023.06.13
    Meet SwiftData (feat. WWDC 2023)  (8) 2023.06.08
Designed by Tistory.