在 dribbble.com 搜索 timeline 可以搜到不少優(yōu)秀的原型設(shè)計(jì)赃泡。在 Github 上找了下好像沒(méi)有現(xiàn)成的布局,有一個(gè)實(shí)現(xiàn)了類似 Path 的效果但不是使用布局實(shí)現(xiàn)鞋真,有一個(gè)是用于 Mac 平臺(tái)的崇堰,于是動(dòng)手實(shí)現(xiàn)了下,Demo 地址:TimelineLayout。本以為這類布局可以通用的海诲,動(dòng)手實(shí)現(xiàn)了兩個(gè)例子發(fā)現(xiàn)很難實(shí)現(xiàn)一個(gè)非常通用的布局繁莹,需要根據(jù)具體的場(chǎng)景進(jìn)行選擇。
初次接觸這類布局的人最大的疑惑估計(jì)是怎么生成那條軸線特幔,其實(shí)線只不過(guò)是寬度比較小的矩形而已咨演,這么說(shuō)你就明白了吧。那么這類布局里這條軸線可以用多種方法實(shí)現(xiàn)蚯斯,SupplementaryView (也就是通常說(shuō)的 HeaderView 和 FooterView)和 DecorationView 都可用來(lái)實(shí)現(xiàn)這條線雪标,甚至使用 Cell 來(lái)實(shí)現(xiàn)這條線也是可以的,具體要看你的場(chǎng)景要求來(lái)進(jìn)行選擇溉跃。
在 dribbble.com 上很多針對(duì) iPhone 設(shè)計(jì)的時(shí)間軸布局用 UITableView 來(lái)實(shí)現(xiàn)更方便一點(diǎn),比如下面幾種告抄,這幾種的共同特點(diǎn)是撰茎,元素的種類相同,位置相對(duì)固定打洼。實(shí)現(xiàn)時(shí)在固定位置插入窄矩形視圖當(dāng)作線條龄糊,當(dāng)節(jié)點(diǎn)的圓形用圖片搞定或者代碼畫出來(lái),基本用不上布局募疮,用 UICollectionView 實(shí)現(xiàn)就是多此一舉了炫惩。
下面這兩種就需要 UICollectionView 了,左邊的鏈接在這里阿浓,右邊的鏈接在這里他嚷。這兩個(gè)布局其實(shí)是很普通的 FlowLayout,左邊的是垂直滾動(dòng)的 FlowLayout 加上了一條軸線芭毙,右邊的是橫向滾動(dòng)的 FlowLayout 加一條軸線筋蓖。DecorationView 非常適合用來(lái)實(shí)現(xiàn)這種軸線。Demo 地址:TimelineLayout退敦。
DecorationView 的使用場(chǎng)景較少粘咖,特別是扁平化設(shè)計(jì)普及后更加少見(jiàn)了,也很少看到有關(guān)使用 DecorationView 的教程侈百,不過(guò) DecorationView 在時(shí)間軸這類布局里非常有用瓮下。Mark Pospesel 的這篇三年前的文章 How to Add a Decoration View to a UICollectionView 依然值得一看,而這篇文章的主體 IntroducingCollectionViews 實(shí)現(xiàn)了多種布局并包含了一份詳細(xì)介紹 UICollectionView 各部分的 keynote钝域,值得一顆星讽坏。
只使用 FlowLayout 本身自然是無(wú)法實(shí)現(xiàn)上述的布局的,這意味著使用UICollectionViewFlowLayout
子類网梢,關(guān)于自定義布局入門震缭,推薦官方文檔,詳細(xì)說(shuō)明了布局流程战虏,什么時(shí)候自定義布局以及需要重寫哪些方法拣宰;還有 Objc.io 出品的自定義 Collection View 布局也是好文章党涕。
自定義布局的主要流程是:
1.prepareLayout
:做一些準(zhǔn)備工作。
2.collectionViewContentSize
:返回 collectionView 的內(nèi)容尺寸用于滾動(dòng)巡社。
3.layoutAttributesForElementsInRect:
:最關(guān)鍵的部分膛堤,返回指定區(qū)域(也就是可視區(qū)域)內(nèi)所有的布局屬性,根據(jù)這些屬性來(lái)配置所有 Cell, SupplementaryView 和 DecorationView 的布局晌该。
DecorationView 并不是數(shù)據(jù)驅(qū)動(dòng)的視圖肥荔,它的數(shù)量以及布局完全由 CollectionView 的布局屬性決定。上面的兩種布局主要在 FlowLayout 的基礎(chǔ)上添加了 DecorationView 布局屬性朝群,這三個(gè)方法只用重寫第3個(gè)方法燕耿,外帶實(shí)現(xiàn) DecorationView 的默認(rèn)布局。
添加 DecorationView
DecorationView 必須是UICollectionReusableView
的子類姜胖,添加前必須在UICollectionViewLayout
里注冊(cè)誉帅,有兩種注冊(cè)方法:
func registerClass(_ viewClass: AnyClass?, forDecorationViewOfKind elementKind: String)
func registerNib(_ nib: UINib?, forDecorationViewOfKind elementKind: String)
方法里的elementKind
參數(shù)和 Cell 中的 ReuseIdentifier 作用相同。在兩個(gè)例子里我自定義了UICollectionReusableView
的子類LineView
類右莱,唯一的作用是將其背景色設(shè)置為白色蚜锨。在自定義的UICollectionViewLayout
類初始化方法里注冊(cè)LineView
:
self.registerClass(LineView.self, forDecorationViewOfKind: "LineView")
同時(shí),記得在自定義布局類中提供 DecorationView 布局對(duì)象的默認(rèn)實(shí)現(xiàn)慢蜓,即使只是提供一個(gè)空的布局對(duì)象亚再。文檔告訴我們應(yīng)該這么做,我沒(méi)在意晨抡,直接生成了空的布局對(duì)象氛悬,絕大部分情況下都沒(méi)有問(wèn)題,但后來(lái)掉進(jìn)了某個(gè)坑里凄诞,所以切記重寫這個(gè)方法圆雁,下面的例子里都提供下面的空布局實(shí)現(xiàn):
override func layoutAttributesForDecorationViewOfKind(elementKind: String, atIndexPath indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return UICollectionViewLayoutAttributes(forDecorationViewOfKind: elementKind, withIndexPath: indexPath)
}
在自定義 FlowLayout 的layoutAttributesForElementsInRect:
里添加需要的 DecorationView 布局屬性即可:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttrs = super.layoutAttributesForElementsInRect(rect)
......
let decorationViewLayoutAttr = self.layoutAttributesForDecorationViewOfKind("LineView", atIndexPath: headerLayoutAttr.indexPath)
if decorationViewLayoutAttr != nil{
layoutAttrs?.append(decorationViewLayoutAttr!)
}
/*修改 decorationViewLayoutAttr 的屬性滿足你的需求*/
......
return layoutAttrs
}
這樣就添加了一個(gè) DecorationView 到 CollectionView 里。
時(shí)間軸布局1
Demo 地址:TimelineLayout帆谍,實(shí)際效果以及結(jié)構(gòu)分解:
看圖說(shuō)話伪朽,這樣一來(lái)也沒(méi)什么好解釋了的吧。在這個(gè)布局里汛蝙,使用 DecorationView 來(lái)作為軸線是最優(yōu)解烈涮。至于前面的 section 只有 Header 沒(méi)有 Footer,這個(gè)好辦窖剑,讓 CollectionView 的 delegate 對(duì)象遵守UICollectionViewDelegateFlowLayout
協(xié)議并提供相關(guān)的尺寸信息就好了坚洽。
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
if section == sectionCount - 1{
//實(shí)際上這里的提供的 width 并不能決定 FooterView 的寬度,只不是0或負(fù)數(shù)都可以西土。實(shí)際的值在 Layout 里決定讶舰。
//默認(rèn)情況下 FooterView 的 width 與 CollectionView 的 contentSize 的 width 值一致。
return CGSize(width: 50, height: 2)
}else{
//尺寸為 Zero 時(shí)沒(méi)有 FooterView,實(shí)際上返回的 CGSize 中的 width 或 height 只要有一個(gè)為0或負(fù)數(shù)也會(huì)有同樣的效果跳昼。
return CGSizeZero
}
}
HeaderView 和 FooterView 的尺寸效應(yīng)是一樣的般甲。
在布局里,需要 Cell, SupplementaryView, DecorationView 這三種視圖在布局上精準(zhǔn)配合防止露餡鹅颊,同時(shí)修改 sectionInset 為 DecorationView 留出視覺(jué)上的空間敷存。最核心的layoutAttributesForElementsInRect:
方法如下:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
//在父類的基礎(chǔ)上調(diào)整布局,不需要全部重新計(jì)算
var layoutAttrs = super.layoutAttributesForElementsInRect(rect)
let headerLayoutAttrs = layoutAttrs?.filter({ $0.representedElementKind == UICollectionElementKindSectionHeader })
if headerLayoutAttrs?.count > 0{
let sectionCount = (self.collectionView?.dataSource?.numberOfSectionsInCollectionView!(self.collectionView!))!
for headerLayoutAttr in headerLayoutAttrs!{
//生成一個(gè)空的 DecorationView 布局對(duì)象堪伍,然后通過(guò) header 和 footer 的布局來(lái)計(jì)算屬性锚烦。
let decorationViewLayoutAttr = self.layoutAttributesForDecorationViewOfKind(decorationLineViewKind, atIndexPath: headerLayoutAttr.indexPath)
if decorationViewLayoutAttr != nil{
layoutAttrs?.append(decorationViewLayoutAttr!)
}
let headerSize = headerLayoutAttr.size
var lineLength: CGFloat = 0
if headerLayoutAttr.indexPath.section < sectionCount - 1{
//如果不是最后的 section,獲取下一段的 header
let nexHeaderLayoutAttr = ......
lineLength = nexHeaderLayoutAttr.frame.origin.y - headerLayoutAttr.frame.origin.y
}else{
// 來(lái)到最后一段帝雇,獲取當(dāng)前可視區(qū)域中的 footer涮俄,并調(diào)整 footerView 的位置和尺寸,至多只有一個(gè) footer
let footerLayouts = layoutAttrs?.filter({ $0.representedElementKind == UICollectionElementKindSectionFooter && $0.indexPath.section == headerLayoutAttr.indexPath.section})
if footerLayouts?.count == 1{
let footerLayoutAttr = footerLayouts!.first
footerLayoutAttr!.frame = CGRect(x: footerXOffset, y: footerLayoutAttr!.frame.origin.y, width: 20, height: 2)
lineLength = footerLayoutAttr!.frame.origin.y - headerLayoutAttr.frame.origin.y - headerSize.height / 2
}else{//如果 footerView 尚未出現(xiàn)尸闸,則 line 一直延伸到可視區(qū)域的底部或者直接獲取默認(rèn)的 footer 布局禽拔,此時(shí)的 origin 與我們修改過(guò)后的一致,見(jiàn)代碼室叉。
lineLength = rect.height + rect.origin.y - headerLayoutAttr.frame.origin.y - headerSize.height / 2
}
}
//在非 retina 屏幕上,當(dāng)視圖的寬度<0.54時(shí)肉眼不可見(jiàn)硫惕,保險(xiǎn)起見(jiàn)使用0.55茧痕;在 retina 屏幕的極限則是0.27
decorationViewLayoutAttr?.frame = CGRect(x: decorationLineXOffset, y: (headerLayoutAttr.frame.origin.y + headerSize.height / 2), width: 0.55, height: lineLength)
}
}
return layoutAttrs
}
這里的實(shí)現(xiàn)是使用多個(gè) DecorationView 來(lái)構(gòu)成軸線,也可以只使用一個(gè) DecorationView 從頭至尾貫穿恼除,代碼會(huì)更少踪旷。我也實(shí)現(xiàn)了 DecorationView 和 FooterView 的布局動(dòng)畫用于它們初次出現(xiàn)在屏幕上時(shí)避免突兀的視圖變化,有興趣可以看看代碼豁辉。關(guān)于布局動(dòng)畫令野,推薦 Objc.io 出品的 CollectionView 動(dòng)畫;我實(shí)現(xiàn)了另外一種效果:CollectionView 添加/刪除動(dòng)畫徽级。
PS: 注意需要重寫func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool
方法來(lái)應(yīng)對(duì)屏幕方向變化气破,不然會(huì)軸線缺失和 FooterView 沒(méi)有調(diào)整的奇特 Bug。
時(shí)間軸布局2
Demo 地址:TimelineLayout餐抢,實(shí)際效果:
這個(gè)布局比上面的稍微麻煩一些现使,這是一個(gè)橫向滾動(dòng)的 FlowLayout,圓形的節(jié)點(diǎn)可以在 Cell 里實(shí)現(xiàn)旷痕,頭尾的虛線分別由 HeaderView 和 FooterView 擔(dān)當(dāng)碳锈,中間的軸線有兩種方法:HeaderView(或FooterView) 和 DecorationView。同時(shí)這也是一個(gè)基于內(nèi)容的布局欺抗,時(shí)間軸的間隔和年代有關(guān)售碳,我的實(shí)現(xiàn)里沒(méi)有那么嚴(yán)格計(jì)算間隔。
使用 HeaderView 來(lái)充當(dāng)軸線時(shí),在中部區(qū)域滾動(dòng)時(shí)可能會(huì)出現(xiàn)一些軸線缺失的情況贸人,因?yàn)樵趦蓚?cè) HeaderView 可能不處于可視區(qū)域內(nèi)间景,我們通過(guò)延長(zhǎng) HeaderView 寬度的作弊手段可能失效,這與節(jié)點(diǎn)的間隔長(zhǎng)度有關(guān)灸姊。為了最大程度解決這種情況拱燃,使用兩種 Cell,頭尾段的 Cell 使用左邊的力惯,中間段的 Cell 使用右邊的碗誉,如下所示:
即使加上上面的補(bǔ)救措施,依然有可能出現(xiàn)軸線缺失的情況父晶,不完美哮缺。其實(shí),這個(gè)布局我首選的是DecorationView 充當(dāng)軸線甲喝,但最后遇到了嚴(yán)重的NSInternalInconsistencyException
尝苇,不得已使用 HeaderView 實(shí)現(xiàn)了一次,好在最終解決了這個(gè) bug埠胖,解決方法見(jiàn)這里糠溜。
現(xiàn)在就說(shuō)說(shuō)使用 DecorationView 實(shí)現(xiàn)軸線。經(jīng)過(guò)上面的例子的練手直撤,我決定在這里使用單個(gè) DecorationView 來(lái)貫穿頭尾非竿。軸線在 X 軸方向的起始位置為首段里節(jié)點(diǎn) Cell 的center.x
(或者從節(jié)點(diǎn) Cell 的圓形右側(cè)邊緣往左邊一點(diǎn)點(diǎn))谋竖,終點(diǎn)位置為末段節(jié)點(diǎn) Cell 的center.x
(或者從節(jié)點(diǎn) Cell 的圓形左側(cè)邊緣往右邊一點(diǎn)點(diǎn));在 Y 軸方向锤悄,與節(jié)點(diǎn) Cell 的圓心在同一高度嘉抒。
另外,橫向滾動(dòng)的 FlowLayout 的 Cell 的起始布局是從上往下握牧,我們需要將其逆轉(zhuǎn)娩梨。
重寫layoutAttributesForElementsInRect:
方法:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttrs = super.layoutAttributesForElementsInRect(rect)
//逆轉(zhuǎn) Cell 的位置
let cellLayoutAttrs = layoutAttrs?.filter({ $0.representedElementCategory == .Cell })
let fixedHeight = self.collectionView!.bounds.height - self.sectionInset.top - self.sectionInset.bottom
var isCalculated = false
var timelineY: CGFloat = 0.0//時(shí)間軸在 Y 軸方向的位置
if cellLayoutAttrs?.count > 0{
for layoutAttribute in cellLayoutAttrs!{
layoutAttribute.center = CGPoint(x: layoutAttribute.center.x, y: fixedHeight - layoutAttribute.center.y)
if layoutAttribute.indexPath.item == 1 && !isCalculated{
timelineY = layoutAttribute.frame.origin.y + layoutAttribute.size.height - nodeRadius - lineThickness / 2
isCalculated = true
}
}
}
//添加 DecorationView并調(diào)整位置和尺寸
if let decorationViewLayoutAttr = self.layoutAttributesForDecorationViewOfKind(decorationLineViewKind, atIndexPath: NSIndexPath(forItem: 0, inSection: 0)){
let sectionCount = self.collectionView!.dataSource!.numberOfSectionsInCollectionView!(self.collectionView!)
let firstNodeCellAttr = self.layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: 1, inSection: 0))
let lastNodeCellAttr = self.layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: 1, inSection: sectionCount - 1))
let timelineStartX = firstNodeCellAttr!.center.x
let timelineEndX = lastNodeCellAttr!.center.x
decorationViewLayoutAttr.frame = CGRect(x: timelineStartX, y: timelineY, width: timelineEndX - timelineStartX, height: lineThickness)
layoutAttrs?.append(decorationViewLayoutAttr)
}
/*
調(diào)整頭尾段的 HeaderView 和 FooterView 的布局屬性颂龙,見(jiàn)代碼。
*/
return layoutAttrs
}
自定義布局 Tips
- Layout 會(huì)預(yù)加載沿滾動(dòng)方向可視區(qū)域后面的布局信息躲叼,可以通過(guò)觀察
layoutAttributesForElementsInRect(rect: CGRect)
里 rect 的值得知企巢,但是 Layout 只負(fù)責(zé)可視區(qū)域的布局。這樣一來(lái)浪规,Layout 可能無(wú)法處理可視區(qū)域前面的布局,這樣就解釋了在上面的案例2中使用 SupplementaryView 實(shí)現(xiàn)軸線時(shí)當(dāng) SupplementaryView 恰好離開(kāi)可視區(qū)域邊緣造成軸線缺失的情況誉裆。 -
layoutAttributesForElementsInRect:
返回的布局屬性才決定視圖布局缸濒,即使你重寫了下面兩個(gè)方法,這個(gè)方法不一定會(huì)調(diào)用你的版本斩跌,除非你明確調(diào)用捞慌。
layoutAttributesForCellWithIndexPath:
layoutAttributesForSupplementaryViewOfKind:withIndexPath:
- 盡管 FlowLayout 能夠正確應(yīng)對(duì)屏幕方向變化造成的布局變化,你仍然應(yīng)該在你定義的 FlowLayout 子類里重寫
func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool
方法。 - 在 CollectionView 的頂部和底部時(shí)锻霎,不要讓某個(gè)位置的布局屬性發(fā)生變化揪漩,不然會(huì)觸發(fā)
layout attributes changed from xxx to xxx without invalidating the layout
這種NSInternalInconsistencyException旋恼。這么說(shuō)你可能不明白冰更,如果你遇到了這個(gè)問(wèn)題昂勒,可以參考我解決這個(gè)問(wèn)題的經(jīng)歷。 - 盡管 CollectionView 每個(gè) section 里至多只可以有一個(gè) Header 和一個(gè) Footer奠衔,但 CollectionView 并不限制 SupplementaryView 的種類,和 Cell 的管理方式相同归斤,只不過(guò)在 storyboard 里由于限制至多也只能注冊(cè)一個(gè) Header 和一個(gè) Footer脏里,剩下的只能通過(guò)代碼注冊(cè)了她我。
- 文檔里要求重寫的方法切記重寫番舆,不然可能就哪兒出問(wèn)題了,具體是哪些方法看官方文檔合蔽。