Slide 1

Slide 1 text

Unlimited power of data-driven UI by @DAlooG // 2018 @ Mobius conf

Slide 2

Slide 2 text

О себе — инженер в Sigma Software — 7 лет мобильной разработки — 100% запуск чужих продуктов by @DAlooG // 2018 @ Mobius conf 2

Slide 3

Slide 3 text

О чем поговорим? — про проблемы разработки UI — про однонаправленный поток данных — про тестирование UI — про анимации by @DAlooG // 2018 @ Mobius conf 3

Slide 4

Slide 4 text

Проблемы разработки UI by @DAlooG // 2018 @ Mobius conf 4

Slide 5

Slide 5 text

Моё обычное приложение — 80% бюджета уйдет на UI (дизайн, тестирование, разработка) — 100% приложений изменят свой UI после релиза — новый функционал всегда влияет на уже готовый UI — тестирование UI ограничено возможностями уже написанной системы by @DAlooG // 2018 @ Mobius conf 5

Slide 6

Slide 6 text

UI это итеративный процесс by @DAlooG // 2018 @ Mobius conf 6

Slide 7

Slide 7 text

Нужно много дешевых итераций by @DAlooG // 2018 @ Mobius conf 7

Slide 8

Slide 8 text

Идеальный код UI — дешево тестировать — дешево добавлять новый функционал — дешево удалять функционал — дешево двигать функционал между экранами by @DAlooG // 2018 @ Mobius conf 8

Slide 9

Slide 9 text

Как снизить стоимость UI разработки? — быстрые итерации — масштабировать по людям — не трогать то что работает by @DAlooG // 2018 @ Mobius conf 9

Slide 10

Slide 10 text

Почему этого трудно добиться в iOS? — системные зависимости в UI слое — жизненный цикл UIKit — UI в конце цикла поставки — зависимость от реализации back-end by @DAlooG // 2018 @ Mobius conf 10

Slide 11

Slide 11 text

by @DAlooG // 2018 @ Mobius conf 11

Slide 12

Slide 12 text

Попытка №1: MVVM + FRP — выносим логику из VC — держим ссылку на VM в VC — слушаем изменения VM by @DAlooG // 2018 @ Mobius conf 12

Slide 13

Slide 13 text

by @DAlooG // 2018 @ Mobius conf 13

Slide 14

Slide 14 text

Проблемы MVVM — зависимость от порядка изменения полей — накопление ошибки переходных состояний — реактивная сложность — жизненный цикл UI by @DAlooG // 2018 @ Mobius conf 14

Slide 15

Slide 15 text

Попытка №2: Presenter — внешний компонент у View — посылает команды внутрь View — принимает команды из View — знает про жизненный цикл View by @DAlooG // 2018 @ Mobius conf 15

Slide 16

Slide 16 text

by @DAlooG // 2018 @ Mobius conf 16

Slide 17

Slide 17 text

Попытка №2: шаблон Presenter protocol View { func enableLoginAction() func disableLoginAction() func beginUserLoading() ... } by @DAlooG // 2018 @ Mobius conf 17

Slide 18

Slide 18 text

class Presenter { let view: View let network: NetworkService func login() { view.disableLoginAction() view.beginUserLoading() network.login().onComplete { view.enableLoginAction() view.endUserLoading() } } } by @DAlooG // 2018 @ Mobius conf 18

Slide 19

Slide 19 text

class Presenter { let view: View let network: NetworkService func login() { view.disableLoginAction() view.beginUserLoading() network.login().onComplete { view.enableLoginAction() view.endUserLoading() } } } by @DAlooG // 2018 @ Mobius conf 19

Slide 20

Slide 20 text

Недостатки Presenter: — зависимость от порядка операция — зависимость от жизненного цикла — иногда зависимость от предыдущих операций — внутреннее состояние ожидания ввода by @DAlooG // 2018 @ Mobius conf 20

Slide 21

Slide 21 text

Однонаправленный поток данных by @DAlooG // 2018 @ Mobius conf 21

Slide 22

Slide 22 text

Попытка №3: Props — внутренний компонент у View — полностью изолирован — просто данные — не несет контекста рендеринга by @DAlooG // 2018 @ Mobius conf 22

Slide 23

Slide 23 text

by @DAlooG // 2018 @ Mobius conf 23

Slide 24

Slide 24 text

class ViewController { struct Props { let useraname: Field let password: Field let login: LoginAction } render(props: Props) { ... } } by @DAlooG // 2018 @ Mobius conf 24

Slide 25

Slide 25 text

struct ViewController.Props.Field { let value: String let update: CommandWith } enum ViewController.Props.LoginAction { case possible(Command) case inProgress case disabled } by @DAlooG // 2018 @ Mobius conf 25

