Re-creating Apple Photos layout & animations with Compositional Layout

Let's see how we can create layout similar to the native Photos app in iOS. Complete with the transitions.

Published: Feb. 21, 2021
See books

The Photos app got pretty big update with iOS 13 and I was always fascinated by the smooth animations and transitions when switching between the Years, Months, Days and All Photos views. I decided to try to implement this with the help of Compositional Layout and Diffable Data Source.

This post shows how I implemented switch between Months summary and All Photos view. And below is finished example as a motivation to keep reading 🙂 I should also mention that point of this post is not to show how to exactly replicate Apple Photos but rather show how you can go about creating layout that transitions between quite different states.

What we are going to build

Compositional Layout - Photos

Before we start with the code, I think it is helpful to show how to approach layout like this and how to kind of "slice" it into separate tasks. For example one such task is to prepare the layout for the All Photos mode where we have one big photo followed by three small ones. The next job is to figure out how to hide the small photos and keep just the large one.

Mapping out approach

Here I got a bit stuck because I realized I need to keep these cells in place and not change the the data representation in the snapshot, otherwise when calling the apply(snapshot: method on the datasource I would get the default fade-out/fade-in animation. So next step is to construct the data model and the snapshot such that data for these photos stay the same.

In the end I prepared special cell for these large photos that is able to animate itself to toggle between showing just the image and displaying the summary style which rounds the corners, shows day label, button and subtle gradient so the white text is always legible.

The code to switch this state also cannot be part of the Diffable Data Source sadly because as far as I know there is not a lot of freedom regarding the animations for changes. Diffable is for adding/removing and rearranging cells, not for animating their appearance.

So I had to ask the collection view to give me all the visible cells, cast them with compactMap and based on the selected mode in the view controller apply either the month summary or all photos style. This same also needs to be configured when dequeuing cells to keep them consistent when scrolling.

The "July 2020" and "August 2020" are standard collection view headers conditionally applied only when month summary is selected.

I hope the previous text was useful to kind of show what is needed and now we can take a look at the code.

 Complete example

Since the complete example is available on GitHub, I want to show just the truly relevant code here to make it hopefully more clear where the "magic" lies.

Data model

Let's start with the data model. As usual I have enums for Section and also Item:

typealias Datasource = UICollectionViewDiffableDataSource<Section, Item>
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>

enum Section: Hashable {
    case collection(header: String)

    var title: String {
        switch self {
        case .collection(let header):
            return header
        }
    }
}

enum Item: Hashable {
    case largePhoto(Photo)
    case photo(Photo)
}

The Item has two cases so we can easily dequeue the correct cell. The Photo is just this struct I also used in the Instagram profile example:

struct Photo {
    let id = UUID()
    let image: UIImage
}

extension Photo: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

Next I have enum to track the selected mode and property in the view controller:

enum Mode: Int {
    case monthSummary
    case allPhotos
}
private var mode = Mode.allPhotos

The enum is backed by Int because I am setting the mode with selected index from the segmented control that is used to switch between modes.

Layout definitions

There is single type of NSCollectionLayoutSection and the definition is in dedicated method (I will explain the why later):

private func layoutSection(forIndex index: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
    let photoItemHeight: NSCollectionLayoutDimension
    switch mode {
    case .allPhotos:
        photoItemHeight = .fractionalWidth(1.0)
    case .monthSummary:
        photoItemHeight = .fractionalWidth(0.8)
    }

    let photoItem = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: photoItemHeight))

    if mode == .monthSummary {
        photoItem.contentInsets = .init(horizontal: 16, vertical: 2)
    } else {
        photoItem.contentInsets = .init(horizontal: 2, vertical: 2)
    }

    let smallPhotoItem = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalWidth(1/3)))
    smallPhotoItem.contentInsets = .init(horizontal: 2, vertical: 0)

    let photoGroup = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1/3)), subitem: smallPhotoItem, count: 3)

    let group = NSCollectionLayoutGroup.vertical(layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(500)), subitems: [photoItem, photoGroup])

    let section = NSCollectionLayoutSection(group: group)

    if mode == .monthSummary {
        let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
        let headerElement = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
        section.boundarySupplementaryItems = [headerElement]
    }
    return section
}

