How to make an SMS confirmation screen on iOS

Hello, Habr!





My name is Igor, I am Head of Mobile at AGIMA. 





Many projects and evaluations go through us , the functionality is often repeated there, so I decided to show how we solve typical tasks and share it with you. We'll start from the very beginning. Typically, applications start with authorization. Let's consider the classic case of entering a phone number and SMS and dwell in more detail on the SMS confirmation screen.





Important: the example code on github will have a full example with entering a phone number and a code, but the phone number entry screen is quite boring, so today we enter the code :)





It looks not very difficult, but if you look closely, the functionality of the screen is quite large, namely:





  • send the code to the server;





  • enable re-send timer + display visually;





  • after the end of the timer, show the "send again" button;





  • ;





  • ;





  • .





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")])
    }
      
      



, . (, ), .





, , ,   .








All Articles