Русский
Русский
English
Статистика
Реклама

Uicollectionview

Мой Covid-19 lockdown проект, или как я полез в кастомный UICollectionViewLayout и получил ChatLayout

14.10.2020 20:13:37 | Автор: admin

image


Да, да. Я понимаю что на дворе 2020 год, что все хардкорные IOS разработчики пишут исключительно на SwiftUI и Combine, и писать статьи про UIKit как то не айс. И, тем не менее, 2020 год выдался не таким как все предыдущие года. Совсем не таким.


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


Чат использует MessageKit в качестве UI компонента. MessageKit swift библиотека поддерживаемая комьюнити призванная заменить устаревшую JSQMessagesViewController. Мне довелось работать с JSQMessagesViewController лет эдак 5 назад. Он справлялся со своими задачами, был минимально гибок, написан в лучших традициях UIKit где все наследуется от всего и, конечно, к выходу swift-а уже полностью морально устарел. К счастью, мне не пришлось к нему больше возвращаться и я только порадовался появившейся тогда инициативе написать библиотеку для создания UI для чата которая бы заменила JSQMessagesViewController. И забыл об этом до марта 2020-го.


Как же я возненавидел MessageKit в марте 2020-го. Она мне показалась построчной перепиской JSQMessagesViewController с Objective-C на Swift и приветом из IOS разработки 2009 года. Я не буду вдаваться в подробности и ругать чужую работу в которой я не принимал участие.


Но, среди огромного количества issues, которые остаются открытыми на гит хабе, или тех, которые прямо документированы в коде библиотеки, остро выделялась проблема скроллинга. UIScrollView по умолчанию ведет себя так, что якорь скроллинга закреплен в в верхнем левом углу контента и новый контент добавляется в низ без сдвига остального контента. Это поведение подходит для 99% случаев использования, но не для чата, где ожидается обратное поведение. При добавлении нового контента в низ нам нужно сместить коэффициент сдвига (contentOffset) на величину добавленного контента.


Этот факт натолкнул меня на простую мысль. Все это делается какими-то странными хуками и паразитированием на UICollectionViewFlowLayout. Почему бы просто не написать лайаут который делал бы все это из коробки?


Второй момент, который подтолкнул меня к тому что хорошо бы разобраться с UICollectionViewLayout было то что несмотря на паразитирование на UICollectionViewFlowLayout, MessageKit не поддерживала автоматические размеры ячеек используя Auto-Layout и нужно было все размеры считать в коде самому. Не смотря на то что данная фича доступна в UICollectionViewFlowLayout из коробки.


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


Анимированные вставка, удаление, обновление и изменение порядка


И вот тут то меня ждал первый сюрприз. Официальная документация на UICollectionViewLayout отсутствовала. Точнее она как бы номинально присутствует. Но слабо соответсвует действительности или опускает некоторые очень важные моменты.


Так, например, первым делом, я решил разобраться даже не с размером ячеек, а с анимацией вставки, удаления, обновления и изменения порядка ячеек. Как видим тут 4 типа обновления ячейки. За это дело в UICollectionViewLayout отвечают 2 метода:
initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
А вот и официальная документация initialLayoutAttributesForAppearingItem и finalLayoutAttributesForDisappearingItem
Давайте прочтем документацию к initialLayoutAttributesForAppearingItem вместе:


When your app inserts new items into the collection view, the collection view calls this method for each item you insert. Because new items are not yet visible in the collection view, the attributes you return represent the items starting state. For example, you might return attributes that position the item offscreen or set its initial alpha to 0. The collection view uses the attributes you return as the starting point for any animations. (The end point of the animation is the items new location and attributes.) If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.
The default implementation of this method returns nil. Subclasses are expected to override this method, as needed, and provide any initial attributes.

Кроме сообщения нам как делать вставку и, еще, немного не очень ясной дополнительной информации, ни о каком апдейте ячейки или сдвиге ячейки речи не идет. С finalLayoutAttributesForDisappearingItem тоже самое. Ладно, можно попробовать по разному толковать


If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.

и попробовать реализовать все остальное используя вот это утверждение.


Не тут то было. Оно работает так для самых примитивных случаев. Но, еще и самое забавное что никак не описан момент а что же происходит c itemIndexPath? Вот вставил я ячейку с индексом 0. Та которая была до этого 0 вероятно станет 1? А в какой момент? А если я вставляю ячейку 0 и сдвину ячейку которая была до этого номером 0 за ячейку номер 2?


Я не считаю себя шибко умным, но, мне всегда казалось, что с базовой арифметикой и логикой у меня порядок. Но с какими бы теориями я не подходил к данному вопросу, с базовой логикой, с нахрапу, я ответить на эти вопросы не мог. Самое забавное что еще и поведение на IOS 12 и IOS 13 отличалось. Один подход мог работать в одной и не работать в другой и наоборот. Ну что ж.


Тогда я решил что я буду логировать вызовы функций UICollectionViewFlowLayout и то, что выводит мой ChatLayout, и, когда приведу их к одному и тому же состоянию, наверняка, мой код будет вести себя идентично. Я провел несколько вечером вглядываясь в листинги вызова функций и отдаваемых значений UICollectionViewFlowLayout и моего лайаута, пытаясь уловить логику и подкручивая мой лайаут что бы он выдавал те же значения. О, Наивный. В тот момент я еще не знал/не думал что UICollectionViewFlowLayout использует private API мне не доступное Нетленная классика от Apple и UIKit. Вообще, это все тянет на отдельную статью. Скажу только что в какой то момент, когда я уже был готов сдаться, у меня все типы этой анимации стали получаться. При том что мой лог вызовов/значений отличался от того что выдавал UICollectionViewFlowLayout. Ну работает и работает.


Определение размера ячейки при помощи AutoLayout



Настало время идти дальше и разобраться с автоматическим определением размеров используя AutoLayout. Тут я стал задаваться вопросом. А не изобретаю ли я велосипед. Почему же все так сложно? Померить то еще можно, но как это все впихнуть еще и в анимацию? Может уже есть кто-то кто разобрался со всеми этими проблемами и готовое решение лежит на гит-хабе?


Первая странность была в том что кастомных UICollectionViewLayout не очень то и много. Да они существуют. Но они могут только разложить ячейки в зависимости от задачи которые они решают, а вот анимацию или не поддерживают толком или анимированная вставка будет из коробки. Либо они наследуются от UICollectionViewFlowLayout и как то там подменяют ему атрибуты в prepare методе и пытаются как то с этим взлететь. Причем, вся эта анимация и поддержка AutoLayout ячеек отсутсвует или оставляет желать лучшего. И тут, я натолкнулся на луч света в темном царстве: airbnb/MagazineLayout. Вот прям реально и без сарказма. Это был единственный реально кастомный лайаут который делал почти все что я хотел. И я с удивлением обнаружил что моя реализация initialLayoutAttributesForAppearingItem /finalLayoutAttributesForDisappearingItem ну прям очень похожа на то что написали ребята из AirBnB.


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


