UICollectionView Custom Layout Tutorial: A Spinning Wheel

UICollectionView Custom Layout Tutorial: A Spinning Wheel

There are some really creative websites on the Internet, and a few weeks ago, I came across one such website called Form Follows Function, which is a collection of different kinds of interactive experiences. What really caught my attention was the site’s spinning navigation wheel, which contained posters that represented each kind of experience.

Screenshot 2015-05-27 00.15.28

This tutorial will show you how to use a UICollectionView custom layout to recreate this spinning navigation wheel. To get the most out of your time here, you’ll need to have basic knowledge of 2D transforms, collection views and custom layouts. If you’re unfamiliar with any of these topics then I recommend you check out the following before continuing:

By the end of this tutorial, you’ll know how to:

  • Create your own collection view layout from scratch, without using UICollectionViewFlowLayout as your base class
  • Rotate views around a point outside their bounds

And much, much more! Time to jump in.

Getting Started

First, download the starter project for this tutorial, open it in Xcode, and build and run. You’ll see a grid of cells, each representing a book from the raywenderlich.com store:

Screenshot 2015-05-27 00.48.36

The project’s setup is fairly straight forward. There’s CollectionViewController, and a custom collection view cell with an image view inside of it. The book covers are in a directory called Images, and CollectionViewController populates the collection view using the directory as its data source.

Your task is to create a UICollectionViewLayout subclass to lay these cells out in a circular fashion.


Here’s a diagram of the wheel structure along with the cells. The yellow area is the iPhone’s screen, the blue rounded rectangles are the cells, and the dotted line is the circle you’ll place them around:

Screenshot 2015-06-01 14.11.42

You’ll need three main parameters to describe this arrangement:

  1. The radius of the circle (radius);
  2. The angle between each cell (anglePerItem);
  3. The angular position of cells.

As you probably noticed, not all the cells fit within the screen’s bounds.

Assume that the 0th cell has an angle of x degrees, then the 1st cell will have an angular position of x + anglePerItem, the second x + (2 * anglePerItem) and so on. This can be generalized for the nth item as:

angle_for_i = x + (i * anglePerItem)

Below, you’ll see a depiction of the angular coordinate system. An angle of 0 degrees refers to the center, while positive angles are shown towards the right and negative are towards the left. So a cell with an angle of 0 will lie in the center — completely vertical.

Screenshot 2015-06-01 14.41.07

Now that you’re clear on the underlying theories, you’re ready to start coding!

Circular Collection View Layout

Create a new Swift file with the iOS\Source\Cocoa Touch Class template. Name it CircularCollectionViewLayout, and make it a subclass of UICollectionViewLayout:


Click Next, and then Create. This collection view layout subclass will contain all the positioning code.

As this is a subclass of UICollectionViewLayout rather than UICollectionViewFlowLayout, you’ll have to handle all parts of the layout process yourself instead of piggybacking the parents implementation using calls to super.

On that note, I find that flow layout is well suited for grids, but not for circular layouts.

In CircularCollectionViewLayout, create properties for itemSize and radius:

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

When the radius changes, you recalculate everything, hence the call to invalidateLayout() inside didSet.

Below the radius declaration, define anglePerItem:

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

anglePerItem can be any value you want, but this formula ensures that the cells aren’t spread too far apart.

Next, implement collectionViewContentSize() to declare how big the content of your collection view should be:

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

The height will be the same as the collection view, but its width will be itemSize.width * numberOfItems.

Now, open Main.storyboard, select Collection View in the document outline:


Open the Attributes Inspector and change Layout to Custom, and Class to CircularCollectionViewLayout:


Build and run. Apart from a scrollable area, you won’t see anything, but that’s exactly what you want to see! It confirms that you’ve correctly told the collection view to use CircularCollectionViewLayout as its layout class.

Screenshot 2015-06-01 03.07.31

Custom Layout Attributes

Along with a collection view layout subclass, you’ll also need to subclass UICollectionViewLayoutAttributes to store the angular position and anchorPoint.

Add the following code to CircularCollectionViewLayout.swift, just above the CircularCollectionViewLayout class declaration:

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. You need anchorPoint because the rotation happens around a point that isn’t the center.
  2. While setting angle, you internally set transform to be equal to a rotation of angle radians. You also want cells on the right to overlap the ones to their left, so you set zIndex to a function that increases in angle. Since angle is expressed in radians, you amplify its value by 1,000,000 to ensure that adjacent values don’t get rounded up to the same value of zIndex, which is an Int.
  3. This overrides copyWithZone(). Subclasses of UICollectionViewLayoutAttributes need to conform to the NSCopying protocol because the attribute’s objects can be copied internally when the collection view is performing a layout. You override this method to guarantee that both the anchorPoint and angle properties are set when the object is copied.

Now, jump back to CircularCollectionViewLayout and implement layoutAttributesClass():

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

This tells the collection view that you’ll be using CircularCollectionViewLayoutAttributes, and not the default UICollectionViewLayoutAttributes for your layout attributes.

To hold layout attributes instances, create an array called attributesList below all other property declarations in CircularCollectionViewLayout:

var attributesList = [CircularCollectionViewLayoutAttributes]()

Preparing the Layout

The first time the collection view appears on screen, the UICollectionViewLayout method prepareLayout() is called. This method is also called each time the layout is invalidated.

This is one of the most crucial methods of the layout process, because it’s where you create and store layout attributes. Make it happen by adding the following to CircularCollectionViewLayout:

override func 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

In short, you iterate over each item in the collection view and execute the closure. Keep reading for a line-by-line explanation:

  1. Create an instance of CircularCollectionViewLayoutAttributes for each index path, and then set its size.
  2. Position each item at the center of the screen.
  3. Rotate each item by the amount anglePerItem * i, in radians.
Note: The method used here, map, is part of the Swift standard library and creates a new array with the results of the closure for each element in the range. You can find out more about the functional programming side of Swift here.

To properly subclass UICollectionViewLayout you’ll also have to override the following methods, which return the layout attributes for the items in the given rect, and the item at the given index path respectively. The collection view will call these method numerous times throughout the layout process, as well as when the user scrolls the collection view, so it’s important that they’re efficient – hence why you create and cache the layout attributes in prepareLayout(). Add them below prepareLayout():

override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
  return attributesList
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) 
    -> UICollectionViewLayoutAttributes! {
  return attributesList[indexPath.row]

The first method simply returns the entire array of attributes, and the second method returns the attributes for the item at the given index path. This approach is OK for the purposes of this tutorial since you only have a small number of items, but usually you would want to iterate over the array and check whether the frame of the layout attributes intersects with the given rect, and only return those layout attributes whose frame does intersect. This would result in the collection view only drawing those items that should be on-screen, or which are about to come on screen.

Build and run. You’ll see cells appear on screen, but rather than rotating around an external point, they rotate around themselves. It’s not quite the desired effect, but it is cool, don’t you think?

Screenshot 2015-05-27 17.56.29

Any guess as to why this is happening?

Did Someone Say Anchor Point?

Do you remember the discussion about the anchor point of the cell? You didn’t set it yet, hence the rotation is a touch crazy and not quite what you were looking to achieve.


The anchor point is a property of CALayer around which all rotations and scaling transforms take place. The default value of this property is the center, as you saw in the last build and run.

For the actual anchor point, the x value will remain 0.5, as you’ll observe in the diagram below. The y value, however, will be radius + (itemSize.height / 2), and since the anchor point is defined in the unit coordinate space, you’ll divide the result by itemSize.height.

Screenshot 2015-06-01 16.22.12

So jump back to prepareLayout(), and right below the definition of centerX define anchorPointY:

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

And inside the map(_:) closure, right before the return statement, add this line:

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

Next, open CircularCollectionViewCell.swift and override applyLayoutAttributes(_:) with the following:

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

Here, you’re using the superclass implementation to apply the default properties like center and transform, but since anchorPoint is a custom property, you have to apply that manually. You also update center.y to the center of the layout circle to compensate for the change in anchorPoint.y.

Build and run. You’ll see the cells are now laid out in a circle and when you scroll they…wait, what’s going on here? They’re just moving off-screen rather than rotating!?

It’s going to be terribly difficult to find the right book! :]

scrolling off

Improving Scrolling

The most challenging part of laying out the items is done, congratulations! :]

Now you just have to just play around with angle values to implement scrolling.

Jump back to CircularCollectionViewLayout and add the following to the bottom of the class:

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

Returning true from this method tells the collection view to invalidate it’s layout as it scrolls, which in turn calls prepareLayout() where you can recalculate the cells’ layout with updated angular positions.

angle is defined as the angular position of the 0th item. You’ll implement scrolling by converting contentOffset.x into a suitable value from angle.

