Post

Replace ModelContainerPreview with PreviewModifier

Replace ModelContainerPreview with PreviewModifier

PreviewModifier 取代 ModelContainerPreview

上一篇(搞懂 SwiftData Preview 為什麼需要 ModelContainerPreview)解釋了 Apple 範例裡 ModelContainerPreview 在做什麼。

這篇講的是:如果你的專案支援 iOS 18+,可以用 Apple 官方的 PreviewModifier protocol 取代它,刪掉一個檔案。

來源

Apple 在 WWDC24 的 What’s new in SwiftData(8:15 開始)直接示範了這個做法:

To use preview traits, you can create a new struct that conforms to PreviewModifier which has two functions: one for setting up a shared context for the preview, and another to apply the shared context to a view. For SwiftData previews, you can vend a ModelContainer as the shared context, creating a ModelConfiguration that stores data in memory only.

官方 API 文件:PreviewModifier – Apple Developer Documentation

另外 Donny Wals 也有寫專文介紹。

PreviewModifier 是什麼?

它是 SwiftUI framework 內建的 protocol(iOS 18+ / macOS 15+),讓你定義「Preview 的共享環境」。你只要寫一個 struct,實作兩個 method:

  1. makeSharedContext() — 準備什麼東西(建 container、塞假資料)
  2. body(content:context:) — 怎麼把那個東西注入到 View 裡

然後在 #Previewtraits: 參數一行搞定。

它取代了什麼?

PreviewModifier 讓你可以刪掉 ModelContainerPreview.swift 這個手刻的 wrapper View。

ModelContainerPreview 手動處理的三件事,PreviewModifier 全部幫你做了:

ModelContainerPreview 手動處理的PreviewModifier 怎麼處理
MainActor.assumeIsolated 確保在 main threadmakeSharedContext()async,framework 自動排程
try / fatalError 錯誤處理makeSharedContext()throws,framework 自動 catch
.modelContainer(container) 注入body(content:context:) 裡寫一次就好

ModelContainerPreview.swift 做的事 framework 都幫你做了,所以可以刪掉。

完整對照:舊 → 新

舊的做法(兩個檔案)

1
2
3
PreviewHelper/
├── ModelContainerPreview.swift   ← 刪掉
└── Preview+ModelContainer.swift  ← 要改寫

ModelContainerPreview.swift(整個檔案要刪掉):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 手刻一個 wrapper View,處理 MainActor、try/catch、注入 container
struct ModelContainerPreview<Content: View>: View {
    var content: () -> Content
    @State var container: ModelContainer

    init(_ modelContainer: @escaping () throws -> ModelContainer,
         @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        do {
            self.container = try MainActor.assumeIsolated(modelContainer)
        } catch {
            fatalError("Failed to create the model container: \(error.localizedDescription)")
        }
    }

    var body: some View {
        content()
            .modelContainer(container)
    }
}

Preview+ModelContainer.swift(舊的):

1
2
3
4
5
6
7
8
9
10
11
// 提供一個 closure 給 ModelContainerPreview 呼叫
extension ModelContainer {
    @MainActor static let sample: () throws -> ModelContainer = {
        let schema = Schema([FoodLog.self, CareEvent.self])
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: schema, configurations: [configuration])
        FoodLog.insertSampleData(modelContext: container.mainContext)
        CareEventSeeder.seedForPreview(into: container.mainContext)
        return container
    }
}

舊的 Preview 寫法:

1
2
3
4
5
#Preview {
    ModelContainerPreview(ModelContainer.sample) {
        DailyTimelineView()
    }
}

新的做法(一個檔案)

1
2
3
PreviewHelper/
├── ModelContainerPreview.swift   ← 刪掉了
└── Preview+ModelContainer.swift  ← 改寫成 PreviewModifier

Preview+ModelContainer.swift(新的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftData
import SwiftUI

/// Preview 用的 SwiftData 環境:in-memory container + 假資料。
///
/// 遵循 PreviewModifier protocol(iOS 18+),取代舊的 ModelContainerPreview wrapper。
/// - `makeSharedContext()` 負責建 container 和塞假資料
/// - `body(content:context:)` 負責把 container 注入到 View 的 environment
///
/// 來源:[What's new in SwiftData – WWDC24](https://developer.apple.com/videos/play/wwdc2024/10137/?time=495)
struct SampleDataPreviewModifier: PreviewModifier {
    static func makeSharedContext() async throws -> ModelContainer {
        let schema = Schema([FoodLog.self, CareEvent.self])
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: schema, configurations: [config])
        #if DEBUG
        FoodLog.insertSampleData(modelContext: container.mainContext)
        CareEventSeeder.seedForPreview(into: container.mainContext)
        #endif
        return container
    }

    func body(content: Content, context: ModelContainer) -> some View {
        content.modelContainer(context)
    }
}

extension PreviewTrait where T == Preview.ViewTraits {
    /// 在 #Preview 裡用 `traits: .sampleData` 即可注入 SwiftData 環境。
    @MainActor static var sampleData: Self = .modifier(SampleDataPreviewModifier())
}

新的 Preview 寫法:

1
2
3
#Preview(traits: .sampleData) {
    DailyTimelineView()
}

遷移步驟

如果你的專案 deployment target 是 iOS 18+(MeowCare 是 18.1),遷移很簡單:

  1. 改寫 Preview+ModelContainer.swift — 把 ModelContainer.sample extension 換成上面的 SampleDataPreviewModifier + PreviewTrait extension
  2. 全專案搜尋 ModelContainerPreview( — 把每個 #Preview 從舊語法改成新語法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    // 舊
    #Preview {
        ModelContainerPreview(ModelContainer.sample) {
            SomeView()
        }
    }
    
    // 新
    #Preview(traits: .sampleData) {
        SomeView()
    }
    
  3. 搜尋 ModelContainer.sample) — 有些 Preview 可能直接用 .modelContainer(try! ModelContainer.sample()),也一起改掉
  4. 刪掉 ModelContainerPreview.swift — 確認沒有任何地方引用後刪除

注意事項

makeSharedContext 是共享的

PreviewModifiermakeSharedContext() 回傳的 context 會被 Xcode 快取,多個 Preview 可能共用同一個 container

這跟舊的 ModelContainerPreview 不同——舊的每次都呼叫 closure 建新 container。

實際上大部分情況這不是問題,因為 Preview 通常只讀資料。但如果你的 Preview 會測試刪除或修改,要注意 Preview 之間可能互相影響。

只適用 iOS 18+ / macOS 15+

PreviewModifier 是 iOS 18 新增的 API。如果你的專案要支援 iOS 17,就不能用,繼續用 ModelContainerPreview

不影響不依賴 SwiftData 的 View

如果 View 不使用 @Query 也不使用 @Environment(\.modelContext),就不需要 traits: .sampleData,直接寫 #Preview { ... } 即可。

This post is licensed under CC BY 4.0 by the author.