前语

由于本专栏第五篇 RxSwift登录页Demo 是RxSwift中文版的原文,无法满意真正项目中的开发需求,所以笔者决定以实际项目需求完成一个登录页面,看完本篇期望小伙伴们能够在实际开发中开始运用RxSwift。

登录页要求

页面元素

  1. app图
  2. app名称
  3. 手机号输入框
  4. 验证码输入框
  5. 登录协议及复选框

效果图

第六篇 RxSwift采用MVVM实现登录页面

要求

  1. 手机号做正则校验,11位有用数字,最多输入11位
  2. 验证码为4位有用数字,最多输入4位
  3. 复选框默认不选中
  4. 获取验证码按钮默认灰色,不行点击
  5. 条件1满意时,获取验证码按钮高亮显现,支撑点击
  6. 复选框未选中时,点击获取验证码按钮,提示“请阅览并赞同协议”
  7. 复选框选中时,点击获取验证码按钮,按钮开启60秒倒计时,且调用服务端接口发送验证码,倒计时完毕,恢复可点击状况
  8. 条件1、2满意且复选框选中时,调用登录接口

完成

此页面比较简单,小编这儿直接选用xib进行布局了,略微对倒计时和登录协议做一下讲解

倒计时相关

倒计时UI

一个view和一个button左右选用SnapKit进行布局

        let line = UIView()
        line.backgroundColor = .darkGray
        addSubview(line)
        addSubview(smsBtn)
        line.snp.makeConstraints { make in
            make.top.left.equalTo(5)
            make.width.equalTo(1)
            make.centerY.equalToSuperview()
        }
        smsBtn.snp.makeConstraints { make in
            make.left.equalTo(line.snp.right).offset(0)
            make.top.bottom.right.equalToSuperview()
            make.width.equalTo(120)
        }

倒计时逻辑

首要是经过timercountDownStopped两个序列完成,获取验证码按钮点击之后,timer负责倒计时,countDownStopped表示倒计时是否完毕,代码如下:

let countDownStopped = BehaviorRelay(value: true)
    var leftTime = countDownSeconds
    let timer = Observable<Int>.interval(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance)
func countdownTime(){
    // 开始倒计时
    self.countDownStopped.accept(false)
    timer.take(until: countDownStopped.asObservable().filter{$0})
        .observe(on: MainScheduler.asyncInstance)
        .subscribe(onNext: { [weak self](event) in
            guard let self = self else {
                return
            }
            self.leftTime -= 1
            /// UI操作
            self.smsBtn.setTitle("\(self.leftTime)秒后从头获取", for: .normal)
            if (self.leftTime == 0) {
                print("倒计时完毕")
                self.countDownStopped.accept(true)
                self.leftTime = countDownSeconds
            }
        }, onError: nil )
        .disposed(by: disposeBag)
    }

倒计时完好代码

//
//  JZLoginSMSRightView.swift
//  
//
//  Created by 陈武琦 on 2023/4/20.
//
import UIKit
import SnapKit
import RxSwift
import RxRelay
private let countDownSeconds: Int = 60
class JZLoginSMSRightView: UIView {
    var disposeBag = DisposeBag()
    let countDownStopped = BehaviorRelay(value: true)
    var leftTime = countDownSeconds
    let timer = Observable<Int>.interval(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance)
    public lazy var smsBtn = {
        let btn = UIButton(frame: CGRect(x: 0, y: 0, width: 160, height: 50))
        btn.setTitle("获取验证码", for: .normal)
        btn.setTitleColor(.red, for: .normal)
        btn.setTitleColor(.gray, for: .disabled)
        btn.titleLabel?.font = UIFont.systemFont(ofSize: 14)
        return btn
    }()
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func setup() {
        let line = UIView()
        line.backgroundColor = .darkGray
        addSubview(line)
        addSubview(smsBtn)
        line.snp.makeConstraints { make in
            make.top.left.equalTo(5)
            make.width.equalTo(1)
            make.centerY.equalToSuperview()
        }
        smsBtn.snp.makeConstraints { make in
            make.left.equalTo(line.snp.right).offset(0)
            make.top.bottom.right.equalToSuperview()
            make.width.equalTo(120)
        }
        countDownStopped.subscribe {[weak self] stoped in
            guard let self = self else {
                return
            }
            if stoped {
                self.smsBtn.setTitle("获取验证码", for: .normal)
            }
        }.disposed(by: disposeBag)
    }
    func countdownTime(){
        // 开始倒计时
        self.countDownStopped.accept(false)
        timer.take(until: countDownStopped.asObservable().filter{$0})
             .observe(on: MainScheduler.asyncInstance)
             .subscribe(onNext: { [weak self](event) in
                 guard let self = self else {
                     return
                 }
                 self.leftTime -= 1
                 /// UI操作
                 self.smsBtn.setTitle("\(self.leftTime)秒后从头获取", for: .normal)
                 if (self.leftTime == 0) {
                    print("倒计时完毕")
                    self.countDownStopped.accept(true)
                    self.leftTime = countDownSeconds
                   /// UI操作
                 }
               }, onError: nil )
               .disposed(by: disposeBag)
        }
}

