輪轉(zhuǎn)式卡片效果 - 個性化UICollectionView Layout(譯)

輪轉(zhuǎn)式卡片
最終效果

前言

通過本教程你可以了解到:

  • 怎么使用collection view layout 做出自己喜歡的效果
  • 輪子般轉(zhuǎn)動的原理

開始準備


首先下載個project,這個鏈接下載鏈接下載打開項目后,可以看到如圖的整齊排列好的item(卡片)

然后我們的任務就是將這些item弄為輪轉(zhuǎn)式。不bb了,上教程

原理


圖片里的黃色區(qū)域代表的是iPhone的屏幕,然后哪些綠色的卡片就代表著item,紅色的虛線就是卡片item運動路徑。

我們要用到的三個主要的參數(shù):
1.半徑(radius);
2.就是兩個item之間相差的角度(anglePerItem)
3.每個item的位置(用角度表示)

首先假設,第0個item的角度位置為 x 度,接著第1個 item的角度位置則為x + anglePerItem,第二個item為x + (2 * anglePerItem)然后以此類推。。
第i個item的位置則為:

angle_for_i = x + (i * anglePerItem)

如下,是角度的坐標圖,0度代表中間,正值代表向右旋轉(zhuǎn),負值代表向左旋轉(zhuǎn)。
例如item是0度則是垂直中間


坐標

了解了底層理論后,就let’s coding

Circular Collection View Layout


創(chuàng)建一個新的Swift文件用iOS\\Source\\Cocoa Touch Classtemplate,將其命名為CircularCollectionViewLayout,并令其繼承UICollectionViewLayout

點擊Next然后點擊Create.

CircularCollectionViewLayout,添加兩個參數(shù),item大小的itemSize和半徑radius:

let itemSize = CGSize(width: 133, height: 173)
 
var radius: CGFloat = 500 {
  didSet {
    invalidateLayout()
  }
}

當半徑radius變化的時候就重新設置Layout利用didSet里的invalidateLayout()
下面使用 radius 定義參數(shù)anglePerItem:

var anglePerItem: CGFloat {
  return atan(itemSize.width / radius)
}

事實anglePerItem可以任意數(shù)值,但是用這表達式可以確保item之間不會相距太遠。顯得緊湊些。

接下來,使用 collectionViewContentSize() 定義collection view的content大小

override func collectionViewContentSize() -> CGSize {
  return CGSize(width: CGFloat(collectionView!.numberOfItemsInSection(0)) * itemSize.width,
      height: CGRectGetHeight(collectionView!.bounds))
}

好了,現(xiàn)在打開Main.storyboard,點擊Collection View

打開Attributes Inspector然后將Layout設置為Custom, Class設置為CircularCollectionViewLayout:

Build 和 run,然后item(卡片)都變沒有了,別慌!,這正證明你成功地將CircularCollectionViewLayout作為Collection View的Layout

自定義 Layout Attributes


接著需要UICollectionViewLayoutAttributes類去存儲:
item的位置和參照點anchorPoint。

添加以下代碼到CircularCollectionViewLayout.swift,就添加在CircularCollectionViewLayout類定義的前面:

class CircularCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {
  // 1
  var anchorPoint = CGPoint(x: 0.5, y: 0.5)
  var angle: CGFloat = 0 {
    // 2 
    didSet {
      zIndex = Int(angle * 1000000)
      transform = CGAffineTransformMakeRotation(angle)
    }
  }
  // 3
  override func copyWithZone(zone: NSZone) -> AnyObject {
    let copiedAttributes: CircularCollectionViewLayoutAttributes = 
        super.copyWithZone(zone) as! CircularCollectionViewLayoutAttributes
    copiedAttributes.anchorPoint = self.anchorPoint
    copiedAttributes.angle = self.angle
    return copiedAttributes
  }
}

1.需要anchorPoint,是因為旋轉(zhuǎn)不是圍繞著每個item的中心點轉(zhuǎn)的
2.當angle參數(shù)設置時,就立即令其transform等于angle的角度,而zIndex則是使得后一個item覆蓋前一個item,從而實現(xiàn)右邊的item覆蓋在左邊的item的效果。
3.覆蓋copyWithZone(),是因為當collection view實施layout時會copy參數(shù),覆蓋這個method確保anchorPoint和angle會被copy。

好了,現(xiàn)在回過到CircularCollectionViewLayout并且實施layoutAttributesClass():

override class func layoutAttributesClass() -> AnyClass {
  return CircularCollectionViewLayoutAttributes.self
}

這method會告訴collection view,你會使用CircularCollectionViewLayoutAttributes,而不是UICollectionViewLayoutAttributes作為你的layout參數(shù)。

為了保存這些layout參數(shù)對象,需要新建數(shù)組attributesList存儲其:

var attributesList = [CircularCollectionViewLayoutAttributes]()

Preparing the Layout