Так вот, некое подобие состояния ячейки описано в UICollectionViewLayoutAttributes, и если во время анимации вы сохраните именно тот объект который вы вернули из initialLayoutAttributesForAppearingItem а потом в методе invalidationContext(forPreferredLayoutAttributes:, withOriginalAttributes originalAttributes:) вы возьмете этот объект и поменяете у него значение frame то внутри UICollectionView сработает какое то KVO (Key-Value-Observing) и она выдаст вам анимацию которую вы ожидаете. Забудьте о preferredAttributes, забудьте originalAttributes. Стабильно работает только вот так. РукаЛицо.


О прокрутке


Теперь, когда вроде что то как то заработало настала очередь разбираться с прокруткой. Точнее мне нужно было изменить поведение UIScrollView на обратное стандартному, что бы когда мы добавляем контент в конец, он не уходил вниз, а наоборот все остальное сдвигалось вверх. Некоторые разработчики решают это поворачивая коллекцию вверх ногами а потом переворачивая контент внутри обратно. Но тогда вы теряете стандартные вещи такие как adjustedContextInsets, и отступ клавиатуры надо отсчитывать сверху а не снизу, и вообще вся геометрия встает с ног на голову. Я подумал что решить это наверняка можно прям из UICollectionViewLayout. Казалось бы, простая задача. У UICollectionViewLayout есть метод targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint Переопределяй его и будет тебе счастье, UICollectionView перед обновлением скажет тебе я мол вот собираюсь сделать свой contentOffset вот таким, а если ты хочешь его поправить верни мне свой.


Вот только проблема в том что этот вызов происходит еще до того как ты можешь получить какие то реальные размеры ячеек, и, если какие то расчеты еще происходят во время анимированного апдейта, использовать его не получится. Да, можно частично в процессе анимированного апдейта возложить расчеты на имеющийся у UICollectionViewLayoutInvalidationContext contentOffsetAdjustment, но, вы не всегда можете им воспользоваться до завершения транзакции анимации. Поэтому пришлось рассчитывать 2 разных величины, proposedContentOffset то, что можно понять до начала анимации, и вторую которой я воспользуюсь в конце транзакции в методе finalizeCollectionViewUpdates. Вроде добился того что надо. Все это работало хорошо пока вы не удаляете ячейки таким образом, что у вас сверху не появляются ячейки, которые еще не были рассчитаны с помощью AutoLayout, и которые вовремя анимации могут изменить свой размер. А вот UICollectionView при уменьшении размера контента (contentSize) начинает игнорировать contentOffsetAdjustment и никаким образом я не смог заставить ее работать так как хотелось мне: ни используя contentSizeAdjustment в различных комбинациях, ни даже меняя contentOffset напрямую. Решением оказалось игнорировать необработанные ячейки при анимации и рассчитывать все остальное потом.


Но главное что желаемого удалось достичь.


Немного о багах и private API



У меня, при прокрутке, изображение слегка подергивалось и я никак не мог понять почему. Но решил не сосредотачиваться пока более менее не прояснится картинка. А потом начал гуглить и нашел вот такой rdar://40926834: Self Sizing + Prefetching Bugs От тех же замечательных ребят из AirBnB. Выключив префетчинг который включен по умолчанию, я моментально избавился от подобного поведения.


А вот еще один от них же rdar://46865293: Cell's autoresizing mask breaks self-sizing. Ну вот не вызывается func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? столько раз сколько обещано, хоть ты тресни.


Мой любимый, так же подсмотренный в MagazineLayout это использование UICollectionViewFlowLayout приватного метода _prepareForCollectionViewUpdates:withDataSourceTranslator: для того что бы все красиво рассчитать до вызова открытого метода prepareForCollectionViewUpdates. Ай да Apple, Ай да UIKit. Это удалось обойти введение специального флага: если транзакция начинается скажи UICollectionView что в ней ничего нет и начинай рассчитывать исходя из того что получишь в prepareForCollectionViewUpdates. Это позволило избавиться от некоторых артефактов.


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


Может, конечно, это и не баги вовсе. Возможно, я что то не понял. И, вообще, зря я на ребят из UIKit наезжаю. Я оставлю этот вопрос открытым.


Немного о хрупкости


Вообще, все эти UICollectionView + UICollectionViewLayout вещи довольно хрупкие. Меняете размер коллекции и, в этот момент, что то вставляете получите артефакты. Меняете анимированно contentInset, например, для клавиатуры, и что то меняете в коллекции получите артефакты. Скроллите коллекцию и меняете ее получите артефакты. Просто что то делаете не так получите артефакты. Почему я не исправлял некоторые вещи? Если я подставлял вместо своего лайаута UICollectionViewFlowLayout и получал точно такое же поведение я записывал это в недостатки самого устройства связки UICollectionView + UICollectionViewLayout и это означало, что они должны быть исправлены "из вне". Поэтому, вы увидите в приложении-примере, которое идет вместе с библиотекой, набор флагов из серии: выдвигаешь клавиатуру не обновляй/Выдвинул обнови и т.д.


Пора в продакшен?



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


Из нового кода оказался только UIViewController средних размеров. Средних это если вы понимаете о чем я. И я смог убедиться что все это может и должно работать в продакшене. Показал коллегам и после полного тестирования было/стало мы смержили это в master.


Больше мы не используем MessageKit. И пока что всем довольны. Производительность и удобство внесения изменений оказались достойными периодических похвал коллег. А большинство открытых багов чата в Jira закрылись сами собой.


Что в остатке?


А в остатке оказалась открытая библиотека ChatLayout. Не смотря на то, что я, пока, считаю ее preview, она вполне пригодна для использования в продакшене. И, мне кажется, что если кто-то еще начнет ее использовать, то, благодаря возможностям комьюнити, получится устранить ее возможные изъяны и добавить функции, которые мне не были нужны. Цель этой статьи представить ее сообществу и привлечь разработчиков.


Я оставил пока код слегка не оптимизированым для того что бы было удобнее вносить изменения. Без всякой там прямой записи в память при изменении и т.д. Производительность в релизной сборке на актуальных устройствах достойная и без этого. Кроме того: надо вычистить приложение-пример. Да и тестов добавить. Я сам помимо основной работы поддерживаю библиотеку RouteComposer. и, боюсь, что 2 open-source проекта я просто не потяну. Я, вот, едва нашел время и вдохновение написать эту статью.


Перечислю то что я на данный момент считаю достоинствами данного подхода.


О библиотеке


ChatLayout альтернативный подход к созданию UI для чата. Фактически библиотека состоит кастомного UICollectionViewLayout, который предоставляет некоторые дополнительные методы, которые могут потребоваться для решения этой задачи. Плюсом, в Extras лежат небольшие UIView, которые, при желании, можно использовать для каких-то тривиальных задач.


Достоинства


  • Полностью поддерживает динамический лайаут для ячеек (UICollectionViewCell) и вспомогательных UIView (UICollectionReusableView).
  • Поддерживает анимированную вставку/удаление/обновление/перемещение ячеек.
  • Удерживает contentOffset у основания видимой области UICollectionView во время апдейтов.
  • Предоставляет методы для точной прокрутки к нужной ячейке.
  • Поставляется с простыми UIView-контейнерами, которые могут облегчить создание кастомных ячеек сообщений.

