mthr blog

あれやこれを書いたり

ReSwift + MVVM で副作用を管理する (SwiftUI)

単一方向のデータフローのアーキテクチャiOS および Swift で実現する ReSwift において、アクションを発行してデータ更新と共にイベントを発火させたい。

あるイベントを発火させるには、通常であれば、呼び出す・表示させるコンポーネントでイベント制御を定義しますが、これは呼び出し箇所やパターンにより複雑になる場合あります。単一のデータフローの仕組みに乗って制御できれば、シンプルに制御できるのでは?。

今回は、アクションを発行して、トースト ToastUI を表示する例をまとめました。サンプルコードは こちら で参照できます。例は SwiftUI で書いてますが、Storyboard でも大方同じような書き方でできます。

まず 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 はないので、どうするか。ワンチャン、作るか・・・。