Pull to refresh

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

Reading time 11 min
Views 4.9K

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 item’s 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 item’s new location and attributes.) If you return nil, the layout uses the item’s 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 item’s 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). Вы можете использовать ее или любое другое решение.

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


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







Tags:
Hubs:
+10
Comments 22
Comments Comments 22

Articles