Недостатки (но, по-моему, все равно достоинства)


ChatLayout это кастомный UICollectionViewLayout, поэтому:


  • Вам не нужно наследоваться от какого либо UIViewController или UICollectionView. Вам нужно написать их самим. Так как вам удобно
  • Вам не нужно использовать какие то особые UICollectionViewCell которые идут с библиотекой. Создавайте их так как вам удобно.
  • Вам не нужно в обязательном порядке рассчитывать размеры ячеек. ChatLayout сделает это за вас. При том, только для видимых в данный момент ячеек, не важно, сколько их в всего в коллекции. Но, если вы укажите хотя бы приблизительный размер производительность только выиграет.
  • Вам не предоставляется никакая базовая модель данных. Создавайте ее как вам удобно. И напишите свой UICollectionViewDataSource который будет отображать ее на экране. Приложение-пример использует для расчета изменений модели DifferenceKit, но вы можете использовать что угодно.
  • ChatLayout не работает с клавиатурой. Вы можете написать свое решение или можете использовать любую подходящую для этого библиотеку. Все что требуется от вас в конечно итоге для поддержки клавиатуры это изменить contentInsets вашего UICollectionView.
  • ChatLayout не предоставляет вам никакого поля для ввода текста. Приложение-пример использует стороннюю библиотеку InputBarAccessoryView (ту же что использует MessageKit). Вы можете использовать ее или любое другое решение.

Собственно все. Спасибо за внимание. Буду рад Вашим комментариям.


Ах, да. Гифки. (Осторожно эпилептикам)






Подробнее..

Мой Covid-19 lockdown проект, или, как я полез в кастомный UICollectionViewLayout и получил ChatLayout

14.10.2020 22:06:16 | Автор: admin

image


Да, да. Я понимаю что на дворе 2020 год, что все хардкорные IOS разработчики пишут исключительно на SwiftUI и Combine, и писать статьи про UIKit как то не айс. Тем не менее, 2020 год выдался не таким как все предыдущие года. Совсем не таким.


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


Наш чат использует MessageKit в качестве UI компонента. MessageKit swift библиотека, поддерживаемая комьюнити, призванная заменить устаревшую JSQMessagesViewController. Мне довелось работать с JSQMessagesViewController лет эдак 5 назад. Он справлялся со своими задачами, был минимально гибок, написан в лучших традициях UIKit где все наследуется от всего, и он, конечно, к выходу swift-а уже полностью морально устарел. К счастью, мне не пришлось к нему больше возвращаться и я только порадовался появившейся тогда инициативе написать библиотеку для создания UI для чата которая бы заменила JSQMessagesViewController. И забыл об этом до марта 2020-го.


Как же я возненавидел MessageKit в марте 2020-го. Она мне показалась построчной перепиской JSQMessagesViewController с Objective-C на Swift и приветом из IOS разработки 2009 года. Я не буду вдаваться в подробности и ругать чужую работу, в которой я не принимал участие.


Но, среди огромного количества issues, которые остаются открытыми на гит хабе, или тех, которые прямо документированы в коде библиотеки остро выделялась проблема скроллинга.


UIScrollView по умолчанию ведет себя так, что якорь скроллинга закреплен в в верхнем левом углу, и новый контент добавляется в низ без сдвига остального контента. Это поведение подходит для 99% случаев использования, но не для чата, где ожидается обратное поведение. При добавлении нового контента в низ нам нужно сместить коэффициент сдвига (contentOffset) на величину добавленного контента.


Этот факт натолкнул меня на простую мысль. Все это делается какими-то странными хуками и паразитированием на UICollectionViewFlowLayout. Почему бы просто не написать лайаут который делал бы все это из коробки?


Второй момент, который подтолкнул меня к тому что хорошо бы разобраться с UICollectionViewLayout было то что несмотря на паразитирование на UICollectionViewFlowLayout, MessageKit не поддерживала автоматические размеры ячеек используя Auto-Layout и нужно было все размеры считать в коде самому. Не смотря на то, что данная фича доступна в UICollectionViewFlowLayout из коробки.


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


Все казалось довольно простым и я полез в документацию.


Анимированные вставка, удаление, обновление и изменение порядка


И вот тут то меня ждал первый сюрприз. Официальная документация на UICollectionViewLayout отсутствовала. Точнее она как бы номинально присутствует. Но слабо соответсвует действительности или опускает некоторые очень важные моменты.


Так, например, первым делом, я решил разобраться даже не с размером ячеек, а с анимацией вставки, удаления, обновления и изменения порядка ячеек. Как видим тут 4 типа обновления ячейки.


За это дело в UICollectionViewLayout отвечают 2 метода:
initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?


А вот и официальная документация initialLayoutAttributesForAppearingItem и finalLayoutAttributesForDisappearingItem


Давайте прочтем документацию к initialLayoutAttributesForAppearingItem вместе:


When your app inserts new items into the collection view, the collection view calls this method for each item you insert. Because new items are not yet visible in the collection view, the attributes you return represent the items starting state. For example, you might return attributes that position the item offscreen or set its initial alpha to 0. The collection view uses the attributes you return as the starting point for any animations. (The end point of the animation is the items new location and attributes.) If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.
The default implementation of this method returns nil. Subclasses are expected to override this method, as needed, and provide any initial attributes.

Кроме сообщения нам как делать вставку и, еще, немного не очень ясной дополнительной информации, ни о каком апдейте ячейки или сдвиге ячейки речи не идет. С finalLayoutAttributesForDisappearingItem тоже самое. Ладно, можно попробовать по разному толковать


If you return nil, the layout uses the items final attributes for both the start point and end point of the animation.

и попробовать реализовать все остальное используя вот это утверждение.


Не тут то было. Оно работает так как описано только для самых примитивных случаев. Еще и самое забавное, что никак не описан момент а что же происходит c itemIndexPath? Вот вставил я ячейку с индексом 0. Та которая была до этого 0 вероятно станет 1? А в какой момент? А если я вставляю ячейку 0 и сдвину ячейку которая была до этого номером 0 за ячейку номер 2?


Я не считаю себя шибко умным, но, мне всегда казалось, что с базовой арифметикой и логикой у меня порядок. Но с какими бы теориями я не подходил к данному вопросу: с базовой логикой, с нахрапом, ответить на эти вопросы я не мог. Самое забавное, что еще и поведение на IOS 12 и IOS 13 отличалось. Один подход мог работать в одной и не работать в другой и наоборот. Ну что ж.


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


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


О, Наивный. В тот момент я еще не знал/не думал что UICollectionViewFlowLayout использует private API мне не доступное Нетленная классика от Apple и UIKit. Вообще, это все тянет на отдельную статью. Скажу только, что в какой то момент, когда я уже был готов сдаться, у меня все типы этой анимации стали получаться. При том, что мой лог вызовов/значений отличался от того что выдавал UICollectionViewFlowLayout. Ну работает и работает.


