Привет, Хабр! Меня зовут Сергей Велеско. Я Android-разработчик и мне сегодня трудно представить сколько-нибудь серьезное мобильное приложение без нижнего меню навигации. В Android за это отвечает компонент BottomNavigationView. В этой статье я поделюсь опытом, как гибко и приятно организовать его настройку и научить его загружать свою конфигурацию из удаленного источника.
Какие требования и зачем?
Нижнее меню навигации должно быть конфигурируемым в любой момент. Это дает возможность обновлять меню и навигацию моментально, без зависимости на релизный цикл. Например, изменять иконки к новому году или Хэлоуину. Или, если это приложение-магазин, в определенные дни можно настраивать соответствующие секции для перехода в раздел акций и т.п.
Меню должно уметь отображать как предопределенные иконки из ресурсов, так и растровые иконки, загруженные по url.
Какие задачи нужно решить?
Чтобы удовлетворить вышеперечисленным требованиям, нужно решить 3 задачи:
Задача 1. Предоставить простой и удобный API для программной настройки меню в рантайме при старте приложения. Нужна настройка именно на лету, т.к. конфигурация известна только на этапе выполнения.
Задача 2. Загрузить растровую иконку по url, если в конфигурации для секции пришел url иконки.
Задача 3. Загрузить конфигурацию меню по сети (список секций с названием, типом иконки и навигационной ссылкой)
Начнем по порядку.
API для настройки BottomNavigationView
Так как меню навигации создается во время выполнения, я к счастью вынужден отказаться от настройки его секций в XML. Однако, перспектива открыть макаронную фабрику прямо в Activity или FlowFragment, который является хостом для меню, меня не сильно привлекла. Поэтому я создал модуль, в котором инкапсулировал всю логику по созданию и настройке ui для меню, а точкой входа в конфигуратор сделал extension-функцию setup для BottomNavigationView. Чтобы можно было написать что-то типа такого:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ... binding.bottomNavigationView.setup { // build your bottom navigation using custom dsl } ... }
Пусть эта extension-функция предоставит доступ к билдеру с красивым удобным DSL. В этом мне конечно же помогут лямбды с получателями из Kotlin. Но для начала нужно определиться, какие настройки вообще нужны.
В моем случае конфигурация включает в себя список секций, и общие настройки для всего меню. Для каждой секции есть название, ссылка для навигации на соответствующий секции экран, источник иконки (id ресурса или url):

В качестве общих настроек для всего меню можно добавить состояния цветов, click listener и лоадер иконок по url. Создаем необходимые сущности:
BottomNavigationConfig - главный конфиг меню, содержит все настройки
data class BottomNavigationConfig( val sectionList: List<BottomNavigationSection>, @ColorRes val tint: Int? = null, val onItemClicked: (BottomNavigationSection) -> Unit, val loader: MenuIconLoader )
BottomNavigationSection - конфиг секции, содержит название секции, источник иконки, ссылку на экран
data class BottomNavigationSection( val title: String, val iconSource: IconSource = IconSource.NotDefined, val link: String )
IconSource - источник иконки
sealed class IconSource { data class Url(val url: String) : IconSource() data class ResourceId(@DrawableRes val drawableResourceId: Int) : IconSource() object NotDefined : IconSource() companion object { fun url(url: String): Url = Url(url) fun resource(@DrawableRes resourceId: Int) = ResourceId(resourceId) fun notDefined() = NotDefined } }
MenuIconLoader - загрузчик изображений по url
interface MenuIconLoader { fun loadIcon(menuItem: MenuItem, url: String) }
И билдеры...
BottomNavigationConfigBuilder - билдер главного конфига
class BottomNavigationConfigBuilder { private var onItemClicked: (BottomNavigationSection) -> Unit = {} private var loader: MenuIconLoader = object : MenuIconLoader { override fun loadIcon(menuItem: MenuItem, url: String) { // fallback implementation, ignore } } private val sections: MutableList<BottomNavigationSection> = mutableListOf() @ColorRes private var tintRes: Int? = null fun sections(sectionList: List<BottomNavigationSection>) { sections.clear() sections.addAll(sectionList) } fun sections(builder: BottomNavigationSectionsBlockBuilder.() -> Unit) { val sectionList = BottomNavigationSectionsBlockBuilder().apply(builder).build() sections.clear() sections.addAll(sectionList.sections) } fun onItemClicked(listener: (BottomNavigationSection) -> Unit) { onItemClicked = listener } fun remoteLoader(loader: MenuIconLoader) { this.loader = loader } fun tint(@ColorRes colorSelectorIdRes: Int) { tintRes = colorSelectorIdRes } fun build(): BottomNavigationConfig = BottomNavigationConfig(sections, tintRes, onItemClicked, loader) }
BottomNavigationSectionsBlockBuilder - билдер блока с секциями
class BottomNavigationSectionsBlockBuilder { private val sections: MutableList<BottomNavigationSection> = mutableListOf() fun section(builder: BottomNavigationSectionBuilder.() -> Unit = {}) { BottomNavigationSectionBuilder().apply(builder).build() .apply(sections::add) } fun build(): SectionsBlock = SectionsBlock(sections) }
BottomNavigationSectionBuilder - билдер секции
class BottomNavigationSectionBuilder { private var _id: String = "" private var _title: String = "" private var _iconSource: IconSource = IconSource.NotDefined fun link(id: String) { _id = id } fun title(title: String) { _title = title } fun iconSource(iconSource: IconSource) { _iconSource = iconSource } fun build(): BottomNavigationSection = BottomNavigationSection( link = _id, title = _title, iconSource = _iconSource ) }
После того, как билдеры созданы, напишем extension-функцию для BottomNavigationView, которая и будет конфигурировать компонент.
Код extension-функции
fun BottomNavigationView.setup(builder: BottomNavigationConfigBuilder.() -> Unit = {}) { val bottomNavigationConfig = BottomNavigationConfigBuilder().apply(builder).build() setBottomNavigationSections(bottomNavigationConfig) setBottomNavigationTint(bottomNavigationConfig) } private fun BottomNavigationView.setBottomNavigationSections(bottomNavigationConfig: BottomNavigationConfig) { menu.clear() bottomNavigationConfig.sectionList.forEachIndexed { index, bottomNavigationSection -> menu.add(0, index, index, bottomNavigationSection.title).apply { when (val src = bottomNavigationSection.iconSource) { is IconSource.ResourceId -> setIcon(src.drawableResourceId) is IconSource.Url -> bottomNavigationConfig.loader.loadIcon(this, src.url) IconSource.NotDefined -> {} } setOnMenuItemClickListener { bottomNavigationConfig.onItemClicked(bottomNavigationSection) false } } } } private fun BottomNavigationView.setBottomNavigationTint(config: BottomNavigationConfig) { config.tint?.let { itemIconTintList = ContextCompat.getColorStateList(context, it) itemTextColor = ContextCompat.getColorStateList(context, it) } }
В результате использования такого DSL настройка для клиента будет выглядеть например вот так:
Настройка меню во фрагменте
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { // ... binding.bottomNavigationView.setup { sections { section { title("Dashboard") iconSource(resource(R.drawable.ic_dashboard_black_24dp)) link("dashboard") } section { title("Home") iconSource(resource(R.drawable.ic_home_black_24dp)) link("home") } section { title("Notifications") iconSource(url("https://www.seekpng.com/png/full/138-1387657_app-icon-set-login-icon-comments-avatar-icon.png")) link("notifications") } } tint(R.color.bottom_nav_tint) remoteLoader(GlideMenuIconLoader(context = context.applicationContext)) onItemClicked { section -> navController.navigate(route = section.link) Log.d(TAG, "section clicked: $section") } } // ... }
В зависимости от ваших потребностей, можно накинуть гибкости в конфиг, добавив настройку размера текста, раздельные цветовые состояния для текста и иконок и т.д.
Загрузка изображения по url в качестве иконки для MenuItem
В предыдущем разделе появился интерфейс MenuIconLoader. Он определяет контракт для загрузчика изображений. Для его имплементации я создам еще один модуль, в котором реализую загрузчик на основе широко известной библиотеки glide (вы можете реализовать любой другой, просто нужно написать имплементацию MenuIconLoader и предоставить ее в билдер конфига)
Для начала нужно написать кастомный таргет для MenuItem:
CustomTarget
internal class MenuItemTarget( private val context: Context, private val menuItem: MenuItem ) : CustomTarget<Bitmap>() { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { menuItem.icon = resource.toDrawable(context.resources) } override fun onLoadCleared(placeholder: Drawable?) { // ignore } }
Теперь все готово для реализации glide-загрузчика. У меня получилась вот такая имплементация:
Загрузчик иконок на основе Glide
class GlideMenuIconLoader(private val context: Context) : MenuIconLoader { override fun loadIcon(menuItem: MenuItem, url: String) { Glide.with(context) .asBitmap() .load(url) .diskCacheStrategy(DiskCacheStrategy.DATA) .into(MenuItemTarget(context, menuItem)) // используем здесь кастомный таргет } }
Теперь можно использовать GlideMenuIconLoader при настройке BottomNavigationView в качестве загрузчика иконок по url. Нужно лишь позаботиться о предоставлении ему Context.
Загрузка конфигурации нижнего меню из удаленного источника
Источником актуальной конфигурации меню может быть:
сервис на бэке
firebase remote config
ваш вариант
Чтобы абстрагироваться от того, какой будет источник, напишем репозиторий со следующим контрактом:
Контракт репозитория
interface BottomNavigationRepository { val bottomNavigationData: List<BottomNavigationSectionData> suspend fun load() } data class BottomNavigationSectionData( val title: String, val link: String, val iconUrl: String = "", )
Основное требование - репозиторий всегда и сразу отдает какую-то конфигурацию (свойство bottomNavigationData). Также репозиторий имеет функцию load(), которая синхронизирует конфигурацию с удаленным источником. Отсюда вырисовывается следующая схема работы с репозиторием в типичном приложении:

Когда синхронизировать конфигурацию меню? Конечно же во время показа splash screen, когда меню еще не доступно, и мы можем спокойно попытаться сходить в сеть. Если не вышло, просто покажем дефолтную конфигурацию.
Пример реализации репозитория
class BottomNavigationRepositoryImpl( remoteBottomNavSource: RemoteBottomNavSource ) : BottomNavigationRepository { private val _bottomNavigationData: MutableList<BottomNavigationSectionData> = DEFAULT_BOTTOM_NAV_CONFIG // Should be called in main activity or flow fragment override val bottomNavigationData: List<BottomNavigationSectionData> get() = _bottomNavigationData // Should be called in splash screen override suspend fun load() { val sectionList: List<BottomNavigationSectionData> = remoteBottomNavSource.fetchBottomNavigationConfig() _bottomNavigationData.clear() _bottomNavigationData.addAll(sectionList) } companion object { private val DEFAULT_BOTTOM_NAV_CONFIG = mutableListOf( BottomNavigationSectionData("Home", "home"), BottomNavigationSectionData("Dashboard", "dashboard"), BottomNavigationSectionData("Notifications", "notifications", "https://www.seekpng.com/png/full/138-1387657_app-icon-set-login-icon-comments-avatar-icon.png"), ) } }
Теперь остается предоставить репозиторий в качестве зависимости для view model экрана-хоста и смапить внутри нее загруженный конфиг List<BottomNavigationSectionData> с ui-конфигом BottomNavigationConfig, который требует наш новый DSL:
Код ViewModel
class MainViewModel : ViewModel() { private val bottomNavigationRepository = BottomNavigationRepositoryImpl() private val _bottomNavigationSections = MutableLiveData<List<BottomNavigationSection>>() val bottomNavigationSections: LiveData<List<BottomNavigationSection>> get() = _bottomNavigationSections init { _bottomNavigationSections.postValue( bottomNavigationRepository.bottomNavigationData.toBottomNavigationSections() ) } private fun List<BottomNavigationSectionData>.toBottomNavigationSections(): List<BottomNavigationSection> = filter { it.iconUrl.isNotEmpty() || it.link.isNotEmpty() }.map { BottomNavigationSection( title = it.title, iconSource = if (it.iconUrl.isEmpty()) { IconSource.resource(it.link.mapLinkToDrawableRes()) } else { IconSource.url(it.iconUrl) }, link = it.link, ) } private fun String.mapLinkToDrawableRes(): Int { return when (this) { "dashboard" -> R.drawable.ic_dashboard_black_24dp "home" -> R.drawable.ic_home_black_24dp "notifications" -> R.drawable.ic_notifications_black_24dp else -> throw IllegalStateException() } } }
Что получилось?
Настройка меню во фрагменте с помощью view model
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { // ... viewModel.bottomNavigationSections.observe(viewLifecycleOwner) { bottomNavigationSections -> binding.bottomNavigationView.setup { sections(bottomNavigationSections) tint(R.color.bottom_nav_tint) remoteLoader(GlideMenuIconLoader(context = this@MainActivity.applicationContext)) onItemClicked { section -> navController.navigate(route = section.link) Log.d(TAG, "section clicked: $section") } } } // ... }
Все стандартно. Подписываемся на livedata в ViewModel и получаем готовый конфиг. Настраиваем наш BottomNavigationView в 5 строчек.

Для примера я замокал репозиторий, который отдает конфигурацию из трех секций, одна из которых имеет url в качестве источника иконки (секция Notifications на скриншоте).
Заключение
BottomNavigationView настроен без боли (ну почти). Процесс его настройки стал понятен, удобен, а его функционал расширен. На этом всё! Спасибо тем, кто читает эти строки, за интерес к статье :) Код сэмпла можно посмотреть тут.
