Avid developer who has a passion for

Learn more about myself and what makes me so passionate about various forms of software development.

Facebook-like image grid iOS


January 20, 2020

First off, happy 2020 everyone!

A few months back, I was working on improving the way photos were presented on the app I'm working on at work. I was looking around on various apps and Facebook & Pinterest had what I was looking for.

Facebook Photo Grid

On Android, they provide a nice layout called StaggeredGridLayoutManager but on iOS, it was a little more work.

For iOS, the best way is to use a UICollectionView and create a custom layout.

Apple actually provided a really good article on how do this but the issue was the code provided was in swift, so I converted the code into C#.

I'm going to be hitting on the main points, feel free to follow along with the code provided.

first, let's make the custom layout. This tells the collection view cells how to present the cell.

[Register("MosaicCollectionLayout")]
public class MosaicCollectionLayout : UICollectionViewLayout
{
    private CGRect _contentBounds = CGRect.Empty;
    private readonly List<UICollectionViewLayoutAttributes> _cachedAttributes = new List<UICollectionViewLayoutAttributes>();

    public MosaicCollectionLayout(IntPtr handle) : base(handle)
    {
        // Note: this .ctor should not contain any initialization logic.
    }

    public override void PrepareLayout()
    {
        base.PrepareLayout();

        var sectionCount = CollectionView.NumberOfSections();

        if (sectionCount == 0)
        {
            return;
        }

        // Reset Cache
        _cachedAttributes.Clear();
        _contentBounds = new CGRect(CGPoint.Empty, CollectionView.Bounds.Size);

        var itemCount = CollectionView.NumberOfItemsInSection(0);
        var currentIndex = 0;
        var itemStyle = DetermineItemStyle(itemCount);
        var lastFrame = CGRect.Empty;
        var cvWidth = CollectionView.Bounds.Width;
        var cvHeight = CollectionView.Bounds.Height;

        while (currentIndex < itemCount)
        {
            CGRect segmentFrame;
            var segmentRects = new List<CGRect>();

            switch (itemStyle)
            {
                case MosaicStyle.FullWidth:
                    segmentFrame = new CGRect(0, lastFrame.GetMaxY() + 1, cvWidth, cvHeight);

                    segmentRects.Add(segmentFrame);
                    break;
                case MosaicStyle.HalfWidth:
                    segmentFrame = new CGRect(0, lastFrame.GetMaxY() + 1, cvWidth, cvHeight / 2);

                    var slices = segmentFrame.DividedIntegral((nfloat) 0.5, CGRectEdge.MinXEdge);

                    segmentRects.Add(slices["First"]);
                    segmentRects.Add(slices["Second"]);

                    break;
                case MosaicStyle.TwoThirdsByOneThird:
                    segmentFrame = new CGRect(0, lastFrame.GetMaxY() + 1, cvWidth, cvHeight / 3);

                    var twoThirdsHorizontalSlices = segmentFrame.DividedIntegral((nfloat) (2.0 / 3.0), CGRectEdge.MinXEdge);
                    var twoThirdsVerticalSlices = segmentFrame.DividedIntegral((nfloat) 0.5, CGRectEdge.MinYEdge);

                    segmentRects.Add(twoThirdsHorizontalSlices["First"]);
                    segmentRects.Add(twoThirdsVerticalSlices["First"]);
                    segmentRects.Add(twoThirdsVerticalSlices["Second"]);
                    break;
                case MosaicStyle.OneThirdByTwoThirds:
                    segmentFrame = new CGRect(0, lastFrame.GetMaxY() + 1, cvWidth, cvHeight / 3);

                    var oneThirdHorizontalSlices = segmentFrame.DividedIntegral((nfloat) (1.0 / 3.0), CGRectEdge.MinXEdge);
                    var oneThirdVerticalSlices = segmentFrame.DividedIntegral((nfloat) 0.5, CGRectEdge.MinYEdge);

                    segmentRects.Add(oneThirdVerticalSlices["First"]);
                    segmentRects.Add(oneThirdVerticalSlices["Second"]);
                    segmentRects.Add(oneThirdHorizontalSlices["Second"]);
                    break;
            }

            // cache attributes.
            foreach (var rect in segmentRects)
            {
                var indexPath = NSIndexPath.FromRowSection(currentIndex, 0);
                var attributes = UICollectionViewLayoutAttributes.CreateForCell(indexPath);

                attributes.Frame = rect;

                _cachedAttributes.Add(attributes);
                _contentBounds = _contentBounds.UnionWith(lastFrame);

                currentIndex++;
                lastFrame = rect;
            }

            // determine style for next segment
            switch (itemCount - currentIndex)
            {
                case 1:
                    itemStyle = MosaicStyle.FullWidth;
                    break;
                case 2:
                    itemStyle = MosaicStyle.HalfWidth;
                    break;
                default:
                    switch (itemStyle)
                    {
                        case MosaicStyle.FullWidth:
                            itemStyle = MosaicStyle.HalfWidth;
                            break;
                        case MosaicStyle.HalfWidth:
                            itemStyle = MosaicStyle.TwoThirdsByOneThird;
                            break;
                        case MosaicStyle.TwoThirdsByOneThird:
                            itemStyle = MosaicStyle.OneThirdByTwoThirds;
                            break;
                        case MosaicStyle.OneThirdByTwoThirds:
                            itemStyle = MosaicStyle.HalfWidth;
                            break;
                    }
                    break;
            }
        }
    }