Определение размера ячейки при помощи AutoLayout



Настало время идти дальше и разобраться с автоматическим определением размеров используя AutoLayout. Тут я стал задаваться вопросом. А не изобретаю ли я велосипед? Почему же все так сложно? Измерить ячейку то еще можно, но как это все впихнуть еще и в анимацию? Может уже есть кто-то кто разобрался со всеми этими проблемами и готовое решение лежит на гит-хабе?


Первая странность была в том что кастомных UICollectionViewLayout не очень то и много. Да они существуют. Но они могут только разложить ячейки в зависимости от задачи которые они решают, а вот анимацию или не поддерживают толком или анимированная вставка будет из коробки. Либо они наследуются от UICollectionViewFlowLayout и, как то там подменяя ему атрибуты в prepare методе, пытаются как то с этим взлететь. Причем, вся эта анимация и поддержка AutoLayout ячеек отсутсвует или оставляет желать лучшего.


И тут, я натолкнулся на луч света в темном царстве: airbnb/MagazineLayout. Вот прям реально и без сарказма. Это был единственный реально кастомный лайаут который делал почти все что я хотел. И я с удивлением обнаружил, что моя реализация initialLayoutAttributesForAppearingItem /finalLayoutAttributesForDisappearingItem ну прям очень похожа на то, что написали ребята из AirBnB.


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


Для справки: некое подобие состояния ячейки описывается в UICollectionViewLayoutAttributes, и, если во время анимации вы сохраните именно тот объект который вы вернули из initialLayoutAttributesForAppearingItem, а потом в методе invalidationContext(forPreferredLayoutAttributes:, withOriginalAttributes originalAttributes:) вы возьмете этот объект и поменяете у него значение frame то внутри UICollectionView сработает какое то KVO (Key-Value-Observing) и она выдаст вам анимацию которую вы ожидаете. Забудьте о preferredAttributes, забудьте originalAttributes. Стабильно работает только вот так. РукаЛицо.


О прокрутке


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


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


Я подумал, что решить это наверняка можно прямо из UICollectionViewLayout. Казалось бы, простая задача. У UICollectionViewLayout есть метод targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint Переопределяй его и будет тебе счастье, UICollectionView перед обновлением скажет тебе: я, мол, вот, собираюсь сделать свой contentOffset вот таким то, а если ты хочешь его поправить верни мне свой.


Вот только проблема в том, что этот вызов происходит еще до того как ты можешь получить какие то реальные размеры ячеек, и, если какие то расчеты еще происходят во время анимированного апдейта, использовать его не получится. Да, можно частично в процессе анимированного апдейта возложить расчеты на имеющийся у UICollectionViewLayoutInvalidationContext contentOffsetAdjustment, но вы не всегда можете им воспользоваться до завершения транзакции анимации. Поэтому пришлось рассчитывать 2 разных величины, proposedContentOffset то, что можно понять до начала анимации, и вторую которой я воспользуюсь в конце транзакции в методе finalizeCollectionViewUpdates. Вроде добился того что надо.


Все это работает хорошо пока вы не удаляете ячейки таким образом, что у вас сверху не начинают появляться ячейки, которые еще не были рассчитаны с помощью AutoLayout и которые вовремя анимации могут изменить свой размер. А вот UICollectionView при уменьшении размера контента (contentSize) начинает игнорировать contentOffsetAdjustment и, никаким образом, я не смог заставить ее работать так как хотелось мне: ни используя contentSizeAdjustment в различных комбинациях, ни даже меняя contentOffset напрямую. Решением оказалось игнорировать необработанные ячейки при анимации и рассчитывать все остальное потом.


Но главное что желаемого результата удалось достичь.


Немного о багах и private API



У меня, при прокрутке, изображение слегка подергивалось и я никак не мог понять почему. Но решил не сосредотачиваться на этом пока более менее не прояснится картинка. А, потом, начал гуглить и нашел вот такой rdar://40926834: Self Sizing + Prefetching Bugs От тех же замечательных ребят из AirBnB. Выключив префетчинг который включен по умолчанию, я моментально избавился от подобного поведения.


А вот еще один от них же rdar://46865293: Cell's autoresizing mask breaks self-sizing. Ну вот не вызывается layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? столько раз сколько обещано, хоть ты тресни.


Мой любимый трюк, так же подсмотренный в MagazineLayout это наглое использование UICollectionViewFlowLayout приватного метода _prepareForCollectionViewUpdates:withDataSourceTranslator: для того что бы все красиво рассчитать до вызова открытого метода prepareForCollectionViewUpdates. Ай да Apple, ай да UIKit. Это удалось обойти введением специального флага: если транзакция начинается скажи UICollectionView что в ней ничего нет и начинай рассчитывать исходя из того что получишь в prepareForCollectionViewUpdates. Это позволило избавиться от некоторых артефактов.


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


Может, конечно, это и не баги вовсе. Возможно, я что то не понял. И, вообще, зря я на ребят из UIKit наезжаю. Я оставлю этот вопрос открытым.


Немного о хрупкости


Вообще, все эти UICollectionView + UICollectionViewLayout вещи довольно хрупкие. Меняете размер коллекции и, в этот момент, что то вставляете получите артефакты. Меняете анимированно contentInset, например, для клавиатуры, и что то меняете в коллекции получите артефакты. Скроллите коллекцию и меняете ее получите артефакты. Просто что то делаете не так получите артефакты. Почему я не исправлял некоторые вещи? Если я подставлял вместо своего лайаута стандартный UICollectionViewFlowLayout и получал точно такое же поведение я записывал это в недостатки самого устройства связки UICollectionView + UICollectionViewLayout и, это означало, что они должны быть исправлены "из вне". Поэтому, вы увидите в приложении-примере, которое идет вместе с библиотекой, набор флагов из серии: выдвигаешь клавиатуру не обновляй/Выдвинул обнови и т.д.


Пора в продакшен?



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


Из нового кода оказался только UIViewController средних размеров. Средних это если вы понимаете о чем я. И, я смог убедиться, что все это может и должно работать в продакшене. Показал коллегам и, после полного тестирования, было/стало мы смержили это в master.


Больше мы не используем MessageKit. И пока что всем довольны. Производительность и удобство внесения изменений оказались достойными периодических похвал коллег. А большинство открытых багов чата в Jira закрылись сами собой.


Что в остатке?


А в остатке оказалась открытая библиотека ChatLayout.


Не смотря на то, что я, пока, считаю ее preview, она вполне пригодна для использования в в реальном. И, мне кажется, что если кто-то еще начнет ее использовать, то, благодаря возможностям комьюнити, получится устранить ее возможные изъяны и добавить функции, которые мне не были нужны. Цель этой статьи представить ее сообществу и привлечь разработчиков.


Я оставил пока код слегка не оптимизированым, для того что бы было удобнее вносить изменения. Без всякой там прямой записи в память при изменении и т.д. Производительность в релизной сборке на актуальных устройствах достойная и без этого. Кроме того: надо вычистить приложение-пример. Да и тестов добавить. Я сам помимо основной работы поддерживаю библиотеку RouteComposer. и, боюсь, что 2 open-source проекта я просто не потяну. Я, вот, едва нашел время и вдохновение написать эту статью.


