Swift-MVVM 簡單演練(三)

宮城良田發表於2019-02-07

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

歡迎來我的簡書看看:紅鯉魚與綠鯉魚與驢___

相關文章