Compositional Layout: стоит ли игра свеч?

от автора

Всем привет! Меня зовут Алексей Скоробогатов, я ведущий iOS-разработчик в Delivery Club. Сегодня я хотел бы рассказать про вёрстку в нашем приложении на примере использования Compositional Layout. В конце прошлого года волевым решением iOS-команды и апрувом руководства мы перешли на iOS 13+. Этот манёвр позволил нам начать использовать новые нативные инструменты, в том числе и новый декларативный подход к описанию layout-коллекций. Расскажу о переводе нашего экрана поиска и его компонентов на Compositional Layout, а также о проблемах, с которыми я столкнулся.

Для полноценного представления о Compositional Layout предлагаю прочитать вот эту статью и ознакомиться с примерами Apple.

Экскурс в экран и его компоновку

Давайте посмотрим, как экран выглядел на iOS 12. Классический подход к вёрстке, вертикальная коллекция с ячейками, которые могут содержать горизонтальные коллекции со своими ячейками.

В чём основные минусы такого подхода?

  • Для каждой горизонтальной коллекции нужно делать свою ячейку с UICollectionView, со всем бойлерплейтом (настройкой делегатов и прочим).
  • Иногда встречаются проблемы с анимациями и auto-sizing ячейками.
  • Поддержка экрана превращается в настоящее приключение.

Так что же нам даёт Compositional Layout?

  • Мы можем описать весь layout экрана в декларативном стиле в одном месте.
  • Перестать использовать связку UICollectionviewCell с UICollectionView, и использовать только конкретные ячейки.
  • Легко описывать auto-sizing ячейки.

Внедрение Composition Layout

Давайте перейдём к практике. Для начала заменим стандартный flowLayout на UICollectionViewCompositionalLayout:

private lazy var collectionView: UICollectionView = {     let collectionViewLayout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex, environment) -> NSCollectionLayoutSection? in         guard let self = self else { return nil }           let section = self.viewModel.sections[sectionIndex]         switch section.kind {         //Тут мы должны вернуть NSCollectionLayoutSection для конкретной секции         }     }       let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)     collectionView.dataSource = self     collectionView.delegate = self     //Регистрируем ячейки компонентов       return collectionView }()

Как видите, существенно ничего не изменилось, у нас просто появился handler для описания каждой конкретной секции. Рассмотрим основные классы Compositional Layout, которые мы будем использовать:

  • NSCollectionLayoutItem — отвечает за layout конкретного элемента;
  • NSCollectionLayoutGroup — отвечает за layout группы элементов;
  • NSCollectionLayoutSection — описывает layout конкретной секции.

Методы UICollectionViewDelegateFlowLayout удаляем, они нам не понадобятся. Конфигурирование ячеек остаётся без изменений и происходит через cellForItemAtIndexPath:

func collectionView(_ collectionView: UICollectionView,                     cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {     let section = viewModel.sections[indexPath.section]     let item = section.items[indexPath.row]       switch item {     case .restaurant:         //Настройка UICollectionViewCell для элемента карточки ресторанов         return UICollectionViewCell()     } }

Коллекция карточек

Рассмотрим подробно самый распространенный компонент на экране — коллекцию горизонтальных карточек.

Опишем реализацию секции:

func restaurantsSectionLayout(sectionItems: [SearchViewModel.Item],                               environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {     let contentSize = environment.container.contentSize     let itemSize = NSCollectionLayoutSize(         widthDimension: .absolute(contentSize.width * 0.6),         heightDimension: .estimated(216)     )     let items: [NSCollectionLayoutItem] = sectionItems.map({ _ in         .init(layoutSize: itemSize)     })       //Тут можно рассчитать приблизительный размер горизонтальной группы     let groupEstimateSize = CGSize(width: 50, height: 50)       let groupSize = NSCollectionLayoutSize(         widthDimension: .estimated(groupEstimateSize.width),         heightDimension: .estimated(groupEstimateSize.height)     )     let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: items)     group.interItemSpacing = .fixed(12)       let section = NSCollectionLayoutSection(group: group)     section.orthogonalScrollingBehavior = .continuous       section.boundarySupplementaryItems = [headerItem]     section.supplementariesFollowContentInsets = false       return section }

На вход функции мы подаём массив элементов в коллекции, чтобы полноценно описать наш layout компонента и NSCollectionLayoutEnvironment, отвечающий за информацию о размере контейнера.

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

let contentSize = environment.container.contentSize let itemSize = NSCollectionLayoutSize(     widthDimension: .absolute(contentSize.width * 0.6),     heightDimension: .estimated(216) )

Далее нам надо описать группу, которая будет содержать все элементы. Укажем, что размер нашей группы groupSize должен быть рассчитан на этапе рендеринга, также зададим расстояние между элементами коллекции через interItemSpacing.

//Тут можно рассчитать приблизительный размер горизонтальной группы let groupEstimateSize = CGSize(width: 50, height: 50)   let groupSize = NSCollectionLayoutSize(     widthDimension: .estimated(groupEstimateSize.width),     heightDimension: .estimated(groupEstimateSize.height) ) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: items) group.interItemSpacing = .fixed(12)

Самое главное — не забыть указать, что наша коллекция будет горизонтальной, для этого выставим:

section.orthogonalScrollingBehavior = .continuous

Как видите, у компонента также присутствует блок с заголовком и стрелкой перехода на полный список. Вы, наверное, уже догадались, что это Supplementary View, его тоже можно легко описать через класс NSCollectionLayoutBoundarySupplementaryItem, где укажем размер и расположение:

private var headerItem: NSCollectionLayoutBoundarySupplementaryItem {     let headerSize = NSCollectionLayoutSize(         widthDimension: .fractionalWidth(1.0),         heightDimension: .estimated(50)     )       return NSCollectionLayoutBoundarySupplementaryItem(         layoutSize: headerSize,         elementKind: UICollectionView.elementKindSectionHeader,         alignment: .topLeading     ) }

Далее добавляем к секции наш Supplementary View. Чтобы заголовок игнорировал contentInsets нашей секции, выставим supplementariesFollowContentInsets в false:

section.boundarySupplementaryItems = [headerItem] section.supplementariesFollowContentInsets = false

Вот и всё, что нам потребовалось для описания секции.

Коллекция карточек кухонь

Особенность этой секции заключается в вертикальном расположении карточек по два элемента на каждую строку:

Настройку NSCollectionLayoutSection для этой секции делаем аналогично предыдущей:

func kitchenSectionLayout() -> NSCollectionLayoutSection {     let itemSize = NSCollectionLayoutSize(         widthDimension: .fractionalWidth(1.0),         heightDimension: .fractionalHeight(1.0)     )     let item = NSCollectionLayoutItem(layoutSize: itemSize)       let groupSize = NSCollectionLayoutSize(         widthDimension: .fractionalWidth(1.0),         heightDimension: .absolute(128)     )     let group = NSCollectionLayoutGroup.horizontal(         layoutSize: groupSize,         subitem: item,         count: 2     )     group.interItemSpacing = .fixed(12)       let section = NSCollectionLayoutSection(group: group)     section.interGroupSpacing = 12       return section }

Размер элемента указываем зависимым от размеров контейнера. Размер группы задаём зависимым от контейнера по ширине и с абсолютным значением высоты. Саму группу мы описываем как горизонтальный контейнер, состоящий из двух элементов, с заранее заданным расстоянием между элементами. Так как у нас будет множество таких групп, расположенных вертикально, не забываем указать расстояние между этими группами: interGroupSpacing. Кнопку «Показать все» задаём как Supplementary View аналогично headerItem, за исключением того, что это elementKindSectionFooter, и располагается он под секцией.

private var footerButtonItem: NSCollectionLayoutBoundarySupplementaryItem {     let footerSize = NSCollectionLayoutSize(         widthDimension: .fractionalWidth(1.0),         heightDimension: .estimated(50)     )       return NSCollectionLayoutBoundarySupplementaryItem(         layoutSize: footerSize,         elementKind: UICollectionView.elementKindSectionFooter,         alignment: .bottom     ) }

Коллекция блюд

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

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

Поэтому намучившись несколько часов с документацией и гуглом, пришлось вернуться к истокам и по старинке использовать связку UICollectionViewCell и UICollectionView.

Проблемы

На данный момент это все компоненты на этом экране, теперь я бы хотел поговорить о проблемах и костылях, с которыми мне пришлось столкнуться при внедрении Compositional Layout.

Не очень гибкий self-sizing для ячеек

С помощью NSCollectionLayoutEdgeSpacing для секции довольно просто описываются варианты, когда ячейки должны быть прижаты к краям контента (top, bottom, left, right), но вот указать, чтобы ячейки растягивались до максимального размера элемента в группе, мне так и не удалось, поэтому такие дизайнерские решения сейчас отклоняются.

Работа с горизонтальными коллекциями

В коллекции карточек у нас есть динамически анимированный компонент SupplementaryItem, прижатый к правому краю, который ведёт на экран со списком. Его анимация зависит от contentOffset и contentSize горизонтального UIScrollView.

Если раньше для получения этих данных мы использовали метод scrollViewDidScroll у делегата UICollectionView в UICollectionViewCell, то теперь возник вопрос, как быть с Compositional Layout. Первое, что пришло в голову, — подписаться на делегат основного вертикального UICollectionView, но я сразу получил оплеуху в виде того, что в scrollViewDidScroll падают события только вертикальной прокрутки. Резонный вопрос: почему? Оказывается, инженеры Apple для горзинтальных коллекций используют приватный класс _UICollectionViewOrthogonalScrollerEmbeddedScrollView, который является наследником UIScrollView, у которого subviews — это UICollectionVIewCell.

Как обычно, легального способа добраться до этого scrollView Apple нам не оставила, но дала доступ к visibleItemsInvalidationHandler в NSCollectionLayoutSection, который будет вызываться при прокрутке:

// Called for each layout pass to allow modification of item properties right before they are displayed. open var visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?

Как видно, на входе мы будем иметь массив visibleItems, contentOffset и environment.

public typealias NSCollectionLayoutSectionVisibleItemsInvalidationHandler = ([NSCollectionLayoutVisibleItem], CGPoint, NSCollectionLayoutEnvironment) -> Void

В итоге для простых вещей этого должно хватить, но, увы, если вам нужен доступ к другим методам UIScrollViewDelegate, то без костылей вам не обойтись, так как UICollectionViewOrthogonalScrollerEmbeddedScrollView — приватный и уже имеет свой системный делегат.

Баг с горизонтальными коллекциями

После релиза в продакшен нового layout, у нас в поддержку стали падать тикеты о том, что иногда горизонтальные коллекции не реагируют на нажатия и скроллинг. Исследование этой проблемы завело меня на старый добрый StackOverflow, и как оказалось, в версии iOS 14.3+ инженеры Apple сломали расположение горизонтального UIScrollView, когда вы используете estimate в NSCollectionLayoutSize.

Этот баг у нас проявился в корзине с рекомендациями: как видите, коллекция визуально находится на своём месте, но вот её UICollectionViewOrthogonalScrollerEmbeddedScrollView (подсвечен синим) распологается в неправильном месте.

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

Выводы

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

Если говорить про наш проект, то переход к декларативному описанию лейаута, позволил нам сократить время разработки компонентов. Упростилась поддержка экрана, так как почти вся информация о вёрстке находится в одном месте и имеет общий вид (раньше приходилось скакать по xib-файлам и их классам, чтобы поменять какой-нибудь отступ и т.д.). Также теперь не должно возникать проблем со сложной версткой элементов (пламенный привет всем тем, кто занимался переопределением метода layoutAttributesForElements в UICollectionViewFlowLayout).

Поэтому если у вас есть возможность и время, я настоятельно рекомендую начать использовать Compositional Layout в своих коллекциях и не бояться сопутствующих проблем.

ссылка на оригинал статьи https://habr.com/ru/company/mailru/blog/548792/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *