Upgrade to Pro — share decks privately, control downloads, hide ads and more …

MVVM + Flux

MVVM + Flux

『モバイルアプリ開発エキスパート養成読本』出版記念 Tech Talks
https://connpass.com/event/50979/

QiitaWithFluxSample
https://github.com/marty-suzuki/QiitaWithFluxSample

Taiki Suzuki

April 26, 2017
Tweet

More Decks by Taiki Suzuki

Other Decks in Programming

Transcript

  1. MVVM + Flux ϓꙎ ϹωЀϓЄτϴЀϺυϐμΨView͔Β෼཭Ͱ͖Δ • Action • Dispatcher •

    Store • ViewModel ΠϕϯτͷൃՐΛ͠ɺStoreͰͷมߋΛड͚औΔ
  2. Application • ApplicationAction func requestAccessToken(withCode code: String) func removeAccessToken() •

    ApplicationStore let accessToken: Property<String?> let accessTokenError: Observable<Error>
  3. Propertyͱ͸ https://github.com/inamiy/RxProperty set΍bind͕Ͱ͖ͳ͍Variable final class ViewModel { let intValue: Property<Int>

    private let _intValue = Variable<Int>(0) public init() { self.intValue = Property(_intValue) } } viewModel.intValue.value = 1 // error // Observable<Int> observable.bind(to: viewModel.intValue) // error
  4. RouteAction • RouteAction func show(loginDisplayType: LoginDisplayType) func show(searchDisplayType: SearchDisplayType) •

    RouteStore let login: Observable<LoginDisplayType?> let search: Observable<SearchDisplayType?>
  5. RootViewModel final class RootViewModel { private let disposeBag = DisposeBag()

    let login: Observable<LoginDisplayType?> let search: Observable<SearchDisplayType?> init(applicationStore: ApplicationStore = .shared, routeAction: RouteAction = .shared, routeStore: RouteStore = .shared) { let accessTokenObservable = applicationStore.accessToken.asObservable() // AccessToken͕nilͰͳ͍ͱ͖ɺSearchͷTopΛදࣔ͢ΔΞΫγϣϯΛ౤͛Δ accessTokenObservable.filter { $0 != nil }.map { _ in SearchDisplayType.root } .bind(onNext: routeAction.show).addDisposableTo(disposeBag) // AccessToken͕nilͷͱ͖ʹɺLoginը໘Λදࣔ͢ΔΞΫγϣϯΛ౤͛Δ accessTokenObservable.filter { $0 == nil }.map { _ in LoginDisplayType.root } .bind(onNext: routeAction.show).addDisposableTo(disposeBag) // ViewControllerͰsubscribe͢Δ༻ͷObservable self.login = routeStore.login self.search = routeStore.search } }
  6. RootViewController // ViewController಺ͰViewModelͷobservableΛsubscribe͠ɺը໘ભҠΛߦ͏ // ॳճىಈ࣌͸AccessToken͕ͳ͍ͷͰLoginը໘Λදࣔ͢Δ viewModel.login .observeOn(ConcurrentMainScheduler.instance) .filterNil() .subscribe(onNext: {

    [weak self] displayType in guard let me = self else { return } let loginNC: LoginNavigationController if let nc = me.currentViewController as? LoginNavigationController { loginNC = nc } else { loginNC = LoginNavigationController() me.currentViewController = loginNC } switch displayType { case .root: if loginNC.topViewController is LoginTopViewController { return } loginNC.popToRootViewController(animated: true) case .webView: if loginNC.topViewController is LoginViewController { return } loginNC.pushViewController(LoginViewController.instantiate(), animated: true) } }) .addDisposableTo(disposeBag)
  7. LoginViewModel final class LoginViewModel { let isLoading: Property<Bool> private let

    _isLoading = Variable<Bool>(false) let authorizeRequest: OauthAuthorizeRequest let authorizeUrl: URL private let applicationAction: ApplicationAction private let disposeBag = DisposeBag() init(applicationStore: ApplicationStore = .shared, applicationAction: ApplicationAction = .shared, config: Config = .shared) { self.applicationAction = applicationAction // and so on... } func requestAccessToken(withCode code: String) { // and so on... // AccessTokenͷϦΫΤετΛ͠ɺऔಘ͕׬ྃ͢ΔͱDispatcherΛհ͠Store΁ applicationAction.requestAccessToken(withCode: code) } }
  8. LoginViewController class LoginViewController: UIViewController, Storyboardable { let loadingView = LoadingView(indicatorStyle:

    .whiteLarge) let webView: WKWebView = WKWebView(frame: .zero) private let viewModel = LoginViewModel() private(set) lazy var dataSource: LoginViewDataSource = .init(viewModel: self.viewModel) private let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() configureWebView() // and so on... } private func configureWebView() { // navigationDelegateΛLoginViewDataSourceͱ͢Δ webView.navigationDelegate = dataSource // and so on... } }
  9. LoginViewDataSource final class LoginViewDataSource: WKNavigationDelegate { let viewModel: LoginViewModel init(viewModel:

    LoginViewModel) { self.viewModel = viewModel } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) return } if url.absoluteString.hasPrefix(Config.shared.redirectUrl) { guard let URLComponents = URLComponents(string: url.absoluteString), // and so on... let code = codeItem.value else { fatalError("can not find \"code\" from URL query") } // viewModel͔ΒAccessTokenΛϦΫΤετ viewModel.requestAccessToken(withCode: code) decisionHandler(.cancel) return } decisionHandler(.allow) } }
  10. RootViewController // AccessTokenΛऔಘͨ͠ޙʹɺݕࡧը໘Λදࣔ͢Δ viewModel.search .observeOn(ConcurrentMainScheduler.instance) .filterNil() .subscribe(onNext: { [weak self]

    displayType in guard let me = self else { return } let searchNC: SearchNavigationController if let nc = me.currentViewController as? SearchNavigationController { searchNC = nc } else { searchNC = SearchNavigationController() me.currentViewController = searchNC } switch displayType { case .root: if searchNC.topViewController is SearchTopViewController { return } searchNC.popToRootViewController(animated: true) case .webView(let url): if searchNC.topViewController is SFSafariViewController { return } searchNC.pushViewController(SFSafariViewController(url: url), animated: true) } }) .addDisposableTo(disposeBag)
  11. SearchTopViewModel // ݕࡧը໘Ͱར༻͢Δσʔλ͸ɺ͜ͷը໘಺Ͱऩ·ΔͷͰSearchͷFluxΦϒδΣΫτ͸࡞Βͳ͍ // ϓϨθϯςʔγϣϯʹؔ͢Δঢ়ଶΛ࣋ͪɺActionΛར༻ͯ͠Apiͱͷ௨৴Λߦ͏ final class SearchTopViewModel { let

    lastItemsRequest: Property<ItemsRequest?> private let _lastItemsRequest = Variable<ItemsRequest?>(nil) let items: Property<[Item]> private let _items = Variable<[Item]>([]) let totalCount: Property<Int> private let _totalCount = Variable<Int>(0) let error: Property<Error?> private let _error = Variable<Error?>(nil) let hasNext: Property<Bool> private let _hasNext = Variable<Bool>(true) let searchAction: Action<ItemsRequest, ElementsResponse<Item>> private let perPage: Int = 20 let noResult: Observable<Bool> let reloadData: Observable<Void> let isFirstLoading: Observable<Bool> let keyboardWillShow: Observable<UIKeyboardInfo> let keyboardWillHide: Observable<UIKeyboardInfo> }
  12. Actionͱ͸ https://github.com/RxSwiftCommunity/Action enable͕trueͷͱ͖ͷΈʹ࣮ߦͰ͖Δ let searchAction = Action<ItemsRequest, ElementsResponse<Item>> { request

    in return QiitaSession.shared.send(request) } let request = ItemsRequest(page: nextPage, perPage: perPage, query: nextQuery) searchAction.execute(request) searchAction.elements .subscribe(onNext: { items: ElementsResponse<Item> in // and so on }) .addDisposableTo(disposeBag) searchAction.errors searchAction.executing searchAction.enabled
  13. SearchTopViewModel // ViewController͔ΒͷΠϕϯτΛड͚औΓɺViewModel಺ͷΞΫγϣϯΛݺͿ func observe(textControlProperty: ControlProperty<String?>, deleteButtonTap: ControlEvent<Void>, reachedBottom: Observable<Void>)

    { externalDisposeBag = DisposeBag() textControlProperty.orEmpty .distinctUntilChanged() .debounce(0.3, scheduler: ConcurrentMainScheduler.instance) .subscribe(onNext: { [weak self] text in self?.search(query: text) }) .addDisposableTo(externalDisposeBag) deleteButtonTap .subscribe(onNext: { [weak self] in self?.removeAccessToken() }) .addDisposableTo(externalDisposeBag) reachedBottom .subscribe(onNext: { [weak self] in self?.search() }) .addDisposableTo(externalDisposeBag) }