Перечислю то, что я на данный момент считаю достоинствами данного подхода.


О библиотеке


ChatLayout альтернативный подход к созданию UI для чата. Фактически, библиотека состоит кастомного UICollectionViewLayout, который предоставляет некоторые дополнительные методы, которые могут потребоваться для решения этой задачи. Плюсом, в Extras лежат небольшие UIView, которые, при желании, можно использовать для каких-то тривиальных задач.


Достоинства


  • Полностью поддерживает динамический лайаут для ячеек (UICollectionViewCell) и вспомогательных UIView (UICollectionReusableView).
  • Поддерживает анимированную вставку/удаление/обновление/перемещение ячеек.
  • Удерживает contentOffset у основания видимой области UICollectionView во время апдейтов.
  • Предоставляет методы для точной прокрутки к нужной ячейке.
  • Поставляется с простыми UIView-контейнерами, которые могут облегчить создание кастомных ячеек сообщений.

Недостатки (но, по-моему, все равно достоинства)


ChatLayout это кастомный UICollectionViewLayout, поэтому:


  • Вам не нужно наследоваться от какого либо UIViewController или UICollectionView. Вам нужно написать их самим. Так, как вам удобно
  • Вам не нужно использовать какие то особые UICollectionViewCell которые идут с библиотекой. Создавайте их так, как вам удобно.
  • Вам не нужно в обязательном порядке рассчитывать размеры ячеек. ChatLayout сделает это за вас. При том, только для видимых в данный момент ячеек, не важно сколько их в всего в коллекции. Но, если вы укажите хотя бы приблизительный размер производительность только выиграет.
  • Вам не предоставляется никакая базовая модель данных. Создавайте ее как вам удобно. Напишите свой UICollectionViewDataSource который будет отображать ее на экране. Приложение-пример использует для расчета изменений модели DifferenceKit, но вы можете использовать что вам угодно.
  • ChatLayout не работает с клавиатурой. Вы можете написать свое решение или можете использовать любую подходящую для этого библиотеку. Все что требуется от вас в конечно итоге для поддержки клавиатуры это изменить contentInsets вашего UICollectionView.
  • ChatLayout не предоставляет вам никакого поля для ввода текста. Приложение-пример использует стороннюю библиотеку InputBarAccessoryView (ту же, что использует MessageKit). Вы можете использовать ее или любое другое решение.

Собственно все. Спасибо за внимание. Буду рад Вашим комментариям.


Ах, да. Гифки. (Осторожно эпилептикам)






Подробнее..

Как создавать гибкие списки обзор динамического UICollectionView IGListKit

22.12.2020 12:11:10 | Автор: admin
Коллекции есть во многих мобильных приложениях например, это могут быть списки публикаций в соцсети, рецепты, формы обратной связи и многое другое. Для их создания часто используют UICollectionView. Для формирования гибкого списка нужно синхронизировать модель данных и представление, но при этом возможны различные сбои.

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



Как работать с IGListKit


Применение фреймворка IGListKit в общих чертах схоже со стандартной реализацией UICollectionView. При этом у нас есть:

  • модель данных;
  • ViewController;
  • ячейки коллекции UICollectionViewCell.

Кроме того, есть вспомогательные классы:

  • SectionController отвечает за конфигурацию ячеек в текущей секции;
  • SectionControllerModel для каждой секции своя модель данных;
  • UICollectionViewCellModel для каждой ячейки, также своя модель данных.

Рассмотрим их использование подробнее.

Создание модели данных


Для начала нам нужно создать модель, которая представляет собой класс, а не структуру. Эта особенность связана с тем, что IGListKit написан на Objective-C.

final class Company {        let id: String    let title: String    let logo: UIImage    let logoSymbol: UIImage    var isExpanded: Bool = false        init(id: String, title: String, logo: UIImage, logoSymbol: UIImage) {        self.id = id        self.title = title        self.logo = logo        self.logoSymbol = logoSymbol    }}

Теперь расширим модель протоколом ListDiffable.

extension Company: ListDiffable {    func diffIdentifier() -> NSObjectProtocol {        return id as NSObjectProtocol    }     func isEqual(toDiffableObject object: ListDiffable?) -> Bool {        guard let object = object as? Company else { return false }        return id == object.id    }}

ListDiffable позволяет однозначно идентифицировать и сравнивать объекты, чтобы безошибочно автоматически обновлять данные внутри UICollectionView.

Протокол требует реализации двух методов:

func diffIdentifier() -> NSObjectProtocol


Этот метод возвращает уникальный идентификатор модели, используемый для сравнения.

func isEqual(toDiffableObject object: ListDiffable?) -> Bool


Этот метод служит для сравнения двух моделей между собой.

При работе с IGListKit принято использовать модели для создания и работы каждой из ячеек и SectionController. Эти модели создают по правилам, описанным выше. Пример можно посмотреть в репозитории.

Синхронизация ячейки с моделью данных


После создания модели ячейки необходимо синхронизировать данные с заполнением самой ячейки. Допустим, у нас уже есть сверстанная ячейка ExpandingCell. Добавим к ней возможность работы с IGListKit и расширим для работы с протоколом ListBindable.

extension ExpandingCell: ListBindable {    func bindViewModel(_ viewModel: Any) {        guard let model = viewModel as? ExpandingCellModel else { return }        logoImageView.image = model.logo        titleLable.text = model.title        upDownImageView.image = model.isExpanded            ? UIImage(named: "up")            : UIImage(named: "down")    }}

Данный протокол требует реализации метода func bindViewModel(_ viewModel: Any). Этот метод обновляет данные в ячейке.

Формируем список ячеек SectionController


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

final class InfoSectionController: ListBindingSectionController<ListDiffable> {     weak var delegate: InfoSectionControllerDelegate?        override init() {        super.init()                dataSource = self    }}

Наш класс наследуется от
ListBindingSectionController<ListDiffable>

Это означает, что для работы с SectionController подойдет любая модель, которая соответствует ListDiffable.

Также нам необходимо расширить SectionController протоколом ListBindingSectionControllerDataSource.

extension InfoSectionController: ListBindingSectionControllerDataSource {    func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, viewModelsFor object: Any) -> [ListDiffable] {        guard let sectionModel = object as? InfoSectionModel else {            return []        }                var models = [ListDiffable]()                for item in sectionModel.companies {            models.append(                ExpandingCellModel(                    identifier: item.id,                    isExpanded: item.isExpanded,                    title: item.title,                    logo: item.logoSymbol                )            )                        if item.isExpanded {                models.append(                    ImageCellModel(logo: item.logo)                )            }        }                return models    }        func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, cellForViewModel viewModel: Any, at index: Int) -> UICollectionViewCell & ListBindable {         let cell = self.cell(for: viewModel, at: index)        return cell    }        func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, sizeForViewModel viewModel: Any, at index: Int) -> CGSize {        let width = collectionContext?.containerSize.width ?? 0        var height: CGFloat        switch viewModel {        case is ExpandingCellModel:            height = 60        case is ImageCellModel:            height = 70        default:            height = 0        }                return CGSize(width: width, height: height)    }}


