7
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-04-09

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

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

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

APIデザイン設計

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

参考にしたデザイン

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

ゴール

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

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で書いて検証しました。

struct SampleView : View {

    var body : some View {
        NavigationView {
            Text("Hello world")
                .navigationBarItems(leading: Text("Leading"))
        }
    }

}
let sampleView = SampleView()
print(type(of: sampleView.body))

このViewの具体型は

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

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

EnvironmentReaderView

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

_PreferenceWritingModifier

こちらのAppleの非公開APIのModifierですが、iOS13以降のSwiftUIのswiftinterfaceを見る事で存在を確認できます。

@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で隠蔽されているので、通常知ることはできませんが)

@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が保持する情報を読み出すための機構です。公式ドキュメントは記述が少ないので、チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2(PreferenceKey編)という記事が分かりやすかったです。

コンテナViewで内部Viewの保持する情報を読み出す

PreferenceKeyを利用する事で内部Viewの情報が読める事がわかりました。

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

  • 内側のViewで与えられる@ViewBuilderのViewを保持しておくために、PreferenceKeyに適合したEmptyStateContentsKeyを自作する。
  • extension Viewで以下のような関数を用意し、emptyStateItemsを注入する。
func emptyStateItems<Items>(@ViewBuilder items: () -> (Items)) -> some View where Items : View { }
  • EmptyStateViewにおいて、bodyで以下のようにうまくpreferenceの値を読み出してemptyContentとして返したい。
struct EmptyStateView : View {
    @Binding var empty: Bool
    var body: some View {
        if empty {
            return emptyContent
        } else {
            return filledContent
        }
    }
}

調べて分からなかった事

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

実装

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

PreferenceKeyの自作

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の自作

PreferenceKeyassociatedvalue 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()としています。

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

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

EmptyStateViewの自作

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

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

おわりに

ここまでで

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

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

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

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

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

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

改定履歴

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

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1