SwiftUI の redacted Modifier を TCA でスマートに扱う
redacted
Modifier について
SwiftUI には redacted
という便利な Modifier が存在します。
redacted
Modifier を使えば、ロード中の View でよく使われるスケルトンビューのようなものを Modifier を付与するだけで実現することができます。
イメージとしては、こんな感じのクマがいた場合、
redacted
Modifier を View 全体に付与するだけで、
こんな感じにすることができます。
そんな便利な redacted
Modifier ですが、状態管理含め実際に適用していくとなると意外と面倒な部分も出てきたりします。
今回の記事では MVVM で redacted
Modifier を扱う場合と TCA で redacted
Modifier を扱う場合について見ていきたいと思います。
例として説明するアプリと共通のコード
例として説明するアプリ
まず、今回例として説明するアプリは以下のようなものになります。
単純な List ですが、ロード中であれば redacted
を使ってそれっぽく見せ、ロードが完了すれば redacted
が外れた状態の View を表示するようなものです。
共通のコード
異なる方法でアプリを作るとは言っても基本的な部分は共通であるため、いくつか共通部分のコードを紹介します。
セル一つに表示する Item
まず、セル一つ一つに表示する Item struct です。
struct Item: Equatable, Identifiable {
let id: UUID
let title: String
let description: String
}
非常に単純ですね。
プレースホルダー用の変数
次にプレースホルダー用の変数を紹介します。
プレースホルダーとはいえども、redacted
Modifier は表示されている View の大きさに基づいてグレーっぽくスケルトン表示してくれるため、プレースホルダー用の変数も例えばテキストであれば、それなりの文字数を確保しておきたいです。
それを踏まえると以下のようにプレースホルダー用の変数を用意することができます。
let placeholderListItem = (0...10).map { _ in
Item(
id: .init(),
title: String(repeating: " ", count: .random(in: 50...100)),
description: String(repeating: " ", count: .random(in: 10...30))
)
}
適当ですね。
ロード完了後用の変数
最後にロード完了後用の本物っぽい変数も用意します。
let liveListItem = [
Item(
id: .init(),
title: "これは redacted",
description: String(repeating: "おはよう", count: 10)
),
Item(
id: .init(),
title: "This is redacted",
description: String(repeating: "Good morning", count: 10)
),
Item(
id: .init(),
title: "よろしくお願いします",
description: String(repeating: "yes, yes", count: 10)
)
]
こんな感じでそれっぽい変数を用意しておきます。
redacted
を扱う場合
MVVM で それでは、MVVM で redacted
を扱う場合のコードから見ていきます。
@ObservableObject
状態管理用の まずは、状態管理用の @ObservableObject
を用意します。
class ListItemViewModel: ObservableObject {
@Published var listItem: [Item] = []
@Published var isLoading = false
init() {
isLoading = true
// 4s 経ったら自動的に動作するようにする
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.isLoading = false
self.listItem = liveListItem
}
}
}
今回は API 通信実際に行うと余計に複雑になってしまうため、API 通信してる風を装うために DispatchQueue.main.asyncAfter
を利用しています。
これにより、4秒後に redacted
が解除されるような動作を実現できます。
@ObservableObject
を利用する View
次に先ほど定義した ListItemViewModel を利用した View を書いていきます。
まず、コードの全体を示します。
struct MVVMListItemView: View {
@ObservedObject private var viewModel = ListItemViewModel()
var body: some View {
List {
if viewModel.isLoading {
// ローディング中なら ActivityIndicator を動作させる
// (ActivityIndicator は独自に作ってあるものですが、本筋と関係ないため説明略)
ActivityIndicator().frame(maxWidth: .infinity).padding()
}
ForEach(
viewModel.isLoading
? placeholderListItem
: viewModel.listItem) { item in
Button(action: {
guard !self.viewModel.isLoading else { return }
print("Button was tapped")
}) {
HStack(alignment: .top) {
Image("kuma")
.resizable()
.frame(width: 80, height: 80)
VStack(alignment: .leading, spacing: 10) {
Text(item.title).font(.title2)
Text(item.description).font(.body)
}
}
}
.redacted(reason: viewModel.isLoading ? .placeholder : [])
.disabled(viewModel.isLoading)
}
)
}
}
}
少しずつ説明していきます。
まず、以下では ViewModel の isLoading
の状態に応じて List に表示する Item を切り替えています。
ロード中であれば用意しておいた placeholderListItem
を利用し、ロード完了後であれば listItem
を利用しています。
ForEach(
viewModel.isLoading
? placeholderListItem
: viewModel.listItem) { item in
以下では、ロード中か否かに応じてボタンを押したときに print 文を実行するかを決めています。
今は print 文なので、仮にロード中に実行されたとして問題ありませんが、ロード中に処理を実行したくない場合はこのようにする必要があるかと思います。
Button(action: {
guard !self.viewModel.isLoading else { return }
print("Button was tapped")
}) {
最後に以下の redacted
と disabled
を利用している部分について説明します。
.redacted(reason: viewModel.isLoading ? .placeholder : [])
.disabled(viewModel.isLoading)
redacted
はこれまたロード中か否かに応じて状態を切り替えています。
redacted
は .placeholder
を指定するとスケルトン表示できるため、ロード中であれば placeholder
を指定するようにしています。
ロード完了後は redacted
は有効になって欲しくないため、何も指定しないことによって redacted
を無効にしています。
disabled
はロード中の View のタップを防ぐために追加しています。
先ほど Button の部分でも制御は行っていましたが、仮にタップできて何か状態を変更できてしまってはまずいため、disabled
を念のため付与するようにしています。
以上が ViewModel の場合についての説明になります。
一見そこまで大きな問題はなさそうに見えますが、以下のような問題点が実は存在しています。
- 状態を変更できないように
viewModel.isLoading
によっていくつかの制御を行っている- 仮に状態が増えてきた場合、おそらく
viewModel.isLoading
はもっと増えることになってしまう
- 仮に状態が増えてきた場合、おそらく
-
disabled
の利用シーンとしては微妙- もし
onAppear
で動作するものがあった場合、disabled
では防ぐことができない -
disabled
を利用すると僅かに View の色が変わるため、本来意図している View の色とは異なるものになる可能性がある
- もし
redacted
を扱う場合
TCA で では次に TCA で redacted
を扱う場合について説明していきます。
State, Action, Environment, Reducer
まず、TCA の基本的な要素であるものについてコードを示します。
以下は State です。
struct ListItemState: Equatable {
var listItem: [Item] = []
var isLoading = false
}
Action は以下のようになります。
enum ListItemAction {
case listItemResponse([Item]?)
case onAppear
}
Environment はないので空で定義します。
struct ListItemEnvironment {}
最後に Reducer です。
let itemListReducer = Reducer<ListItemState, ListItemAction, ListItemEnvironment> { state, action, environment in
switch action {
case let .listItemResponse(listItem):
state.isLoading = false
state.listItem = listItem ?? []
return .none
case .onAppear:
state.isLoading = true
return Effect(value: .listItemResponse(liveListItem))
.delay(for: 4, scheduler: DispatchQueue.main)
.eraseToEffect()
}
}
onAppear
では ViewModel の場合 init
で行っていた処理を実行しています。
listItemResponse
は onAppear
で返却される Effect によって発火し、isLoading
を false
に、listItem
に受け取った配列を格納します。
View
次に TCA を利用した場合の View の全体コードを示します。
struct TCAListItemView: View {
let store: Store<ListItemState, ListItemAction>
var body: some View {
WithViewStore(store) { viewStore in
List {
if viewStore.isLoading {
ActivityIndicator().padding().frame(maxWidth: .infinity)
}
ListItemView(
store: viewStore.isLoading
? Store(
initialState: .init(listItem: placeholderListItem),
reducer: .empty,
environment: ()
) : self.store
)
.redacted(reason: viewStore.isLoading ? .placeholder : [])
}
}
}
}
struct ListItemView: View {
let store: Store<ListItemState, ListItemAction>
var body: some View {
WithViewStore(store) { viewStore in
ForEach(viewStore.listItem) { item in
Button(action: {
// ロード中の場合に、ここで仮に何か処理があったとしても
// 偽物の store の状態を変更しようとするだけなので何も問題はない
}) {
HStack(alignment: .top) {
// ここは ViewModel の部分と同じなので省略
}
}
}
}
}
}
分かりやすさのために、View を分割していますが、重要な部分のみ説明します。
以下のコードが今回 TCA を利用する最大のメリットとなる部分になります。
ListItemView(
store: viewStore.isLoading
? Store(
initialState: .init(listItem: placeholderListItem),
reducer: .empty,
environment: ()
) : self.store
)
何をしているかというと、ロード中であれば偽物の Store を子 View に渡し、ロード完了後であれば本物の Store を渡すという単純なことをしているだけになります。
ロード中であれば偽物の Store を渡すという点が重要な点で、これによりロード中にどんな Action が仮に起こったとしても本物の Store には影響を与えないということができるようになります。
そのため、「ロード中だったらこうして、ロード完了後だったらこうして...」のようなことをほとんど気にしなくて良くなるというメリットを享受することができるようになります。
ViewModel を利用していた場合の問題点について
他の部分については大きく ViewModel と変わる部分はないですが、最後に ViewModel を利用した場合に問題となっていた点を確認してみましょう。
- 状態を変更できないように
viewModel.isLoading
によっていくつかの制御を行っている- 仮に状態が増えてきた場合、おそらく
viewModel.isLoading
はもっと増えることになってしまう
- 仮に状態が増えてきた場合、おそらく
この問題については、TCAを利用した場合ロード中であれば偽物の Store を渡すため、状態を変更できないように isLoading
によって制御を行う必要はなくなったと考えることができます。
-
disabled
の利用シーンとしては微妙- もし
onAppear
で動作するものがあった場合、disabled
では防ぐことができない -
disabled
を利用すると僅かに View の色が変わるため、本来意図している View の色とは異なるものになる可能性がある
- もし
また、こちらの問題についても TCA を利用した場合、「onAppearで仮に何か動作がするものがあったとしても偽物の Store に対しての処理であれば問題になることはない」、「状態を変更する処理が走っても問題なくなったため、disabled は利用しなくても良くなった」 と考えることができます。
おわりに
今回は単純な例でしたが、状態管理が複雑になればなるほど TCA を利用した場合の恩恵は大きくなりそうだと思います(もちろん TCA 以外でも工夫すればもっと良い方法で書けるかもしれないとは思います🙏 )。
ロード中か否かの場合に応じて View を切り替えるために、開発者が余計な苦労をするのは避けたいところなので、TCA を利用した場合そういった苦労をしなくて済むのは非常に嬉しいなと感じました。
今回の記事の内容については Point-Free さんが公開されている Episode115~ あたりの話を参考にさせて頂きました。
また、以前 iOSアプリ開発のためのFuctional Architecture情報共有会で発表させて頂いた内容を記事向けに書いてみたものでもあるため、そちらもぜひ参照していただけると嬉しいです。
Discussion