Для соответствия протоколу реализуем 3 метода:

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, viewModelsFor object: Any) -> [ListDiffable] 

Этот метод формирует массив моделей в порядке вывода в UICollectionView.

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, cellForViewModel viewModel: Any, at index: Int) -> UICollectionViewCell & ListBindable 

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

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, sizeForViewModel viewModel: Any, at index: Int) -> CGSize 

Метод возвращает размер для каждой ячейки.

Настраиваем ViewController


Подключим в имеющийся ViewController ListAdapter и модель данных, а также заполним ее. ListAdapter позволяет создавать и обновлять UICollectionView с ячейками.

class ViewController: UIViewController {     var companies: [Company]        private lazy var adapter = {        ListAdapter(updater: ListAdapterUpdater(), viewController: self)    }()        required init?(coder: NSCoder) {        self.companies = [            Company(                id: "ss",                title: "SimbirSoft",                logo: UIImage(named: "ss_text")!,                logoSymbol: UIImage(named: "ss_symbol")!            ),            Company(                id: "mobile-ss",                title: "mobile SimbirSoft",                logo: UIImage(named: "mobile_text")!,                logoSymbol: UIImage(named: "mobile_symbol")!            )        ]                super.init(coder: coder)    }        override func viewDidLoad() {        super.viewDidLoad()                configureCollectionView()    }        private func configureCollectionView() {        adapter.collectionView = collectionView        adapter.dataSource = self    }}


Для корректной работы адаптера необходимо расширить ViewController протоколом ListAdapterDataSource.

extension ViewController: ListAdapterDataSource {    func objects(for listAdapter: ListAdapter) -> [ListDiffable] {         return [            InfoSectionModel(companies: companies)        ]    }        func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {        let sectionController = InfoSectionController()        return sectionController    }        func emptyView(for listAdapter: ListAdapter) -> UIView? {        return nil    }}

Протокол реализует 3 метода:

func objects(for listAdapter: ListAdapter) -> [ListDiffable]


Метод требует вернуть массив заполненной модели для SectionController.

func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController


Этот метод инициализирует нужный нам SectionController.

func emptyView(for listAdapter: ListAdapter) -> UIView?


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

На этом можно запустить проект и проверить работу UICollectionView должен быть сформирован. Также, поскольку в нашей статье мы затронули динамические списки, добавим обработку нажатий на ячейку и отображение вложенной ячейки.

Обработка событий нажатия


Нам требуется расширить SectionController протоколом ListBindingSectionControllerSelectionDelegate и добавить в инициализаторе соответствие протоколу.

dataSource = selfextension InfoSectionController: ListBindingSectionControllerSelectionDelegate {    func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, didSelectItemAt index: Int, viewModel: Any) {        guard let cellModel = viewModel as? ExpandingCellModel        else {            return        }                delegate?.sectionControllerDidTapField(cellModel)    }}


Следующий метод вызывается в случае нажатия по ячейке:

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, didSelectItemAt index: Int, viewModel: Any) 


Для обновления модели данных воспользуемся делегатом.

protocol InfoSectionControllerDelegate: class {    func sectionControllerDidTapField(_ field: ExpandingCellModel)}

Мы расширим ViewController и теперь при нажатии на ячейку ExpandingCellModel в модели данных Company изменим свойство isOpened. Далее адаптер обновит состояние UICollectionView, и следующий метод из SectionController отрисует новую открывшуюся ячейку:

func sectionController(_ sectionController: ListBindingSectionController<ListDiffable>, viewModelsFor object: Any) -> [ListDiffable] 

extension ViewController: InfoSectionControllerDelegate {    func sectionControllerDidTapField(_ field: ExpandingCellModel) {        guard let company = companies.first(where: { $0.id == field.identifier })        else { return }        company.isExpanded.toggle()                adapter.performUpdates(animated: true, completion: nil)    }}


Подводя итоги


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

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

Спасибо за внимание! Пример работы с фреймворком можно посмотреть в нашем репозитории.

Пример в gif

Подробнее..

Подходы к спискам на UICollectionView

14.04.2021 18:22:15 | Автор: admin

Введение

Уже давным давно, во всех известных нам галактиках мобильные приложения представляют информацию в виде списков - будь то доставка еды на Татуине, имперская почта или обычный ежедневник джедая. С незапамятных времен мы писали UI на UITableView и не задумывались.

Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.

Почему коллекция?

До сих пор коллекции пребывали в тени, многие побаивались их чрезмерной гибкости или считали их функционал избыточным.

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

Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.

  • Ячейки в таблице содержат лишние элементы: content view, group editing view, slide actions view, accessory view.

  • Использование UICollectionView дает единообразность при работе с любыми списками объектов, так как ее API в целом схож с UITableView.

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

Так же у нас были некоторые опасения:

  • Возможность использовать Pull to refresh

  • Отсутсвие лагов при отрисовке

  • Возможность скролла в ячейках

Но в ходе реализации все они развеялись.

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

Адаптеры

Коллекции это, конечно, хорошо, но пробовали ли вы избавиться от привычного боилерплейта с датасорсами и делегатами, чтобы создание экранного списка занимало не больше 10 строк? Для сравнения, вспомним классическую реализацию экрана со списком на UITableView.

final class CurrencyViewController: UIViewController {    var tableView = UITableView()    var items: [ViewModel] = []    func setup() {        tableView.delegate = self        tableView.dataSource = self        tableView.backgroundColor = .white    tableView.rowHeight = 72.0                        tableView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)        tableView.reloadData()    }}extension CurrencyViewController: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        output.didSelectBalance(at: indexPath.row)    }}extension CurrencyViewController: UITableViewDataSource {    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        items.count    }    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {                let cell = tableView.dequeueReusable(cell: object.cellClass, at: indexPath)        cell.setup(with: object)                return cell    }}extension UITableView {    func dequeueReusable(cell type: UITableViewCell.Type, at indexPath: IndexPath) -> UITableViewCell {        if let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name()) {            return cell        }        self.register(cell: type)        let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name(), for: indexPath)        return cell    }    private func register(cell type: UITableViewCell.Type) {        let identifier: String = type.name()                self.register(type, forCellReuseIdentifier: identifier)     }}

Приходят на помощь джедаи адаптеры.

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

Ниже приведен пример такого использования.

private let listAdapter = CurrencyVerticalListAdapter()private let collectionView = UICollectionView(    frame: .zero,    collectionViewLayout: UICollectionViewFlowLayout())private var viewModel: BalancePickerViewModelfunc setup() {    listAdapter.setup(collectionView: collectionView)    collectionView.backgroundColor = .c0    collectionView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)    listAdapter.onSelectItem = output.didSelectBalance    listAdapter.heightMode = .fixed(height: 72.0)    listAdapter.spacing = 8.0    listAdapter.reload(items: viewModel.items)}

