LoginSignup
10
7
iOS強化月間 - iOSアプリ開発の知見を共有しよう -

【SwiftUI】Observableマクロの基本

Last updated at Posted at 2023-09-11

この記事は何?

Appleの開発者向けドキュメントの「Managing model data in your app」を独自に解説する。

なお、SwiftUIのObservationマクロは、iOS 17、iPadOS 17、macOS 14、tvOS 17、およびwatchOS 10以降でサポートされる。

メモ
既存のアプリにObservableマクロを採用する方法については、「ObservableObjectプロトコルからObservableマクロへの移行」を参照。

Swiftマクロについては、こちらの記事で解説済み。

Swiftを基礎から学ぶには
自著、工学社より発売中の「まるごと分かるSwiftプログラミング」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。

概要

SwiftUIアプリが表示するデータは、アプリのUIを介して変更できる。
アプリがデータを管理するためには、「データを表すカスタム型」のデータモデルを作成する。
データモデルがあることによって、データ自体と「データと対話するビュー」の分離を実現する。
この分離はモジュール性とテスト性を向上させるだけでなく、アプリの仕組みを把握しやすくする。

一般的に、モデルデータ(つまり、データモデルのインスタンス)と「画面の表示内容」を同期させることは簡単ではない。
データが複数のビューに表示される場合は、特に難しい。

SwiftUIのObservationマクロは、データに加えられた変更をアプリに反映して、UIを最新の状態に保つのに役立つ。
Observationマクロを使用すると、SwiftUIはビューと「観測可能なObservableデータモデル」の依存関係を形成できる。
そして、データが変更されたときに自動でUIを更新する。

モデルデータを観察可能にする

データの変更をUIに反映するには、データモデルにObservable()マクロを適用する。
このマクロは、コンパイル時にデータモデルに「Observationのサポートを追加するためのコード」を生成し、データモデルの格納プロパティを監視させる。

たとえば、以下に定義するBookクラスは書籍情報を示すデータモデル。

@Observable class Book: Identifiable {
    var title = "Sample Book Title"
    var author = Author()
    var isAvailable = true
}

class Author {
    var name = "Sample Author"
}

Observationは、参照型データと値型データの両方をサポートしている。
「データモデルにどちらを使用するか」を決定するには、構造体とクラスの選択を参照。

重要
Observable()マクロは、オブザベーション機能を追加することに加えて、データモデルをObservableプロトコルに適合させる。
さらに、型がオブザベーションをサポートする他のAPIへのシグナルとして機能する。
データモデルにObservableプロトコルを採用するだけでは、Observation機能が追加されない。
型にObservationサポートを追加するときは常に、Observable()マクロを使用すること。

ビューでモデルデータを観察する

SwiftUIは、ビューのbodyプロパティが「(Book型インスタンスなどの)Observableなオブジェクト」のプロパティを読み取るときに、ビューとデータモデルのオブジェクトに依存関係を形成する。
bodyプロパティが「Observableなデータモデルのオブジェクト」のプロパティを読み取らない場合、ビューは依存関係を追跡しない。

追跡中のプロパティが変更されるとSwiftUIはビューを更新するが、他のプロパティが変更されたり、bodyが読み込まない場合はビューは影響を受けない。
それによって、ビューの不要な更新を回避できる。

ケース1

たとえば、以下に定義するBookViewビューはbook.titleプロパティが変更された場合にのみ、ビューが更新される。
bookインスタンスのauthorisAvailableプロパティが変更されても、ビューは更新されない。

// Book型のtitle, author, isAvailableは全て追跡中のプロパティだが
// BookViewのUIが更新されるのは、bodyで読み込むtitleプロパティに変更があった時だけ
struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

#Preview {
    BookView(book: Book())
}

ケース2

ビューのプロパティがObservableではなくても、SwiftUIはグローバルプロパティやシングルトンを使用して、追跡の依存関係を形成できる。

// globalBook変数はObservationをサポートするBook型インスタンス
// トップレベルに定義されたグローバル変数
var globalBook: Book = Book()

struct BookView: View {
    var body: some View {
        // このテキストビューは変更が追跡されている
        Text(globalBook.title)
    }
}

ケース3

Observation()マクロは、Observableなプロパティが計算プロパティであっても追跡できる。
たとえば、次のコードでは、BookインスタンスのisAvailableが変更されると依存先のビューが更新される。

Libraryクラス
// データモデルのクラス
@Observable class Library {
    var books: [Book] = [Book(), Book(), Book()]

