transformPreference(_:_:) を使った子要素からの値伝搬

開発環境

$xcodebuild -version
Xcode 15.2
Build version 15C500b
  • 動作確認したシミュレータ: iPhone 15 Pro Max(iOS 17.2)

モチベーション

SwiftUIでviewの親と子それぞれに preference(key:value:) を指定すると、親のPreference値だけがその上位存在の onPreferenceChange(_:perform:) にて受け取ることができる。子の値も送りたい...。

import SwiftUI

struct CustomPreferenceKey: PreferenceKey {
  typealias Value = [String]
  
  static var defaultValue: Value = []
  
  static func reduce(value: inout Value, nextValue: () -> Value) {
    value += nextValue()
  }
}

struct ContentView: View {
  var body: some View {
    VStack {
      VStack {
        Text("Hello, world! 1")
          .preference(key: CustomPreferenceKey.self, value: ["child-1"])
        Text("Hello, world! 2")
          .preference(key: CustomPreferenceKey.self, value: ["child-2"])
      }
      .preference(key: CustomPreferenceKey.self, value: ["parent-1"])
    }
    .onPreferenceChange(CustomPreferenceKey.self) { value in
      print("received: \(value)")  // received: ["parent-1"]
    }
  }
}

transformPreference(_:_:)

この問題に出くわした当時はbackgroundにviewを生成し、親子関係にならないようにして回避した。落ち着いた後に「わりとよくある使用パターンだから公式が何か用意しているのではないか」と思ってワークアラウンドを施している悔しさからPreferenceのドキュメントを片っ端から読んでいると、使ったことがない transformPreference(_:_:) を見つけた。

transformPreference(_:_:) | Apple Developer Documentation

「transform...? よく見たらanchorPreferenceにもtransformAnchorPreference(key:value:transform:)があるな。anchorePreferenceは元々transformクロージャを受け取るのにどういうことだ?」と思って手元で触ってみたらビンゴで、子要素から伝搬されたPreference値を加工してその親に渡すものみたい。加工ができるということは追加もできる。

struct ContentView: View {
  var body: some View {
    VStack {
      VStack {
        Text("Hello, world! 1")
          .preference(key: CustomPreferenceKey.self, value: ["child-1"])
        Text("Hello, world! 2")
          .preference(key: CustomPreferenceKey.self, value: ["child-2"])
      }
      .transformPreference(CustomPreferenceKey.self) { value in  // change
        value += ["parent-1"]
      }
    }
    .onPreferenceChange(CustomPreferenceKey.self) { value in
      print("received: \(value)")  // received: ["child-1", "child-2", "parent-1"]
    }
  }
}

子も自分(親)もその上位存在にPreference値を送りたい時は transformPreference(_:_:) を使おう。

transformPreference(_:_:)は重ねがけ可能

ドキュメントから予想はできるが実際に試して重ねがけができることを確認した。

struct ContentView: View {
  var body: some View {
    VStack {
      VStack {
        VStack {
          Text("Hello, world! 1")
            .preference(key: CustomPreferenceKey.self, value: ["child-1"])
          Text("Hello, world! 2")
            .preference(key: CustomPreferenceKey.self, value: ["child-2"])
        }
        .transformPreference(CustomPreferenceKey.self) { value in
          value += ["parent-1"]
        }
      }
      .transformPreference(CustomPreferenceKey.self) { value in
        value += ["grand-parent-1"]
      }
    }
    .onPreferenceChange(CustomPreferenceKey.self) { value in
      print("received: \(value)")  // received: ["child-1", "child-2", "parent-1", "grand-parent-1"]
    }
  }
}

子から transformPreference(_:_:) を使えば良いのでは?

これも予想できるが動く。

struct ContentView: View {
  var body: some View {
    VStack {
      VStack {
        VStack {
          Text("Hello, world! 1")
            .transformPreference(CustomPreferenceKey.self) { value in
              value += ["child-1"]
            }
          Text("Hello, world! 2")
            .transformPreference(CustomPreferenceKey.self) { value in
              value += ["child-2"]
            }
        }
        .transformPreference(CustomPreferenceKey.self) { value in
          value += ["parent-1"]
        }
      }
      .transformPreference(CustomPreferenceKey.self) { value in
        value += ["grand-parent-1"]
      }
    }
    .onPreferenceChange(CustomPreferenceKey.self) { value in
      print("received: \(value)")  // received: ["child-1", "child-2", "parent-1", "grand-parent-1"]
    }
  }
}

子も親も全ての階層で transformPreference(_:_:) を使えば良いのでは?と思ったが、preference(key:value:) はinputキーワードを持つクロージャが無いので、ただ値を送信するのみに徹するので素直に使い分けると良さそう。preference(key:value:) ができることは transformPreference(_:_:) もできる。

少し踏み込む

preference(key:value:) を親子に指定した時に親のPreference値だけその上位存在で受け取れるという点はSwiftUIのViewが内部で構築しているであろうView Treesの構成が絡んでいそう。View Treesにおいて親は子の後に評価されること、preference(key:value:) がその親に値を伝搬しないことを考えると子ではなく親のPreference値が優先される理由が予想できる気がする。「View Treesにおいて親は子の後に評価される」というのはSwiftUIのViewにおいて親のサイズは基本的に子のサイズによって決まるのでおそらくそうではないか、と予想している。