    public override CGSize CollectionViewContentSize => _contentBounds.Size;

    public override bool ShouldInvalidateLayoutForBoundsChange(CGRect newBounds)
    {
        return newBounds.Size != CollectionView.Bounds.Size;
    }

    public override UICollectionViewLayoutAttributes LayoutAttributesForItem(NSIndexPath indexPath)
    {
        return _cachedAttributes[indexPath.Row];
    }

    public override UICollectionViewLayoutAttributes[] LayoutAttributesForElementsInRect(CGRect rect)
    {
        var attributes = new List<UICollectionViewLayoutAttributes>();

        foreach (var attribute in _cachedAttributes)
        {
            if (rect.IntersectsWith(attribute.Frame))
            {
                attributes.Add(attribute);
            }
        }

        return attributes.ToArray();
    }

    private static MosaicStyle DetermineItemStyle(nint count)
    {
        switch (count)
        {
            case 1:
                return MosaicStyle.FullWidth;
            case 2:
                return MosaicStyle.HalfWidth;
            case 3:
            case 5:
                return MosaicStyle.OneThirdByTwoThirds;
            default:
                return MosaicStyle.TwoThirdsByOneThird;
        }
    }
}

as you see we have different styles on how the cell is displayed, I made this an enum as that made perfect sense

public enum MosaicStyle
{
    FullWidth,
    HalfWidth,
    TwoThirdsByOneThird,
    OneThirdByTwoThirds
}

I also needed to make a helper for dividing out our cells

public static Dictionary<string, CGRect> DividedIntegral(this CGRect rect, nfloat fraction, CGRectEdge fromEdge)
{
    nfloat dimension = 0.0f;

    switch (fromEdge)
    {
        case CGRectEdge.MinXEdge:
        case CGRectEdge.MaxXEdge:
            dimension = rect.Size.Width;
            break;
        case CGRectEdge.MinYEdge:
        case CGRectEdge.MaxYEdge:
            dimension = rect.Size.Height;
            break;
    }

    var distance = Math.Round(dimension * fraction);
    rect.Divide((nfloat) distance, fromEdge, out var slice, out var remainder);

    var remainderSize = remainder.Size;

    switch (fromEdge)
    {
        case CGRectEdge.MinXEdge:
        case CGRectEdge.MaxXEdge:
            remainder.X += 1;
            remainderSize.Width -= 1;
            remainder.Size = remainderSize;
            break;
        case CGRectEdge.MinYEdge:
        case CGRectEdge.MaxYEdge:
            remainder.Y += 1;
            remainderSize.Height -= 1;
            remainder.Size = remainderSize;
            break;
    }

    return new Dictionary<string, CGRect>
    {
        { "First", slice },
        { "Second", remainder }
    };
}

That should finish up that, you now have to tell the collectionview to use that layout. You can do this a couple ways I opted to set the layout on the storyboard but you can do it by code as well if you prefer.

check out the full code on github to see it in action.