Bonjour Habr !
Je m'appelle Igor, je suis Responsable Mobile chez AGIMA.
De nombreux projets et évaluations passent par nous , la fonctionnalité y est souvent répétée, j'ai donc décidé de montrer comment nous résolvons des tâches typiques et de la partager avec vous. Nous allons commencer par le tout début. En règle générale, les applications commencent par une autorisation. Considérons le cas classique de la saisie d'un numéro de téléphone et d'un SMS et attardons-nous plus en détail sur l'écran de confirmation SMS.
Important : l'exemple de code sur github aura un exemple complet avec la saisie d'un numéro de téléphone et d'un code, mais l'écran de saisie du numéro de téléphone est assez ennuyeux, donc aujourd'hui nous entrons le code :)
Cela n'a pas l'air très difficile, mais si vous regardez de près, les fonctionnalités de l'écran sont assez larges, à savoir :
envoyer le code au serveur ;
activer la minuterie de renvoi + affichage visuel ;
après la fin de la minuterie, afficher le bouton « envoyer à nouveau » ;
;
;
.
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 « »:
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>
- .
. , , . , , . , ( ), 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")])
}