【SwiftUI】EmptyStateを作って理解するPreferenceKey

この記事は、SwiftUIで以下のような**「子孫Viewの情報をコンテナViewで読み取るデザインのカスタムViewを作りたい」**というiOSエンジニアへの知見共有を目的としており、調べた情報をなるべく素直に伝えていけるように書いていきます。

```swift
NavigationView {
    Text("Hello World")
        .navigationTitle("MyApp")
}
```

自分が`EmptyState`をSwiftUIで作ってみたいと思って調べ始めたので、その流れに沿って進めていきます。

# APIデザイン設計
冒頭にも挙げましたが、SwiftUIとのAPI一貫性を保つコンポーネントを作るために以下のNavigationViewようなAPIを目指しました。

## 参考にしたデザイン

```swift
NavigationView {
    Text("Hello World")
        .navigationTitle("MyApp")
}
```

## ゴール
これまでのUIKit製のライブラリではDelegateを使ったEmptyStateの設定が多かったですが、SwiftUIでは@ViewBuilderを利用してカスタマイズ製が高く簡潔なEmptyStateのAPIを目指します。

```swift
EmptyStateView(empty: $empty) {
    Text("Hello World")
        .emptyStateItems {
            VStack(spacing: 15) {
                Image("EmptyImage")
                Text("EmptyTitle")
                Text("EmptyDescription")
            }
        }
}
```

# 調べて分かった事
上記のAPIを構築するために調べる必要があったのは以下の2つです。

- `.navigationTitle(text:)`適応後のViewの具体型がどのようになっているのか？
- コンテナViewがどのようにして内部Viewの属性を知るのか？

以降はこの2つについて解説していきます。

## NavigationViewが構築する具体型
構築された具体型を知るために、以下のようなコードをPlaygroundで書いて検証しました。

```swift
struct SampleView : View {
    
    var body : some View {
        NavigationView {
            Text("Hello world")
                .navigationBarItems(leading: Text("Leading"))
        }
    }
    
}
let sampleView = SampleView()
print(type(of: sampleView.body))
```

このViewの具体型は

```swift
NavigationView<EnvironmentReaderView<ModifiedContent<Text, _PreferenceWritingModifier<NavigationBarItemsKey>>>>
```

になります。ModifiedContent以外は見慣れない型だと思うので、解説していきます。

### EnvironmentReaderView
Appleの非公開APIのViewです。これについては全く情報がありません。

