Swift 开发 wanandroid 客户端——登录、注册页面

2,392 阅读5分钟

这是我参与更文挑战的第27天,活动详情查看: 更文挑战

先看看UI效果

RPReplay_Final1624634067.2021-06-25 23_17_11.gif

上图的Gif大概显示了这样一个流程:

1.在我的页面点击登录按钮,push到登录页面。

2.在登录页面点击还没有注册,push到注册页面,可以看集注册页面与登录页面非常的相似,只是多了一个请再次输入密码的textField。

3.回到登录页面,在手机号输入框,输入手机号码,这里只做了手机号的位数校验,那么第二行的密码输入框才会enable,在手机号位数正确,并且密码不为空的情况下,按钮才会enable。 注意录屏中对于密码输入看不见不是我造成的,而是系统录屏就是如此。

4.点击登录,进行登录的接口请求,请求成功后,pop到我的页面。

5.并在“登录成功”Toast后,接着请求我的积分接口,获取用的相关个人积分数据。

今天主要说登录与注册页面的编写以及登录页面的两个输入、一个按钮的绑定逻辑和请求。

编写登录与注册页面的基类页面

就上面分析的,我们可以看到其实登录与注册页面有一些相似之处,基于能少写代码就少写代码的原则,我们完全可以抽一个基类过来进行复用,里面包含2个输入框,1个按钮以及登录接口和注册接口。

import UIKit

import MBProgressHUD

class AccountBaseController: BaseViewController {
    /// 懒加载用户名输入框 因为在子类中需要使用,所以没有使用private修饰
    lazy var usernameFiled: UITextField = {
        let textField = UITextField()
        textField.layer.borderWidth = 0.5
        textField.layer.borderColor = UIColor.gray.cgColor
        textField.backgroundColor = .white
        textField.keyboardType = .numberPad
        textField.returnKeyType = .done
        textField.font = UIFont.systemFont(ofSize: 15)
        textField.placeholder = "请输入用户名"
        
        let emptyView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 1))
        textField.leftView = emptyView
        textField.rightView = emptyView
        textField.leftViewMode = .always
        textField.rightViewMode = .always
        return textField
    }()
    
    /// 懒加载密码输入框
    lazy var passwordField: UITextField = {
        let textField = UITextField()
        textField.layer.borderWidth = 0.5
        textField.layer.borderColor = UIColor.gray.cgColor
        textField.backgroundColor = .white
        textField.returnKeyType = .done
        textField.font = UIFont.systemFont(ofSize: 15)
        textField.isSecureTextEntry = true
        textField.placeholder = "请输入密码"
        
        let emptyView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 1))
        textField.leftView = emptyView
        textField.rightView = emptyView
        textField.leftViewMode = .always
        textField.rightViewMode = .always
        return textField
    }()
    
    /// 懒加载行为按钮,登录页面为登录按钮, 注册页面为注册按钮
    lazy var actionButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setTitleColor(.white, for: .normal)
        button.isEnabled = false
        button.layer.cornerRadius = 22
        button.layer.masksToBounds = true
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
    
    /// 布局,这里只布局了两个输入框,而行为按钮因为两个页面的不同,需要进行单独布局,就不在基类中进行了
    private func setupUI() {
        view.addSubview(usernameFiled)
        usernameFiled.snp.makeConstraints { make in
            make.top.equalTo(view).offset(kTopMargin + 16)
            make.leading.equalTo(view).offset(16)
            make.trailing.equalTo(view).offset(-16)
            make.height.equalTo(44)
        }
        usernameFiled.layer.cornerRadius = 22
        usernameFiled.layer.masksToBounds = true
        
        view.addSubview(passwordField)
        passwordField.snp.makeConstraints { make in
            make.top.equalTo(usernameFiled.snp.bottom).offset(16)
            make.leading.trailing.height.equalTo(usernameFiled)
        }
        passwordField.layer.cornerRadius = 22
        passwordField.layer.masksToBounds = true
    }
}

extension AccountBaseController {
    /// 登录接口
    func login(username: String, password: String) {
        accountProvider.rx.request(AccountService.login(username, password))
            .map(BaseModel<AccountInfo>.self)
            .subscribe { baseModel in
                if baseModel.errorCode == 0 {
                   /// 这里单例进行登录信息的保存会在后面的账户模块进行说明,这里先写出来,不影响流程
                   AccountManager.shared.saveLoginUsernameAndPassword(info: baseModel.data, username: username, password: password)
                   /// 通过GCD切到主线程用MB,这里我其实尝试在在主线程订阅并使用MB,但是还是有问题,没办法祭出了GCD,有经验的小伙伴望可以教教我。
                    DispatchQueue.main.async {
                        MBProgressHUD.showText("登录成功")
                    }
 /// 回退页面
 self.navigationController?.popToRootViewController(animated: true)
                }
            } onError: { _ in
                
            }.disposed(by: rx.disposeBag)
    }
    
    /// 注册接口,在注册成功后立刻调用登录接口
    func registerAndLogin(username: String, password: String, repassword: String) {
        accountProvider.rx.request(AccountService.register(username, password, repassword))
            .map(BaseModel<AccountInfo>.self)
            .subscribe { baseModel in
                if baseModel.errorCode == 0 {
                    DispatchQueue.main.async {
                        MBProgressHUD.showText("注册成功")
                    }
                    self.login(username: username, password: password)
                }
            } onError: { _ in
                
            }.disposed(by: rx.disposeBag)
    }
}

在AccountBaseController中,并没有什么特别复杂的逻辑,就是登录接口的订阅中:

  • 我提前将AccountManager的部分逻辑写出来了,但是不影响流程(账户管理模块)。

  • 另外在订阅中通过GCD切换到主线程使用MB,我尝试在主线程订阅,不使用GCD弹MB,但是有问题,如果有知道的大佬还麻烦教教我。

这里我也特别想了解,如何在RxSwift中的订阅中正确的使用MBProgressHUD?

登录页面

有了上面这个AccountBaseController,我们写起LoginControll基本上更加关注的是两个输入框与登录按钮的使能关系逻辑上来。注意看注释喔。

import UIKit

import RxSwift
import RxCocoa
import MBProgressHUD

class LoginController: AccountBaseController {
    
    /// 懒加载还没有注册按钮
    private lazy var toRegisterButton: UIButton = {
        let button = UIButton(type: .custom)
        let attString = NSAttributedString(string: "还没有注册?", attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue, .foregroundColor: UIColor.systemBlue, .font: UIFont.systemFont(ofSize: 15)])
        button.setAttributedTitle(attString, for: .normal)
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
    
    private func setupUI() {
        /// 标题赋值
        title = "登录"
        
        /// 行为按钮赋值
        actionButton.setTitle(title, for: .normal)
        
        /// 还没有注册按钮的布局
        view.addSubview(toRegisterButton)
        toRegisterButton.snp.makeConstraints { make in
            make.top.equalTo(passwordField.snp.bottom).offset(16)
            make.trailing.equalTo(usernameFiled)
        }
        
        /// 登录按钮的布局
        view.addSubview(actionButton)
        actionButton.snp.makeConstraints { make in
            make.top.equalTo(toRegisterButton.snp.bottom).offset(16)
            make.leading.trailing.height.equalTo(usernameFiled)
        }
        
        /// 用户名的有效性序列,这里特别通过对位数多了校验,字符串必须大于等于11位,通过截取显示过多输入,并返回true,少于11位返回false
        let usernameValid = usernameFiled.rx.text.orEmpty
            .map { [weak self] text -> Bool in
                if text.count >= 11 {
                    print("超出了,进行截取")
                    self?.usernameFiled.text = String(text.prefix(11))
                    return true
                }else {
                    return false
                }
            }
            .share(replay: 1) // without this map would be executed once for each binding, rx is stateless by default

        /// 密码的有效性序列,这里设置是密码大于1位即可,大家可以自己玩,搞个正则也是可以的
        let passwordValid = passwordField.rx.text.orEmpty
            .map { $0.count > 0 }
            .share(replay: 1)

        /// 使用序列的combineLatest联合上面两个序列的,用于表示按钮的有效性
        let everythingValid = Observable.combineLatest(usernameValid, passwordValid) { $0 && $1 }
            .share(replay: 1)
        
        /// 用户名的有效性与密码输入框的使能绑定
        usernameValid
            .bind(to: passwordField.rx.isEnabled)
            .disposed(by: rx.disposeBag)
        
        /// 用户名的有效性map成为颜色与密码输入框的背景色绑定
        usernameValid.map { $0 ? UIColor.white : UIColor.gray.withAlphaComponent(0.5) }
            .bind(to: passwordField.rx.backgroundColor)
            .disposed(by: rx.disposeBag)
        
        /// 两个输入框联合的有效性与按钮的使能绑定
        everythingValid
            .bind(to: actionButton.rx.isEnabled)
            .disposed(by: rx.disposeBag)

        /// 两个输入框联合的有效性map成为颜色与按钮的背景色绑定
        everythingValid.map { $0 ? UIColor.systemBlue : UIColor.systemBlue.withAlphaComponent(0.5) }
            .bind(to: actionButton.rx.backgroundColor)
            .disposed(by: rx.disposeBag)
        
        /// 还没有注册按钮的点击事件
        toRegisterButton.rx.tap
            .subscribe { [weak self] _ in
                self?.navigationController?.pushViewController(RegisterController(), animated: true)
            }.disposed(by: rx.disposeBag)
        
        /// 登录页面的点击事件,调用基类中的登录接口
        actionButton.rx.tap
            .subscribe(onNext: { [weak self] _ in
                guard let username = self?.usernameFiled.text,
                      let password = self?.passwordField.text else {
                    return
                }
                self?.login(username: username, password: password)
            })
            .disposed(by: rx.disposeBag)
    }
}

