トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、モバイルオーダー「トレタO/X」などを運営するトレタの開発メンバーによるブログです。

SwiftUIとCombineを使ったMVVMの実装

この記事は トレタ Advent Calendar 2019 の23日目の記事です。

こんにちは。iOS & Androidエンジニアの山口です。 トレタでは、主にトレタnowの開発を行っています。

これまで、UIKit、RxSwift(RxCocoa)を使ったMVVMアーキテクチャで実装することが多かったこともあり、 SwiftUI、Combineを使った場合にはどう書けばよいか考えてみました。

なお、この記事はSwiftUIを使う際に重要なポイントとなる PropertyWrappersやDynamicMemberLookup、それらを利用したObservedObjectやBindingについて の知識がある前提で記述しています。

ViewModel Protocol の定義

早速ですが、コードから説明していきます。 まず、すべてのViewModelが準拠すべきシンプルなProtocolを定義しました。 意図としては下記2点が挙げられます。

  • チーム開発する上でのViewModelの書き方をある程度統一したい
  • SwiftUI側で利用しやすい形にしたい
/// ViewModelが準拠するプロトコル
protocol ViewModelObject: ObservableObject {

    associatedtype Input: InputObject
    associatedtype Binding: BindingObject
    associatedtype Output: OutputObject

    var input: Input { get }
    var binding: Binding { get }
    var output: Output { get }

}

extension ViewModelObject where Binding.ObjectWillChangePublisher == ObservableObjectPublisher, Output.ObjectWillChangePublisher == ObservableObjectPublisher {

    var objectWillChange: AnyPublisher<Void, Never> {
        return Publishers.Merge(binding.objectWillChange, output.objectWillChange).eraseToAnyPublisher()
    }

}

protocol InputObject: AnyObject {
}

protocol BindingObject: ObservableObject {
}

protocol OutputObject: ObservableObject {
}

@propertyWrapper struct BindableObject<T: BindingObject> {

    @dynamicMemberLookup struct Wrapper {
        fileprivate let binding: T
        subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Value>) -> Binding<Value> {
            return .init(
                get: { self.binding[keyPath: keyPath] },
                set: { self.binding[keyPath: keyPath] = $0 }
            )
        }
    }

    var wrappedValue: T

    var projectedValue: Wrapper {
        return Wrapper(binding: wrappedValue)
    }

}
ViewModelObject
  • 後述するInputObject、BindingObject、OutputObjectをプロパティとして持つ
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠
InputObject
  • ユーザからの入力であるタップなどのイベント一覧をPassthroughSubjectとして定義
BindingObject
  • TextFieldの文字列やToggleのオン・オフなどのような双方向バインディングする値の一覧を@Publishedとして定義
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠
OutputObject
  • Textに表示する文字列やListに表示する配列などUIに出力する値の一覧を@Publishedとして定義
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠

SwiftUIからViewModelを使うための工夫

SwiftUI側はViewModelインスタンスを@ObservedObjectとして保持します。 ViewModelが持つBindingObjectとOutputObjectの値が更新された際に再描画させたいので、 その2つのobjectWillChangeをマージした結果をViewModelObjectのobjectWillChangeとしています。

また、BindingObjectから双方向バインディングするためのBindingを取り出すために、 @propertyWrapperと@dynamicMemberLookupを利用しています。

MVVMの実装

では実際にViewModelObjectを利用して、サインアップ画面ぽいものを実装してみます。 UIの構成要素は下記の通りです。

  • ID入力欄(6文字以上)
  • パスワード入力欄(8文字以上)
  • 確認用パスワード入力欄
  • 規約同意のトグルスイッチ
  • サインアップボタン

f:id:kyo__hei:20191224104341p:plain

ViewModelの実装

final class SignUpViewModel: ViewModelObject {

    final class Input: InputObject {
        /// サインインボタンのタップイベント
        let signUpTapped = PassthroughSubject<Void, Never>()
    }
    
    final class Binding: BindingObject {
        /// ユーザID
        @Published var id = ""
        
        /// パスワード
        @Published var password = ""
        
        /// 確認用パスワード
        @Published var confirmPassword = ""
        
        /// 利用規約同意フラグ
        @Published var agreed = false
        
        /// サインアップ完了アラート表示フラグ
        @Published var isCompletionAlertPresented = false
    }
    