Однако внутри адаптер представляет собой даже не один класс.

Рассмотрим для начала базовый (и вообще говоря абстрактный) класс адаптера списков:

public class ListAdapter<Cell> : NSObject, ListAdapterInput, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDragDelegate, UICollectionViewDropDelegate, UIScrollViewDelegate where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView {    public typealias Model = Cell.Model    public typealias ResizeCallback = (_ insertions: [Int], _ removals: [Int], _ skipNext: Bool) -> Void    public typealias SelectionCallback = ((Int) -> Void)?    public typealias ReadyCallback = () -> Void    public enum DragAndDropStyle {        case reorder        case none    }    public var dragAndDropStyle: DragAndDropStyle { get set }    internal var headerModel: ListHeaderView.Model?    public var spacing: CGFloat    public var itemSizeCacher: UICollectionItemSizeCaching?    public var onSelectItem: ((Int) -> Void)?    public var onDeselectItem: ((Int) -> Void)?    public var onWillDisplayCell: ((Cell) -> Void)?    public var onDidEndDisplayingCell: ((Cell) -> Void)?    public var onDidScroll: ((CGPoint) -> Void)?    public var onDidEndDragging: ((CGPoint) -> Void)?    public var onWillBeginDragging: (() -> Void)?    public var onDidEndDecelerating: (() -> Void)?    public var onDidEndScrollingAnimation: (() -> Void)?    public var onReorderIndexes: (((Int, Int)) -> Void)?    public var onWillBeginReorder: ((IndexPath) -> Void)?    public var onReorderEnter: (() -> Void)?    public var onReorderExit: (() -> Void)?    internal func subscribe(_ subscriber: AnyObject, onResize: @escaping ResizeCallback)    internal func unsubscribe(fromResize subscriber: AnyObject)    internal func subscribe(_ subscriber: AnyObject, onReady: @escaping ReadyCallback)    internal func unsubscribe(fromReady subscriber: AnyObject)    internal weak var collectionView: UICollectionView?    public internal(set) var items: [Model] { get set }    public func setup(collectionView: UICollectionView)    public func setHeader(_ model: ListHeaderView.Model)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: @escaping (ListAdapter) -> Void, completion: ((Bool) -> Void)?)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)    }public typealias ListAdapterCellConstraints = UICollectionViewCell & RegistrableView & AnimatedConfigurableViewpublic typealias VerticalListAdapterCellConstraints = ListAdapterCellConstraints & HeightMeasurableViewpublic typealias HorizontalListAdapterCellConstraints = ListAdapterCellConstraints & WidthMeasurableView

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

Как можно увидеть из примера выше: сначала идёт блок typealias'ов для того, чтобы определить ограничения на используемые типы.

DragAndDropStyle отвечает за возможность менять местами ячейки внутри коллекции.

headerModel - модель, которая представляет заголовок коллекции

spacing - расстояние между элементами

Дальше идёт блок замыканий, которые позволяют подписаться на определённые изменения в коллекции.

Методы для подписки onReady и onResize позволяют понять, когда коллекция адаптера стала готова к работе, и когда изменился размер коллекции из-за добавления или удаления объектов, соответственно.

collectionView, setup(collectionView:) - непосредственно используемый экземпляр коллекции и метод для её установки

items - набор моделей для отображения

setHeader - метод для установки заголовка коллекции

itemSizeCacher - класс, реализующий кеширование размеров элементов списка. Дефолтная реализация представлена ниже:

final class DefaultItemSizeCacher: UICollectionItemSizeCaching {        private var sizeCache: [IndexPath: CGSize] = [:]        func itemSize(cachedAt indexPath: IndexPath) -> CGSize? {        sizeCache[indexPath]    }        func cache(itemSize: CGSize, at indexPath: IndexPath) {        sizeCache[indexPath] = itemSize    }        func invalidateItemSizeCache(at indexPath: IndexPath) {        sizeCache[indexPath] = nil    }        func invalidate() {        sizeCache = [:]    }    }

Остальную часть интерфейса представляют методы для обновления элементов.

Также есть конкретные реализации, которые, например, заточены под определенное расположение ячеек по оси.

AnyListAdapter

До тех пор, пока мы работаем с динамическим контентом, все хорошо. Но во введении мы не зря говорили про infinite-scroll дизайн. Что делать, если в таблице нужно одновременно отображать и ячейки динамического контента(данные из сети) и статические вью? Для этого нам послужит AnyListAdapter.

public typealias AnyListSliceAdapter = ListSliceAdapter<AnyListCell>public final class AnyListAdapter : ListAdapter<AnyListCell>, UICollectionViewDelegateFlowLayout {    public var dimensionCalculationMode: DesignKit.AnyListAdapter.DimensionCalculationMode    public let axis: Axis    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.HeightMeasurableView, Cell : DesignKit.RegistrableView    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView, Cell : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.HeightMeasurableView, C3 : DesignKit.RegistrableView    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.RegistrableView, C3 : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    public enum Axis {        case horizontal        case vertical    }    public enum DimensionCalculationMode {        case automatic        case fixed(constant: CGFloat? = nil)    }}

Как не трудно догадаться, AnyListAdapter абстрагируется от конкретного типа ячейки. Его можно проинициализировать несколькими типами ячеек, но они все должны быть либо для горизонтального лейаута, либо вертикального. Условием здесь выступает удовлетворение протоколу HeightMeasurableView и WidthMeasurableView.

public protocol HeightMeasurableView where Self: ConfigurableView {    static func calculateHeight(model: Model, width: CGFloat) -> CGFloat    func measureHeight(model: Model, width: CGFloat) -> CGFloat   }public protocol WidthMeasurableView where Self: ConfigurableView {    static func calculateWidth(model: Model, height: CGFloat) -> CGFloat    func measureWidth(model: Model, height: CGFloat) -> CGFloat}

У списка так же фиксируется алгоритм подсчета высоты:

  • фиксированный(константа или статический метод расчета по модели)

  • автоматический (на основе лейаута).

Сила вся внутри ячейки-контейнера AnyListCell спрятана.

