Мы используем файлы cookie. Чтобы улучшить работу сайта и предоставить вам больше возможностей. Продолжая использовать сайт, высоглашаетесь с условиями использования cookie.

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

AGIMA4 июня 2021Игорь Веденеев·Руководитель отдела мобильной разработки

Экран подтверждения СМС-кода должен управлять вводом, запросом на сервер, загрузкой, ошибками и повторной отправкой по таймеру. В MVVM эти состояния удобно держать во ViewModel, а RxSwift использовать для связывания пользовательских событий с интерфейсом.

Коротко

СостояниеПоведение интерфейса
ВводПринимает код и отправляет его после достижения нужной длины
ЗагрузкаБлокирует редактирование и показывает индикатор
ОшибкаОтображает сообщение, не разрывая реактивную цепочку
УспехПередаёт результат следующему шагу авторизации
Повторная отправкаПоказывает таймер, затем открывает кнопку нового запроса

Пример отражает API и практику RxSwift 2021 года; перед использованием проверьте актуальные версии библиотек и требования конкретного бэкенда.

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

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

  • отправить код на сервер;
  • включить таймер повторной отправки + отобразить визуально;
  • после завершения таймера показать кнопку «отправить еще раз»;
  • отправить повторный запрос на получение кода;
  • отобразить все ошибки;
  • обработать успешное подтверждение кода.

Архитектура экрана

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

Схема взаимодействия View, ViewModel и сервиса на экране подтверждения СМС-кода

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

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

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

php
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 будет выглядеть так:

text
/// Например, после успешного подтверждения кода нам могут предложить ввести перс. данные enum AuthResult { case success case needPersonalData

		 protocol ConfirmCodeViewModelProtocol { /// Введенный пользователем код для подтверждения var code: AnyObserver { get }

		 /// Пользователь нажал на «отправить повторно» var getNewCode: AnyObserver { get }
	 /// Результат подтверждения кода var didAuthorize: Driver { get }

		 /// Один индикатор на все запросы на этом экране var isLoading: Driver { get }

		 /// Ошибки из всех запросов на этом экране var errors: Driver { get }

		 /// Таймер отправки нового кода var newCodeTimer: Driver { get }

		 /// Запросили новый код при нажатии на «отправить заново» var didRequestNewCode: Driver { get } }

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

text
View отдает следующие потоки данных: let codeText = codeTextField.rx.text.share()
	 codeText .bind(to: viewModel.code) .disposed(by: disposeBag)

	 retryButton.rx.tap .bind(to: viewModel.getNewCode)

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

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

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

text
let _codeSubject = PublishSubject() self.code = _codeSubject.asObserver() .disposed(by: disposeBag)

		 let codeObservable = _codeSubject.asObservable()

		 let validCodeObservable = codeObservable.filter { $0.count == codeLength } _codeSubject — это поток данных из textfield ввода кода. validCodeObservable — отфильтровывает значения нужной длины, которые мы будем отправлять на сервер.

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

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

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

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

text
isLoadingRelay = BehaviorRelay(value: false) isLoading = isLoadingRelay.asDriver()
		 codeEvents.mapTo(false).bind(to: isLoading).disposed(by: disposeBag)

		 codeEvents.mapTo(false).bind(to: isLoading).disposed(by: disposeBag)

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

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

text
didAuthorize = codeEvents.elements()...

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

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

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

  • мы отправили код на подтверждение (validCodeObservable.mapTo(Void()));
  • мы перезапросили код (didRequestNewCode);
  • сразу же при заходе на экран (.just(Void())).

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

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

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

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

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

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

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

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

php
class ConfirmCodeViewModelTests: XCTestCase {

// properties
// methods

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

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

text
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 событие об ошибках:

text
func test_errorEmmitedValueAtFailure() throws {
 bindCodeInputEvents()
 setConfirmCodeResult(.error(0, MockError.confirmFailure))

 let sut = scheduler.start { self.viewModel.errors }
 XCTAssertEqual(sut.events, [.next(400, "confirmFailure")])
 }

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

iOSRxSwiftMVVMавторизациямобильная разработка
Загружаем чат

Напишите в поддержку

Опишите проблему — команда разберётся.