В прошлом году у нашей Android-команды на проекте Burger King был мощный вызов: сделать редизайн главного меню. Задача была непростая по двум причинам.
Первая — легаси код. Вторая — А/В тестирование.
И результат — старое меню и его логику нужно сохранить. Мы решили написать меню с нуля. Так было бы проще организовать А/В тест и потом избавиться от старого меню (не волнуйтесь, при создании нового меню ни один воппер не пострадал ?).
Сегодня мы расскажем о том, как мы делали часть этой фичи — табы и саб-табы.
Новое меню, новые вызовы
Создать новый экран со списком блюд — это не очень сложно. Но нам нужно было синхронизировать скролл списка и выбор табов.
И на первый взгляд, это было просто. При этом решения из коробки не было, а советы из гугла помогали лишь отчасти. Material-библиотека предлагает TabLayoutMediator, но он работает для связки TabLayout и ViewPager2.
Что надо было решить при синхронизации табов:
Синхронизация табов и списка
При скролле мы должны были понять, в какой категории товаров находится пользователь, и выбрать нужный таб. И наоборот — при клике на таб должен происходить подскролл списка к нужной категории.Синхронизация саб-табов и списка
Она похожа на первый пункт. При выборе категории с подкатегориями появляется второй TabLayout, в котором и отображаются эти подкатегории.
Объединяет эти пункты общая задача: надо корректно собрать индексы категорий в списке в виде parentIndex -> [childIndex1, childIndex2, childIndex3]
Пример: 5 -> [6, 9, 15]Обратный скролл
Каждый раз нужно выбирать категорию в TabLayout при скролле в обе стороны.Скролл при клике на таб
TabLayout не различает, кто кликнул по табу: пользователь или программа. Он всё равно вызывает callback OnTabSelectedListener. Это вынуждает использовать boolean-флаги, чтобы скролл пальцем и скролл по клику на таб не мешали друг другу. Забегая вперёд скажем, что нужно учитывать scrollstate у RecyclerView. Да, жонглирование флагами никто не отменял.Кастомный вид табов
У TabLayout.Tab есть поле customView. Это поле помогает легко установить верстку с иконкой и текстом.Ripple-эффект табов
View в TabLayout.Tab не меняет ripple-эффект, если установлен кастомный индикатор.
Синхронизация табов и саб-табов
Для синхронизации табов и саб-табов напишем свой TabLayoutMediator.
Если писать TabLayoutMediator для работы сразу с двумя TabLayout, то получится много условий и кода, чтобы различить клики по табам и правильно синхронизировать скролл.
Поэтому разобьем задачу на части – сделаем TabLayoutMediator для работы с одним TabLayout и научим его работать с другим TabLayoutMediator. Так, логика синхронизации двух TabLayout практически не будет пересекаться.
recyclerView и tabLayout — то, что мы обвесим листенерами и синхронизируем;
tabFactory нужен для верстки табов. Вынесем это за пределы TabLayoutMediator , чтобы не усложнять внутреннюю реализацию и не тянуть лишних сущностей типа Context или библиотеки загрузки картинок;
indicesProvider — возвращает список индексов ячеек.
Внешнее API работы простое. Метод attach()
производит инициализацию всех листенеров и заполняет табы.
detach()
— наоборот, всё очищает.
Рассмотрим реализацию методов attach и detach подробнее.
Для автоматического перезаполнения TabLayout назначим recyclerView PagerAdapterObserver[2]. Он будет наблюдать за изменениями в recyclerView и сразу перезаполнять TabLayout. Не забываем о RecyclerView.OnScrollListener для отслеживания скролла.
Вызываем indicesProvider()[7] для получения индексов ячеек. После — разобьём список индексов на страницы[9]. Это нужно для отслеживания скролла в обе стороны.
И последнее — установим листенер на TabLayout [13].
В detach(), соответственно, делаем наоборот. Удаляем всё, что создано в attach().
Скролл при клике и обратный скролл
При сихронизации TabLayout и RecyclerView необходимо, чтобы скролл лишний раз не триггерил колбэки у TabLayout. А клики по табам не мешали скроллу списка.
За отслеживание скролла отвечает RecyclerView.OnScrollListener.
Начнём с метода onScrollStateChanged
Чтобы правильно отслеживать положение скролла, одного boolean флага мало. Надо понимать, из какого и в какое состояние переходит скролл. Для этого нужно смотреть не только текущее состояние скролла, но и сравнивать его с предыдущим. Зато в результате всё работает предсказуемо и понятно.
В паре с этим методом работает метод onScrolled. В нём производится поиск индекса ячейки и выбор таба.
Сначала ищем первый видимый элемент[17] в списке и смотрим, к какому табу он относится [24]. Когда находим, выбираем этот таб[37]. Индекс таба находится по принадлежности к странице[24], что помогает отслеживать скролл в обе стороны.
Бывает, что список проскроллен до конца, но последний таб не выбирается. Для этого делаем отдельную проверку[14].
Подключаем и смотрим, как всё работает. Для реализации работы списка мы используем библиотеку EasyAdapter — она сильно упрощает работу со списками и ViewHolder-ами. Вы можете попробовать реализовать то же самое со стандартным ListAdapter.
Сначала напишем моковые данные с категориями и подкатегориями и подготовим всё для работы с табами:
Заполним список данными:
Соберём индексы категорий и подкатегорий. Важно делать это каждый раз после заполнения и изменения списка, чтобы при скролле списка табы выбирались корректно.
Подключим TabLayoutMediator. Сначала родительский:
При выборе родительского таба дочерний TabLayoutMediator будет перезаполняться подкатегориями.
Кстомизация Tab’ов и ripple-эффект
Рассмотрим работу с индикатором табов. То, как он работал «из коробки», нас не устраивало.
Да, из коробки можно было заменить цвет и форму индикатора. Но рипл-эффект этого не учитывал и работал по умолчанию — без границ анимации.
Флаг app:tabUnboundedRipple="false" не особо помог. Круг превратился в квадрат.
Поскольку у нас лёгкий доступ к View, можем сами установить нужный drawable.
Вынесем в удобный extension для дальнейшего переиспользования.
Если нужно, чтобы эффект нажатия был поверх View таба, то заменим foreground. Если нет — background. Но помним, что это может быть критично, если в табе есть иконка.
Вот так, достаточно просто, мы и решили задачу. Исходники можно посмотреть тут.
А вот так выглядит меню уже в приложении:
Больше полезного про Android — в Telegram-канале Surf Android Team.
Кейсы, лучшие практики, новости и вакансии в команду Android Surf в одном месте. Присоединяйтесь!
Комментарии (6)
Kundello
04.12.2024 10:19Вот бы еще бэк у вас так же хорошо работал, как и фронт. А то заказ просто не доходит до ресторана, деньги списываются, а поддержка динамит месяц, пока досудебку не присылаешь.
Или когда карта в приложении в этом году не привязывалась у всех больше месяца, а старые отвязались)
Mox
Я помню что на React Native аналогичная задача решалась прям суперпросто, но я
предвычислил все позиции исходя из фиксированной высоты элемента списка и заголовка (как и в этом случае).
поскольку тут механика "табов" в виде всяких горизонтальных свайпов не используется - то я все горизонтальные меню сделал тоже просто скроллами
Тогда у меня просто получился onScroll обработчик, который быстро находил актуальный таб, ну и при тэпе на таб было ясно куда скроллить.
ozh-dev
Да, тут больше проблем приносит TabLayout. Из-за него приходится использовать лишние флаги и условия.