public class AnyListCell: ListAdapterCellConstraints {        // MARK: - ConfigurableView        public enum Model {        case `static`(UIView)        case `dynamic`(DynamicModel)    }        public func configure(model: Model, animated: Bool, completion: (() -> Void)?) {        switch model {        case let .static(view):            guard !contentView.subviews.contains(view) else { return }                        clearSubviews()            contentView.addSubview(view)            view.layout {                $0.pin(to: contentView)            }        case let .dynamic(model):            model.configure(cell: self)        }        completion?()    }        // MARK: - RegistrableView        public static var registrationMethod: ViewRegistrationMethod = .class        public override func prepareForReuse() {        super.prepareForReuse()                clearSubviews()    }        private func clearSubviews() {        contentView.subviews.forEach {            $0.removeFromSuperview()        }    }    }

Такая ячейка конфигурируется двумя видами модели: статической и динамической.

Первая как раз отвечает за отображение в списке обычных вью.

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

struct DynamicModel {    public init<Cell>(model: Cell.Model,                    cell: Cell.Type) {            // ...    }    func dequeueReusableCell(from collectionView: UICollectionView, for indexPath: IndexPath) -> UICollectionViewCell    func configure(cell: UICollectionViewCell)    func calcucalteDimension(otherDimension: CGFloat) -> CGFloat    func measureDimension(otherDimension: CGFloat) -> CGFloat}

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

private let listAdapter = AnyListAdapter(    dynamicCellTypes: (CommonCollectionViewCell.self, OperationCell.self))func configureSearchResults(with model: OperationsSearchViewModel) {    var items: [AnyListCell.Model] = []    model.sections.forEach {        let header = VerticalSectionHeaderView().configured(with: $0.header)        items.append(.static(header))        switch $0 {        case .tags(nil), .operations(nil):            items.append(                .static(OperationsNoResultsView().configured(with: Localisation.feed_search_no_results))            )        case let .tags(models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: CommonCollectionViewCell.self                    ))                }            )        case .operations(let models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: OperationCell.self                    ))                }            )        }    }    UIView.performWithoutAnimation {        listAdapter.deleteItemsIfNeeded(at: 0...)        listAdapter.reloadItems(items, at: 0...)    }}

Таким образом, стало легко строить экраны, на которых весь контент группируется в бесконечном списке, при этом не теряя производительности переиспользования ячеек.

Список по кускам

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

Сам по себе AnyListAdapter не дает удобного решения. Очень легко наткнуться на NSInternalInconsistencyException или удалить элемент не из той секции. Поиск причины этой ошибки может занять время.

Для того, чтобы обезопасить себя при работе с вставкой/удалением/обновлением элементов, мы используем концепцию слайсов по аналогии с ArraySlice, представленным в стандартной библиотеке языка Swift.

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

Приведем пример сложного экрана.

let subjectsSectionHeader = SectionHeaderView(title: "Subjects")let pocketsSectionHeader = SectionHeaderView(title: "Pockets")let cardsSectionHeader = SectionHeaderView(title: "Cards")let categoriesHeader = SectionHeaderView(title: "Categories")let list = AnyListAdapter()listAdapter.reloadItems([    .static(subjectsSectionHeader),    .static(pocketsSectionHeader)    .static(cardsSectionHeader),    .static(categoriesHeader)])

Теперь распределим эти секции по контроллерам. Для простоты рассмотрим лишь один, так как остальные будут похожими на него.

class PocketsViewController: UIViewController {    var listAdapter: AnyListSliceAdapter! {        didSet {reload()        }    }    var pocketsService = PocketsService()    func reload() {        pocketsService.fetch { pockets, error in            guard let pocket = pockets else { return }            listAdapter.reloadItems(                pockets.map { .dynamic(.init(model: $0, cell: PocketCell.self)) },                at: 1...            )        }    }    func didTapRemoveButton(at index: Int) {listAdapter.deleteItemsIfNeeded(at: index)    }}let subjectsVC = PocketsViewController()subjectsVC.listAdapter = list[1..<2]

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

public extension ListAdapter {    subscript(range: Range<Int>) -> ListSliceAdapter<Cell> {        .init(listAdapter: self, range: range)    }    init(listAdapter: ListAdapter<Cell>,               range: Range<Int>) {        self.listAdapter = listAdapter        self.sliceRange = range        let updateSliceRange: ([Int], [Int], Bool) -> Void = { [unowned self] insertions, removals, skipNextResize in            self.handleParentListChanges(insertions: insertions, removals: removals)            self.skipNextResize = skipNextResize        }        let enableWorkingWithSlice = { [weak self] in            self?.onReady?()            return        }        listAdapter.subscribe(self, onResize: updateSliceRange)        listAdapter.subscribe(self, onReady: enableWorkingWithSlice)    }}

Теперь работать с секцией списка можно ничего не зная об оригинальном списке и не беспокоясь о правильности индексации.

Кроме данных о рендже слайса, интерфейс слайс адаптера мало чем отличается от оригинального ListAdapter.

public final class ListSliceAdapter<Cell> : ListAdapterInput where Cell : UICollectionViewCell, Cell : ConfigurableView, Cell : RegistrableView {    public var items: [Model] { get }    public var onReady: (() -> Void)?    internal private(set) var sliceRange: Range<Int> { get set }    internal init(listAdapter: ListAdapter<Cell>, range: Range<Int>)    convenience internal init(listAdapter: ListAdapter<Cell>, index: Int)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)}

Нетрудно догадаться, что внутри проксирующих методов происходит математика индексов.

public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>) {    guard canDelete(index: range.lowerBound) else { return }    let start = globalIndex(of: range.lowerBound)    let end = sliceRange.upperBound - 1    listAdapter.deleteItems(at: Array(start...end))}

При этом ключевую роль играет поддержка кусков внутри самого ListAdapter.

public class ListAdapter {    // ...    var resizeSubscribers = NSMapTable<AnyObject, NSObjectWrapper<ResizeCallback>>.weakToStrongObjects()}extension ListAdapter {public func appendItem(_ item: Model) {        let index = items.count               let changes = {            self.items.append(item)            self.handleSizeChange(insert: self.items.endIndex)            self.collectionView?.insertItems(at: [IndexPath(item: index, section: 0)])        }                if #available(iOS 13, *) {            changes()        } else {            performBatchUpdates(updates: changes, completion: nil)        }    }    func handleSizeChange(removal index: Int) {        notifyAboutResize(removals: [index])    }    func handleSizeChange(insert index: Int) {        notifyAboutResize(insertions: [index])    }    func notifyAboutResize(insertions: [Int] = [], removals: [Int] = [], skipNextResize: Bool = false) {        resizeSubscribers            .objectEnumerator()?            .allObjects            .forEach {                ($0 as? NSObjectWrapper<ResizeCallback>)?.object(insertions, removals, skipNextResize)            }    }    func shiftSubscribers(after index: Int, by shiftCount: Int) {        guard shiftCount > 0 else { return }        notifyAboutResize(            insertions: Array(repeating: index, count: shiftCount),            skipNextResize: true        )    }}

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

Выводы

Не помешает убедиться, что все это было не зря, поэтому освежим в памяти полученные бенифиты. Во-первых, мы получили единый интерфейс для разных видов списков. В том числе с разным лейаутом: горизонтальный и вертикальный. Если под капотом нас вдруг не устроит производительность (или баги новой iOS) у UICollectionView, то легко сможем поддержать тот же протокол и для таблиц.

И, что для ленивых самое важное - сетап экрана со списком занимает меньше 10 строк кода.

Если мы раньше боялись усложнять экран работой с таблицей для отображения разнородных данных, то сейчас смело пишем каждый третий экран( ~30%) на списках, вооружившись одним из нашего обширного арсенала адаптеров. А если хотите в модульную декомпозицию - то к вашим услугам адаптеры для куска списка.

Теперь вы с легкостью найдете любимый фрукт джогон в удобном поиске приложения доставки еды на Татуине, а если вы джедай - то без задержек доскролите до конца список заданий от магистра Йоды.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru