setting bottom navigation menu with dynamic configuration in android app

  • Task 1. Provide a simple and convenient API for programmatically setting menus at runtime at application startup. You need to configure it on the fly, because. the configuration is only known at runtime.

  • Task 2. Load a bitmap icon by url, if the url of the icon was received in the configuration for the section.

  • Task 3. Download menu configuration over network (list of sections with name, icon type and navigation link)

Let’s start in order.

API to customize BottomNavigationView

Since the navigation menu is created at runtime, I Luckily forced to abandon customization of its sections in XML. However, the prospect of opening a pasta factory directly in an Activity or a FlowFragment that hosts a menu didn’t appeal to me much. Therefore, I created a module in which I encapsulated all the logic for creating and configuring ui for the menu, and made the extension function the entry point to the configurator setup for BottomNavigationView. To be able to write something like this:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    ...
    binding.bottomNavigationView.setup {
        // build your bottom navigation using custom dsl
    }
    ...
}

Let this extension function provide access to the builder with beautiful convenient DSL. This will certainly help me. lambdas with recipients from Kotlin. But first you need to decide what settings are generally needed.

In my case, the configuration includes a list of sections, and general settings for the entire menu. Each section has a title, a link to navigate to the screen corresponding to the section, an icon source (resource id or url):

Data for menu sections
Data for menu sections

As general settings for the entire menu, you can add color states, a click listener, and an icon loader by url. We create the necessary entities:

BottomNavigationConfig – main menu config, contains all settings
data class BottomNavigationConfig(
    val sectionList: List<BottomNavigationSection>,
    @ColorRes val tint: Int? = null,
    val onItemClicked: (BottomNavigationSection) -> Unit,
    val loader: MenuIconLoader
)
BottomNavigationSection – section config, contains section name, icon source, screen link
data class BottomNavigationSection(
    val title: String,
    val iconSource: IconSource = IconSource.NotDefined,
    val link: String
)
IconSource – icon source
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 – image loader by url
interface MenuIconLoader {
    fun loadIcon(menuItem: MenuItem, url: String)
}

And builders…

BottomNavigationConfigBuilder – main config builder
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 – block builder with sections
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 – section builder
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
    )
}

After the builders are created, we will write an extension function for the BottomNavigationView, which will configure the component.

extension function code
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)
    }
}

As a result of using such a DSL, the configuration for the client will look like this:

Customizing the Menu in a Fragment
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 = this@MainActivity.applicationContext))
        onItemClicked { section ->
            navController.navigate(route = section.link)
            Log.d(TAG, "section clicked: $section")
        }
    }
    // ...
}

Depending on your needs, you can add flexibility to the config by adding a text size setting, separate color states for text and icons, etc.

Loading an image by url as an icon for a MenuItem

The previous section introduced the MenuIconLoader interface. It defines a contract for an image loader. To implement it, I will create another module in which I implement a loader based on a well-known library slide (you can implement any other, you just need to write the MenuIconLoader implementation and provide it to the config builder)

First you need to write a custom target for the 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
    }
}

Now we are ready to implement the glide loader. I got this implementation:

Icon loader based on 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)) // используем здесь кастомный таргет
    }
}

It is now possible to use GlideMenuIconLoader when configuring BottomNavigationView as url icon loader. You just need to take care of giving it a Context.

Loading the bottom menu configuration from a remote source

The source of the actual menu configuration can be:

To abstract from what the source will be, let’s write a repository with the following contract:

Repository contract
interface BottomNavigationRepository {
    val bottomNavigationData: List<BottomNavigationSectionData>
    suspend fun load()
}

data class BottomNavigationSectionData(
    val title: String,
    val link: String,
    val iconUrl: String = "",
)

The main requirement is that the repository always and immediately returns some kind of configuration (property bottomNavigationData). The repository also has a load() function that synchronizes the configuration with a remote source. From here, the following scheme of working with a repository in a typical application emerges:

Scheme of the application with BottomNavigationRepository
Scheme of the application with BottomNavigationRepository

When to synchronize the menu configuration? Of course, during the splash screen, when the menu is not yet available, and we can safely try to go online. If not, just show the default configuration.

Repository Implementation Example
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"),
        )
    }
}

Now it remains to provide the repository as a dependency for the view model of the host screen and map the loaded config inside it List<BottomNavigationSectionData> with ui config BottomNavigationConfigwhich requires our new DSL:

ViewModel code
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()
        }
    }
}

What happened?

Setting up a menu in a fragment using the 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")
            }
        }
    }
    // ...
}

Everything is standard. We subscribe to livedata in the ViewModel and get the finished config. We set up our BottomNavigationView in 5 lines.

For example, I have coded a repository that gives a configuration of three sections, one of which has url as the icon source (Notifications section in the screenshot).

Conclusion

BottomNavigationView set up without pain (well, almost). The process of setting it up has become clear, convenient, and its functionality has been expanded. That’s all! Thanks to those who read these lines for their interest in the article 🙂 Sample code can be viewed here.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *