Swift-MVVM 简单演练(三)

1,102 阅读20分钟

Swift-MVVM 简单演练(一)

Swift-MVVM 简单演练(二)

Swift-MVVM 简单演练(四)

优化一些小细节

设置SVProgressHUD最小提示时间

在我们用SVProgressHUD的时候,它默认的显示时长可能会不符合你的使用规则。我们可以更改它显示的最小时间(setMinimumDismissTimeInterval)

像这种全局都能用到的东西,我们最好是设置在一个方便管理的地方,这里以在AppDelegate中设置

extension AppDelegate {

    fileprivate func setupAddtions() {

        // 设置`SVProgressHUD`最小解除时间
        SVProgressHUD.setMinimumDismissTimeInterval(1)
    }
}

设置AFN指示器

很多好的应用程序是非常人性化的,如果有网络请求的时候,会在状态栏的位置有一个Loading的很小的标志,这是苹果自带的标志,其实我们应该把它在应该显示的时候显示出来的。幸运的是,我们赶上了一个好的时代。AFN这个框架已经帮我们实现了。

extension AppDelegate {

    fileprivate func setupAddtions() {

        // 设置网络加载指示器
        AFNetworkActivityIndicatorManager.shared().isEnabled = true
    }
}

这里需要强调一下,现在不论是移动网络还是无线网络,网速越来越快了(我们赶上了一个好的时代)。如果网速很快的时候,即使是设置了这个,一般也是看不到的。但是网速不好的时候,它就起作用了。

将询问发送通知授权的代码也抽取出来

swiftextension是可以无限多个写的,我们如果能将更多的零碎的方法抽取出来,放到extension中去。代码会清晰很多,也会方便管理很多。

extension AppDelegate {

    fileprivate func setupNotification() {

        if #available(iOS 10.0, *) {
            UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound, .carPlay]) { (sucess, error) in
//                print("授权" + (sucess ? "成功" : "失败"))
            }
        } else {
            // Fallback on earlier versions
            let notificationSettings = UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
            UIApplication.shared.registerUserNotificationSettings(notificationSettings)
        }
    }
}

值得注意的是,之前下面这段代码本来是这样的

} else {
    // Fallback on earlier versions
    let notificationSettings = UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
    application.registerUserNotificationSettings(notificationSettings)
}

如果放到extensionapplication是需要当做参数传递过去的,而我们本着省事的原则,直接使用UIApplication.shared就可以了,UIApplication是单例,只要用的时候直接取出它就可以了。


处理登录相关通知

Tokennil时测试

所有的网络请求都是基于token的,如果没有token的话(虽然实际程序中几乎不可能出现token = nil的情况),我们应该使程序在当token = nil并且用户又一次进行了网络请求的时候将提示用户,并且将登录控制器展现出来。

HQNetWorkManager中,发送登录通知

/// 带`token`的网络请求方法
func tokenRequest(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: AnyObject]?, completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {

    // 判断`token`是否为`nil`,为`nil`直接返回,程序执行过程中,一般`token`不会为`nil`
    guard let token = userAccount.token else {

        // 发送通知,提示用户登录
        print("没有 token 需要重新登录")
        NotificationCenter.default.post(
            name: NSNotification.Name(rawValue: HQUserShouldLoginNotification),
            object: nil)
        completion(nil, false)
        return
    }

写的任何代码都要测试,随便找一个控制器的viewDidLoad方法里面。将token置为nil

class HQDViewController: HQBaseViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        HQNetWorkManager.shared.userAccount.token = nil
    }

接下来再回到首页,下拉刷新。由于又进行了网络请求,而且我们判断了当tokennil时的判断,因此会发送一个登录的通知。在HQMainViewController中,之前我们添加了监听的方法

class HQMainViewController: UITabBarController {

    override func viewDidLoad() {
        super.viewDidLoad()

        NotificationCenter.default.addObserver(self, selector: #selector(login), name: NSNotification.Name(rawValue: HQUserShouldLoginNotification), object: nil)

因此,监听到通知,就会走login的方法,弹出登录界面了。

// MARK: - Targrt Action
extension HQMainViewController {

    // MARK: - 登录监听方法
    @objc fileprivate func login(n: Notification) {

        print("用户登录通知 \(n)")

        SVProgressHUD.setDefaultMaskType(.clear)
        let nav = UINavigationController(rootViewController: HQLoginController())
        self.present(nav, animated: true, completion: nil)
    }

Token的过期处理

HQNetWorkManager内目前就两个方法,而且还是有关联的,所以处理完第一个方法的时候,我们理应看下第二个方法。如果token不为nil,我们该在什么地方做何处理呢?

这里根据请求失败的返回码处理一下,当statusCode == 403时,我们再次发送用户登录的通知

/// 封装 AFN 的 GET/POST 请求
///
/// - Parameters:
///   - method: GET/POST
///   - URLString: URLString
///   - parameters: parameters
///   - completion: 完成回调(json, isSuccess)
func request(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: AnyObject]?, completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {

    let success = { (task: URLSessionDataTask, json: Any?)->() in
        completion(json, true)
    }

    let failure = { (task: URLSessionDataTask?, error: Error)->() in

        if (task?.response as? HTTPURLResponse)?.statusCode == 403 {
            print("token 过期了")

            // 发送通知,提示用户再次登录
            NotificationCenter.default.post(
                name: NSNotification.Name(rawValue: HQUserShouldLoginNotification),
                object: "bad token")
        }

        print("网络请求错误 \(error)")
        completion(nil, false)
    }

任何情况都要进行测试,再次回到之前的测试控制器里面,给token赋值一个非空的值测试

class HQDViewController: HQBaseViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        HQNetWorkManager.shared.userAccount.token = "bad token"
    }

如果我们再次回到首页控制器,进行网络请求,就会再次弹出登录界面。

处理弹出登录界面的一些UI细节

如果我们不做一些提示,或者动画过度一下的话,直接就硬生生弹出登录控制器,逻辑上没有问题,但是交互总是感觉不那么好。因此我们最好做一点小提示。

但是在哪里做提示比较好呢。建议还是放在接收到登录通知的监听方法里面处理比较好。

首先,我们发送登录通知的时候,附带一个自定义的object(这里是字符串"bad token")过去。

// 发送通知,提示用户再次登录
NotificationCenter.default.post(
    name: NSNotification.Name(rawValue: HQUserShouldLoginNotification),
    object: "bad token")

然后在处理监听登录通知的方法里处理交互显示的问题,仅仅是增加一点点提示的UI而已,有了下面的代码,交互就会感觉好了很多了。这里主要学习的是如果突然增加需求,我们如何在合适的位置处理问题。

// MARK: - Targrt Action
extension HQMainViewController {

    // MARK: - 登录监听方法
    @objc fileprivate func login(n: Notification) {

        print("用户登录通知 \(n)")

        if n.object != nil {
            SVProgressHUD.setDefaultMaskType(.gradient)
            SVProgressHUD.showInfo(withStatus: "登录超时,请重新登录")
        }

        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2) {

            SVProgressHUD.setDefaultMaskType(.clear)
            let nav = UINavigationController(rootViewController: HQLoginController())
            self.present(nav, animated: true, completion: nil)
        }
    }

看看自己为了完成某一需求而改的代码,有没有影响到其它地方

时刻提醒自己,当我们兴高采烈的为完成了某一处的改动而沾沾自喜的时候。要在对其它有可能会被影响的地方测试一下。不然,日后遗留的问题可能会让你百思不得其解。

这不就,我们刚为了处理token过期而设置的延迟两秒钟再弹出登录界面,果然就影响到了其它的登录地方。

比如,一开始没有登录的时候,运行程序,会出现登录注册的按钮。当我们点击登录的按钮的时候,我们期望立刻弹出登录控制器。

但是我们刚才写的代码,真的有影响到这里了。点击登录也是延迟2秒钟才弹出登录界面,给人的感觉总是怪怪的。

下面我们想办法测试一下

将存储用户账户相关的文件删除

然后运行程序,就直接到登录界面,然后点击登录按钮发现总是需要等待2秒钟,我们找到之前延迟两秒钟的地方处理一下。

增加一个时间变量,如果token过期了,就将时间增减2秒,否则不增加。

// MARK: - Targrt Action
extension HQMainViewController {

    // MARK: - 登录监听方法
    @objc fileprivate func login(n: Notification) {

        print("用户登录通知 \(n)")

        var when = DispatchTime.now()

        if n.object != nil {
            SVProgressHUD.setDefaultMaskType(.gradient)
            SVProgressHUD.showInfo(withStatus: "登录超时,请重新登录")

            // 修改延迟时间
            when = DispatchTime.now() + 2
        }

        DispatchQueue.main.asyncAfter(deadline: when) {

            SVProgressHUD.setDefaultMaskType(.clear)
            let nav = UINavigationController(rootViewController: HQLoginController())
            self.present(nav, animated: true, completion: nil)
        }
    }

这样就可以解决普通登录状态下的展现登录界面的延迟问题了。


加载用户个人信息

获取用户个人信息数据

接口地址


/// 个人信息
let HQUserInfoUrlString = "https://api.weibo.com/2/users/show.json"

HQNetWorkManager+Extension中增加用户个人信息获取的网络请求方法

// MARK: - 用户信息
extension HQNetWorkManager {

    /// 加载用户信息
    func loadUserInfo(completion: @escaping (_ dict: [String: AnyObject]) -> ()) {

        guard let uid = userAccount.uid else {
            return
        }
        let params = ["uid": uid]

        tokenRequest(URLString: HQUserInfoUrlString, parameters: params as [String : AnyObject]) { (json, isSuccess) in

            // 完成回调
            completion(json as? [String : AnyObject] ?? [:])
        }
    }
}

那么问题来了,此方法在哪里调用比较合适呢?

因为,我们需要拿到这个在首页就展示昵称或者头像。所以在登录成功但是没有执行完成回调的时候去执行该方法获取用户个人信息是比较理想的位置。

下面我这里并没有做网络请求交互获取token,只是模拟了一下而已。

// MARK: - 请求`Token`
extension HQNetWorkManager {

    /// 根据`帐号`和`密码`获取`Token`
    ///
    /// - Parameters:
    ///   - account: account
    ///   - password: password
    ///   - completion: 完成回调
    func loadAccessToken(account: String, password: String, completion: @escaping (_ isSuccess: Bool)->()) {

        // 从`bundle`加载`data`
        let path = Bundle.main.path(forResource: "userAccount.json", ofType: nil)
        let data = NSData(contentsOfFile: path!)

        // 从`Bundle`加载配置的`userAccount.json`
        guard let dict = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as? [String: AnyObject]
            else {
                return
        }

        // 直接用字典设置`userAccount`的属性
        self.userAccount.yy_modelSet(with: dict ?? [:])

        self.userAccount.saveAccount()

        // 加载用户信息
        self.loadUserInfo { (dict) in
            print(dict)
            // 用户信息加载完成再执行,首页数据加载的完成回调
            completion(true)
        }

    }
}

保存所需要的个人信息(昵称、头像地址)

获取到个人信息之后,这种个人信息可能会在很多地方需要用到,我们最好将其像保存token那样将其保存起来。

因此,扩展一下个人信息模型,增加两个属性

/// 用户昵称
var screen_name: String?
/// 用户头像地址(大图),180x180
var avatar_large: String?

HQNetWorkManager+Extension中的请求token的方法里保存,之前只是保存了tokenuidexpires_in(过期时间),现在需要将新获取到的screen_nameavatar_large(头像地址)也保存到此

func loadAccessToken(account: String, password: String, completion: @escaping (_ isSuccess: Bool)->()) {

    // 从`bundle`加载`data`
    let path = Bundle.main.path(forResource: "userAccount.json", ofType: nil)
    let data = NSData(contentsOfFile: path!)

    // 从`Bundle`加载配置的`userAccount.json`
    guard let dict = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as? [String: AnyObject]
        else {
            return
    }

    // 直接用字典设置`userAccount`的属性
    self.userAccount.yy_modelSet(with: dict ?? [:])

    // 加载用户信息
    self.loadUserInfo { (dict) in

        self.userAccount.yy_modelSet(with: dict)
        self.userAccount.saveAccount()

        // 用户信息加载完成再执行,首页数据加载的完成回调
        completion(true)
    }

和之前的对比一下,应该会看的更清楚


更改导航栏标题显示样式

之前微博的版本和现在多少有点区别,在首页的导航栏的标题位置仅仅是显示自己的昵称,并且可下拉展开。这里不去做那么复杂,只是表达一下,更改导航栏标题显示样式和Button的文字图片左右对调,之前我也写过Objective-C的相关方法iOS-自定义 UIButton-文字在左、图片在右(一)iOS-自定义 UIButton-文字在左、图片在右(二)

将导航栏标题设置成自定义Button

这个没什么技术含量,直接上代码了。

/// 设置导航栏标题演示
    fileprivate func setupNavTitle() {

        let btn = UIButton(hq_title: "王红庆", fontSize: 17, normalColor: UIColor.darkGray, highlightedColor: UIColor.red)
        btn.setImage(UIImage(named: "nav_arrow_down"), for: .normal)
        btn.setImage(UIImage(named: "nav_arrow_up"), for: .selected)
        navItem.titleView = btn

        btn.addTarget(self, action: #selector(clickTitleButton), for: .touchUpInside)
    }

    @objc fileprivate func clickTitleButton(btn: UIButton) {

        btn.isSelected = !btn.isSelected
    }

抽取创建类似标题按钮的逻辑

类似这种需求可能一个项目中不止一个地方会用到,即便是目前就这一个地方会用到,我们也应该尽量将其抽取出来。因为要设置图像和文字,并且颠倒其位置的这些代码,应该封装起来的。只留给使用者(包括我们自己)一个快速创建此按钮的方法就可以了。

我选择在ButtonExtension中搞定这个。

/// 文字在左、图片在右的 Button
class HQTitleButton: UIButton {

    /// 重载构造函数
    ///
    /// - Parameter title: title 如果是 nil,就显示首页
    /// - Parameter title: title 如果不是 nil,显示 title 和 箭头
    init(title: String?) {
        super.init(frame: CGRect())

        if title == nil {
            setTitle("首页", for: .normal)
        } else {
            setTitle(title!, for: .normal)
            setImage(UIImage(named: "nav_arrow_down"), for: .normal)
            setImage(UIImage(named: "nav_arrow_up"), for: .selected)
        }

        titleLabel?.font = UIFont.boldSystemFont(ofSize: 17)
        setTitleColor(UIColor.darkGray, for: .normal)

        // 设置大小
        sizeToFit()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

这样我们设置的时候就可以简化很多,目前还没有实现将文字和图片颠倒

/// 设置导航栏标题演示
fileprivate func setupNavTitle() {

    let title = HQNetWorkManager.shared.userAccount.screen_name

    let btn = HQTitleButton(title: title)

    navItem.titleView = btn

    btn.addTarget(self, action: #selector(clickTitleButton), for: .touchUpInside)
}

@objc fileprivate func clickTitleButton(btn: UIButton) {

    btn.isSelected = !btn.isSelected
}

利用layoutSubViews方法重新调整按钮文字和图像的位置

在调用override func layoutSubviews()方法的时候,一定要调用super.layoutSubviews(),如果不调用,就会出现显示不出来的情况。

/// 文字在左、图片在右的 Button
class HQTitleButton: UIButton {

    /// 重载构造函数
    ///
    /// - Parameter title: title 如果是 nil,就显示首页
    /// - Parameter title: title 如果不是 nil,显示 title 和 箭头
    init(title: String?) {
        super.init(frame: CGRect())

        if title == nil {
            setTitle("首页", for: .normal)
        } else {
            setTitle(title! + " ", for: .normal)
            setImage(UIImage(named: "nav_arrow_down"), for: .normal)
            setImage(UIImage(named: "nav_arrow_up"), for: .selected)
        }

        titleLabel?.font = UIFont.boldSystemFont(ofSize: 17)
        setTitleColor(UIColor.darkGray, for: .normal)

        // 设置大小
        sizeToFit()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    /// 重新布局子视图
    override func layoutSubviews() {
        super.layoutSubviews()

        // 判断`label`和`imageView`是否同时存在
        guard let titleLabel = titleLabel,
            let imageView = imageView
            else {
                return
        }

        // 将`titleLabel`的`x`向左移动`imageView`的`width`,值得注意的是,这里我们需要将`width / 2`        
        titleEdgeInsets = UIEdgeInsetsMake(0, -imageView.bounds.width, 0, imageView.bounds.width)
        // 将`imageView`的`x`向右移动`titleLabel`的`width`,值得注意的是,这里我们需要将`width / 2`
        imageEdgeInsets = UIEdgeInsetsMake(0, titleLabel.bounds.width, 0, -titleLabel.bounds.width)
        /********** 下面这种做法不推荐 **********/
        // 会有问题
//        titleLabel.frame = titleLabel.frame.offsetBy(dx: -imageView.bounds.width, dy: 0)
//        imageView.frame = imageView.frame.offsetBy(dx: titleLabel.bounds.width, dy: 0)

    }
}

这里我要多写点东西。因为最开始,我是设置ButtontitleLabelimageViewframe属性的offSet的。

/********** 下面这种做法不推荐 **********/
// 会有问题
titleLabel.frame = titleLabel.frame.offsetBy(dx: -imageView.bounds.width, dy: 0)
imageView.frame = imageView.frame.offsetBy(dx: titleLabel.bounds.width, dy: 0)

如果按照道理上讲的话,应该是没有什么问题的,titleLabel左移imageView的宽度。imageView右移titleLabel的宽度。但实际上还是出了问题。运行程序的时候你会发现,箭头图标不见了。

然后我就试着把偏移的距离缩小一倍

居然就好了,我就很开心。虽然我心里也一直纳闷,为什么会是一半的距离!就在我百思不得其解时候,我不小心点击了一下按钮。结果又是令我非常意外

仔细看,箭头图片在文字中央的位置,再多次点击的话,都是在这个位置切换图片。在这个位置我是可以理解的,因为点击按钮就会执行layoutSubviews方法,就会将titleLabelimageView按照代码里面的偏移量移动,而偏移量又是我们之前设置的各个宽度的二分之一。

于是我就想到了,如果不设置偏移量是各个宽度的一半的话,最开始显示虽然有问题,但是是不是,点击就正常了呢。果不其然。

于是我测试了强行layoutIfNeeded这种方法也无济于事,我只好参照自己之前用Objctive-C的方法,通过设置titleEdgeInsetsimageEdgeInsets来搞定。

// 将`titleLabel`的`x`向左移动`imageView`的`width`,值得注意的是,这里我们需要将`width / 2`        
titleEdgeInsets = UIEdgeInsetsMake(0, -imageView.bounds.width, 0, imageView.bounds.width)
// 将`imageView`的`x`向右移动`titleLabel`的`width`,值得注意的是,这里我们需要将`width / 2`
imageEdgeInsets = UIEdgeInsetsMake(0, titleLabel.bounds.width, 0, -titleLabel.bounds.width)

这里还有一点我要强调的是,如果只是按照我那样将titleLabelimageView的顺序颠倒的话,titleLabelimageView也是紧紧的挨在一起的。大概是下面这个样子

而比较理想的状态应该是,文字与图片之间有一定的间距,这样看起来比较舒服。

如果想达到这种状态,我们可能会延续上面的思维,将偏移量增大一点。这种操作表面上看着没什么问题,但是实际上imageView其实已经超出了Button的右侧边界了,显然是不太好的。

// 将`titleLabel`的`x`向左移动`imageView`的`width`,值得注意的是,这里我们需要将`width / 2`
titleEdgeInsets = UIEdgeInsetsMake(0, -imageView.bounds.width, 0, imageView.bounds.width)
// 将`imageView`的`x`向右移动`titleLabel`的`width`,值得注意的是,这里我们需要将`width / 2`
imageEdgeInsets = UIEdgeInsetsMake(0, titleLabel.bounds.width + 20, 0, -titleLabel.bounds.width - 20)

为此,我们可以尝试转换一种解决思路。给title的文字追加一个空格。

/// 文字在左、图片在右的 Button
class HQTitleButton: UIButton {

    /// 重载构造函数
    ///
    /// - Parameter title: title 如果是 nil,就显示首页
    /// - Parameter title: title 如果不是 nil,显示 title 和 箭头
    init(title: String?) {
        super.init(frame: CGRect())

        if title == nil {
            setTitle("首页", for: .normal)
        } else {
            setTitle(title! + " ", for: .normal)

这种看起来就比较合适了。


新特性

每次有新的版本的时候,都会出现的一个界面,目的是介绍APP新增的功能之类的。

关于版本号的简单介绍:

  • APP Store每次升级应用程序,版本号都要增加
  • 版本号一般由x.x.x组成,分别对应主版本号.次版本号.修订版本号
  • 主版本号:意味着大的修改,使用者也需要做大的适应,比如Xcode每年会更新一个主版本号8.3.3
  • 次版本号:意味着小的修改,某些函数和方法的使用或者参数有变化,对应APP可能是主功能不变,但是新增了附加的一些新功能
  • 修订版本号:程序内部bug的修订,一些功能的紧急修复,一般不会对APP使用者有任何影响
// MARK: - 新特性
extension HQMainViewController {

    fileprivate func setupNewFeatureView() {

        // 如果用户没有登录,则不显示新特性界面,直接返回
        if !HQNetWorkManager.shared.userLogon {
            return
        }

        let v = isNewVersion ? HQNewFeatureView() : HQWelcomeView()

        v.frame = view.bounds

        view.addSubview(v)
    }

    /// 计算型属性,不占用存储空间
    fileprivate var isNewVersion: Bool {

        // 获取当前版本号
        let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""

        // 拼接保存到沙盒的路径
        let path = String.hq_appendDocmentDirectory(fileName: "version") ?? ""
        let savedVersion = (try? String(contentsOfFile: path)) ?? ""

        // 将当前版本保存到沙盒路径下
        try? currentVersion.write(toFile: path, atomically: true, encoding: .utf8)

        // 比较两个版本是否相同
        return currentVersion != savedVersion
    }
}

判断新版本这里,可能会有用将版本号转换成数字,然后去逐个对比的做法,个人感觉其实不用那么复杂。因为提交到App Store的版本一定是递增的,那么只要比较当前版本和我们自己保存的版本就完全可以比对出来的。

给头像做动画处理

准备代码

class HQWelcomeView: UIView {

    fileprivate lazy var backImageView: UIImageView = UIImageView(hq_imageName: "ad_background")
    /// 头像
    fileprivate lazy var avatarImageView: UIImageView = {

        let iv = UIImageView(hq_imageName: "avatar_default_big")
        iv.layer.cornerRadius = 45
        iv.layer.masksToBounds = true
        return iv
    }()
    fileprivate lazy var welcomeLabel: UILabel = {

        let label = UILabel(hq_title: "欢迎归来", fontSize: 18, color: UIColor.hq_titleTextColor)
        label.alpha = 0
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        self.frame = UIScreen.main.bounds

        setupUI()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
// MARK: - UI
extension HQWelcomeView {

    fileprivate func setupUI() {

        addSubview(backImageView)
        addSubview(avatarImageView)
        addSubview(welcomeLabel)

        backImageView.frame = self.bounds
        avatarImageView.snp.makeConstraints { (make) in
            make.bottom.equalTo(self).offset(-200)
            make.centerX.equalTo(self)
            make.width.equalTo(90)
            make.height.equalTo(90)
        }
        welcomeLabel.snp.makeConstraints { (make) in
            make.top.equalTo(avatarImageView.snp.bottom).offset(16)
            make.centerX.equalTo(avatarImageView)
        }
    }
}

如果这是一个控制器的话,我们可以选择在viewDidAppear方法里来处理。这里有一个关于自动布局开发的使用原则:

  • 所有使用约束设置位置的控件,不要再设置 frame
    • 原因:自动布局系统会根据设置的约束,自动计算控件的frame
    • layoutSubviews函数中设置frame
    • 如果我们主动修改frame,会引起 自动布局系统计算错误!

工作原理:

  • 当有一个运行循环启动,自动布局系统,会收集所有的约束变化
  • 在运行循环结束前,调用layoutSubviews函数统一设置frame
  • 如果希望某些约束提前更新!使用layoutIfNeeded 函数让自动布局系统,提前更新当前收集到的约束变化

但是我们这里不是控制器,只是一个View,里面并没有viewDidAppear方法。我们就要找到一个类似的办法。系统提供了一个方法didMoveToWindow,字面上我们直接可以翻译出它的意思,就是视图被添加到window,表示视图已经显示,和Controller里面的viewDidAppear方法类似。

// MARK: - Animation
extension HQWelcomeView {

    /// 视图被添加到`window`上,表示视图已经显示
    override func didMoveToWindow() {
        super.didMoveToWindow()

        avatarImageView.snp.updateConstraints { (make) in
            make.bottom.equalTo(self).offset(-bounds.size.height + 200)
        }

        UIView.animate(withDuration: 4.0,
                       delay: 0,
                       options: [],
                       animations: { 
                        self.layoutIfNeeded()
        }) { (_) in

        }
    }
}

经过测试我们发现,确实可以出现动画了,但是出现的方式有点和我们所想的不一样,我们是希望控件已经被创建到我们之前代码写好的位置,然后再通过动画,移动到下图中最终的位置。该如何处理呢?

上面说自动布局工作原理的时候提到过

  • 如果希望某些约束提前更新!使用layoutIfNeeded 函数让自动布局系统,提前更新当前收集到的约束变化

因此,我们手动调用一下layoutIfNeeded方法,将代码布局的约束都创建好,并显示出来,然后再进行更新约束的动画。

// MARK: - Animation
extension HQWelcomeView {

    /// 视图被添加到`window`上,表示视图已经显示
    override func didMoveToWindow() {
        super.didMoveToWindow()

        // 将代码布局的约束都创建好并显示出来,然后再进行下一步的更新动画
        layoutIfNeeded()

        avatarImageView.snp.updateConstraints { (make) in
            make.bottom.equalTo(self).offset(-bounds.size.height + 200)
        }

        UIView.animate(withDuration: 2.0,
                       delay: 0,
                       usingSpringWithDamping: 0.7,
                       initialSpringVelocity: 0,
                       options: [],
                       animations: { 
                        self.layoutIfNeeded()
        }) { (_) in

            UIView.animate(withDuration: 1.0,
                           animations: { 
                            self.welcomeLabel.alpha = 1
            }, completion: { (_) in
                self.removeFromSuperview()
            })
        }
    }
}

设置头像

UI布局完毕以后,就剩下将头像设置到上面了,一般来讲这些都是没什么技术含量的。但是这里我还是想简单介绍一下。

我这里还是将设置头像的代码放在了didMoveToWindowlayoutIfNeeded方法后面去执行,

这里需要提醒的是,如果是纯代码开发,不会走这个方法,即便是这段话仍然需要加上,但是如果你在init?(coder aDecoder: NSCoder)中写代码,会提示你Will never be executed

而且即便是xib开发,这里也仅仅是将xib的二进制文件将视图数据加载完成,还没有和代码连线建立起关系,所以开发时,不能在这个方法里面处理UI,而且如果是xib开发的话,你打印视图的话,结果都是nil的。

/// 设置头像
fileprivate func setAvatar() {

    guard let urlString = HQNetWorkManager.shared.userAccount.avatar_large else {
        return
    }
    avatarImageView.hq_setImage(urlString: urlString, placeholderImage: UIImage(named: "avatar_default_big"))
}

新特性界面

由于我们之前在HQMainViewController中做好了判断是显示新特性界面还是显示欢迎界面。因此,我们处理好欢迎界面以后,就仿照类似的方法创建新特性界面就好了。

// MARK: - 新特性
extension HQMainViewController {

    fileprivate func setupNewFeatureView() {

        // 如果用户没有登录,则不显示新特性界面,直接返回
        if !HQNetWorkManager.shared.userLogon {
            return
        }

        let v = isNewVersion ? HQNewFeatureView() : HQWelcomeView()

HQNewFeatureView中,进行布局,我写UI布局套路都比较单一,懒加载控件,在extensionsetupUI,如果有按钮的监听方法,再将按钮的监听方法抽取到extension中,只是暂时我自己习惯这样写而已。

class HQNewFeatureView: UIView {

    /// 开始体验按钮
    fileprivate lazy var startButton: UIButton = UIButton(hq_title: "开始体验", color: UIColor.white, backImageName: "new_feature_finish_button")
    /// pageControl
    fileprivate lazy var pageControl: UIPageControl = {
        let pageControl = UIPageControl()
        pageControl.numberOfPages = 4
        pageControl.currentPageIndicatorTintColor = UIColor.orange
        pageControl.pageIndicatorTintColor = UIColor.black
        return pageControl
    }()
    fileprivate lazy var scrollView: UIScrollView = {
        let scrollView = UIScrollView(frame: UIScreen.main.bounds)
        return scrollView
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        self.frame = UIScreen.main.bounds

        setupUI()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
// MARK: - UI
extension HQNewFeatureView {

    /// setupUI
    fileprivate func setupUI() {

        addSubview(scrollView)
        addSubview(startButton)
        addSubview(pageControl)

        startButton.isHidden = true
        startButton.addTarget(self, action: #selector(enter), for: .touchUpInside)

        setupScrollView()

        startButton.snp.makeConstraints { (make) in
            make.centerX.equalTo(self)
            make.bottom.equalTo(self).multipliedBy(0.7)
        }
        pageControl.snp.makeConstraints { (make) in
            make.centerX.equalTo(startButton)
            make.top.equalTo(startButton.snp.bottom).offset(16)
        }
    }

    /// setupImageViewFrame
    fileprivate func setupScrollView() {

        let count = 4
        let rect = UIScreen.main.bounds

        for i in 0..<count {

            let imageName = "new_feature_\(i + 1)"
            let iv = UIImageView(hq_imageName: imageName)

            iv.frame = rect.offsetBy(dx: CGFloat(i) * rect.width, dy: 0)
            scrollView.addSubview(iv)
        }

        /// 设置`scrollView`的属性
        // 这里加`1`是为了让`scrollView`可以多滚动一屏
        scrollView.contentSize = CGSize(width: CGFloat(count + 1) * rect.width, height: rect.height)
        scrollView.bounces = false
        scrollView.isPagingEnabled = true
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.showsVerticalScrollIndicator = false

    }
}
// MARK: - Target Action
extension HQNewFeatureView {

    @objc fileprivate func enter() {
        print("enter")
    }
}

界面布局完毕以后,剩下的就是完善其它的业务逻辑了。主要还得靠scrollViewdelegate去实现

// MARK: - UIScrollViewDelegate
extension HQNewFeatureView: UIScrollViewDelegate {

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {

        // 滚动到最后一个空白页面,将新特性页面从父视图移除
        let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)

        if page == scrollView.subviews.count {
            removeFromSuperview()
        }
        // 如果不是倒数第二页,那么就隐藏`startButton`按钮
        startButton.isHidden = (page != scrollView.subviews.count - 1)
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        // 一旦滚动,隐藏按钮
        startButton.isHidden = true

        // 设置当前的偏移量,+0.5是为了处理`scrollView`滚动超过屏幕一半的时候,`pageControl`也滚动到下一页
        let page = Int(scrollView.contentOffset.x / scrollView.bounds.width + 0.5)

        // 设置分页控件
        pageControl.currentPage = page

        // 分页控件的隐藏,滚动到最后一页的时候
        pageControl.isHidden = (page == scrollView.subviews.count)
    }
}
// MARK: - Target Action
extension HQNewFeatureView {

    @objc fileprivate func enter() {
        removeFromSuperview()
    }
}

效果如下图所示

至此为止,整体框架基本搭建完毕,下一篇介绍自定义微博的cell及体会MVVM的好处。

DEMO传送门:HQSwiftMVVM

欢迎来我的简书看看:红鲤鱼与绿鲤鱼与驴___