WWDC 2014 Session筆記 - 可視化開發,IB 的新時代

轉載於:http://onevcat.com/2014/10/ib-customize-view/

WWDC 2014 Session筆記 - 可視化開發,IB 的新時代

本文是我的 WWDC 2014 筆記 中的一篇,涉及的 Session 有

如果說在 WWDC 14 之前 Interface Builder (IB) 還是可選項的話,我相信在此之後 IB 已經是毫無疑問的 iOS 開發標配了,純代碼界面可以說已經漸行漸遠,可以逐漸離開我們的視線了。

一言蔽之,就是 Apple 在催促大家使用 IB,特別是 Storyboard 做爲界面開發的唯一選擇這件事上,下定了決心,也做出了實際的行動。

如果是純代碼 UI 在此之前還能有所掙扎的話,那麼壓死這個方案的最後一根稻草就是 Size Classes。我已經在之前的筆記中對這方面內容做了些簡單的探索,但是還遠遠不夠,也許在將來某一天我還會重新整理下 Size Classes 這個主題的內容,以及使用 IB 適配不同屏幕的一些實踐,但是不是這次。這篇文章裏想要介紹的是 Xcode 6 中爲 IB 錦上添花的一個特性,那就是實時地預覽自定義 view,這個特性讓 IB 開發的流程更加直觀可視,也可以減少很多無聊的參數配置和 UI 設置的時間。

以前 IB 的不足

作爲可視化開發的工具,IB 和 Storyboard 在組織和構建 ViewController 及其導航關係時已經做得很好的。對於 ViewController 的 view 畫布上的諸如 UILabel 或者 UIImageView 這樣的基礎的類,IB 是能夠很好地支持並實時在設計的時候進行顯示的。但是對於那些自定義的類,之前的 IB 就束手無策了。我們能做的僅僅是在 IB 中拖放一個 UIView,然後通過將 Custom Class 屬性設置爲我們自定義的 UIView 的子類來在 “暗示” IB 在運行時初始化一個對應的子類。這樣的問題是在開發自定義的 view 時,我們不得不一遍遍地修改代碼並運行,再根據運行結果進行調整和修正。而實際上,單一對某個 view 的調試這種問題只涉及到設計層面,而非運行層面,如果我們能夠在設計時就有一個實時地對自定義 view 的預覽該多好。

沒錯,Apple 也是這麼想的,並且在 Xcode 6 中,我們就已經可以創建這樣的 UIView 子類了:利用新加入的 @IBDesignable@IBInspectable,我們可以非常方便地完成在 IB 中實時顯示自定義視圖,甚至和其他一些內置 UIView 子類一樣,直接在 IB 的 Inspector 改變某些屬性,甚至我們還能通過設置斷點來在 IB 中顯示視圖時進行調試。新的這些特性非常強大,使用起來卻出乎意料的簡單。下面我將通過一個實際的小例子加以說明。最終的完整例子已經放在 GitHub 上了,現在我們從開始一步步開始吧。這些代碼基於 Xcode 6.1 和 Swift 1.1。

時鐘 view 的例子

單純的自定義 view

假設我們有一個自定義的 view,用來描畫一個時鐘,如果有在讀 objc.io 或者 objc 中國 的讀者,可能會發現這段代碼是動畫一章一篇文章裏代碼的改造過的 Swift 版本。

在這裏我們有一個自定義的 UIView 的子類:ClockFaceView,其中嵌套了一個 ClockFaceLayer 作爲 layer。如果我們不需要動畫,我們也可以簡單地使用 -drawRect: 來完成繪製。但是在這裏我們還是選擇使用添加 CALayer 的方式,這會使之後做動畫簡單好幾個數量級 -- 因爲我們可以簡單地通過 CA 動畫而不是每幀去計算繪製來完成動畫 (在這篇帖子裏不會涉及這些內容)。

// ClockFaceView.swift
import UIKit

class ClockFaceView : UIView {

    class ClockFaceLayer : CAShapeLayer {

        private let hourHand: CAShapeLayer
        private let minuteHand: CAShapeLayer

        override init() {
            hourHand = CAShapeLayer()
            minuteHand = CAShapeLayer()

            super.init()
            frame = CGRect(x: 0, y: 0, width: 200, height: 200)
            path = UIBezierPath(ovalInRect: CGRectInset(frame, 5, 5)).CGPath
            fillColor = UIColor.whiteColor().CGColor
            strokeColor = UIColor.blackColor().CGColor
            lineWidth = 4

            hourHand.path = UIBezierPath(rect: CGRect(x: -2, y: -70, width: 4, height: 70)).CGPath
            hourHand.fillColor = UIColor.blackColor().CGColor
            hourHand.position = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2)
            addSublayer(hourHand)

            minuteHand.path = UIBezierPath(rect: CGRect(x: -1, y: -90, width: 2, height: 90)).CGPath
            minuteHand.fillColor = UIColor.blackColor().CGColor
            minuteHand.position = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2)
            addSublayer(minuteHand)   
        }

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

        func refreshToHour(hour: Int, minute: Int) {
            hourHand.setAffineTransform(CGAffineTransformMakeRotation(CGFloat(Double(hour) / 12.0 * 2.0 * M_PI)))
            minuteHand.setAffineTransform(CGAffineTransformMakeRotation(CGFloat(Double(minute) / 60.0 * 2.0 * M_PI)))
        }
    }

    private let clockFace: ClockFaceLayer

    var time: NSDate? {
        didSet {
            refreshTime()
        }
    }

    private func refreshTime() {
        if let realTime = time {
            if let calendar = NSCalendar(calendarIdentifier: NSGregorianCalendar) {
                let components = calendar.components(NSCalendarUnit.CalendarUnitHour |
                                                     NSCalendarUnit.CalendarUnitMinute, fromDate: realTime)
                clockFace.refreshToHour(components.hour, minute: components.minute)
            }
        }
    }

    override init(frame: CGRect) {
        clockFace = ClockFaceLayer()
        super.init(frame: frame)
        layer.addSublayer(clockFace)
    }

    required init(coder aDecoder: NSCoder) {
        clockFace = ClockFaceLayer()
        super.init(coder: aDecoder)
        layer.addSublayer(clockFace)
    }
}

如果你沒有耐心看完的話也沒有關係,簡單來說就是 ClockFaceView 在被初始化時會向自己添加一個 ClockFaceLayer,用來顯示分針和時針。通過設置 time 屬性我們可以更新時鐘的位置。因爲提供了 initWithCoder:,因此我們是可以直接從 IB 里加載這個 view 的。方法就是最普通的類型指定,並讓 app 在加載時初始化對應的類型:在新建的 Single View Application 的 Storyboard 中添加一個 UIView 控件,然後設置好約束,並且將 Class 設置爲 ClockFaceView

運行應用,可以看到 ClockFaceView 被正確地初始化了,指針指向默認的 12 點整。通過爲這個 view 建立 outlet 或者用其他 (比如 tag 的方式,雖然我不太喜歡這麼做,但是我見過不少人這麼弄) 方法找到這個 ClockFaceView 並設置時間的話,我們可以正確地改變其時針和分針的指向:

// ViewController.swift
class ViewController: UIViewController {

    @IBOutlet weak var clockFaceView: ClockFaceView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        clockFaceView.time = NSDate()
    }
}

IBDesignable,IB 中自定義 view 的渲染

把大象裝進冰箱有三個步驟,而讓 IB 顯示自定義 view 居然只有一個步驟!

只要我們在 class ClockFaceView : UIView 這個類型定義上面加上一個 @IBDesignable 的標記,就完成了!

在進行更改並等待編譯和 IB 自動識別後,我們就可以在 IB 中原來一塊白色的地方看到初始化後的時鐘了:

如你所想,這個標記的作用是告訴 IB 如果遇到對應的 UIView 子類的話,可以對其進行渲染。深入一些來說,IB 將尋找你的子類中的 -initWithFrame: 方法,並給入當前自定義 view 的 frame 對其進行調用。需要注意的是,在使用 IB 初始化 view 時,被調用的是 -initWithCoder: 而非 frame 版本,所以說在想要實現自定義 view 在 IB 中的預覽的話,我們至少必須實現這兩個版本的初始化方法。不過好消息是,如果我們只添加了 @IBDesignable,而忘了實現 -initWithFrame: 的話,在 IB 渲染 view 時會給我們拋出大大的錯誤,所以因爲遺漏而花大量時間在查找哪裏出了問題這種事情應該不太可能發生。

僅設計時的配置

現在在 IB 中我們顯示的時鐘只能默認地指向 0 點 0 分,這是因爲在設計的時候,我們並沒有機會去設定這個 view 的 time 屬性,所以時針和分針都停留在了初始的位置上。在 Xcode 6 中可以在 @IBDesignable 標記的 UIView 子類中添加一個 prepareForInterfaceBuilder 方法。每次在 IB 即將把這個自定義的 view 渲染到畫布之前會調用這個方法進行最後的配置。比如我們想在 IB 中這個時鐘的 view 上顯示當前時間的話,可以在 ClockFaceView 中加入這個方法:

class ClockFaceView : UIView {
    //...

    override func prepareForInterfaceBuilder() {
        time = NSDate()
    }

    //...
}

保存並切換到 IB,靜待自動編譯和執行,可以看到類似下面的結果:

挺好的...現在我們的 IB 不僅被用來設計界面了,還兼備了看時間的功能 - 雖然這個時鐘並不是實時的,只有在切換編輯器界面到 IB 或者是修改了相關文件時纔會進行刷新。

另外雖然這篇文章沒有涉及,但是需要一提的是,如果你想要在 prepareForInterfaceBuilder 里加載圖片的話,需要弄清楚 bundle 的概念。IB 使用的 bundle 和 app 運行時的 mainBundle 不是一個概念,我們需要在設計時的 IB 的 bundle 可以通過在自定義的 view 自身的 bundle 中進行查找並使用。比如想要加載一張名爲 image.png 的圖片的話:

let bundle = NSBundle(forClass: self.dynamicType)
if let fileName = bundle.pathForResource("image", ofType: "png") {
    if let image = UIImage(contentsOfFile: fileName) {
        // 在此處可以使用 image
    }
}

在使用 IB 中的方法讀取資源時一定要注意運行環境不同這一點。

用 IBInspectable 在 IB 中調整屬性

IBDesignable 的 view 的另一個很方便的地方是我們可以向 Inspector 中添加自定義的內容了。通過這樣做,就可以直接在 IB 中對 view 進行一些編輯和配置。以前對於自定義 view,我們通常只能通過用類似 IBOutlet 的方式在代碼中進行設置,或者是配置 Runtime Attribute 來進行,而現在我們有能力直接通過像給一個 UILabel 設定字符串或者給 UIImageView 設定圖片這樣的方式來設置自定義 view 的部分屬性了,這也使得在 IB 中的自定義 view 的易用性和完整性得到了極大增強。

使用方法也非常簡單,只需要在某個屬性前加上 @IBInspectable 標記即可。比如我們可以在 ClockFaceView 中加入以下代碼:

class ClockFaceView : UIView {
    //...

    @IBInspectable
    var color: UIColor? {
        didSet {
            refreshColor()
        }
    }

    private func refreshColor() {
        if let realColor = color {
            clockFace.refreshColor(realColor)
        }
    }

    //...
}

然後在 ClockFaceLayer 中加入對應的 refreshColor 方法:

class ClockFaceLayer : CAShapeLayer {
    //...

    func refreshColor(color: UIColor) {
        hourHand.fillColor = color.CGColor
        minuteHand.fillColor = color.CGColor
        strokeColor = color.CGColor
    }

    //...
}

我們對 ClockFaceView 中的 color 屬性添加了 @IBInspectable,在保存和編譯後,這會在 IB 中對應的 view 的 Attribute Inspector 中添加一個顏色選取的屬性:

當我們在 IB 中設置這個屬性的時候,對應的 didSet 將會被執行,通過 refreshColor 方法就可以直接改變 IB 中這個 view 的時針和分針的顏色了。

注意這個改變並不像 prepareForInterfaceBuilder 那樣僅發生在設計時,我們直接運行代碼,會看到運行時的顏色也是發生了改變的。其實 @IBInspectable 並沒有做什麼太神奇的事情,我們如果查看 IB 中這個 view 的 Identity Inspector 的話會看到剛纔所設定的顏色值被作爲了 Runtime Attribute 被使用了。其實手動直接在 Runtime Attributes 中設定顏色也有同樣的效果,因此 @IBInspectable 唯一做的事情就是在 IB 面板上爲我們提供了一個很方便地修改屬性的入口,別沒有其他太多神奇之處。

這個原理同時也決定了 @IBInspectable 是有一定限制的,即只有能夠在 Runtime Attributes 中指定的類型才能夠被標記後顯示在 IB 中,這些類型包括 BooleanNumberStringPonitSizeRectRangeColorImage。像是如果想要把類似 time 這樣的屬性標記爲 @IBInspectable 的話,在 IB 中還是無法顯示的,因爲 Xcode 並沒有準備 NSDate 類型。不過其實通過 KVC 進行動態設定這種事情在原理上是沒有問題的,界面的支持應該也可以通過 Xcode 插件進行擴展,感覺上並不是一件特別困難的事情,有興趣的同學不妨嘗試,應該挺有意思 (當然也有可能會是個坑)。

自定義渲染 view 的調試

對於簡單的自定義 view 來說,實時顯示和屬性設定什麼的並不是一件很難的事情。但是對於那些比較複雜的 view,如果我們遇到某些渲染上的問題的話,如果只能靠猜的話,就未免太可憐了。幸好,Apple 爲 view 在 IB 中的渲染的調試也提供了相應的方法。在 view 的源代碼中設置好斷點,然後切到 IB,點選中我們的自定義的 view 後,我們就可以使用菜單裏的 Editor -> Debug Selected Views 來讓 IB 對這個自定義 view 進行渲染。如果觸發了代碼中的斷點,那我們的代碼就會被暫停在斷點處,lldb 也會就位聽我們調遣。一切都感覺良好,不是麼?

總結

Xcode 6 中的很多 key feature 都是基於或者重度依賴 Interface Builder 的。比如 Size Classes,比如 xib 的啓動畫面,再比如本篇文章中說到的自定義 view 渲染等等。在 iOS 或者 Mac 開發中,IB 現在處於一個比以往任何時候都重要的時期,使用 IB 和這些方便的特性進行開發已經從可選項變爲了必須項。很難想象沒有 IB 的話要怎麼才能使用這些工具,更進一步地說,很難想象沒有 IB 的話開發者需要浪費多少時間在本應該迅速完成的工作中。

如果你還在使用代碼來構建 UI 的話,現在也許是你最後的放下代碼,拿起 IB 武裝自己的機會了。一開始可能會有迷惑,會不習慣,會覺着被拽出了舒適區渾身無力。但是一旦適應以後,你不僅能夠收穫最新的技能和工具,也有機會站在一個全新的高度,來審視 app 中界面開發的種種,並從中找到樂趣。

P.S. 如果你不知道要從哪裏入手,推薦可以從 raywenderlich 家的這篇 AutoLayout 教程開始你的 IB 之旅。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章