當collection view出現(xiàn)時,會調(diào)用UIcollectionViewLayout的方法prepareLayout(),并且每次layout被invalid都會調(diào)用這個方法。

這步是至關重要的步驟,因為這里是用來創(chuàng)建和存儲layout參數(shù)的。
CircularCollectionViewLayout添加:

override func prepareLayout() {
  super.prepareLayout()
 
  let centerX = collectionView!.contentOffset.x + (CGRectGetWidth(collectionView!.bounds) / 2.0)
  attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i) 
      -> CircularCollectionViewLayoutAttributes in
    // 1
    let attributes = CircularCollectionViewLayoutAttributes(forCellWithIndexPath: NSIndexPath(forItem: i,
        inSection: 0))
    attributes.size = self.itemSize
    // 2
    attributes.center = CGPoint(x: centerX, y: CGRectGetMidY(self.collectionView!.bounds))
    // 3
    attributes.angle = self.anglePerItem*CGFloat(i)
    return attributes
  }
}

迭代collection view里的item并且執(zhí)行閉包里的代碼。
注釋:
1.創(chuàng)建每個idexPath的CircularCollectionViewLayoutAttributes對象,并且設置size
2.將每個item的位置都設置為屏幕中心
3.將每個item都旋轉(zhuǎn)(anglePerItem * i)度

為了能使用UICollectionViewLayout,你還需要覆蓋以下method。
這些method都會被引用很多次,所以要盡可能保持代碼小量簡潔。

//設置給出rect下的items的attributesList
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
  return attributesList
}

//設置item用到的attribute
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) 
    -> UICollectionViewLayoutAttributes! {
  return attributesList[indexPath.row]
}

ok,Build 和 run,你會看見一堆圖片旋轉(zhuǎn)在中間。

為什么這樣?

是因為Anchor Point是每個item的中心

Anchor Point


Anchor Point是CALayer里的參數(shù),用來作為旋轉(zhuǎn)或者拉伸的參照點。默認值是中心。

anchor point

我們之前把這個值設置為0.5,而沒有改變所以就出現(xiàn)前面的旋轉(zhuǎn)都是中心旋轉(zhuǎn)。如下圖,anchor point的y值等于radius + itemSize.height,然而anchor point是定義在單元坐標(1x1)里的,所以要除以itemSize.height

回到prepareLayout,定義anchorPointY:

let anchorPointY = ((itemSize.height / 2.0) + radius) / itemSize.height

然后在map(_:)的閉包里,將以下代碼添加在return前面:

attributes.anchorPoint = CGPoint(x: 0.5, y: anchorPointY)

下一步,在CircularCollectionViewCell.swift覆蓋函數(shù)applyLayoutAttributes(_:)

override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes!) {
  super.applyLayoutAttributes(layoutAttributes)
  let circularlayoutAttributes = layoutAttributes as! CircularCollectionViewLayoutAttributes
  self.layer.anchorPoint = circularlayoutAttributes.anchorPoint
  self.center.y += (circularlayoutAttributes.anchorPoint.y - 0.5) * CGRectGetHeight(self.bounds)
}

這里使用super來將默認的值設置好,例如:center和transform。但是anchorPoint是不是默認設置的,所以就添加代碼上去,。而且因為anchorPoint變化了center也會變化,所以進行補償。

center 因anchor 不同而不同,所以需要補償

build and run ,你會看到終于像個輪子了,但是當你向左滑時,它是平移而不是旋轉(zhuǎn)。

改進滾動效果


跳到CircularCollectionViewLayout添加如下代碼:

override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
  return true
}

ruturn true 告訴collection view滾動時,調(diào)用prepareLayout()來重新計算每一個item的位置。

angle是用來表示第0個item的位置。接下來會將滾動時的contentOffset.x轉(zhuǎn)為用第0個item的角度位置angle

contentOffset.x滾動的最小值為0,最大值為collectionViewContentSize().width - CGRectGetWidth(collectionView!.bounds),將滾動的最大值contentOffset.x命名為maxContentOffset.滾動值為0時,第0個item垂直于中間,而到達極限值時,最后一個item也是垂直于中間。這意味著最后一個item的角度也是0

angle_for_last_item = angle_for_zero_item + (totalItems - 1) * anglePerItem

//將angle_for_last_item代入上式
0 = angle_for_zero_item + (totalItems - 1) * anglePerItem

angle_for_zero_item = -(totalItems - 1) * anglePerItem

那么如上就求得當?shù)竭_最后一個item時,第0個item的角度angle_for_zero_item
,接著將這個表達式-(totalItems - 1) * anglePerItem定義為第0個item的最大角度angleAtExtreme。

contentOffset.x = 0, angle = 0
contentOffset.x = maxContentOffset, angle = angleAtExtreme

從上面的表達式不難推斷出下面這條表達式:

angle = -angleAtExtreme * contentOffset.x / maxContentOffset

