🌀

SwiftUI の redacted Modifier を TCA でスマートに扱う

2021/03/15に公開

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)
  )
]

こんな感じでそれっぽい変数を用意しておきます。

MVVM で redacted を扱う場合

それでは、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")
}) {

最後に以下の redacteddisabled を利用している部分について説明します。

.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 の色とは異なるものになる可能性がある

TCA で redacted を扱う場合

では次に 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 で行っていた処理を実行しています。
listItemResponseonAppear で返却される Effect によって発火し、isLoadingfalse に、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~ あたりの話を参考にさせて頂きました。

https://www.pointfree.co/episodes/ep115-redacted-swiftui-the-problem

また、以前 iOSアプリ開発のためのFuctional Architecture情報共有会で発表させて頂いた内容を記事向けに書いてみたものでもあるため、そちらもぜひ参照していただけると嬉しいです。

https://speakerdeck.com/kalupas226/redacted-wo-tca-desumatonixi-u

Discussion