В прошлом году у нашей Android-команды на проекте Burger King был мощный вызов: сделать редизайн главного меню. Задача была непростая по двум причинам.
Первая — легаси код. Вторая — А/В тестирование.

И результат — старое меню и его логику нужно сохранить. Мы решили написать меню с нуля. Так было бы проще организовать А/В тест и потом избавиться от старого меню (не волнуйтесь, при создании нового меню ни один воппер не пострадал ?).

Сегодня мы расскажем о том, как мы делали часть этой фичи — табы и саб-табы.

Новое меню, новые вызовы

Создать новый экран со списком блюд — это не очень сложно. Но нам нужно было синхронизировать скролл списка и выбор табов.

И на первый взгляд, это было просто. При этом решения из коробки не было, а советы из гугла помогали лишь отчасти. Material-библиотека предлагает TabLayoutMediator, но он работает для связки TabLayout и ViewPager2

Что надо было решить при синхронизации табов:

  1. Синхронизация табов и списка

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

  2. Синхронизация саб-табов и списка

    Она похожа на первый пункт. При выборе категории с подкатегориями появляется второй TabLayout, в котором и отображаются эти подкатегории.
    Объединяет эти пункты общая задача: надо корректно собрать индексы категорий в списке в виде parentIndex -> [childIndex1, childIndex2, childIndex3]
    Пример: 5 -> [6, 9, 15]

  3. Обратный скролл

    Каждый раз нужно выбирать категорию в TabLayout при скролле в обе стороны.

  4. Скролл при клике на таб

    TabLayout не различает, кто кликнул по табу: пользователь или программа. Он всё равно вызывает callback OnTabSelectedListener. Это вынуждает использовать boolean-флаги, чтобы скролл пальцем и скролл по клику на таб не мешали друг другу. Забегая вперёд скажем, что нужно учитывать scrollstate у RecyclerView. Да, жонглирование флагами никто не отменял.

  5. Кастомный вид табов

    У TabLayout.Tab есть поле customView. Это поле помогает легко установить верстку с иконкой и текстом.

  6. 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)


  1. Mox
    04.12.2024 10:19

    Я помню что на React Native аналогичная задача решалась прям суперпросто, но я

    • предвычислил все позиции исходя из фиксированной высоты элемента списка и заголовка (как и в этом случае).

    • поскольку тут механика "табов" в виде всяких горизонтальных свайпов не используется - то я все горизонтальные меню сделал тоже просто скроллами

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


    1. ozh-dev
      04.12.2024 10:19

      Да, тут больше проблем приносит TabLayout. Из-за него приходится использовать лишние флаги и условия.


  1. artemiyzverev
    04.12.2024 10:19

    клевый кейс!


  1. YellowFive
    04.12.2024 10:19

    Ваших рук дело значит?)


    1. ozh-dev
      04.12.2024 10:19

      Наших - мобильное приложение)


  1. Kundello
    04.12.2024 10:19

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

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