到此,登录页面的布局与逻辑基本上就完成了,在这个页面中我们更多的是同valid来进行各种绑定关系来完成页面的功能,大家可以试想一下,不使用RxSwift,而是用UITextField的delegate来做,会写多少代码,反正逻辑上会比这个复杂,因为我自己以前也是用原生写这种逻辑关系的。

另外我想说的是,这个输入绑定的逻辑,我是很早偷师了RxSwift中文官网的例子,嘻嘻。

注册页面

既然登录页面已经完成,那么注册页面不过是比登录页面稍许多些逻辑,这里就直接放代码了:

import UIKit

import RxSwift
import RxCocoa

class RegisterController: AccountBaseController {
    
    private lazy var repasswordField: UITextField = {
        let textField = UITextField()
        textField.layer.borderWidth = 0.5
        textField.layer.borderColor = UIColor.gray.cgColor
        textField.backgroundColor = .white
        textField.returnKeyType = .done
        textField.font = UIFont.systemFont(ofSize: 15)
        textField.isSecureTextEntry = true
        textField.placeholder = "请再次输入密码"
        
        let emptyView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 1))
        textField.leftView = emptyView
        textField.rightView = emptyView
        textField.leftViewMode = .always
        textField.rightViewMode = .always
        return textField
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
    
    private func setupUI() {
        
        title = "注册"
        actionButton.setTitle(title, for: .normal)
        
        view.addSubview(repasswordField)
        repasswordField.snp.makeConstraints { make in
            make.top.equalTo(passwordField.snp.bottom).offset(16)
            make.leading.trailing.height.equalTo(usernameFiled)
        }
        repasswordField.layer.cornerRadius = 22
        repasswordField.layer.masksToBounds = true
        
        view.addSubview(actionButton)
        actionButton.snp.makeConstraints { make in
            make.top.equalTo(repasswordField.snp.bottom).offset(16)
            make.leading.trailing.height.equalTo(usernameFiled)
        }
        
        let usernameValid = usernameFiled.rx.text.orEmpty
            .map { [weak self] text -> Bool in
                if text.count >= 11 {
                    print("超出了,进行截取")
                    self?.usernameFiled.text = String(text.prefix(11))
                    return true
                }else {
                    return false
                }
            }
            .share(replay: 1) // without this map would be executed once for each binding, rx is stateless by default

        let passwordValid = passwordField.rx.text.orEmpty
            .map { $0.count > 0 }
            .share(replay: 1)
        
        let repasswordValid = repasswordField.rx.text.orEmpty
            .map { $0.count > 0 }
            .share(replay: 1)
        
        let isSamePassword = Observable.combineLatest(passwordField.rx.text.orEmpty, repasswordField.rx.text.orEmpty) { $0 == $1 }.share(replay: 1)

        let everythingValid = Observable.combineLatest(usernameValid, passwordValid, repasswordValid, isSamePassword) { $0 && $1 && $2 && $3 }
            .share(replay: 1)

        usernameValid
            .bind(to: passwordField.rx.isEnabled)
            .disposed(by: rx.disposeBag)
        
        usernameValid.map { $0 ? UIColor.white : UIColor.gray.withAlphaComponent(0.5) }
            .bind(to: passwordField.rx.backgroundColor)
            .disposed(by: rx.disposeBag)
        
        usernameValid.map { $0 ? UIColor.white : UIColor.gray.withAlphaComponent(0.5) }
            .bind(to: repasswordField.rx.backgroundColor)
            .disposed(by: rx.disposeBag)

        everythingValid
            .bind(to: actionButton.rx.isEnabled)
            .disposed(by: rx.disposeBag)

        everythingValid.map { $0 ? UIColor.systemBlue : UIColor.systemBlue.withAlphaComponent(0.5) }
            .bind(to: actionButton.rx.backgroundColor)
            .disposed(by: rx.disposeBag)

        actionButton.rx.tap
            .subscribe(onNext: { [weak self] _ in
                guard let username = self?.usernameFiled.text,
                      let password = self?.passwordField.text,
                      let repassword = self?.repasswordField.text else {
                    return
                }
                self?.registerAndLogin(username: username, password: password, repassword: repassword)
            })
            .disposed(by: rx.disposeBag)
    }

}

总结

我们今天进行了登录、注册页面的编写:

  • 通过编写基类AccountBaseController,我们抽出两个页面特征相似的地方,并定义好接口。

  • 登录页面,对登录页面独有的控件进行了布局,完成了各种绑定关系,已保证逻辑的正确性。Observable.combineLatest函数值得掌握其用法。

  • 注册页面,页面内容与逻辑和登录页面非常相似,就没有多费笔墨。

明日继续

完成登录、注册页面后,其实我们需要对账户做一个跟踪管理,这个该如何实现是下一篇的内容。

大家加油!