    // books配列について、利用可能な冊数を返す計算プロパティ
    var availableBooksCount: Int {
        // Book型インスタンスのisAVailableに変更があると
        // 依存先のLibraryViewが更新される
        books.filter(\.isAvailable).count
    }
}

extension EnvironmentValues {
    var library: Library {
        get { self[LibraryKey.self] }
        set { self[LibraryKey.self] = newValue }
    }
}

private struct LibraryKey: EnvironmentKey {
    static var defaultValue: Library = Library()
}
App.swift
@main
struct SampleApp: App {
    @State private var library = Library()
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(\.library, library)    // \.libraryは独自の環境値
        }
    }
}
LibraryView構造体
// データモデルと依存関係にあるビュー
struct LibraryView: View {
    // アプリ環境に配備されたモデルデータ
    @Environment(Library.self) private var library
    
    var body: some View {
    // bodyで、libraryのavailableBooksCountを読み込んでいる
        NavigationStack {
            List(library.books) { book in
                BookView(book: book)
            }
            .navigationTitle("Books available: \(library.availableBooksCount)")
        }
    }
}

ケース4

ビューが「オブジェクトのコレクション(配列、辞書など何でも)」と依存関係を形成する場合、ビューはコレクション自体に起こる変更も追跡する。
たとえば、次のLibraryViewビューは、bodyが読み込むbooksコレクションと依存関係を形成している。
コレクションにアイテムの挿入、削除、移動、置換などの変更が発生すると、SwiftUIはビューを更新する。

struct LibraryView: View {
    // 配列の状態変数(要素はObservableなBook型インスタンス)
    // 配列自体が操作(挿入、削除、移動、置換など)されると、このビューが更新される
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            Text(book.title)  // 間接的だが、Bookのtitleプロパティにアクセス
        }
    }
}

ただし、このビューのbodyではtitleプロパティを間接的に読み取るだけなので、LibraryViewtitleプロパティとは依存関係を形成しない。
なお、Listコンテナは、コンテンツを@escapingクロージャとして保持する。
そのため、SwiftUIはリスト項目が画面に表示されるまでに、それらを作成するクロージャの呼び出を遅延する。
これは、LibraryViewbook.titleに依存するのではなく、リスト内の各Texttitleと依存関係にあることを意味する。
つまり、titleが変更された「個々のTextビュー」を更新するが、リストにある他の「titleに変更がないTextビュー」は更新しない。

メモ
Observationマクロは、ビューのbodyプロパティの実行スコープにあるObservableプロパティへの変更を追跡する。

ケース5

Observableなモデルデータを、別のビューと共有することもできる。
受信側ビューはbody内でモデルデータ型のプロパティを読み取る場合、依存関係を形成する。
たとえば、以下のLibraryViewビューとBookViewビューは、Book型インスタンスを共有している。
そして、BookViewビューではbook.titleを表示する。
titleプロパティが変更されると、SwiftUIはBookViewビューの画面を更新する。
ただし、LibraryViewビューはtitleプロパティを読まないので更新されない。

// モデルデータのtitleプロパティが変更されると...
// bodyでtitleにアクセスしていないので、ビューは更新されない
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            BookView(book: book)    // モデルデータを渡す
        }
    }
}

// LibraryViewのサブビュー(受信側)
// モデルデータのtitleプロパティが変更されると...
// bodyでtitleにアクセスしているので、ビューが更新される
struct BookView: View {
    var book: Book    // モデルデータ
    
    var body: some View {
        Text(book.title)
    }
}

ケース6

ビューに「モデルデータとの依存関係」がない場合、SwiftUIはデータが変更されてもビューを更新しない。
このアプローチにより、Observableなモデルデータは中間ビューと依存関係を形成することなく、ビュー階層のレイヤーを通過できる。

// ビュー階層; LibraryView >> LibraryItemView >> BookView

// bookプロパティが変更されても、ビューは更新されない
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]
    
    var body: some View {
        LibraryItemView(book: book)
    }
}

// LibraryViewnのサブビュー
// bookプロパティが変更されても、ビューは更新されない
struct LibraryItemView: View {
    var book: Book
    
    var body: some View {
        BookView(book: book)
    }
}

// LibraryItemViewのサブビュー
// book.titleが変更されると、ビューは更新される
struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

ただし、参照が変更された場合、Observableなオブジェクトへの参照を格納するビューが更新される。
これは、保存された参照がビューの値の一部だからであり、オブジェクトが観測可能だからではない。
たとえば、以下のようにbookへの参照が変更された場合、SwiftUIはビューを更新する。