登录协议图文混排

这儿运用了UILabelImageText,一个UILabel支撑图文混排,小编在前面的文章里做过详细的介绍,有兴趣的小伙伴能够去看看,本次运用代码如下

  func setupAgreement() {
        agreeL.imageText(normalImage: UIImage(named: "common_icon_unselected"), selectedImage: UIImage(named: "common_icon_selected"), content: " 我已阅览并赞同《用户协议》和《隐私协议》", font: UIFont.systemFont(ofSize: 12), largeFont: UIFont.systemFont(ofSize: 20), alignment: .left)
        agreeL.setImageCallBack {[weak self] in
            Toast("点击图标")
            self?.agreementSelected.onNext(self?.agreeL.selected ?? false)
        }
        agreeL.setSubstringCallBack(substring: "《用户协议》", color: .gray) {
            Toast("点击《用户协议》")
        }
        agreeL.setSubstringCallBack(substring: "《隐私协议》", color: .gray) {
            Toast("点击《隐私协议》")
        }
    }

ViewModel

经过多个序列及多个序列的联合来表示不同的事情,只需求订阅序列,在序列推送时处理相关事情即可,首要序列如下:

    //手机号长度约束序列
    let phoneTextMaxLengthObservable: Observable<String>
    //验证码长度约束序列
    let smsTextMaxLengthObservable: Observable<String>
    //验证码按钮是否可用序列
    let smsBtnEnableObservable: Observable<Bool>
    //一切准备好序列
    let everyThingValidObservable: Observable<Bool>

手机号长度约束序列

//手机号长度约束
phoneTextMaxLengthObservable = phone.map({ phoneNumber in
    return String(phoneNumber.prefix(11))
})

验证码长度约束

//验证码长度约束
smsTextMaxLengthObservable = smsCode.map({ phoneNumber in
    return String(phoneNumber.prefix(4))
})

手机号是否有用

let phoneVaild = phone.map {
    let regex = "^1[3456789]\\d{9}$"
    let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
    return predicate.evaluate(with: $0) && $0.count == phoneMaxLength
}.distinctUntilChanged().share(replay: 1)

share(replay: 1)是为了将序列共享,在本专栏第五篇末尾做过介绍。 distinctUntilChanged()是为了避免输入框内容不变时重复推送,如果不加,当光标获取和失去时都会推送。

验证码是否有用


let smsValid = smsCode.map {
    let regex = "^\\d{4}$"
    let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
    return predicate.evaluate(with: $0) && $0.count == smsMaxLength
}.distinctUntilChanged().share(replay: 1)

验证码按钮可点击操控


smsBtnEnableObservable = Observable.combineLatest(phoneVaild, smsCountDown).map({
    $0 && $1
}).share(replay: 1)

登录条件


everyThingValidObservable = Observable.combineLatest(phoneVaild, smsValid, checkBox).map {$0 && $1 && $2}

ViewModel完好代码


