使用 SwiftUI PreviewModifier 設定 preview 共用的資料和 modifier — iOS 18 新功能

開發 SwiftUI App 時,我們常常需要在很多畫面的 preview 設定共用的資料和 modifier,比方 Observable 的物件,SwiftData 的 Model Container,偏好的顏色模式等。

從 iOS 18 開始有簡化程式的方法,我們可以用 PreviewModifier 將 preview 共用的資料和 modifier 集中在一個地方,不用再重覆撰寫。

以下我們分別以 Observable 的物件和 SwiftData 的 Model Container 舉例說明。

設定 preview 共用的 Observable 物件

以電影資料為例,以下我們用 MovieStore & Movie 定義電影資料。

MovieStore.swift

import Foundation

@Observable
class MovieStore {
var movies = [
Movie(title: "哥吉拉-1.0", year: 2023, rating: 4.5),
]
}

Movie.swift

import Foundation

struct Movie: Identifiable {
let id = UUID()
let title: String
let year: Int
let rating: Double
}

當我們想在畫面存取 MovieStore & 顯示 dark mode 模式時,preview 裡要記得加上 .environment(MovieStore()).preferredColorScheme(.dark)

import SwiftUI

struct MovieListView: View {
@Environment(MovieStore.self) private var movieStore

var body: some View {
List {
ForEach(movieStore.movies) { movie in
Text(movie.title)
}

}
}
}

#Preview {
MovieListView()
.environment(MovieStore())
.preferredColorScheme(.dark)
}

接下來讓我們用 iOS 18 的 PreviewModifier 簡化 preview 的程式。

import SwiftUI

struct SampleMovieStoreEnvironment: PreviewModifier {
static func makeSharedContext() async throws -> MovieStore {
MovieStore()
}

func body(content: Content, context: MovieStore) -> some View {
content
.environment(context)
.preferredColorScheme(.dark)
}
}

說明。

  • 定義遵從 PreviewModifier 的型別 SampleMovieStoreEnvironment。
  • function makeSharedContext 回傳 preview 需要的資料,在此我們回傳 MovieStore。
  • 在 function body 設定 preview 需要的資料跟 modifier,body 的參數 content 是 preview 顯示的畫面,context 是 makeSharedContext 回傳的 MovieStore。

之後定義 preview 畫面時,只要在參數 traits 傳入 .modifier(SampleMovieStoreEnvironment()),即可讓 preview 畫面存取 MovieStore 和套用 dark mode。

#Preview(traits: .modifier(SampleMovieStoreEnvironment())) {
MovieListView()
}

我們還可進一步簡化,透過定義 PreviewTrait 的 extension,宣告 type property sampleMovieStoreEnvironment 是 .modifier(SampleMovieStoreEnvironment())。

import SwiftUI

struct SampleMovieStoreEnvironment: PreviewModifier {
static func makeSharedContext() async throws -> MovieStore {
MovieStore()
}

func body(content: Content, context: MovieStore) -> some View {
content
.environment(context)
.preferredColorScheme(.dark)
}
}

extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var sampleMovieStoreEnvironment: Self = .modifier(SampleMovieStoreEnvironment())
}

之後定義 preview 畫面時,traits 參數將更簡短,輸入 .sampleMovieStoreEnvironment 即可。

#Preview(traits: .sampleMovieStoreEnvironment) {
MovieListView()
}

設定 preview 共用的 SwiftData Model Container

以歌曲資料為例,以下我們用 Song & SampleData 定義歌曲資料。

Song.swift

import Foundation
import SwiftData

@Model
class Song {
var title: String
var singer: String

init(title: String, singer: String) {
self.title = title
self.singer = singer
}

static let sampleData = [
Song(title: "多虧你啊", singer: "戴佩妮")
]
}

SampleData.swift

import Foundation
import SwiftData

@MainActor
class SampleData {
static let shared = SampleData()

let modelContainer: ModelContainer

var context: ModelContext {
modelContainer.mainContext
}

private init() {
let schema = Schema([
Song.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
do {
modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration])
insertSampleData()
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}

func insertSampleData() {
for song in Song.sampleData {
context.insert(song)
}
}

var song: Song {
Song.sampleData[0]
}
}

當我們想在畫面存取 songs & 顯示 dark mode 模式時,preview 裡要記得加上 modelContainer(SampleData.shared.modelContainer).preferredColorScheme(.dark)

import SwiftUI
import SwiftData

struct SongListView: View {
@Query private var songs: [Song]

var body: some View {
List {
ForEach(songs) { song in
Text(song.title)
}
}
}
}

#Preview {
SongListView()
.modelContainer(SampleData.shared.modelContainer)
.preferredColorScheme(.dark)
}

同樣的,定義遵從 PreviewModifier 的型別可以簡化 preview 的程式,這次 makeSharedContext 改成產生需要的 Song 和回傳 SwiftData 需要的 ModelContainer。

import SwiftUI
import SwiftData

struct SampleSongEnvironment: PreviewModifier {
static func makeSharedContext() async throws -> ModelContainer {
let schema = Schema([
Song.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
let modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration])
for song in Song.sampleData {
modelContainer.mainContext.insert(song)
}
return modelContainer
}

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

extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var sampleSongEnvironment: Self = .modifier(SampleSongEnvironment())
}

preview 程式成功地簡化,現在只需在參數 traits 傳入 .sampleSongEnvironment 即可存取 SwiftData 需要的 model container。

#Preview(traits: .sampleSongEnvironment) {
SongListView()
}

參考連結

剛剛例子的寫法主要參考 Apple 的官方範例,有興趣的朋友也可進一步研究以下程式。

  • BOT-anist。
import SwiftUI

struct SampleAppStateEnvironment: PreviewModifier {
static func makeSharedContext() async throws -> AppState {
AppState()
}

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

extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var sampleAppState: Self = .modifier(SampleAppStateEnvironment())
}
#Preview(traits: .sampleAppState) {
ContentView()
}
  • Destination Video。
import Foundation
import SwiftData
import SwiftUI

/// A modifier that creates the a model container for the preview data.
struct PreviewData: PreviewModifier {
static func makeSharedContext() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: Video.self, Genre.self, Person.self, UpNextItem.self,
configurations: config
)
try Importer.importVideoMetadata(into: container.mainContext, isPreview: true)
return container
}

func body(content: Content, context: ModelContainer) -> some View {
content.modelContainer(context)
.environment(PlayerModel(modelContainer: context))
#if os(visionOS)
.environment(ImmersiveEnvironment())
#endif
#if os(iOS) || os(macOS)
.preferredColorScheme(.dark)
#endif
}
}

extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var previewData: Self = .modifier(PreviewData())
}
#Preview(traits: .previewData) {
ContentView()
}
  • Previewing your app’s interface in Xcode。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com