It is quite long, mainly because of the conditional switching but I think if you read my previous Compositional Layout posts then it should make sense. If not, I recommend checking the anatomy post and deep dive into NSCollectionLayoutGroup.

In the method we first create the photoItem which represents the large photo in the collection view. The conditional height is just a little bonus, I found it to look much better when the summary wasn't square.

The selected mode is also then used to set different content insets which is responsible for the horizontal padding in the month summary view.

Next we are creating the smallPhotoItem and its group which will create the row of three small photos under the large one.

And finally we have the main group which has as its items the large photo and the row of small photos represented by the photoGroup.

We then create the actual NSCollectionLayoutSection and if the month summary is selected, we are adding the header item.

As a final step there is the createLayout method:

func createLayout() -> UICollectionViewLayout {
    return UICollectionViewCompositionalLayout { [unowned self] index, env in
        return self.sectionFor(index: index, environment: env)
    }
}

Because we are using the sectionProvider variant, our layoutSection method is invoked when the data source changes. Which is perfect because we can react to the selected mode property.

Animating cell content

As I said in the intro, there is no built-in way to change how the cell looks without reloading it and getting the standard animation. Below is code that runs when segmented control is used to toggle between the month summary and all photos mode:

@objc func modeSegmentedControlValueChanged(sender: UISegmentedControl) {
    mode = Mode(rawValue: sender.selectedSegmentIndex)!
    datasource.apply(snapshot(), animatingDifferences: true)
    let summaryCells = collectionView.visibleCells.compactMap({ $0 as? PhotoSummaryCell })
    summaryCells.forEach { (cell) in
        cell.configure(forMode: mode)
    }
}

The first two lines are straightforward. We use the index to set a new mode and then apply animated snapshot. We will look at the snapshot() method in a second.

The last lines are used to correctly animate the summary cells. We first get them from the collectionView and then tell each cell to configure itself for the selected mode.

Here is the relevant code from PhotoSummaryCell:

func configure(forMode mode: PhotosViewController.Mode) {
    UIView.animate(withDuration: 0.2) {
        switch mode {
        case .allPhotos:
            self.configureForAllPhotos()
        case .monthSummary:
            self.configureForSummary()
        }
    }
}

private func configureForAllPhotos() {
    dayLabel.alpha = 0
    moreButton.alpha = 0
    topGradientView.alpha = 0
    contentView.layer.cornerRadius = 0
}

private func configureForSummary() {
    dayLabel.alpha = 1
    moreButton.alpha = 1
    topGradientView.alpha = 1
    contentView.layer.cornerRadius = 16
}

Snapshot

The actual method to create the snapshot if maybe simpler than you would expect. Here it is:

func snapshot() -> Snapshot {
    var snapshot = Snapshot()

    let julyCollection = Section.collection(header: "July 2020")
    let augustCollection = Section.collection(header: "August 2020")

    snapshot.appendSections([julyCollection, augustCollection])

    snapshot.appendItems([.largePhoto(photos1.first!)], toSection: julyCollection)
    snapshot.appendItems([.largePhoto(photos2.first!)], toSection: augustCollection)

    if mode == .allPhotos {
        snapshot.appendItems(photos1.dropFirst().map { Item.photo($0) }, toSection: julyCollection)
        snapshot.appendItems(photos2.dropFirst().map { Item.photo($0) }, toSection: augustCollection)
    }

    return snapshot
}

Of course part of the simplicity is that we are using demo data. We have arrays of Photo as photos1 and photos2. For each array, we use the first photo as the large item and the rest as the small ones - but only if the mode == .allPhotos.

And that's it! Once again the complete code is on GitHub. I will be happy if you check it out and maybe ⭐ the repo 🙂


Looking for iOS Developer Jobs?

Bluesky logo

Follow on Bluesky to not miss new posts

Filip Němeček profile photo

WRITTEN BY

Filip Němeček Mastodon

iOS blogger and developer with interest in Python/Django.

iOS blogger and developer with interest in Python/Django.