//
//  JZLoginViewModel.swift
// 
//
//  Created by 陈武琦 on 2023/4/26.
//
import Foundation
import RxSwift
//手机号长度
let phoneMaxLength = 11
//验证码长度
let smsMaxLength = 4
class JZLoginViewModel {
    //手机号长度约束序列
    let phoneTextMaxLengthObservable: Observable<String>
    //验证码长度约束序列
    let smsTextMaxLengthObservable: Observable<String>
    //验证码按钮是否可用序列
    let smsBtnEnableObservable: Observable<Bool>
    //一切准备好序列
    let everyThingValidObservable: Observable<Bool>
    init(
        phone: Observable<String>,
        smsCode: Observable<String>,
        smsCountDown: Observable<Bool>,
        checkBox:Observable<Bool>,
        disposeBag:DisposeBag) {
            //手机号是否有用
            let phoneVaild = phone.map {
                let regex = "^1[3456789]\\d{9}$"
                let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
                return predicate.evaluate(with: $0) && $0.count == phoneMaxLength
            }.distinctUntilChanged().share(replay: 1)
            //验证码是否有用
            let smsValid = smsCode.map {
                let regex = "^\\d{4}$"
                let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
                return predicate.evaluate(with: $0) && $0.count == smsMaxLength
            }.distinctUntilChanged().share(replay: 1)
            //手机号长度约束
            phoneTextMaxLengthObservable = phone.map({ phoneNumber in
                return String(phoneNumber.prefix(11))
            })
            //验证码长度约束
            smsTextMaxLengthObservable = smsCode.map({ phoneNumber in
                return String(phoneNumber.prefix(4))
            })
            //验证码按钮可点击操控
            smsBtnEnableObservable = Observable.combineLatest(phoneVaild, smsCountDown).map({
                $0 && $1
            }).share(replay: 1)
            everyThingValidObservable = Observable.combineLatest(phoneVaild, smsValid, checkBox).map {$0 && $1 && $2}
        }
}

LoginViewController

绑定viewmodel

func bindViewModel() {
        let phoneObservable = phoneTextField.rx.text.orEmpty.asObservable()
        let smsObservable = smsCodeTextField.rx.text.orEmpty.asObservable()
        let smsCountDownObservable = smsRightView.countDownStopped.asObservable()
        let checkBoxObservable = agreementSelected.asObservable()
        let viewModel = JZLoginViewModel(phone: phoneObservable,
                                         smsCode: smsObservable,
                                         smsCountDown: smsCountDownObservable,
                                         checkBox: checkBoxObservable,
                                         disposeBag:disposeBag)
        //操控手机号长度
        viewModel.phoneTextMaxLengthObservable
            .bind(to: phoneTextField.rx.text)
            .disposed(by: disposeBag)
        //操控验证码长度
        viewModel.smsTextMaxLengthObservable
            .bind(to: smsCodeTextField.rx.text)
            .disposed(by: disposeBag)
        //操控按钮是否可点击
        viewModel.smsBtnEnableObservable
            .bind(to: smsRightView.smsBtn.rx.isEnabled)
            .disposed(by: disposeBag)
        //订阅按钮点击
        smsRightView.smsBtn.rx.tap
            .withLatestFrom(agreementSelected.asObservable())
            .subscribe {[weak self] checked in
            guard let self = self else {
                return
            }
            if checked {
                self.sendSMSCode()
                self.smsRightView.countdownTime()
            }else {
               Toast("请阅览并赞同协议")
            }
        }.disposed(by: disposeBag)
        //订阅登录
        viewModel.everyThingValidObservable.subscribe {[weak self] valid in
            if valid { //满意登录条件
                self?.login()
            }
        }.disposed(by: disposeBag)
    }

LoginViewController完好代码

