この記事は トレタ 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文字以上)
- 確認用パスワード入力欄
- 規約同意のトグルスイッチ
- サインアップボタン
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のこれからに期待しています!