struct BookView: View {
    var book: Book
    
    var body: some View {
        // 別の`book`インスタンスに参照を変更すると
        // ビューは更新される
    }
}

ケース7

ビューは「別のオブジェクトを介してアクセスされたObservableなモデルデータ」と依存関係を形成することもできる。
たとえば、次のLibraryItemViewビューは、book.author.nameが変更されるとビューを更新する。

// LibraryViewのサブビュー
struct LibraryItemView: View {
    var book: Book

    // author.nameの変更は、bookを介して行われるので
    // 著者を変更すると、ビューは更新される
    var body: some View {
        VStack(alignment: .leading) {
            Text(book.title)
            Text("Written by: \(book.author.name)")
                .font(.caption)
        }
    }
}

「信頼できる情報源」のモデルデータを作成する

「信頼できる情報源」を作成するには、プライベートな変数を宣言し、Observableなデータモデル型のインスタンスで初期化する。
そして、@Stateプロパティラッパーでラップする。

ケース1

たとえば、次のBookViewビューは、データモデルのBook型インスタンスを状態変数bookに保持する。

struct BookView: View {
    // book変数は「信頼できる情報源」を参照する
    @State private var book = Book()
    
    var body: some View {
        Text(book.title)
    }
}

book変数に@Stateをマークすることで、SwiftUIに「Book型インスタンスのストレージを管理する」ように指示できる。
BookViewビューを再作成するたびに、SwiftUIはbook変数を「ストレージが管理されたインスタンス」に接続して、「信頼できる情報源」をビューに提供する。

ケース2

「トップレベルのAppインスタンス」または「アプリのSceneインスタンス」でも、@Stateな状態オブジェクトを作成できる。
たとえば、次のコードは、BookReaderAppアプリのトップレベルにLibrary型インスタンスを作成する。

@main
struct BookReaderApp: App {
    @State private var library = Library()    // 信頼できる情報源を作成
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

ビュー階層全体でモデルデータを共有する

以下にあげる方法で、Libraryクラスのようなデータモデルオブジェクトをアプリ全体で共有できる。

  • ビュー階層の各ビューに、データモデルのオブジェクトを渡す
  • ビューの環境にデータモデルオブジェクトを追加する

ビュー階層がそれほど深くない(たとえば、モデルデータをサブビューと共有しない)場合は、各ビューにモデルデータを渡す方法が簡単。
ただし、通常は「ビューがサブビューにモデルデータを渡す必要があるかどうか」はわからないし、「深いレイヤーのサブビューがモデルデータを必要とするかどうか」もわからない。

毎回、各ビューにモデルデータを渡すのではなく、ビュー階層全体でモデルデータを共有するには、モデルデータを「ビューの環境」に追加する。
そのためには、environment(_:_:)モディファイアまたはenvironment(_:)モディファイアを使用する。

environment(_:_:)モディファイアを使用するには、事前にcustomEnvironmentKeyを作成する。
次に、EnvironmentValues型を拡張して、環境キーを取得および設定する「独自の環境プロパティ」を含める。
たとえば、libraryモデルデータの環境キーとプロパティを作成するコードは以下の通り。

// 環境キーを追加するエクステンション
extension EnvironmentValues {
    // 環境キーは計算プロパティ
    var library: Library {
        // 環境キーの取得はゲッターで、設定はセッターで実装
        get { self[LibraryKey.self] }
        set { self[LibraryKey.self] = newValue }
    }
}

// キーは、EnvironmentKeyプロトコルに適合した構造体
private struct LibraryKey: EnvironmentKey {
    // 静的プロパティをモデルデータのインスタンスで初期化
    static var defaultValue: Library = Library()
}

ケース

独自の環境キーとプロパティがあれば、ビューはモデルデータを環境に追加できる。
たとえば、以下のコードは、LibraryViewビューの環境にLibraryインスタンスの「信頼できる情報源」を追加するために、environment(_:_:)モディファイアを使用する。

@main
struct BookReaderApp: App {
    @State private var library = Library()    // 状態オブジェクト
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(\.library, library)    // 状態オブジェクトを環境に追加
        }
    }
}

あるビューが、環境のLibraryインスタンスを取得するには、そのビューで「インスタンスを参照する変数」を定義する。
そして、「環境値のキーパス」で初期化した@Environmentプロパティラッパーを変数にマークする。

// 環境のモデルデータを受け取るビュー
struct LibraryView: View {
    @Environment(\.library) private var library

    var body: some View {
        // ...
    }
}