//
//  JZLoginViewController.swift
//  DreamVideo
//
//  Created by 陈武琦 on 2023/4/20.
//
import UIKit
import SnapKit
import RxSwift
import MBProgressHUD
import UILabelImageText
import RxCocoa
class JZLoginViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var phoneTextField: UITextField!
    @IBOutlet weak var smsCodeTextField: UITextField!
    @IBOutlet weak var agreeL: UILabel!
    private lazy var smsRightView: JZLoginSMSRightView = {
        let rightView = JZLoginSMSRightView()
        return rightView
    }()
    var disposeBag = DisposeBag()
    let agreementSelected = BehaviorSubject<Bool>(value: false)
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "登录"
        imageView.layer.cornerRadius = 5
        imageView.layer.masksToBounds = true
        smsCodeTextField.rightView = smsRightView
        smsCodeTextField.rightViewMode = .always;
        setupAgreement()
        bindViewModel()
    }
    func setupAgreement() {
        agreeL.imageText(normalImage: UIImage(named: "common_icon_unselected"), selectedImage: UIImage(named: "common_icon_selected"), content: " 我已阅览并赞同《用户协议》和《隐私协议》", font: UIFont.systemFont(ofSize: 12), largeFont: UIFont.systemFont(ofSize: 20), alignment: .left)
        agreeL.setImageCallBack {[weak self] in
            Toast("点击图标")
            self?.agreementSelected.onNext(self?.agreeL.selected ?? false)
        }
        agreeL.setSubstringCallBack(substring: "《用户协议》", color: .gray) {
            Toast("点击《用户协议》")
        }
        agreeL.setSubstringCallBack(substring: "《隐私协议》", color: .gray) {
            Toast("点击《隐私协议》")
        }
    }
}
/// 绑定ViewModel
extension JZLoginViewController {
    func bindViewModel() {
        let phoneObservable = phoneTextField.rx.text.orEmpty.asObservable()
        let smsObservable = smsCodeTextField.rx.text.orEmpty.asObservable()
        let smsCountDownObservable = smsRightView.countDownStopped.asObservable()
        let checkBoxObservable = agreementSelected.asObservable()
        let viewModel = JZLoginViewModel(phone: phoneObservable,
                                         smsCode: smsObservable,
                                         smsCountDown: smsCountDownObservable,
                                         checkBox: checkBoxObservable,
                                         disposeBag:disposeBag)
        //操控手机号长度
        viewModel.phoneTextMaxLengthObservable
            .bind(to: phoneTextField.rx.text)
            .disposed(by: disposeBag)
        //操控验证码长度
        viewModel.smsTextMaxLengthObservable
            .bind(to: smsCodeTextField.rx.text)
            .disposed(by: disposeBag)
        //操控按钮是否可点击
        viewModel.smsBtnEnableObservable
            .bind(to: smsRightView.smsBtn.rx.isEnabled)
            .disposed(by: disposeBag)
        //订阅按钮点击
        smsRightView.smsBtn.rx.tap
            .withLatestFrom(agreementSelected.asObservable())
            .subscribe {[weak self] checked in
            guard let self = self else {
                return
            }
            if checked {
                self.sendSMSCode()
                self.smsRightView.countdownTime()
            }else {
               Toast("请阅览并赞同协议")
            }
        }.disposed(by: disposeBag)
        //订阅登录
        viewModel.everyThingValidObservable.subscribe {[weak self] valid in
            if valid {
                self?.login()
            }
        }.disposed(by: disposeBag)
    }
}
/// 网络请求
extension JZLoginViewController {
    func login() {
        guard let phone = phoneTextField.text, let sms = smsCodeTextField.text else {return}
        let hud = MBProgressHUD.showAdded(to: view, animated: true)
        DispatchQueue.main.asyncAfter(deadline: .now()+2) {
            print("phone:" + phone + " sms:" + sms)
            hud.label.text = "登录成功"
            hud.hide(animated: true, afterDelay: 1)
        }
    }
    func sendSMSCode() {
        print("调用发送验证码接口")
    }
}

总结

以上是运用RxSwift结合MVVM完成登录页面的一切内容,由于小编也在学习中,如果有需求优化或者不对的当地,欢迎小伙伴们谈论区提出来,本篇完好的demo已上传GitHub,有需求的小伙伴能够去下载运转看看。

RxSwift真的是太强壮了,有许多操作符小编也不是很清楚,不过小编学习的步伐不会停,后续依旧会跟随着RxSwift中文文档的教程一步步学习,遇到欠好理解,需求实操的当地,小编会尽量的整理解分享出来,加油!fighting!!!