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 → 1
、1 → 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
で宣言しています。
そして initial
が true
の場合には 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つの手かもしれません。