iOS 17 の新しい onChange(of:initial:_:) とその互換実装

本記事は beta 版ソフトウェアの内容を含んでいるため、将来的に挙動が変更される可能性があります。

iOS 17 では 新しいバージョンの onChange が追加されました。

func onChange<V>(
    of value: V,
    initial: Bool = false,
    _ action: @escaping (V, V) -> Void
) -> some View where V : Equatable

View が表示された時にも実行するか指定できる initial が追加され、値が変更された時に実行するクロージャは古い値と新しい値の両方を受け取るようになり、より直感的に使用できるようになりました。(これに伴って古いバージョンの 旧バージョンの onChange は非推奨 になるようです)

struct ContentView: View {
    @State var count: Int = 0

    var body: some View {
        VStack {
            Text("count: \(count)")
            Button("+") {
                count += 1
            }
        }
        .onChange(of: count, initial: true) { oldValue, newValue in
            print("\(oldValue)  \(newValue)")
        }
    }
}

上記のコードでは、初期表示時に 0 → 0 、ボタンをタップすると 0 → 11 → 2 … と出力が続きます。

なお、 initial

Whether the action should be run when this view initially appears.

初期表示のみ 実行されるような説明がされていますが、TabView で実験したところ現時点では onAppear と同じタイミングで実行されるようでした。(まだ beta 版なので挙動が変更されるかもしれません)

struct ContentView: View {
    var body: some View {
        TabView {
            TabContentView(title: "Received")
                .tabItem {
                    Label("Received", systemImage: "tray.and.arrow.down.fill")
                }
            TabContentView(title: "Sent")
                .tabItem {
                    Label("Sent", systemImage: "tray.and.arrow.up.fill")
                }
            TabContentView(title: "Account")
                .tabItem {
                    Label("Account", systemImage: "person.crop.circle.fill")
                }
        }
    }
}

struct TabContentView: View {
    var title: String

    @State var count: Int = 0

    var body: some View {
        NavigationStack {
            VStack {
                Text("count: \(count)")
                Button("+") {
                    count += 1
                }
            }
            .navigationTitle(title)
            .onAppear {
                print("[\(title)] onAppear:")
            }
            .onChange(of: count, initial: true) { oldValue, newValue in
                // `onAppear`と同タイミングで呼ばれる
                print("[\(title)] onChange: \(oldValue)  \(newValue)")
            }
        }
    }
}

互換 API を実装する

個人的にはこの変更を歓迎しているのですが、iOS 17+ な API ということで気軽には使いづらいので互換 API を実装してみます。

まず、Modifier を作ります。

@available(iOS, deprecated: 17.0, message: "Please use official version")
private struct OnChangeModifier<V: Equatable>: ViewModifier {
    var value: V
    var initial: Bool
    var action: (_ oldValue: V, _ newValue: V) -> Void

    func body(content: Content) -> some View {
        content
            .onAppear {
                if initial {
                    action(value, value)
                }
            }
            .onChange(of: value) { [value] newValue in
                action(value, newValue)
            }
    }
}

まず、onChange で比較する値は Equatable である必要があるので V: Equatable で宣言しています。

そして initialtrue の場合には onAppear を利用して action を実行し、最後に旧バージョンの onChange を使って action を実行しています。

旧バージョンの onChange でのキャプチャを使った古い値の参照方法 { [value] newValue in は、現在では記述が削除されているものの、以前は公式ドキュメントに明記されていた方法なので、少なくとも iOS 16 以前のバージョンでは正しく動作するかと思います。

このまま利用するのも手間なので、View の extension に関数を定義します。

extension View {
    func onChange<V: Equatable>(
        of value: V,
        initial: Bool = false,
        _ action: @escaping (_ oldValue: V, _ newValue: V) -> Void
    ) -> some View {
        modifier(OnChangeModifier(value: value, initial: initial, action: action))
    }
}

これで iOS 16 以前でも新しいバージョンの onChange が利用できるはずです。

プロジェクトの Minimum Deployments を 17.0+ にすると以下のようにコンパイルエラーになるので、あとは互換バージョンの API を削除すれば公式 API を使うように差し替わります。(このあたりのアプローチはプロジェクトによって変わってくるでしょう)



まとめ

今回は iOS 17+ で追加された新しいバージョンの onChange の紹介と、それの互換バージョンの実装方法について簡単にまとめてみました。

互換バージョンの API を実装するコストを割くか(あるいは Backport 的な OSS を利用するか)のトレードオフ判断は難しいですが、費用対効果が高そうであれば導入してみるのも1つの手かもしれません。