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