Slide 26

Slide 26 text

Props это внутренний компонент — меняется только вместе с UI — дублирование кода — не использует доменный словарь — рендерится только одним View by @DAlooG // 2018 @ Mobius conf 26

Slide 27

Slide 27 text

Props не связан с другими слоями — способен противостоять огромным изменениям — позволяет итерировать дизайн без логики — не требует вложений в проектирование by @DAlooG // 2018 @ Mobius conf 27

Slide 28

Slide 28 text

Props не обладает состоянием — семантика значения а не ссылки — неизменяемые значения — композиция — поддерживает enum by @DAlooG // 2018 @ Mobius conf 28

Slide 29

Slide 29 text

View это рендеринг потока Props by @DAlooG // 2018 @ Mobius conf 29

Slide 30

Slide 30 text

Обратная связь: Command by @DAlooG // 2018 @ Mobius conf 30

Slide 31

Slide 31 text

Что такое Command? final class CommandWith { private let action: (T) -> () func perform(with value: T) { action(value) } } by @DAlooG // 2018 @ Mobius conf 31

Slide 32

Slide 32 text

Command это — замыкание 2.0 — гарантирует однонаправленность — помогает в отладке by @DAlooG // 2018 @ Mobius conf 32

Slide 33

Slide 33 text

by @DAlooG // 2018 @ Mobius conf 33

Slide 34

Slide 34 text

by @DAlooG // 2018 @ Mobius conf 34

Slide 35

Slide 35 text

Хорошие Props это — гарантия отсутствия невозможных состояний — краткое описание возможностей View — формат для хранения и передачи представления — однозначное описание UI by @DAlooG // 2018 @ Mobius conf 35

Slide 36

Slide 36 text

Как хорошо рендерить Props? func render(props: Props) { self.lablel.text = props.title ... } by @DAlooG // 2018 @ Mobius conf 36

Slide 37

Slide 37 text

Как хорошо рендерить Props? func render(props: Props) { self.props = props self.tableView.reloadData() } by @DAlooG // 2018 @ Mobius conf 37

Slide 38

Slide 38 text

Как хорошо рендерить Props? func render(props: Props) { self.props = props self.view.setNeedsLayout() } by @DAlooG // 2018 @ Mobius conf 38

Slide 39

Slide 39 text

Как выбрать ячейку в таблице? struct ListViewController.Props { let items: [Item] struct Item { let name: String let select: Command } } by @DAlooG // 2018 @ Mobius conf 39

Slide 40

Slide 40 text

Про тестирование UI by @DAlooG // 2018 @ Mobius conf 40

Slide 41

Slide 41 text

Как писать snapshot1 тесты? func testSomeState() { let props = .init(...) controller.render(props: props) verify(controller, for: Device.iPhoneX.portrait) verify(controller, for: Device.iPhoneX.landscapeLeft) verify(controller, for: Device.iPadPro9.portrait.oneThird) } 1 https://github.com/AndriiDoroshko/SnappyShrimp by @DAlooG // 2018 @ Mobius conf 41

Slide 42

Slide 42 text

Использование в реальном2 проекте: 2 https://github.com/aol-public/OneMobileSDK-controls-ios/tree/master/SnapshotTests by @DAlooG // 2018 @ Mobius conf 42

Slide 43

Slide 43 text

UI storybooks — очень быстрые итерации — исследовательское тестирование — проверка граничных условий верстки by @DAlooG // 2018 @ Mobius conf 43

Slide 44

Slide 44 text

by @DAlooG // 2018 @ Mobius conf 44

Slide 45

Slide 45 text

import Moscapsule let mqttClient: MQTTClient = { let mqttConfig = MQTTConfig( clientId: "iOS Application", host: "127.0.0.1", port: 1883, keepAlive: 60) return MQTT.newConnection(mqttConfig) }() if let data = try? JSONEncoder().encode(self.props) { mqttClient.publish(data, topic: "CardListViewController") } by @DAlooG // 2018 @ Mobius conf 45

Slide 46

Slide 46 text

by @DAlooG // 2018 @ Mobius conf 46

Slide 47

Slide 47 text

mqttClient.subscribe("CardListViewController/setProps", qos: 0) mqttCommands.insert(CommandWith { message in guard message.topic == "CardListViewController/setProps" else { return } guard let payload = message.payload else { return } do { self.props = try JSONDecoder().decode(Props.self, from: payload) } catch { print(error) } }.dispatched(on: .main)) by @DAlooG // 2018 @ Mobius conf 47

Slide 48

Slide 48 text

Про анимацию Props by @DAlooG // 2018 @ Mobius conf 48

Slide 49

Slide 49 text

by @DAlooG // 2018 @ Mobius conf 49