    final class Output: OutputObject {
        /// サインアップボタンの有効フラグ
        @Published fileprivate(set) var isSignUpEnabled = false
    }
    
    let input: Input
    
    @BindableObject private(set) var binding: Binding
    
    let output: Output
    
    private var cancellables = Set<AnyCancellable>()
    

    init() {
        let input = Input()
        let binding = Binding()
        let output = Output()
        
        /// ユーザIDのバリデーション(6文字以上)
        let isIdValid = binding.$id
            .map { $0.count >= 6 }

        /// パスワードのバリデーション(6文字以上)
        let isPasswordValid = binding.$password
            .map { $0.count >= 8 }

        /// 確認用パスワードのバリデーション(`password`と一致)
        let isConfirmPasswordValid = Publishers
            .CombineLatest(
                binding.$password,
                binding.$confirmPassword
            )
            .map { $0.0 == $0.1 }
        
        /// サインアップボタン有効フラグ
        ///   - すべての入力内容が有効
        ///   - 利用規約に同意
        let isSignUpEnabled = Publishers
            .CombineLatest4(
                isIdValid,
                isPasswordValid,
                isConfirmPasswordValid,
                binding.$agreed
            )
            .map { $0.0 && $0.1 && $0.2 && $0.3 }
        
        /// サインアップ完了アラートの表示
        let isCompletionAlertPresented = input.signUpTapped
            .flatMap {
                // 実際にはModel層のサインアップ処理を呼び出す
                Just(true)
            }
        
        
        // 組み立てたストリームをbinding, outputに反映
        cancellables.formUnion([
            isCompletionAlertPresented.assign(to: \.isCompletionAlertPresented, on: binding),
            isSignUpEnabled.assign(to: \.isSignUpEnabled, on: output)
        ])
        
        self.input = input
        self.binding = binding
        self.output = output
    }

}

ViewModelの実装は基本的にイニシャライザのみで、InputObject、BindingObjectから流れてくるストリームの合成を行い、 その結果をBindingObject、OutputObjectに反映させています。BindingObjectに定義されている双方向バインディング用の値は@Publishedとして定義されているため、 $をつけることでPublisherを取り出すことができます。

Viewの実装

import Combine
import SwiftUI

struct SignUpView: View {
    
    @ObservedObject var viewModel: SignUpViewModel
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("ユーザID", text: viewModel.$binding.id)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("パスワード", text: viewModel.$binding.password)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("パスワード(確認用)", text: viewModel.$binding.confirmPassword)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            Toggle(isOn: viewModel.$binding.agreed) {
                Text("利用規約に同意する")
            }
            
            Color.clear.frame(height: 16)
            
            Button(action: { self.viewModel.input.signUpTapped.send(()) }) {
                HStack {
                    Spacer()
                    Text("サインアップ")
                        .padding(.vertical)
                    Spacer()
                }
            }
            .disabled(!viewModel.output.isSignUpEnabled)
            
            Spacer()
        }
        .padding(.horizontal)
        .padding([.top], 64)
        .alert(isPresented: viewModel.$binding.isCompletionAlertPresented) {
            Alert(title: Text("サインアップ完了"), dismissButton: .cancel(Text("OK")))
        }
    }
    
}

@ObservedObjectとしてViewModelのプロパティを定義します。 ボタンタップなどのアクションを受け取るクロージャで、InputObjectのSubjectに対してイベントを送信しています。 TextFieldなどの双方向バインディングする値については、viewModel.$bindingと書くことでBindableObject.Wrapperを取り出すことができ、 DynamicMemberLookupによりBindingプロパティを取得しています。

おわりに

SwiftUIにはバインディングの仕組みが標準的に実装されており、UIKitとRxSwift(RxCocoa)での実装にくらべ、かなりすっきりと書くことができました。 また、今回実装したViewModelはView側をUIKitに差し替えても使うことができるため(バインディングの仕組みが無いので少しつらいですが)、 SwiftUIではまだ実装が厳しい部分については、一部UIKitで実装するなどの選択肢も取ることが可能かと思います。

Combineについては、2019年12月時点ではWithLatestFromオペレータが無いなどの問題はありますが、 RxSwiftを触ったことがあればすんなり実装できるかと思います。 SwiftUIとCombineのこれからに期待しています!

© Toreta, Inc.

Powered by Hatena Blog