接下來將公式轉(zhuǎn)化為代碼寫在itemSize定義下面:

var angleAtExtreme: CGFloat {
  return collectionView!.numberOfItemsInSection(0) > 0 ? 
    -CGFloat(collectionView!.numberOfItemsInSection(0) - 1) * anglePerItem : 0
}
var angle: CGFloat {
  return angleAtExtreme * collectionView!.contentOffset.x / (collectionViewContentSize().width - 
    CGRectGetWidth(collectionView!.bounds))
}

接著將prepareLayout()下面這條代碼:

attributes.angle = (self.anglePerItem * CGFloat(i))

替換為

attributes.angle = self.angle + (self.anglePerItem * CGFloat(i))

這條代碼將attributes.anglecontentOffset.x關聯(lián)起來了

bulid and run ,現(xiàn)在就達到我們想要的效果了。

優(yōu)化


prepareLayout()里你為每一個item都創(chuàng)建一個CircularCollectionViewLayoutAttributes對象。但是不是所有都出現(xiàn)在屏幕上,對于哪些不出現(xiàn)在屏幕上的,能夠完全不去為它創(chuàng)建對象。

這里就需要檢測判斷哪些對象不在屏幕上。如圖,item出現(xiàn)在屏幕上的位置范圍是(-θ, θ) ,而超出這范圍的都不顯示。

為了計算θ,在三角形ABC有以下等式:

tanθ = (collectionView.width / 2) / (radius + (itemSize.height / 2) - (collectionView.height / 2))

將以下代碼添加到prepareLayout()里的anchorPointY下面:

// 1 
let theta = atan2(CGRectGetWidth(collectionView!.bounds) / 2.0, 
    radius + (itemSize.height / 2.0) - (CGRectGetHeight(collectionView!.bounds) / 2.0))
// 2
var startIndex = 0
var endIndex = collectionView!.numberOfItemsInSection(0) - 1 
// 3
if (angle < -theta) {
  startIndex = Int(floor((-theta - angle) / anglePerItem))
}
// 4
endIndex = min(endIndex, Int(ceil((theta - angle) / anglePerItem)))
// 5
if (endIndex < startIndex) {
  endIndex = 0
  startIndex = 0
}

這些代碼是干什么的?
1.用tan的反函數(shù)求出theta
2.初始化startIndexendIndex為0和最后一個
3.如果angle小于-theta,則代表其不在屏幕上。那么出現(xiàn)在屏幕的第一個item的index則為angle至-θ的角度除以anglePerItem,因為angle為負值,所以就先變?yōu)檎怠O蛳氯≌麆t代表item要完全不在屏幕才消失。
4.同樣,最后的item的idex則為angle加上θ除以anglePerItem,然后使用min確保不會超出范圍。
5.最后的會發(fā)生滑動過快,從而使所有的item消失在屏幕。

知道了哪些在屏幕,哪些不在屏幕后,接下來更新改變prepareLayout()的語句:

attributesList = (0..<collectionView!.numberOfItemsInSection(0)).map { (i) 
    -> CircularCollectionViewLayoutAttributes in

替換為

attributesList = (startIndex...endIndex).map { (i) 
    -> CircularCollectionViewLayoutAttributes in

buid and run,發(fā)現(xiàn)沒什么改變,但實際上你已經(jīng)改善了。如果item多起來的話就能看到效果了。

接下來要干什么呢?


實現(xiàn)中間的item總會停留在垂直中間

snap

可以通過覆蓋CircularCollectionViewLayout的targetContentOffsetForProposedContentOffset(_:withScrollingVelocity:)

override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
  var finalContentOffset = proposedContentOffset
  
  //1
  let factor = -angleAtExtreme/(collectionViewContentSize().width - 
      CGRectGetWidth(collectionView!.bounds))
  let proposedAngle = proposedContentOffset.x*factor
  
  let ratio = proposedAngle/anglePerItem
  var multiplier: CGFloat
  //2
  if (velocity.x > 0) {
    multiplier = ceil(ratio)
  } else if (velocity.x < 0) {
    multiplier = floor(ratio)
  } else {
    multiplier = round(ratio)
  }
  //3
  finalContentOffset.x = multiplier*anglePerItem/factor
  return finalContentOffset
}

這些計算是干什么的?
1.計算出將要停下的角度proposedAngle,和比率ratio
2.接著將比率ratio取整
3.再用整數(shù)的比率求出最終的ContentOffset

最后


有了這些原理就可以實現(xiàn)一些你喜歡的效果了,或者加一些效果進去。

例如滾動時標題隨著中間的item變化:

個人項目25min

使用scroll View的delegatescrollViewDidScroll(_:)
然后計算中間item的indexPath,用angle除以anglePerItem得出


文章挺長的,看到這里的估計都是真愛了

這篇文章是翻譯和修改這片文章的raywenderlich

(END and Thank U)

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容