Recently, we released Early Game Alarm 2.0, available in 7 languages and with brand new design, and so we decided to share our experience with different problems we’ve encountered during (almost) 3 years of development. My previous post was about the paging layout, that we used to present the available games and game packs to users. This one will continue to tackle the subject of custom layouts and I will use a simple project to explain how we arranged circular alarms on the main screen.

On the image above you can see how it looks in the app, and on the image below, what we’ll try to achieve in this tutorial. You can download the complete code from this link.

Example of custom layout that we will create in this tutorial.

 

So, the task is simple: we are making a collection of blue circles of specific radius. The value of the radius is displayed on the label in the center. Ok, they don’t really have to be blue - you can pick any color you want. Let’s begin now!

Note: this tutorial is for developers who already have experience with autolayot and collection views. We will not cover every step in detail. For beginner’s tutorials I recommend starting from Ray Wenderlich tutorials.

Step 1: Storyboard settings

After creating a new project, you get one view controller (of the class ViewController) in the Storyboard. For this demo, we will use exactly that one.

Let’s add the following things to the Storyboard: 1 collection view and 1 label inside the collection view cell.

Collection view settings:

  • Let’s set the top, bottom, trailing and leading constraint of the collection view to value of 0.
  • Don’t forget to connect the ViewController to be data source and delegate for the collection view.

Cell settings:

  • Center label horizontally and vertically in the superview (cell)
  • Set pretty fonts and colors for background and text
  • Set cell identifier to “CircleCell”
  • Create the custom UICollectionViewCell (e.g. CircleCell) and connect outlet. You can check, for example, this post if you don’t know how.
Illustration on how to set up data source and delegate in the Storyboard.

Step 2: Data source

Now, to actually show the cells, we need to implement UICollectionViewDataSource methods. Add the following code in the ViewController.m:

// ViewController.m

@interface ViewController ()<UICollectionViewDataSource>
@property (strong, nonatomic) NSMutableArray *dataSource;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.dataSource = [[NSMutableArray alloc] initWithObjects:@120, @160, @80, @120, @80, @140, @100, @200, nil];
}

#pragma mark - <UICollectionViewDataSource>

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return self.dataSource.count;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {   
    CircleCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"CircleCell" forIndexPath:indexPath];
    if (cell == nil) {
        cell = [[CircleCell alloc] init];
    }
    
    cell.circleLabel.text = [NSString stringWithFormat:@"%@", self.dataSource[indexPath.row]];
    cell.layer.cornerRadius = cell.frame.size.width / 2;
    
    return cell;
}

@end

What is this about? First, we created the array of radiuses (just a random example) in viewDidLoad method. Then, we implemented the two mandatory data source methods:

  • The first one to obtain the number of cells that will be displayed on the screen
  • And the second one to dequeue and configure the actual cell. Here we just set the appropriate value as the label’s text, and corner radius.

Build and run and you should see something like this:

Example of the regular grid layout.

 

If the circles are not regular, you can just adjust dimensions of the cell in the Storyboard - make them square-shaped. 

Ok, so we made circular shapes displaying the correct value. However, the dimensions are all wrong. So, let’s continue to the step 3. 

Step 3: Cell sizing

The simplest approach to set different size for every cell (which you probably already know) is to implement UICollectionViewFlowLayoutDelegate’s function: collectionView:layout:sizeForItemAtIndexPath:.

Change the class declaration like this:

// ViewController.m

@interface ViewController ()<UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>

And then add this:

// ViewController.m

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return CGSizeMake([self.dataSource[indexPath.row] doubleValue], [self.dataSource[indexPath.row] doubleValue]);
}

Build and run. Circles should now be the right dimensions. However, the arrangement of the circles is not optimal. You can see the grid in which they are in. There’s to much unused space between some of them.

Layout with different cell sizes

Step 4: Customizations

According to the docs, if out layout looks nothing like a grid or a line-based breaking layout we should subclass UICollectionViewLayout and implement the following functions:

  • collectionViewContentSize
  • layoutAttributesForElementsInRect: 
  • layoutAttributesForItemAtIndexPath:
  • prepareLayout

The first one, as the name suggests, is used to get the total content size of collection view  - like the scroll view content size.
The next two are called to retrieve layout information whenever needed. 
And the fourth one is where all the magic happens. In prepareLayout we need to calculate location and dimensions (frames) for every item in the collection view and save (cache) them so the previous two functions can use them when they need it. Sounds complicated? You’ll (hopefully) understand it along the way.

Let’s add the new class to the project (CircleLayout) whose parent class is UICollectionViewLayout.

First, we need to define variables where we are going to store all the layout information, and to initialize the class:

// CirclesLayout.h

@property (nonatomic, strong) NSDictionary *layoutInfo;
@property (nonatomic) float contentHeight;
@property (nonatomic) UIEdgeInsets viewInsets;
// CirclesLayout.m

- (id)init {
    self = [super init];
    if (self) {
        [self setup];
    }
    return self;
}

- (id)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (self) {
        [self setup];
    }
    return self;
}

- (void)setup {
    self.viewInsets = UIEdgeInsetsMake(20, 20, 20, 20); // top, left, bottom, right
}

Then, let’s see how the cached layout information is used in those required functions:

// CirclesLayout.m

