Да, да. Я понимаю, что на дворе 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
). Вы можете использовать ее или любое другое решение.
Собственно, все. Спасибо за внимание. Буду рад Вашим комментариям.
Ах, да. Гифки. (Осторожно эпилептикам)
Bromles
Проверяйте текст статьи хотя бы в ворде. Отсутствие запятых и орфография режут глаза
spiceginger Автор
Прошу прощения, у меня нет ворда. Нахожусь в состоянии "Английский не выучил, русский забыл". Я посмотрю еще раз. Спасибо.
apro
К libreoffice вроде раньше отдельно продовали проверку орфорграфии от бывших разработчиков ворда.
tvr
spiceginger Автор
Ну, я так понимаю, комьюнити уже подключилось. :)
Большое спасибо некому Павлу, который поправил текст статьи и прислал ее мне, сэкономив мне на покупке ворда.
Akhrameev
Рад помочь. Ты сделал гораздо большее дело (как с точки зрения разработки, так и с точки зрения написания большого и качественного по смыслу текста). Тебе спасибо!
Bromles
в остальном (не глядя на грамотность) у меня не хватает квалификации для оценки технической части статьи (далек от IOS в целом и от Swift в частности), но любое появление хорошего опенсорс решения — благо. Если карма позволит, отблагодарю плюсиком