Воссоздание макета и анимации Apple Photos с помощью Compositional Layout

Давайте посмотрим, как мы можем создать макет, аналогичный родному приложению Photos в iOS. В комплекте с переходами.

Приложение «Фотографии» получило довольно большое обновление с iOS 13, и меня всегда восхищала плавная анимация и переходы при переключении между представлениями «Годы», «Месяцы», «Дни» и «Все фотографии». Я решил попробовать реализовать это с помощью Compositional Layout и Diffable Data Source.

В этом посте показано, как я реализовал переключение между сводкой по месяцам и представлением «Все фотографии». И ниже приведен готовый пример в качестве мотивации для продолжения чтения 🙂 Я также должен упомянуть, что цель этого поста не в том, чтобы показать, как точно воспроизвести Apple Photos, а в том, чтобы показать, как вы можете создать макет, который переходит между совершенно разными состояниями.

Что мы будем строить

Прежде чем мы начнем с кода, я думаю, будет полезно показать, как подходить к такому макету и как «разрезать» его на отдельные задачи. Например, одной из таких задач является подготовка макета для режима «Все фотографии», где у нас есть одна большая фотография, за которой следуют три маленьких. Следующая задача — выяснить, как скрыть маленькие фотографии и оставить только большую.

Картографический подход

Здесь я немного застрял, потому что понял, что мне нужно сохранить эти ячейки на месте и не менять представление данных в снимке, иначе при вызове метода apply(snapshot: для источника данных я получил бы постепенное исчезновение/исчезновение по умолчанию -in анимация, Итак, следующий шаг — построить модель данных и снимок таким образом, чтобы данные для этих фотографий оставались прежними.

В конце концов я подготовил специальную ячейку для этих больших фотографий, которая может анимироваться, чтобы переключаться между отображением только изображения и отображением сводного стиля, который скругляет углы, показывает метку дня, кнопку и тонкий градиент, поэтому белый текст всегда читается.

К сожалению, код для переключения этого состояния также не может быть частью Diffable Data Source, потому что, насколько я знаю, не так много свободы в отношении анимации изменений. Diffable предназначен для добавления/удаления и перестановки ячеек, а не для анимации их внешнего вида.

Поэтому мне пришлось попросить представление коллекции предоставить мне все видимые ячейки, передать их с помощью compactMap и в зависимости от выбранного режима в контроллере представления применить либо сводку за месяц, либо стиль всех фотографий. То же самое необходимо настроить при удалении ячеек из очереди, чтобы они оставались согласованными при прокрутке.

«Июль 2020 г.» и «Август 2020 г.» — это стандартные заголовки представления коллекции, которые условно применяются только при выборе сводки за месяц.

Я надеюсь, что предыдущий текст был полезен, чтобы показать, что нужно, и теперь мы можем взглянуть на код.

 Полный пример

Поскольку полный пример доступен на GitHub, я хочу показать здесь только действительно соответствующий код, чтобы, надеюсь, стало более понятно, в чем заключается «магия».

Модель данных

Начнем с модели данных. Как обычно, у меня есть перечисления для раздела, а также для элемента:

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)
}

Item имеет два случая, поэтому мы можем легко удалить из очереди правильную ячейку. Photo — это именно та структура, которую я также использовал в примере с профилем Instagram:

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

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

Далее у меня есть перечисление для отслеживания выбранного режима и свойства в контроллере представления:

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

Перечисление поддерживается Int, потому что я устанавливаю режим с выбранным индексом из сегментированного элемента управления, который используется для переключения между режимами.

Определение layout

Существует единственный тип NSCollectionLayoutSection, и определение находится в специальном методе (позже я объясню почему):

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
}

Это довольно длинно, в основном из-за условного переключения, но я думаю, что если вы читали мои предыдущие посты о композиционном макете, то это должно иметь смысл. Если нет, я рекомендую проверить пост об анатомии и глубоко погрузиться в NSCollectionLayoutGroup.

В методе мы сначала создаем photoItem, который представляет большую фотографию в представлении коллекции. Условная высота — это всего лишь небольшой бонус, я обнаружил, что она выглядит намного лучше, когда сводка не квадратная.

Выбранный режим также затем используется для установки различных вставок содержимого, которые отвечают за горизонтальное заполнение в сводном представлении месяца.

Затем мы создаем smallPhotoItem и его группу, которая создаст ряд из трех маленьких фотографий под большой.

И, наконец, у нас есть основная группа, в которой в качестве элементов есть большая фотография и ряд маленьких фотографий, представленных фотогруппой.

Затем мы создаем фактический NSCollectionLayoutSection, и если выбрана сводка за месяц, мы добавляем элемент заголовка.

В качестве последнего шага есть метод createLayout:

func createLayout() -> UICollectionViewLayout {
    return UICollectionViewCompositionalLayout(sectionProvider: layoutSection(forIndex:environment:))
}

Поскольку мы используем вариант sectionProvider, наш метод layoutSection вызывается при изменении источника данных. Это идеально, потому что мы можем реагировать на выбранное свойство режима.

Анимация содержимого ячейки

Как я сказал во введении, нет встроенного способа изменить внешний вид ячейки, не перезагружая ее и не получая стандартную анимацию. Ниже приведен код, который запускается, когда сегментированное управление используется для переключения между сводкой месяца и режимом всех фотографий:

@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)
    }
}

Первые две строки понятны. Мы используем индекс для установки нового режима, а затем применяем анимированный снимок. Через секунду мы рассмотрим метод snapshot().

Последние строки используются для правильной анимации сводных ячеек. Сначала мы получаем их из collectionView, а затем сообщаем каждой ячейке настроить себя для выбранного режима.

Вот соответствующий код из 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
}

Снимок

Фактический метод создания моментального снимка, возможно, проще, чем вы ожидаете. Вот:

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
}

Конечно, часть простоты заключается в том, что мы используем демо-данные. У нас есть массивы фотографий как photos1 и photos2. Для каждого массива мы используем первую фотографию как большой элемент, а остальные — как маленькие, но только если режим == .allPhotos.

И это все! И снова полный код находится на GitHub. Я буду рад, если вы проверите это и, возможно, ⭐ репо 🙂