単一方向のデータフローのアーキテクチャを iOS および Swift で実現する ReSwift において、アクションを発行してデータ更新と共にイベントを発火させたい。
あるイベントを発火させるには、通常であれば、呼び出す・表示させるコンポーネントでイベント制御を定義しますが、これは呼び出し箇所やパターンにより複雑になる場合あります。単一のデータフローの仕組みに乗って制御できれば、シンプルに制御できるのでは?。
今回は、アクションを発行して、トースト ToastUI を表示する例をまとめました。サンプルコードは こちら で参照できます。
まず Redux を作る
トーストデータを管理する Redux を作成します。
Toast Module
- ToastState.swift
- トーストのデータ(テキストデータ、表示タイプ)を定義してます
- トーストのデータはキュー管理でできるようにしました
import Foundation
enum ToastType {
case info
case success
case error
}
struct ToastItem: Identifiable, Equatable {
let id: Int
let message: String
let type: ToastType
}
struct ToastState {
let items: [ToastItem]
static func initialState() -> ToastState {
ToastState(items: [])
}
}
- ToastActions.swift
- 表示および非表示のアクションを定義しました
import Foundation
import ReSwift
enum ToastActions: Action {
case enqueueToast(message: String, type: ToastType? = ToastType.info)
case dequeueToast(id: Int)
case clearToast
}
- ToastReducer.swift
- アクションを受けて、state を更新します
-
id
は現時刻の UNIX時間 を設定しました
import Foundation
func toastReducer(action: ToastActions, state: ToastState) -> ToastState {
switch action {
case .enqueueToast(message: let message, type: let type):
let id = Int(Date().timeIntervalSince1970)
let item = ToastItem(id: id, message: message, type: type ?? .info)
return ToastState(items: state.items + [item])
case .dequeueToast(id: let id):
let next = state.items.filter { $0.id != id }
return ToastState(items: next)
case .clearToast:
return ToastState(items: [])
}
}
- ToastSelectors.swift
-
AppState
からトーストに関するデータを取得します - 時系的には後述の
AppStore
の設定後に定義します
-
import Foundation
func selectToastItems(state: AppState) -> [ToastItem] {
state.toast.items
}
func selectToastItem(state: AppState) -> ToastItem? {
let items = selectToastItems(state: state)
return items.first
}
func selectToastItemId(state: AppState) -> Int {
let items = selectToastItems(state: state)
return items.first?.id ?? -1
}
AppStore
トースト用の管理 module からアプリで使う Store を生成していきます。ここは ReSwift の一般的な導入手順と同じです。
- AppStore.swift
import Foundation
import ReSwift
func makeAppStore() -> Store<AppState> {
let store = Store<AppState>(
reducer: appReducer,
state: AppState.initialState()
)
return store
}
- AppState.swift
import Foundation
struct AppState {
let toast: ToastState
static func initialState() -> AppState {
AppState(
toast: ToastState.initialState(),
)
}
}
- AppReducer.swift
import Foundation
import ReSwift
func appReducer(action: Action, state: AppState?) -> AppState {
let state = state ?? AppState.initialState()
var nextToast = state.toast
if action is ToastActions {
nextToast = toastReducer(action: action as! ToastActions, state: state.toast)
}
return AppState(
toast: nextToast,
inAppWeb: nextinAppWeb
)
}
Toast View + ViewModel
トーストデータを取り扱う ViewModel を生成します。View から Redux を直接呼び出すことをせず、ViewModel 経由で操作するようにしています。これは、開発中に Redux を意識させず複雑になるのを防ぐため、他の Redux ライブラリに差し替える場合に簡単にできるようにするためです。
- ToastViewModel.swift
- アクションで設定された表示データを View に渡す働きをします
import Foundation
import ReSwift
final class ToastViewModel: ObservableObject, StoreSubscriber {
typealias StoreSubscriberStateType = ToastItem?
@Published var item: ToastItem?
private var itemId: Int?
init() {
appStore.subscribe(self) {
$0.select { selectToastItem(store: $0) }
}
}
deinit {
appStore.unsubscribe(self)
}
func newState(state: ToastItem?) {
self.item = state
self.itemId = state?.id
}
public func enqueueToast(message: String, type: ToastType?) {
appStore.dispatch(ToastActions.enqueueToast(message: message, type: type))
}
public func dequeueToast() {
if let itemId = self.itemId {
appStore.dispatch(ToastActions.dequeueToast(id: itemId))
}
}
public func clear() {
appStore.dispatch(ToastActions.clearToast)
}
}
- ToastView.swift
- 一般的な ViewModel による表示制御を行なっています
- ZStack で重ねているのは、あまり良くないかも
import SwiftUI
import ToastUI
struct Toast: View {
@ObservedObject private var viewModel = ToastViewModel()
var body: some View {
ZStack {
}.toast(item: $viewModel.item,
dismissAfter: 2.0) {
viewModel.dequeueToast()
} content: { item in
VStack {
Spacer()
Text(item.message)
.bold()
.foregroundColor(.white)
.padding()
.background(Color.green)
.cornerRadius(8.0)
.shadow(radius: 4)
.padding()
}
}
}
}
アプリに適用する
Toast コンポーネントを root に設定して終わりです。トーストを表示させたい箇所でToastViewModel#enqueueToast
を適宜呼び出せば OKです。
import SwiftUI
let appStore = makeAppStore()
@main
struct SampleApp: App {
var body: some Scene {
WindowGroup {
RootView()
Toast()
}
}
}
まとめ
React Native のアプリ開発で Redux saga を使った開発に携わり、シンプルなデータ管理・イベント制御ができて、良い開発体験を感じました。その開発手法を iOS へフィードバックして、ネイティブ開発をしたいと日々思っています。トーストの他、アプリ内ブラウザ(SFSafariViewController)の表示もアクション発行に紐づけて開発しています。
いまは state の変化を監視してますが、本当に Redux saga のようにアクションを発行したというのを監視して実装したいですね。今のところ、活発な ReSwift + saga の OSS はないので、どうするか。ワンチャン、作るか・・・。