Умный BottomNavigationView без боли: настройка нижнего меню навигации с динамической конфигурацией в Android-приложении

от автора

Привет, Хабр! Меня зовут Сергей Велеско. Я 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(), которая синхронизирует конфигурацию с удаленным источником. Отсюда вырисовывается следующая схема работы с репозиторием в типичном приложении:

Схема работы приложения с BottomNavigationRepository
Схема работы приложения с BottomNavigationRepository

Когда синхронизировать конфигурацию меню? Конечно же во время показа 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 настроен без боли (ну почти). Процесс его настройки стал понятен, удобен, а его функционал расширен. На этом всё! Спасибо тем, кто читает эти строки, за интерес к статье 🙂 Код сэмпла можно посмотреть тут.


ссылка на оригинал статьи https://habr.com/ru/articles/697578/