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

в 18:08, , рубрики: android, BottomNavigationView, kotlin, kotlin dsl, mobile development, разработка мобильных приложений, Разработка под 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(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 строчек.

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

Для примера я замокал репозиторий, который отдает конфигурацию из трех секций, одна из которых имеет url в качестве источника иконки (секция Notifications на скриншоте).

Заключение

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

Автор: Сергей Велеско

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js