Как сделать экран подтверждения СМС-кода на iOS

от автора

Привет, Хабр!

Меня зовут Игорь, я Head of Mobile в компании AGIMA. 

Через нас проходит много проектов и оценок, функционал там зачастую повторяется, поэтому я решил показать, как мы решаем типовые задачи, и поделиться этим с вами. Начнем мы с самого начала. Как правило, началом для приложений служит авторизация. Рассмотрим классический случай с вводом номера телефона и смской и остановимся подробнее на экране подтверждения смс.

Важно: в примере кода на github будет полноценный пример с вводом номера телефона и кодом, но экран ввода номера телефона совсем скучный, поэтому сегодня мы вводим код 🙂

Выглядит не очень сложно, но, если присмотреться, функционал экрана довольно большой, а именно:

  • отправить код на сервер;

  • включить таймер повторной отправки + отобразить визуально;

  • после завершения таймера показать кнопку «отправить еще раз»;

  • отправить повторный запрос на получение кода;

  • отобразить все ошибки;

  • обработать успешное подтверждение кода.

Если попробовать разделить экран на UI и логику, получается примерно такое взаимодействие между логикой и интерфейсом.

Можно, конечно, отправить всю логику про таймеры и isLoading на View слой, но мне больше нравится относить это к логике. Особенно учитывая то, что я большой поклонник MVVM+Rx (и буду это использовать в статье), это более чем уместно смотрится. Ну да ладно.

ViewModel в этом случае играет роль некоего «преобразователя» пользовательских действий: у нее есть input и output (видно на картинке выше). За навигацию будет отвечать «кто-то еще», например, координатор.

Со стороны UI нам будут интересны следующие компоненты:

final class ConfirmCodeViewController: BaseViewController {    /// поле ввода кода   private lazy var codeTextField = CodeTextField()    /// лейбл для отображения ошибок    private lazy var errorLabel = UILabel()    /// один лоадер для запросов на отправку кода и на повторный запрос кода   private lazy var loader = UIActivityIndicatorView()    /// лейбл с обратным отсчетом для повторной отправки кода   private lazy var timerLabel = UILabel()    /// кнопка повторной отправки кода   private lazy var retryButton = UIButton(type: .system)    /// это все будет в стеквью   private lazy var stackView = UIStackView() }

ViewModel  будет выглядеть так:

/// Например, после успешного подтверждения кода нам могут предложить ввести перс. данные enum AuthResult { 	case success 	case needPersonalData }  protocol ConfirmCodeViewModelProtocol {     /// Введенный пользователем код для подтверждения     var code: AnyObserver<String> { get }          /// Пользователь нажал на «отправить повторно»     var getNewCode: AnyObserver<Void> { get }          /// Результат подтверждения кода     var didAuthorize: Driver<AuthResult> { get }          /// Один индикатор на все запросы на этом экране     var isLoading: Driver<Bool> { get }          /// Ошибки из всех запросов на этом экране     var errors: Driver<String> { get }          /// Таймер отправки нового кода     var newCodeTimer: Driver<Int> { get }          /// Запросили новый код при нажатии на «отправить заново»     var didRequestNewCode: Driver<Void> { get }        /// Таймер отправки нового кода запущен     var codeTimerIsActive: Driver<Bool> { get } }

Обратите внимание, что при таком подходе мы стараемся не использовать PublishSubject, BehaviourRelay итп, чтобы четко разделить input и output у ViewModel.  Теперь давайте это все свяжем.

View отдает следующие потоки данных:

let codeText = codeTextField.rx.text.share()  codeText     .bind(to: viewModel.code)     .disposed(by: disposeBag)  retryButton.rx.tap     .bind(to: viewModel.getNewCode)     .disposed(by: disposeBag)

ViewModel будет как-то (покажу ниже) обрабатывать ввод кода пользователя, а также делать запрос на повторную отправку кода, если мы нажмем на кнопку.

Сначала давайте посмотрим ViewModel целиком, далее разберем ее более подробно.

ViewModel рассмотрим «по кусочкам»:

let _codeSubject = PublishSubject<String>() self.code = _codeSubject.asObserver()  let codeObservable = _codeSubject.asObservable() let validCodeObservable = codeObservable.filter { $0.count == codeLength }

_codeSubject  — это поток данных из textfield ввода кода.

validCodeObservable  — отфильтровывает значения нужной длины, которые мы будем отправлять на сервер.

Выше мы договорились, что  PublishSubject не используем, но внутри нам от того же кода нужен не только AnyObserver, но и Observable , чтобы использовать его, например, для отправки кода на сервер. В дальнейшем я планирую использовать такую технику: AnyObserver или Observable в публичном интерфейсе и PublishSubject внутри.

let codeEvents: Observable<Result<Void, Error>> = validCodeObservable     .flatMap { (code) in         authService.confirmCode(code: code, token: token).materialize()     }.share()

Собственно, отправка кода на сервер 🙂 Обращаем внимание на .materialize(). Поскольку мы планируем использовать этот Observable в реактивных цепочках, мы не хотим получить ошибку и прерывать их. materialize позволяет завернуть все значения и ошибки в Result<Value, Error> и тем самым мы никогда не прервем реактивную цепочку из-за ошибки.

Ранее я описывал другой вариант с помощью RxAction, его также можно использовать для создания потоков событий значений, ошибок и isLoading.

Состояние загрузки

Здесь довольно интересный момент. Если мы получили валидный код, готовый к отправке, то мы отображаем интерфейс загрузки. Если мы получили ответ от сервера, это означает, что нам надо скрыть состояние загрузки. Таким образом, мы можем взять эти потоки данных (на примерах выше), смаппить их в true или false и забиндить в isLoading.

didAuthorize = codeEvents.elements()...

.elements() работает как фильтр и пропускает только значения из codeEvents и игнорирует ошибки. Напомню, что тип значений у codeEvents — это Result<Void, Error> , что является частью RxSwiftExt.

Таймер повторной отправки кода

Таймер  включается при следующих событиях:

  • мы отправили код на подтверждение (validCodeObservable.mapTo(Void()));

  • мы перезапросили код (didRequestNewCode);

  • сразу же при заходе на экран (.startWith(Void())).

Именно это описано в строчке Observable.merge... Сам таймер делается стандартными средствами RxSwift. Останавливаем таймер с помощью оператора take(while:), пока значение таймера не станет равно 0. 

Лейбл с таймером и кнопка «переотправить» должны скрываться/показываться в зависимости от того, активен ли таймер:

viewModel.codeTimerIsActive     .drive(retryButton.rx.isHidden)     .disposed(by: disposeBag)          viewModel.codeTimerIsActive     .not()     .drive(timerLabel.rx.isHidden)     .disposed(by: disposeBag)

За ошибки отправки и запроса нового кода у нас будет отвечать один поток данных errors.

errors = codeEvents.errors().merge(with: fetchNewCode.errors())             .compactMap { ($0 as? ErrorType)?.localizedDescription }             .asDriver(onErrorJustReturn: "")

Также запретим редактировать код, во вркмя того, как он отправляется:

viewModel.isLoading     .not()     .drive(codeTextField.rx.isEnabled)     .disposed(by: disposeBag)

ViewModel получилась довольно-таки тестируемая, поэтому давайте напишем тесты! Я приведу примеры тестов, которые будут показывать, как ViewModel реагирует на пользовательский ввод. Создадим вспомогательный метод, который будет создавать поток событий ввода кода. Внимание, используется RxTest!

class ConfirmCodeViewModelTests: XCTestCase {      // properties // methods       //MARK:- Helpers     private func bindCodeInputEvents(         _ events: [Recorded<Event<String>>] = [.next(100, "1"), .next(200, "11"), .next(300, "111"), .next(400, "1111")])     {         codeInputEvents = scheduler.createHotObservable(events)         codeInputEvents.bind(to: viewModel.code).disposed(by: disposeBag)     } } 

Например, таймер отправки нового кода должен запускаться и корректно отрабатывает сразу после открытия экрана — напишем вот такой тест:

   func test_timerInvokedAutomatically() {         let sut = scheduler.start(created: 0, subscribed: 0, disposed: 1000) { self.viewModel.newCodeTimer }         XCTAssertEqual(sut.events, [.next(1, 2), .next(2, 1), .next(3, 0)])     }

Или вот такой: проверим, что у нас передается на UI событие об ошибках

 func test_errorEmmitedValueAtFailure() throws {         bindCodeInputEvents()         setConfirmCodeResult(.error(0, MockError.confirmFailure))           let sut = scheduler.start { self.viewModel.errors }         XCTAssertEqual(sut.events, [.next(400, "confirmFailure")])     }

Полный код тестов, да и вообще весь пример можно найти тут. Требования могут слегка меняться от проекта к проекту (например, код можно отправлять по кнопке а не автоматом), но этот код достаточно несложно приспособить к подобным изменениям.

Ну, а если вам понравился наш подход, вам интересны большие проекты по мобильной разработке, тогда  напишите нам.

ссылка на оригинал статьи https://habr.com/ru/company/agima/blog/560828/