🧬

UITraitDefinitionをUIHostingControllerに適用する

に公開

iOS17からUITraitDefinitionが追加され、traitCollectionにカスタムなtraitを追加できるようになりました。
また、UITraitBridgedEnvironmentKeyを使用することで、SwiftUIのEnvironmentとUIKitのtraitCollectionの値を共有させることができるようになりました。

WWDC2023のセッションでは、実際の実装を確認することができます。
https://developer.apple.com/jp/videos/play/wwdc2023/10057/

このセッションではSwiftUIからUIHostingConfigurationUIViewRepresentableへのtraitCollectionを渡す方法が紹介されていました。
しかし、UIHostingControllerの場合は、次のような実装をしてもRootViewUITraitDefinitionが伝播しないことがわかりました。

import UIKit

extension UIViewController {
    func onTapButton() {
        // ここでは、traitValueに"custom"が設定されている
        print(traitCollection.traitValue) 
        let vc = UIHostingController(rootView: RootView())
        present(vc, animated: true)
    }
}

struct RootView: View {
    @Environment(\.traitValue)
    var traitValue

    var body: some View {
        // traitValueは"custom"にならず初期値が表示される
        Text(traitValue) 
    }
}

挙動の深掘り

上記のWWDCのセッションでは、traitCollectionの値はlayoutSubViewsで更新されると説明されていました。
なので、以下のようにviewDidLoadviewDidLayoutSubviewstraitCollectionの値を確認してみました。

import UIKit

final class RootHostingController: UIHostingController<RootView> {
    override func viewDidLoad() {
        super.viewDidLoad()
        // traitCollectionの値は"custom"になっている
        print("viewDidLoad traitValue: \(traitCollection.traitValue)")
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // traitCollectionの値は"initial"になっている
        print("viewDidLayoutSubviews traitValue: \(traitCollection.traitValue)")
    }
}

上記の通り、viewDidLoadではtraitCollectionの値は"custom"になっているのに対して、viewDidLayoutSubviewsでは"initial"になっていました。
このことから、traitCollectionの値はviewDidLoadでは親コントローラから正しく伝播されているものの、viewDidLayoutSubviewsのタイミングで初期値に戻ってしまうことがわかります。
この現象の根本的な原因は明確ではありませんが、viewDidLoadtraitCollectionをオーバーライドすることで問題を解決できます。

ワークアラウンド

traitCollectionの値を明示的にオーバーライドすることで、SwiftUIのViewtraitCollectionの値を正しく伝播させることができます。
具体的には以下のような実装方法があります。

特定のtraitをオーバーライドする方法

public final class WorkaroundViewController<Content: View>: UIHostingController<Content> {
    public override func viewDidLoad() {
        super.viewDidLoad()
        // 特定のtraitだけをオーバーライドする
        traitOverrides.traitValue = traitCollection.traitValue
    }
}

すべてのtraitをオーバーライドする方法

より汎用的な解決策として、以下のようなアプローチも可能です。
ただし、setOverrideTraitCollectionメソッドはiOS17でdeprecatedになっているため、警告が表示される点に注意が必要です。

public final class WorkaroundViewController<Content: View>: UIHostingController<Content> {
    public override func viewDidLoad() {
        super.viewDidLoad()
        // 現在のtraitCollection全体を自身に適用する
        setOverrideTraitCollection(traitCollection, forChild: self)
    }
}

このワークアラウンドにより、UIHostingControllerを使用する際でもカスタムtraitを正しくSwiftUIのViewに伝達できるようになります。

Discussion