この記事は、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
の自作
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()
としています。
NavigationView {
Text("")
.navigationTitle("A")
.navigationTitle("B")
.navigationTitle("C")
}
これで子孫Viewで保持している値を親に流す仕組みは作れました。
EmptyStateViewの自作
特筆して複雑な事はしていませんが、注意すべき箇所は2つです。
-
onPreferenceChange
を利用して@State private var emptyStateItems: AnyView
を更新し、コンテンツが空の場合に表示するようにしています。 -
extension View
でpublic 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
おわりに
ここまでで
-
NavigationView
はNavigationView<EnvironmentReaderView<ModifiedContent<Text, _PreferenceWritingModifier<NavigationBarItemsKey>>>>
という具体型を構築している -
PreferenceKey
のシステムを利用して内部Viewの保持する値にアクセスする事で実現可能。 -
EnvironmentReaderView
については一切わからない。 -
EmptyStateの実現のために、
PreferenceKeyの具体型の自作と
EmptyStateView`の実装
という解説を行いました。
SwiftUI
は深入りしようと思うと
- まだ情報が十分ではない
- 仕組みが難しい
- iOSのバグで奇妙な挙動をする事がある
など大変なため、少しでも情報を公開して知見がネットに貯まれば良いと思い記事化しています。
ここまでお読みいただきありがとうございました。
改定履歴
2020/04/13 PreferenceKeyを利用した実装が実現できたため、関連する部分を差し替えてタイトルを変更しました。