ケース

environment(_:)モディファイアを使用すれば、独自の環境値を定義せずにモデルデータを環境に直接、保存できる。
たとえば、次のコードは、environment(_:)モディファイアを使用してLibraryインスタンスを環境に直接、追加する。

@main
struct BookReaderApp: App {
    @State private var library = Library()
    
    var body: some Scene {
        WindowGroup {
            // ビュー階層のトップレベル
            LibraryView()
                .environment(library)    // environment(_:)モディファイア
        }
    }
}

環境のインスタンスを取得するには、別のビューでインスタンスを保持する変数を定義して、それを@Environmentプロパティラッパーでマークする。
次のコードのようにすると、プロパティラッパーを「環境値のキーパス」で初期化せずに、モデルデータを提供できる。

// 環境のモデルデータを受け取るビュー
struct LibraryView: View {
    // プロパティラッパーをオブジェクト型で初期化
    @Environment(Library.self) private var library    
    
    var body: some View {
        // ...
    }
}

デフォルトでは、オブジェクト型をキーにして環境オブジェクトを読み取ると、非オプショナルのオブジェクトが返る。
この仕組みは、現在の階層のビューが以前にenvironment(_:)モディファイアを使用して、オプショナルではないインスタンスを保持したことが前提になっている。
オブジェクトが環境にない場合に、ビューが非オプショナルなオブジェクトを取得しようとすると、SwiftUIは例外をスローする。

「オブジェクトが環境にある」ことが保証できない場合は下記のように記述して、オブジェクトのオプショナル版を取得する。
こうすると、指定のオブジェクトが環境にない場合、SwiftUIは例外をスローする代わりにnilを返す。

@Environment(Library.self) private var library: Library?

ビューでモデルデータを変更する

ほとんどのアプリでは、アプリが表示するデータを変更できる。
データが変更されたら、それを表示するビューは「変更データを反映する内容」に表示を更新する必要がある。
SwiftUIのObservationマクロを使用すると、ビューはプロパティラッパーやバインディングを使用せずにデータ変更をサポートできる。

ケース1

たとえば、BookViewビューのボタンは、アクションクロージャでbook.isAvailableプロパティを切り替える。

// Bookインスタンスを表示する画面
struct BookView: View {
    // 変更を追跡するための属性は何もマークされていない(Book型はObservable)
    var book: Book    
    
    var body: some View {
        List {
            Text(book.title)
            HStack {
                // isAvailableが変更されると、テキストの表示が更新される
                Text(book.isAvailable ? "Available for checkout" : "Waiting for return")
                Spacer()
                // isAvailableが変更されると、ラベルの表示が更新される
                Button(book.isAvailable ? "Check out" : "Return") {
                    // タップされたら、その本が「利用可能かどうか」を切り替える
                    book.isAvailable.toggle()
                }
            }
        }
    }
}

ケース2

可変プロパティの値を変更できる以前に、ビューはバインディングしか受け取らないかもしれない。
ビューがバインディングを得るには、モデルデータに@Bindableプロパティラッパーをマークする。
例えば、以下のコードにおいて、book変数は@Bindableでラップされている。
すると、TextFieldビューを使ってbooktitleプロパティを変更したり、ToggleビューでisAvailableプロパティを切り替えることができる

// Bookインスタンスを編集する画面
struct BookEditView: View {
    @Bindable var book: Book    // Bookインスタンスの参照をバインドする
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack() {
            HStack {
                Text("Title")
                // テキストフィールドはBookインスタンスを変更するので、$構文
                TextField("Title", text: $book.title)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        dismiss()
                    }
            }

            // トグルはBookインスタンスを変更するので、$構文
            Toggle(isOn: $book.isAvailable) {
                Text("Book is available")
            }
            
            Button("Close") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

ケース3

プロパティと変数の@Bindableプロパティラッパーは、Observableオブジェクトに使用できる。
これには、グローバル変数、SwiftUIの外部に存在するプロパティ、さらにはローカル変数が含まれる。
たとえば、以下のLiblaryビューはbody内に@Bindable変数を作成する。

struct LibraryView: View {
    // books配列は状態変数(BookはObservableではない)
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            // books配列の要素への参照のバインド
            @Bindable var book = book
            // テキストフィールドは$構文なので、状態変数の配列要素を変更できる
            TextField("Title", text: $book.title)   
        }
    }
}

この@Bindablebook変数は「book.titleプロパティに接続するバインディング」なので、TextFieldビューはモデルデータを変更できる。

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7