- (CGSize)collectionViewContentSize {
    return CGSizeMake(self.collectionView.bounds.size.width, self.contentHeight);
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    return self.layoutInfo[indexPath];
}

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray *allAttributes = [NSMutableArray arrayWithCapacity:self.layoutInfo.count];
    [self.layoutInfo enumerateKeysAndObjectsUsingBlock:^(NSIndexPath *indexPath,
                                                         UICollectionViewLayoutAttributes *attributes,
                                                         BOOL *innerStop) {
        if (CGRectIntersectsRect(rect, attributes.frame)) {
            [allAttributes addObject:attributes];
        }
    }];
    
    return allAttributes;
}

The first and the second one are pretty straight-forward. In the third one, we are iterating through the layout attributes for each indexPath and returning them if they are within the given rectangle. 
Now, we just need to calculate these layout attributes. As said before, this is done in the prepareLayout function.

// CirclesLayout.h

@property (nonatomic) float initialX;
@property (nonatomic) float initialY;
// CirclesLayout.m

- (void)prepareLayout {
    NSMutableDictionary *newLayoutInfo = [NSMutableDictionary dictionary];
    self.contentHeight = self.viewInsets.top;
    self.initialX = self.viewInsets.left;
    self.initialY = self.viewInsets.top;
    
    NSIndexPath *indexPath;
    NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
    
    for (NSInteger item = 0; item < itemCount; item++) {
        indexPath = [NSIndexPath indexPathForItem:item inSection:0];
        
        UICollectionViewLayoutAttributes *itemAttributes =
        [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
        itemAttributes.frame = [self frameForCircleAtIndexPath:indexPath layoutInfo:newLayoutInfo];
        
        newLayoutInfo[indexPath] = itemAttributes;
        
        self.contentHeight = MAX(self.contentHeight, (itemAttributes.frame.origin.y + itemAttributes.frame.size.height));
    }
    
    self.contentHeight += self.viewInsets.bottom;
    self.layoutInfo = newLayoutInfo;
}

To calculate the frame for every circle, we need to check whether our new circle is colliding with its n predecessors. As long as it does (distance criteria is not satisfied), we move its origin by a certain position increment. When we are certain that a circle can fit in the screen appropriately, we create a frame for it, store it in layoutInfo, and update initial position for the next circle.

// CirclesLayout.h

#define kPositionIncrement 5
// CirclesLayoyt.m

- (CGRect)frameForCircleAtIndexPath:(NSIndexPath *)indexPath layoutInfo:(NSMutableDictionary *)newLayoutInfo {
    float originX = self.initialX, originY = self.initialY;
    float width = [self sizeForItemAtIndexPath:indexPath].width;
    CGRect circle;
    
    circle = CGRectMake(originX, originY, width, width);
    while (![self distanceConditionForItem:circle AtIndexPath:indexPath InLayout:newLayoutInfo]) {
        originX += kPositionIncrement;
        
        if (originX + width + self.viewInsets.right > self.collectionView.bounds.size.width) {
            originX = self.viewInsets.left;
            originY += kPositionIncrement;
        }
        circle = CGRectMake(originX, originY, width, width);
    }
    
    // Set initial X i Y for the next circle.
    self.initialX = originX + (width / 2);
    self.initialY = originY;
    
    return CGRectMake(originX, originY, width, width);
}

Size

We’ve already defined size of the circles in our ViewController; it is dictated by our data source. So we could use something like this to get this information here:

- (CGSize)sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    if ([[self.collectionView.delegate class] conformsToProtocol:@protocol(UICollectionViewDelegateFlowLayout)]) {
        return [(id)self.collectionView.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:indexPath];
    }
    return CGSizeMake(0, 0);
}

Position

For this particular layout setup, it is enough to check whether the circle collides with up to 4 predecessors. 
Math reminder: circles don’t collide if the distance between their centers is greater than the sum of their radiuses + optional spacing between them. 

// CirclesLayout.h

#define kMinSpaceBetweenCircles 10
#define kMaxPredecessorNum 4
// CirclesLayout.m

- (BOOL)distanceConditionForItem:(CGRect)circle AtIndexPath:(NSIndexPath *)indexPath InLayout:(NSMutableDictionary *)newLayoutInfo {
    BOOL condition = YES;
    long numPredecessors = indexPath.row;
    if (indexPath.row >= kMaxPredecessorNum) {
        numPredecessors = kMaxPredecessorNum;
    }
    for (int i = 1; i <= numPredecessors; i++) {
        NSIndexPath *ip = [NSIndexPath indexPathForItem:(MAX(indexPath.row - i, 0)) inSection:0];
        UICollectionViewLayoutAttributes *attr = newLayoutInfo[ip];
        condition = condition && [self distanceBetween:circle and:attr.frame isLargerThan:kMinSpaceBetweenCircles];
    }
    
    return condition;
}

- (BOOL)distanceBetween:(CGRect)circle1 and:(CGRect)circle2 isLargerThan:(float)delta {
    float r1 = circle1.size.width / 2;
    float cx1 = circle1.origin.x + r1;
    float cy1 = circle1.origin.y + r1;
    
    float r2 = circle2.size.width / 2;
    float cx2 = circle2.origin.x + r2;
    float cy2 = circle2.origin.y + r2;
    
    float d = sqrt((pow(cx1 - cx2, 2) + pow(cy1 - cy2, 2)));
    
    return (d >= r1 + r2 + delta);
}

Final steps

Go to the Storyboard and set the collection view to use the custom layout we just made.

Setting the custom layout on the collection view.

Build and run, and that’s it!

Final project screenshot