Slide 50

Slide 50 text

by @DAlooG // 2018 @ Mobius conf 50

Slide 51

Slide 51 text

func addAnimation(view: UIView, keyPath: String, onComplete: @escaping () -> ()) { let animation = CABasicAnimation(keyPath: keyPath) animation.duration = animationsDuration animation.delegate = AnimationDelegate(didStop: { _, completed in guard completed else { return } onComplete() }) view.layer.add(animation, forKey: keyPath) } by @DAlooG // 2018 @ Mobius conf 51

Slide 52

Slide 52 text

Как анимировать позицию? addAnimation(view: sideBarView, keyPath: "position") { self.sideBarView.isHidden = true } sideBarBottomConstraint.constant = { guard #available(iOS 11, *) else { return view.frame.height - sideBarView.frame.height } return view.frame.height - sideBarView.frame.height - view.safeAreaInsets.top }() sideBarVisibleConstraint.isActive = false sideBarInvisibleConstraint.isActive = true sideBarBottomConstraint.isActive = true by @DAlooG // 2018 @ Mobius conf 52

Slide 53

Slide 53 text

Как анимировать прозрачность? addAnimation(view: shadowView, keyPath: "opacity") { self.shadowView.isHidden = true } shadowView.alpha = 0 by @DAlooG // 2018 @ Mobius conf 53

Slide 54

Slide 54 text

Как применять Props во время анимации func renderShadowView() { switch (currentUIProps.controlsViewHidden, nextUIProps.controlsViewHidden) { case (false, true): hideShadowView() case (true, false): showShadowView() default: guard shadowView.layer.animationKeys() == nil else { return } shadowView.isHidden = nextUIProps.controlsViewHidden } } by @DAlooG // 2018 @ Mobius conf 54

Slide 55

Slide 55 text

А теперь всё вместе3 if nextUIProps.bottomItemsHidden { addAnimation(view: seekerView, keyPath: "position") { self.seekerView.isHidden = true self.seekerView.alpha = 0 setupSeekerView() } seekerToSafeAreaConstraint.isActive = false bottomItemsSeekerConstraint.isActive = true bottomItemsVisibleConstraint.isActive = false bottomItemsInvisibleConstraint.isActive = false bottomItemsAndSeekerAnimatedConstraint.isActive = true } else { addAnimation(view: seekerView, keyPath: "opacity", onComplete: setupSeekerView) if currentUIProps.bottomItemsHidden { addAnimation(view: seekerView, keyPath: "position", onComplete: setupSeekerView) } bottomItemsAndSeekerAnimatedConstraint.isActive = false seekerToSafeAreaConstraint.isActive = false bottomItemsSeekerConstraint.isActive = true seekerView.alpha = 0 } 3 https://github.com/aol-public/OneMobileSDK-controls-ios/blob/master/PlayerControls/ sources/DefaultControlsViewController.swift by @DAlooG // 2018 @ Mobius conf 55

Slide 56

Slide 56 text

Сложные Props struct A { let b: enum B { case c(enum C { case d(struct D { ... by @DAlooG // 2018 @ Mobius conf 56

Slide 57

Slide 57 text

Enum и вложенные значения enum ViewController.Props.LoginAction { case possible(Command) case inProgress case disabled } func render(props: Props.LoginAction) { self.loginButton.isEnabled = ??? self.loadingIndicator.isHidden = ??? } by @DAlooG // 2018 @ Mobius conf 57

Slide 58

Slide 58 text

Призмы!4 extension Props.LoginAction { var possible: Command? { guard case let .possible(command) = self else { return nil } return command } var isInProgress: Bool { guard case .inProgress = self else { return false } return true } var isDisabled: Bool { guard case .disabled = self else { return false } return true } 4 https://medium.com/flawless-app-stories/enums-and-sourcery-5da57cda473b by @DAlooG // 2018 @ Mobius conf 58

Slide 59

Slide 59 text

Enum и вложенные значения enum ViewController.Props.LoginAction { case possible(Command) case inProgress case disabled } func render(props: Props.LoginAction) { self.loginButton.isEnabled = ??? self.loadingIndicator.isHidden = ??? } by @DAlooG // 2018 @ Mobius conf 59

Slide 60

Slide 60 text

Enum и вложенные значения enum ViewController.Props.LoginAction { case possible(Command) case inProgress case disabled } func render(props: Props.LoginAction) { self.loginButton.isEnabled = props.possible != nil self.loadingIndicator.isHidden = props.isInProgress == false } by @DAlooG // 2018 @ Mobius conf 60

Slide 61

Slide 61 text

Вопросы? — Twitter: @DAlooG — Email: [email protected] by @DAlooG // 2018 @ Mobius conf 61