Давайте посмотрим, как мы можем создать макет, аналогичный родному приложению 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. Я буду рад, если вы проверите это и, возможно, ⭐ репо 🙂