### _PreferenceWritingModifier
こちらのAppleの非公開APIのModifierですが、[iOS13以降のSwiftUIのswiftinterface](https://github.com/xybp888/iOS-SDKs/blob/master/iPhoneOS13.4.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64e-apple-ios.swiftinterface)を見る事で存在を確認できます。

```swift
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct _PreferenceWritingModifier<Key> : SwiftUI.ViewModifier where Key : SwiftUI.PreferenceKey {
  public var value: Key.Value
  @inlinable public init(key _: Key.Type = Key.self, value: Key.Value) {
        self.value = value
    }
  public static func _makeView(modifier: SwiftUI._GraphValue<SwiftUI._PreferenceWritingModifier<Key>>, inputs: SwiftUI._ViewInputs, body: @escaping (SwiftUI._Graph, SwiftUI._ViewInputs) -> SwiftUI._ViewOutputs) -> SwiftUI._ViewOutputs
  public typealias Body = Swift.Never
}
```

そして、`func preference()`というModifierの具体型として返却されているのが分かります。(some Viewで隠蔽されているので、通常知ることはできませんが)

```swift
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
  @inlinable public func preference<K>(key _: K.Type = K.self, value: K.Value) -> some SwiftUI.View where K : SwiftUI.PreferenceKey {
        return modifier(_PreferenceWritingModifier<K>(value: value))
    }
  
}
```

`_PreferenceWritingModifier`は、実際には上記のModifierを呼ぶ事で返されているので自作する必要がなさそうです。

### NavigationBarItemsKey
Appleの非公開APIです。ただ、外側の`_PreferenceWritingModifier`が、`Key:SwiftUI.PreferenceKey`を利用しているので、これが`PreferenceKey`に適合した具体型であることは分かります。

`PreferenceKey`はコンテナView側から内部のViewが保持する情報を読み出すための機構です。[公式ドキュメント](https://developer.apple.com/documentation/swiftui/preferencekey)は記述が少ないので、[チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2（PreferenceKey編）](https://qiita.com/takaf51/items/de86221493f313ebfc3c
)という記事が分かりやすかったです。

## コンテナViewで内部Viewの保持する情報を読み出す
`PreferenceKey`を利用する事で内部Viewの情報が読める事がわかりました。

なので方針としてはこのようになります。

- 内側のViewで与えられる@ViewBuilderのViewを保持しておくために、`PreferenceKey`に適合した`EmptyStateContentsKey`を自作する。
- `extension View`で以下のような関数を用意し、`emptyStateItems`を注入する。

```swift
func emptyStateItems<Items>(@ViewBuilder items: () -> (Items)) -> some View where Items : View { }
```

- `EmptyStateView`において、bodyで以下のようにうまくpreferenceの値を読み出して`emptyContent`として返したい。

```swift
struct EmptyStateView : View {
    @Binding var empty: Bool
    var body: some View {
        if empty {
            return emptyContent
        } else {
            return filledContent
        }
    }
}
```

# 調べて分からなかった事
方針は立てましたが、`EnvironmentReaderView`についてはまるっきり分かりません。名前からしてなんらかの`EnvironmentValue`の読み出しに関連しているような事だけは分かります。しかし、今回の実装にはこの`View`は必要ありません。

# 実装
それでは調べた事を踏まえて実装した`EmptyStateView`について解説していきます。

## `PreferenceKey`の自作
```swift
struct EmptyStatePreferenceKey : PreferenceKey {
    static var defaultValue: Value = .init(view: EmptyView().eraseToAnyView())
    
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = nextValue()
    }
    
    struct Value : Equatable {
        var view: AnyView
        var uuid = UUID()
        
        static func == (lhs: EmptyStatePreferenceKey.Value, rhs: EmptyStatePreferenceKey.Value) -> Bool {
            lhs.uuid == rhs.uuid
        }
    }
    
}
```

### `Value`の自作
`PreferenceKey` は`associatedvalue Value`として`Equatable`に適合した型を要求します。
`EmptyStatePreferenceKey`では、クライアント側で渡されたViewを保持しておきコンテンツが空の時に表示する必要があるため、`Value`では`view:AnyView`をプロパティとして保持させています。等価性を実装しないといけないのですが、`AnyView`には上手く利用できそうな情報はないのでここでは`UUID`を利用します。

加えて`static var defaultValue: Value`を返す必要があるので、ここでは`EmptyView`を返しておきます。

### `reduce(value:, nextValue:)`の実装
こちらは**必須実装のメソッド**になります。

`.preference(key: PreferenceKey, value: PreferenceKey.Value)`が呼ばれて値を渡されると、この`reduce`が呼ばれます。この関数では新しく渡された`preference`の値をどのように保持するかを決めます。基本的な実装方針として、`inout`で渡された構造体の参照に対して`next()`の値を加える、もしくは代入する形になります。

例えば`NavigationView`だと以下のように`navigationTitle`を利用する事で複数の`Preference`を設定する事ができますが、最後に設定した値だけが利用されます。上記の`EmptyStatePreferenceKey`の実装でも、常に最後に設定された値だけが使われるように`value = nextValue()`としています。

```swift
NavigationView {
    Text("")
        .navigationTitle("A")
        .navigationTitle("B")
        .navigationTitle("C")
}
```

これで子孫Viewで保持している値を親に流す仕組みは作れました。

## `EmptyStateViewの自作`

特筆して複雑な事はしていませんが、注意すべき箇所は2つです。

1. `onPreferenceChange`を利用して`@State private var emptyStateItems: AnyView`を更新し、コンテンツが空の場合に表示するようにしています。
2. `extension View`で`public func emptyStateItems<Items>`という関数を用意して、任意の箇所で`preference`を注入できるようにしています。

```swift
/// A view for presenting empty state items when inner content is empty.
public struct EmptyStateView<Content> : View where Content : View {
    
    private var content: Content
    
    @State
    private var emptyStateItems: AnyView
    
    @Binding
    private var empty: Bool
            
    public init(empty: Binding<Bool>, @ViewBuilder content: () -> Content) {
        self.content = content()
        
        // Initialized emptyContent from content, Implementation can be simple for several reasons.
        self._emptyStateItems = .init(initialValue: self.content.eraseToAnyView())
        self._empty = empty
    }
    
    public var body: some View {
        
        // I don't know the reason but it goes well with using VStack. It doesn't go well with Group.
        VStack<AnyView> {
            if empty {
                return emptyStateItems
                    .onPreferenceChange(EmptyStatePreferenceKey.self) { (preference) in
                        self.emptyStateItems = preference.view
                }
                .eraseToAnyView()
            } else {
                return content
                    .onPreferenceChange(EmptyStatePreferenceKey.self) { (preference) in
                        self.emptyStateItems = preference.view
                }
                .eraseToAnyView()
            }
        }
    }

}

extension View {
    
    /// Configures the empty state items for the view.
    ///
    /// This modifier only takes effect when this view is inside of and visible within a `EmptyStateView`.
    ///
    /// - Parameter items: A view that appears when the content is empty.
    public func emptyStateItems<Items>(@ViewBuilder items: () -> Items) -> some View where Items : View {
        self.preference(key: EmptyStatePreferenceKey.self, value: .init(view: items().eraseToAnyView()))
    }

}

```

このようにして、子孫Viewの保持する情報を親Viewで読み取って利用する事が出来ました。
ソースコードの完成版はこちらで公開しています。
https://github.com/yosshi4486/EmptyState/tree/master/Sources/EmptyState

# おわりに
ここまでで

- `NavigationView`は`NavigationView<EnvironmentReaderView<ModifiedContent<Text, _PreferenceWritingModifier<NavigationBarItemsKey>>>> `という具体型を構築している
- `PreferenceKey`のシステムを利用して内部Viewの保持する値にアクセスする事で実現可能。
- `EnvironmentReaderView`については一切わからない。
- `EmptyStateの実現のために、`PreferenceKey`の具体型の自作と`EmptyStateView`の実装

という解説を行いました。

`SwiftUI`は深入りしようと思うと

- **まだ情報が十分ではない**
- **仕組みが難しい**
- **iOSのバグで奇妙な挙動をする事がある**

など大変なため、少しでも情報を公開して知見がネットに貯まれば良いと思い記事化しています。

ここまでお読みいただきありがとうございました。

# 改定履歴
2020/04/13 PreferenceKeyを利用した実装が実現できたため、関連する部分を差し替えてタイトルを変更しました。