contentOffset.x goes from 0 to collectionViewContentSize().width - CGRectGetWidth(collectionView!.bounds) as you scroll. Call the extreme value of contentOffset.x as maxContentOffset. At 0, you want the 0th item at the center, and at the extreme, you want the last item at the center of the screen, which means the last item’s angular position will be zero.

State of wheel at start (left) and at end (right)

The state of your navigation wheel at the start (left) and the end (right)

Consider the scenario on the right, and what would happen if you solve the following equation with angle_for_last_item = 0. You would get this:

angle_for_last_item = angle_for_zero_item + (totalItems - 1) * anglePerItem
0 = angle_for_zero_item + (totalItems - 1) * anglePerItem
angle_for_zero_item = -(totalItems - 1) * anglePerItem

Defining -(totalItems - 1) * anglePerItem as angleAtExtreme, you can write:

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

From here, it’s quite easy to interpolate angle for any value of contentOffset.x using the following formula:

angle = -angleAtExtreme * contentOffset.x / maxContentOffset

Keeping all this math in mind, add the following properties below the declaration for 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 - 

Next, replace this line in prepareLayout():

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

with this one:

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

This adds the value of angle to each item, so that rather than being a constant, its angular position is a function of contentOffset.x.

Build and run. Scroll across the screen and you’ll see that the items now rotate as you scroll. Much better!

final scrolling

Since you used the value of contentOffset.x to derive the value for angular position, you get features like rubber banding, extremes checking and deceleration for free — without having to write any additional code. Bet you feel smarter already!

Bonus Material: Optimizations

You’ve successfully recreated the spinning navigation wheel, so give yourself a well deserved pat on the back! You could put your feet up on the desk and end the session here, but why would you stop there when there’s room for some scroll-smoothing optimization?

In prepareLayout() you create instances of CircularCollectionViewLayoutAttributes for every item, but not all of them end up on the screen at once. For these off-screen items, you can completely skip calculations and just not create layout attributes at all.

But there is a bit of a challenge here: you need to determine which item is inside the screen and which is off-screen. In the diagram below, any item that lies outside the range of (-θ, θ) will be off-screen.

Screenshot 2015-06-01 17.46.48

For instance, to calculate θ in triangle ABC, you’d do this:

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

Add the following code to prepareLayout(), just below the declaration of 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

What are you doing here?:

  1. You find theta by using the tan inverse function;
  2. You initialize startIndex and endIndex to 0 and the last item index respectively;
  3. If the angular position of the 0th item is less than -theta, then it lies outside the screen. In that case, the first item on the screen will be the difference between and angle divided by anglePerItem;
  4. Similarly, the last element on the screen will be the difference between θ and angle divided by anglePerItem, and min serves as an additional check to ensure endIndex doesn’t go beyond the total number of items;
  5. Lastly, you add a safety check to make the range 0...0 if endIndex is less than startIndex. This edge case occurs when you scroll with a very high velocity and all the cells go completely off-screen.

Here’s a diagram to explain the calculations above visually:

Click for higher resolution image

Click for higher resolution image

Now that you know which items are on-screen and which aren’t, you need to update the range used to calculate the layout attributes in prepareLayout(). Find this line:

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

and replace it with this one:

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

Now build and run. You’ll see no visual difference because all the changes affect off-screen items, but you should see fewer cells when you open Xcode’s builtin view hierarchy debugger.

And since you’re creating fewer objects, you should also see a improvement in the performance.

Screenshot 2015-05-27 23.33.45

Where To Go From Here

You can download the completed project here.

Screenshot 2015-06-01 03.03.33

Congratulations, you’ve successfully used a UICollectionView custom layout to implement a spinning navigation wheel.

You’ve learned a number of things in this tutorial, including how to rotate views, change their anchor point, create your own custom collection view layout from scratch, and how to make it all look pretty.

To keep the learning party going, try playing around with values likes radius and anglePerItem in the layout to see how they affect the final circular arrangement. While this tutorial focuses on 2D transforms, you can create interesting effects by employing similar techniques to apply rotations in 3D space with transform3D.

You can also implement snapping behavior by overriding targetContentOffsetForProposedContentOffset(_:withScrollingVelocity:) in CircularCollectionViewLayout.

Think you’re up to the task? Go for it. If you get stuck, open the spoiler below.

Solution Inside: Snapping behavior SelectShow

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

If you have questions, comments or would like to show off how you took the concepts in this tutorial to the next level, please join the discussion below!

The post UICollectionView Custom Layout Tutorial: A Spinning Wheel appeared first on Ray Wenderlich.



Write a comment