2.5K Views
March 15, 21
スライド概要
SwiftUI の `redacted` Modifier を TCA で扱う方法についてまとめました🙏
iOS エンジニアをやっています。
redacted iOS を TCA でスマートに扱う アプリ開発のためのFunctional Architecture情報共有会
⾃⼰紹介 アイカワ(@kalupas0930) 新卒 iOS エンジニア 函館出⾝ 最近は Flutter, 機械学習の勉強をしてます SwiftUI と Combine もまだまだ勉強中です 2
redacted とは? のようなもの SwiftUI で iOS14 から使⽤できる ViewModifier とても便利 SkeltetonView 3
4
SwiftUI でどう使う? Text("This is redacted") .redacted(reason: .placeholder) redacted Modifier を付けるだけ 5
ViewModifier ならではの使い⽅ VStack { Image("kuma") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) Text("This is redacted") } Text("kuma") 6
ViewModifier ViewModifier 表現できる ならではの使い⽅ なので簡単に Skeleton を VStack { Image("kuma") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) Text("This is redacted") Text("kuma") } .redacted(reason: .placeholder) 7
もう少しだけ redacted について深掘り は以下のように定義されている redacted func redacted(reason: RedactionReasons) -> some View は? 現時点では先ほど紹介した placeholder しか持っていない OptionSet に適合しているため、将来的には他の reason も 使えるようになるかもしれない ちなみに現時点でもオリジナルの ReactionReasons を作って、 reason を使い分けることはできる RedactionReasons 8
状態管理含めた時の redacted を⾒ていきます まずは @ObservableObejct を利⽤した TCA を利⽤しない SwiftUI での使⽤⽅法を⾒ていく 単純なリスト表⽰をするだけのアプリを作る 9
10
扱う状態 struct Item: Equatable, Identifiable { let id: UUID let title: String let description: String } 基本的な title と description を持っているだけ 11
プレースホルダー⽤の変数 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)) ) } title と description は適当にスペースで埋めてそれっぽくしている 12
ロード完了後⽤の変数 let liveListItem = [ Item(id: .init(), description: Item(id: .init(), description: Item(id: .init(), description: ] これは title: " redacted", String(repeating: " ", count: 10)), title: "This is redacted", String(repeating: "Good morning", count: 10)), title: " ", String(repeating: "yes,yes", count: 10)) おはよう よろしくお願いします 中⾝は適当です 13
状態管理⽤の 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 } } 経ったら⾃動的に動作するようにする } 14
View を少しずつ⾒ていきます @ObservedObject private var viewModel = ListItemViewModel() var body: some View { List { if viewModel.isLoading { ActivityIndicator().frame(maxWidth: .infinity).padding() } ForEach( ... // Item Button(action: { ... // }) { ... // View } } } 繰り返す ボタンを押した時のアクション ボタンの 15
ForEach の中⾝ / ボタンのアクション @ObservedObject private var viewModel = ListItemViewModel() ... // ForEach( viewModel.isLoading ? placeholderListItem : viewModel.listItem) { item in Button(action: { guard !self.viewModel.isLoading else { return } print("Button was tapped") }) { ... // View } } 省略 ボタンの 16
ForEach の中⾝ / ボタンの View @ObservedObject private var viewModel = ListItemViewModel() ... // ForEach( ... ) { item in Button(action: { ... }) { 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) } } } } 省略 17
redacted を追加 @ObservedObject private var viewModel = ListItemViewModel() var body: some View { List { if viewModel.isLoading { ActivityIndicator().frame(maxWidth: .infinity).padding() } ForEach( ... // Item Button(action: { ... // }) { ... // View } .redacted(reason: viewModel.isLoading ? .placeholder: []) } } 繰り返す ボタンを押した時のアクション ボタンの 18
disabled も追加 @ObservedObject private var viewModel = ListItemViewModel() var body: some View { List { if viewModel.isLoading { ActivityIndicator().frame(maxWidth: .infinity).padding() } ForEach( ... // Item Button(action: { ... // }) { ... // View } .redacted(reason: viewModel.isLoading ? .placeholder: []) .disabled(viewModel.isLoading) // } 繰り返す ボタンを押した時のアクション ボタンの これを追加 19
やりたいことは実現できた しかし、この⽅法には問題点がある View のあちこちで viewModel.isLoading を使っている 状態が増えてきた時に開発者が気にしなければならないことが 多くなってしまう disabled によってロード中はタップできないようにできたが、 disabled の利⽤シーンとしては微妙 もし onAppear などがあった際、それを防ぐことはできない ⾊が少し明るくなってしまうので、本来意図している View の⾊ とは異なるものになるかもしれない 20
The Composable Architecture なら? 基本的な TCA の流れ View から Action を送る Action によって Reducer で Store の State が変更される イメージは isLoading によって Store を使い分ける isLoading が true ( ロード中 ) : プレースホルダー⽤の Store false ( ロード完了 ): 本物の Store 21
実際に TCA を使った例を紹介します まずは State struct ListItemState: Equatable { var listItem: [Item] = [] var isLoading = false } 先ほどの @ObservableObject を利⽤した class と⼤きな差はない State は Action を通じてのみ変更されるため、 struct 内に 状態を変化させるための関数はない 22
Action enum ListItemAction { case listItemResponse([Item]?) case onAppear } の onAppear 時に呼ばれる Action その Action によって発⽕する listItemResponse([Item]?) View 23
Reducer let listItemReducer = Reducer<ListItemState, ListItemAction, Void> { 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() } } してから、わざと 4s 遅らせるようにして API 通信してる⾵ にしているだけ onAppear 24
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) or ( store) ) .redacted(reason: viewStore.isLoading ? .placeholder : []) } .onAppear { viewStore.send(.onAppear) } } } プレースホルダー 本物 を渡す 25
ListItemView への Store の渡し⽅ ListItemView( store: viewStore.isLoading ? Store( initialState: .init(listItem: placeholderListItem), reducer: .empty, environment: () ) : self.store ) .redacted(reason: viewStore.isLoading ? .placeholder : []) によって以下を渡す true : placeholder ⽤ Store false : 本物の Store viewStore.isLoading 26
⼀応 ListItemView の中⾝ let store: Store<ListItemState, ListItemAction> var body: some View { WithViewStore(store) { viewStore in ForEach(viewStore.listItem) { item in // ForEachStore Button(action: { // viewStore.send() placeholder store // state send }) { 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) } } } .buttonStyle(PlainButtonStyle()) } } 本当は ここで としても、 に影響はないので、 し放題 などを使うと良い であれば 27
TCA と redacted を組み合わせれば 開発者は最初に isLoading の状態によって、Store を使い分ける という判断だけで良くなる disabled を使⽤せずとも、「ローディング中のセルをタップ しても何も起きないようにする」という動作を実現できた 今回は扱う State を説明のために絞ったが、State が多くなれば なるほど TCA の恩恵を受けることができる 28
おわりに 今回発表した内容は Point-Free さんの Episode115~ の redacted についての記事を参考にしました https://www.pointfree.co/ 記事ではもっと深掘りされた内容が書かれています 扱う状態が増えた時の redacted の扱い⽅ 画⾯も⼀つではなく複数 コンテンツの多くは有料かつ動画は英語ですが、スクリプトが あるので英語が苦⼿でも翻訳頼りで理解できます 29