FlowTab-CMP

Beautiful, animated, framework-agnostic bottom navigation bar with expandable search, blur/glassmorphism effects, badge support, customizable selection indicators, smooth transitions and lightweight presentation-only architecture.

Android JVMKotlin/Native
GitHub stars5
AuthorsAlims-Repo
Open issues0
LicenseApache License 2.0
Creation date5 months ago

Last activity5 months ago
Latest release0.5.6-beta (5 months ago)

๐ŸŽจ FlowTab

A beautiful, animated, and completely framework-agnostic bottom navigation bar for Jetpack Compose and Compose Multiplatform

Maven Central License Kotlin Compose Multiplatform

Features โ€ข Installation โ€ข Quick Start โ€ข Customization โ€ข Examples


๐ŸŽฌ Preview

In Action

Light Theme Dark Theme

Smooth animations, expandable search, and glassmorphism effects

โœจ Features

  • ๐ŸŽฏ 100% Framework Agnostic - Works with any navigation solution (Navigation3, Decompose, Voyager, PreCompose, Appyx) or plain Compose state
  • ๐ŸŽจ Beautiful Animations - Smooth transitions, scale effects, and fluid search bar expansion
  • ๐Ÿ” Built-in Search Bar - Expandable search with customizable callbacks
  • ๐ŸŽญ Blur Effects - Optional glassmorphism with Haze integration
  • ๐Ÿ”” Badge Support - Show notification counts or dot indicators
  • ๐ŸŽจ Customizable Indicators - Choose from Ripple, Dot, or Line selection indicators
  • โšก Lightweight - Zero navigation dependencies, minimal overhead
  • ๐ŸŽ›๏ธ Highly Customizable - Extensive styling options with preset configurations
  • ๐Ÿ“ฑ Production Ready - Battle-tested, performant, and memory-efficient
  • ๐ŸŒ Multiplatform - Android, iOS (iosArm64, iosX64, iosSimulatorArm64)

๐Ÿ“ฆ Installation

Gradle (Kotlin DSL) - Recommended

Add to your libs.versions.toml:

[versions]
flowtab-cmp = "0.5.1-beta"

[libraries]
flowtab-cmp = { module = "io.github.alims-repo:flowtab-cmp", version.ref = "flowtab-cmp" }

Then in your module's build.gradle.kts:

dependencies {
    implementation(libs.flowtab.cmp)
}

Gradle (Groovy)

dependencies {
    implementation 'io.github.alims-repo:flowtab-cmp:0.5.1-beta'
}

๐Ÿš€ Quick Start

Here's a minimal example to get you started:

@Composable
fun MyApp() {
    var selectedScreen by remember { mutableStateOf("home") }

    val navItems = remember {
        listOf(
            NavItem(
                id = "home",
                label = "Home",
                icon = Icons.Outlined.Home,
                selectedIcon = Icons.Filled.Home
            ),
            NavItem(
                id = "search",
                label = "Search",
                icon = Icons.Default.Search,
                type = NavItemType.Search
            ),
            NavItem(
                id = "profile",
                label = "Profile",
                icon = Icons.Outlined.Person,
                selectedIcon = Icons.Filled.Person,
                badge = BadgeData(count = 3)
            )
        )
    }

    Scaffold(
        bottomBar = {
            BottomNavigation(
                items = navItems,
                selectedId = selectedScreen,
                onItemSelected = { item ->
                    selectedScreen = item.id
                }
            )
        }
    ) { padding ->
        Box(modifier = Modifier.padding(padding)) {
            when (selectedScreen) {
                "home" -> HomeScreen()
                "search" -> SearchScreen()
                "profile" -> ProfileScreen()
            }
        }
    }
}

That's it! FlowTab handles the UI and animations while you control the navigation logic.


๐Ÿ“š Core Concepts

Philosophy

FlowTab follows a presentation-only architecture. It manages visual state and animations but delegates all navigation decisions to you through simple callbacks:

BottomNavigation(
    items = navItems,           // Define your navigation structure
    selectedId = selectedId,    // YOU control which item is selected
    onItemSelected = { item ->  // YOU handle navigation logic
        selectedId = item.id
        // Navigate, log analytics, show toasts, etc.
    }
)

This design makes FlowTab compatible with any navigation solution or state management approach.


๐ŸŽฏ Integration Examples

With Navigation Compose (androidx.navigation)

@Composable
fun AppWithNavigation() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route ?: "home"

    Scaffold(
        bottomBar = {
            BottomNavigation(
                items = navItems,
                selectedId = currentRoute,
                onItemSelected = { item ->
                    navController.navigate(item.id) {
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
        }
    ) { padding ->
        NavHost(
            navController = navController,
            startDestination = "home",
            modifier = Modifier.padding(padding)
        ) {
            composable("home") { HomeScreen() }
            composable("search") { SearchScreen() }
            composable("profile") { ProfileScreen() }
        }
    }
}

With Decompose

class RootComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext {
    private val navigation = StackNavigation<Config>()
    
    val stack: Value<ChildStack<*, Child>> = childStack(
        source = navigation,
        initialConfiguration = Config.Home,
        handleBackButton = true,
        childFactory = ::child
    )
    
    fun navigateTo(config: Config) {
        navigation.bringToFront(config)
    }
    
    sealed class Config {
        object Home : Config()
        object Search : Config()
        object Profile : Config()
    }
}

@Composable
fun AppWithDecompose(component: RootComponent) {
    val stack by component.stack.subscribeAsState()
    val currentConfig = stack.active.configuration
    
    Scaffold(
        bottomBar = {
            BottomNavigation(
                items = navItems,
                selectedId = when (currentConfig) {
                    is RootComponent.Config.Home -> "home"
                    is RootComponent.Config.Search -> "search"
                    is RootComponent.Config.Profile -> "profile"
                },
                onItemSelected = { item ->
                    when (item.id) {
                        "home" -> component.navigateTo(RootComponent.Config.Home)
                        "search" -> component.navigateTo(RootComponent.Config.Search)
                        "profile" -> component.navigateTo(RootComponent.Config.Profile)
                    }
                }
            )
        }
    ) { padding ->
        Children(
            stack = stack,
            modifier = Modifier.padding(padding)
        ) {
            when (val child = it.instance) {
                is Child.Home -> HomeScreen()
                is Child.Search -> SearchScreen()
                is Child.Profile -> ProfileScreen()
            }
        }
    }
}

With Voyager

object HomeTab : Tab {
    override val options: TabOptions
        @Composable get() = TabOptions(index = 0u, title = "Home")
    
    @Composable
    override fun Content() { HomeScreen() }
}

@Composable
fun AppWithVoyager() {
    val tabs = remember { listOf(HomeTab, SearchTab, ProfileTab) }
    
    TabNavigator(tab = HomeTab) { tabNavigator ->
        Scaffold(
            bottomBar = {
                BottomNavigation(
                    items = navItems,
                    selectedId = tabs.indexOf(tabNavigator.current).toString(),
                    onItemSelected = { item ->
                        tabNavigator.current = tabs[item.id.toInt()]
                    }
                )
            }
        ) { padding ->
            CurrentTab(modifier = Modifier.padding(padding))
        }
    }
}

๐ŸŽจ Customization

Selection Indicators

Customize how selected items are indicated with three different styles:

Ripple Indicator (Default)

A full-width background highlight that fills behind the selected item:

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    config = NavConfig(
        navIndicator = NavIndicator.Ripple(
            color = MaterialTheme.colorScheme.primaryContainer,
            indicatorPadding = 4.dp
        )
    )
)

Dot Indicator

A small circular indicator below the selected item:

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    config = NavConfig(
        navIndicator = NavIndicator.Dot(
            size = 8.dp,
            color = MaterialTheme.colorScheme.primary,
            indicatorPadding = 4.dp
        )
    )
)

Line Indicator

A horizontal line below the selected item:

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    config = NavConfig(
        navIndicator = NavIndicator.Line(
            height = 3.dp,
            width = 40.dp,
            color = MaterialTheme.colorScheme.primary,
            indicatorPadding = 4.dp
        )
    )
)

Indicator Comparison:

  • Ripple: Best for bold, high-contrast designs. Fills the entire item background.
  • Dot: Minimal and modern. Perfect for clean, Instagram-style navigation.
  • Line: Material Design 3 style. Subtle yet clear indication.

Badges

Add notification counts or dot indicators:

NavItem(
    id = "notifications",
    label = "Notifications",
    icon = Icons.Outlined.Notifications,
    selectedIcon = Icons.Filled.Notifications,
    badge = BadgeData(count = 5)  // Shows "5"
)

NavItem(
    id = "messages",
    label = "Messages",
    icon = Icons.Outlined.Message,
    badge = BadgeData(showDot = true)  // Shows a dot
)

Search Bar

Create an expandable search experience:

val navItems = listOf(
    NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
    NavItem(
        id = "search",
        label = "Search",
        icon = Icons.Default.Search,
        type = NavItemType.Search  // Makes it expandable
    ),
    NavItem(id = "profile", label = "Profile", icon = Icons.Default.Person)
)

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    onQueryChange = { query ->
        // Handle real-time search input
        searchViewModel.updateQuery(query)
    },
    onSearch = { query ->
        // Handle search submission (when user presses search button)
        searchViewModel.performSearch(query)
    }
)

Isolated Items (FAB-like)

Add special action buttons that don't participate in navigation selection:

NavItem(
    id = "add",
    label = "Add",
    icon = Icons.Default.Add,
    type = NavItemType.Isolated(rotation = 45f)  // Rotates icon
)

Styling Presets

Instagram-Style

val instagramConfig = NavConfig(
    height = 50.dp,
    cornerRadius = 0.dp,
    showLabels = false,
    enableBlur = false,
    showBorder = false,
    navColor = NavColor(
        backgroundColor = Color.Black,
        selectedIconColor = Color.White,
        unSelectedIconColor = Color.Gray
    ),
    navIndicator = NavIndicator.Dot(
        size = 6.dp,
        color = Color.White
    )
)

Modern Pill Style

val pillConfig = NavConfig(
    height = 60.dp,
    cornerRadius = 60.dp,
    maxWidth = 400.dp,
    enableBlur = true,
    blurIntensity = 0.95f,
    showBorder = true,
    elevation = 8.dp,
    navIndicator = NavIndicator.Ripple(
        color = MaterialTheme.colorScheme.primaryContainer
    )
)

Floating Minimal

val floatingConfig = NavConfig(
    height = 56.dp,
    cornerRadius = 28.dp,
    showLabels = false,
    elevation = 12.dp,
    maxWidth = 320.dp,
    navIndicator = NavIndicator.Line(
        height = 2.dp,
        width = 40.dp,
        color = MaterialTheme.colorScheme.primary
    )
)

Glassmorphism with Haze

Create beautiful blur effects over scrollable content:

val hazeState = remember { HazeState() }

Scaffold(
    bottomBar = {
        BottomNavigation(
            items = navItems,
            selectedId = selectedId,
            onItemSelected = { /* ... */ },
            hazeState = hazeState,  // Pass the haze state
            config = NavConfig(
                enableBlur = true,
                blurIntensity = 0.95f
            )
        )
    }
) { padding ->
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(padding)
            .hazeChild(state = hazeState)  // Apply to scrollable content
    ) {
        items(100) { index ->
            Text("Item $index")
        }
    }
}

Custom Colors

val customColors = NavColor(
    backgroundColor = Color(0xFF1E1E1E),
    borderColor = Color(0xFF3A3A3A),
    selectedIconColor = Color(0xFF00D9FF),
    unSelectedIconColor = Color(0xFF666666),
    selectedTextColor = Color(0xFF00D9FF),
    unSelectedTextColor = Color(0xFF999999),
    selectedRippleColor = Color(0x3300D9FF)
)

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { /* ... */ },
    config = NavConfig(navColor = customColors)
)

๐ŸŽญ Item Types

FlowTab supports three item types:

sealed class NavItemType {
    // Regular navigation item
    data object Standard : NavItemType()
    
    // Expandable search bar
    data object Search : NavItemType()
    
    // Modal/dialog trigger (doesn't change selectedId)
    data class Isolated(val rotation: Float = 0f) : NavItemType()
}

๐ŸŽฏ Indicator Types

FlowTab offers three selection indicator styles:

sealed class NavIndicator {
    // Full-width background highlight
    data class Ripple(
        val color: Color = Color.Red,
        val indicatorPadding: Dp = 4.dp
    ) : NavIndicator()
    
    // Small circular indicator
    data class Dot(
        val size: Dp = 8.dp,
        val color: Color = Color.Red,
        val indicatorPadding: Dp = 4.dp
    ) : NavIndicator()
    
    // Horizontal line indicator
    data class Line(
        val height: Dp = 2.dp,
        val width: Dp = 40.dp,
        val color: Color = Color.Red,
        val indicatorPadding: Dp = 4.dp
    ) : NavIndicator()
}

๐Ÿ“– Configuration Reference

NavConfig Parameters

Parameter Type Default Description
height Dp 60.dp Height of the navigation bar
cornerRadius Dp 60.dp Corner radius for rounded edges
maxWidth Dp 460.dp Maximum width (useful for tablets)
iconsSize Dp 20.dp Size of navigation icons
animationDuration Int 250 Animation duration in milliseconds
enableBlur Boolean true Enable glassmorphism blur effect
blurIntensity Float 0.95f Blur intensity (0.0 to 1.0)
showLabels Boolean true Show text labels below icons
hideLabelsOnSearchExpand Boolean true Hide labels when search expands
showBorder Boolean true Show border around navigation bar
elevation Dp 0.dp Shadow elevation
navColor NavColor NavColor() Color configuration
navIndicator NavIndicator NavIndicator.Dot() Selection indicator style

๐Ÿ’ก Best Practices

โœ… Do

// โœ… Keep NavItems stable with remember
val navItems = remember {
    listOf(
        NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
        NavItem(id = "search", label = "Search", icon = Icons.Default.Search)
    )
}

// โœ… Use descriptive, unique IDs
NavItem(id = "user_profile", label = "Profile", icon = Icons.Default.Person)
NavItem(id = "settings_screen", label = "Settings", icon = Icons.Default.Settings)

// โœ… Handle search callbacks appropriately
onQueryChange = { query -> viewModel.updateSearchQuery(query) }
onSearch = { query -> viewModel.performSearch(query) }

// โœ… Match indicator style to your design language
navIndicator = NavIndicator.Line()  // Material Design 3
navIndicator = NavIndicator.Dot()   // Minimal/Instagram style
navIndicator = NavIndicator.Ripple() // Bold/high contrast

โŒ Don't

// โŒ Don't recreate items every composition
val navItems = listOf(NavItem(...))  // Missing remember!

// โŒ Don't use generic or duplicate IDs
NavItem(id = "1", ...)
NavItem(id = "screen", ...)

// โŒ Don't ignore the difference between onQueryChange and onSearch
onQueryChange = { query -> performExpensiveSearch(query) }  // Too frequent!

๐Ÿงช Testing

Example unit test:

@Test
fun `bottom navigation handles item selection correctly`() {
    var selectedId = "home"

    composeTestRule.setContent {
        BottomNavigation(
            items = listOf(
                NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
                NavItem(id = "profile", label = "Profile", icon = Icons.Default.Person)
            ),
            selectedId = selectedId,
            onItemSelected = { item -> selectedId = item.id }
        )
    }

    composeTestRule.onNodeWithText("Profile").performClick()
    assertEquals("profile", selectedId)
}

๐Ÿค Contributing

Contributions are welcome! Please follow these steps:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Please read our Contributing Guide for more details.


๐Ÿ“„ License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.

Copyright 2025 Alim

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

๐Ÿ™ Acknowledgments


๐Ÿ“ž Support & Community


๐ŸŒŸ Showcase

Using FlowTab in your app? We'd love to feature it! Open an issue with the showcase label.


Made with โค๏ธ by Alim

โญ Star this repo if you find it helpful! โญ

โฌ† Back to top

Android JVMKotlin/Native
GitHub stars5
AuthorsAlims-Repo
Open issues0
LicenseApache License 2.0
Creation date5 months ago

Last activity5 months ago
Latest release0.5.6-beta (5 months ago)

๐ŸŽจ FlowTab

A beautiful, animated, and completely framework-agnostic bottom navigation bar for Jetpack Compose and Compose Multiplatform

Maven Central License Kotlin Compose Multiplatform

Features โ€ข Installation โ€ข Quick Start โ€ข Customization โ€ข Examples


๐ŸŽฌ Preview

In Action

Light Theme Dark Theme

Smooth animations, expandable search, and glassmorphism effects

โœจ Features

  • ๐ŸŽฏ 100% Framework Agnostic - Works with any navigation solution (Navigation3, Decompose, Voyager, PreCompose, Appyx) or plain Compose state
  • ๐ŸŽจ Beautiful Animations - Smooth transitions, scale effects, and fluid search bar expansion
  • ๐Ÿ” Built-in Search Bar - Expandable search with customizable callbacks
  • ๐ŸŽญ Blur Effects - Optional glassmorphism with Haze integration
  • ๐Ÿ”” Badge Support - Show notification counts or dot indicators
  • ๐ŸŽจ Customizable Indicators - Choose from Ripple, Dot, or Line selection indicators
  • โšก Lightweight - Zero navigation dependencies, minimal overhead
  • ๐ŸŽ›๏ธ Highly Customizable - Extensive styling options with preset configurations
  • ๐Ÿ“ฑ Production Ready - Battle-tested, performant, and memory-efficient
  • ๐ŸŒ Multiplatform - Android, iOS (iosArm64, iosX64, iosSimulatorArm64)

๐Ÿ“ฆ Installation

Gradle (Kotlin DSL) - Recommended

Add to your libs.versions.toml:

[versions]
flowtab-cmp = "0.5.1-beta"

[libraries]
flowtab-cmp = { module = "io.github.alims-repo:flowtab-cmp", version.ref = "flowtab-cmp" }

Then in your module's build.gradle.kts:

dependencies {
    implementation(libs.flowtab.cmp)
}

Gradle (Groovy)

dependencies {
    implementation 'io.github.alims-repo:flowtab-cmp:0.5.1-beta'
}

๐Ÿš€ Quick Start

Here's a minimal example to get you started:

@Composable
fun MyApp() {
    var selectedScreen by remember { mutableStateOf("home") }

    val navItems = remember {
        listOf(
            NavItem(
                id = "home",
                label = "Home",
                icon = Icons.Outlined.Home,
                selectedIcon = Icons.Filled.Home
            ),
            NavItem(
                id = "search",
                label = "Search",
                icon = Icons.Default.Search,
                type = NavItemType.Search
            ),
            NavItem(
                id = "profile",
                label = "Profile",
                icon = Icons.Outlined.Person,
                selectedIcon = Icons.Filled.Person,
                badge = BadgeData(count = 3)
            )
        )
    }

    Scaffold(
        bottomBar = {
            BottomNavigation(
                items = navItems,
                selectedId = selectedScreen,
                onItemSelected = { item ->
                    selectedScreen = item.id
                }
            )
        }
    ) { padding ->
        Box(modifier = Modifier.padding(padding)) {
            when (selectedScreen) {
                "home" -> HomeScreen()
                "search" -> SearchScreen()
                "profile" -> ProfileScreen()
            }
        }
    }
}

That's it! FlowTab handles the UI and animations while you control the navigation logic.


๐Ÿ“š Core Concepts

Philosophy

FlowTab follows a presentation-only architecture. It manages visual state and animations but delegates all navigation decisions to you through simple callbacks:

BottomNavigation(
    items = navItems,           // Define your navigation structure
    selectedId = selectedId,    // YOU control which item is selected
    onItemSelected = { item ->  // YOU handle navigation logic
        selectedId = item.id
        // Navigate, log analytics, show toasts, etc.
    }
)

This design makes FlowTab compatible with any navigation solution or state management approach.


๐ŸŽฏ Integration Examples

With Navigation Compose (androidx.navigation)

@Composable
fun AppWithNavigation() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route ?: "home"

    Scaffold(
        bottomBar = {
            BottomNavigation(
                items = navItems,
                selectedId = currentRoute,
                onItemSelected = { item ->
                    navController.navigate(item.id) {
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                }
            )
        }
    ) { padding ->
        NavHost(
            navController = navController,
            startDestination = "home",
            modifier = Modifier.padding(padding)
        ) {
            composable("home") { HomeScreen() }
            composable("search") { SearchScreen() }
            composable("profile") { ProfileScreen() }
        }
    }
}

With Decompose

class RootComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext {
    private val navigation = StackNavigation<Config>()
    
    val stack: Value<ChildStack<*, Child>> = childStack(
        source = navigation,
        initialConfiguration = Config.Home,
        handleBackButton = true,
        childFactory = ::child
    )
    
    fun navigateTo(config: Config) {
        navigation.bringToFront(config)
    }
    
    sealed class Config {
        object Home : Config()
        object Search : Config()
        object Profile : Config()
    }
}

@Composable
fun AppWithDecompose(component: RootComponent) {
    val stack by component.stack.subscribeAsState()
    val currentConfig = stack.active.configuration
    
    Scaffold(
        bottomBar = {
            BottomNavigation(
                items = navItems,
                selectedId = when (currentConfig) {
                    is RootComponent.Config.Home -> "home"
                    is RootComponent.Config.Search -> "search"
                    is RootComponent.Config.Profile -> "profile"
                },
                onItemSelected = { item ->
                    when (item.id) {
                        "home" -> component.navigateTo(RootComponent.Config.Home)
                        "search" -> component.navigateTo(RootComponent.Config.Search)
                        "profile" -> component.navigateTo(RootComponent.Config.Profile)
                    }
                }
            )
        }
    ) { padding ->
        Children(
            stack = stack,
            modifier = Modifier.padding(padding)
        ) {
            when (val child = it.instance) {
                is Child.Home -> HomeScreen()
                is Child.Search -> SearchScreen()
                is Child.Profile -> ProfileScreen()
            }
        }
    }
}

With Voyager

object HomeTab : Tab {
    override val options: TabOptions
        @Composable get() = TabOptions(index = 0u, title = "Home")
    
    @Composable
    override fun Content() { HomeScreen() }
}

@Composable
fun AppWithVoyager() {
    val tabs = remember { listOf(HomeTab, SearchTab, ProfileTab) }
    
    TabNavigator(tab = HomeTab) { tabNavigator ->
        Scaffold(
            bottomBar = {
                BottomNavigation(
                    items = navItems,
                    selectedId = tabs.indexOf(tabNavigator.current).toString(),
                    onItemSelected = { item ->
                        tabNavigator.current = tabs[item.id.toInt()]
                    }
                )
            }
        ) { padding ->
            CurrentTab(modifier = Modifier.padding(padding))
        }
    }
}

๐ŸŽจ Customization

Selection Indicators

Customize how selected items are indicated with three different styles:

Ripple Indicator (Default)

A full-width background highlight that fills behind the selected item:

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    config = NavConfig(
        navIndicator = NavIndicator.Ripple(
            color = MaterialTheme.colorScheme.primaryContainer,
            indicatorPadding = 4.dp
        )
    )
)

Dot Indicator

A small circular indicator below the selected item:

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    config = NavConfig(
        navIndicator = NavIndicator.Dot(
            size = 8.dp,
            color = MaterialTheme.colorScheme.primary,
            indicatorPadding = 4.dp
        )
    )
)

Line Indicator

A horizontal line below the selected item:

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    config = NavConfig(
        navIndicator = NavIndicator.Line(
            height = 3.dp,
            width = 40.dp,
            color = MaterialTheme.colorScheme.primary,
            indicatorPadding = 4.dp
        )
    )
)

Indicator Comparison:

  • Ripple: Best for bold, high-contrast designs. Fills the entire item background.
  • Dot: Minimal and modern. Perfect for clean, Instagram-style navigation.
  • Line: Material Design 3 style. Subtle yet clear indication.

Badges

Add notification counts or dot indicators:

NavItem(
    id = "notifications",
    label = "Notifications",
    icon = Icons.Outlined.Notifications,
    selectedIcon = Icons.Filled.Notifications,
    badge = BadgeData(count = 5)  // Shows "5"
)

NavItem(
    id = "messages",
    label = "Messages",
    icon = Icons.Outlined.Message,
    badge = BadgeData(showDot = true)  // Shows a dot
)

Search Bar

Create an expandable search experience:

val navItems = listOf(
    NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
    NavItem(
        id = "search",
        label = "Search",
        icon = Icons.Default.Search,
        type = NavItemType.Search  // Makes it expandable
    ),
    NavItem(id = "profile", label = "Profile", icon = Icons.Default.Person)
)

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { item -> selectedId = item.id },
    onQueryChange = { query ->
        // Handle real-time search input
        searchViewModel.updateQuery(query)
    },
    onSearch = { query ->
        // Handle search submission (when user presses search button)
        searchViewModel.performSearch(query)
    }
)

Isolated Items (FAB-like)

Add special action buttons that don't participate in navigation selection:

NavItem(
    id = "add",
    label = "Add",
    icon = Icons.Default.Add,
    type = NavItemType.Isolated(rotation = 45f)  // Rotates icon
)

Styling Presets

Instagram-Style

val instagramConfig = NavConfig(
    height = 50.dp,
    cornerRadius = 0.dp,
    showLabels = false,
    enableBlur = false,
    showBorder = false,
    navColor = NavColor(
        backgroundColor = Color.Black,
        selectedIconColor = Color.White,
        unSelectedIconColor = Color.Gray
    ),
    navIndicator = NavIndicator.Dot(
        size = 6.dp,
        color = Color.White
    )
)

Modern Pill Style

val pillConfig = NavConfig(
    height = 60.dp,
    cornerRadius = 60.dp,
    maxWidth = 400.dp,
    enableBlur = true,
    blurIntensity = 0.95f,
    showBorder = true,
    elevation = 8.dp,
    navIndicator = NavIndicator.Ripple(
        color = MaterialTheme.colorScheme.primaryContainer
    )
)

Floating Minimal

val floatingConfig = NavConfig(
    height = 56.dp,
    cornerRadius = 28.dp,
    showLabels = false,
    elevation = 12.dp,
    maxWidth = 320.dp,
    navIndicator = NavIndicator.Line(
        height = 2.dp,
        width = 40.dp,
        color = MaterialTheme.colorScheme.primary
    )
)

Glassmorphism with Haze

Create beautiful blur effects over scrollable content:

val hazeState = remember { HazeState() }

Scaffold(
    bottomBar = {
        BottomNavigation(
            items = navItems,
            selectedId = selectedId,
            onItemSelected = { /* ... */ },
            hazeState = hazeState,  // Pass the haze state
            config = NavConfig(
                enableBlur = true,
                blurIntensity = 0.95f
            )
        )
    }
) { padding ->
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(padding)
            .hazeChild(state = hazeState)  // Apply to scrollable content
    ) {
        items(100) { index ->
            Text("Item $index")
        }
    }
}

Custom Colors

val customColors = NavColor(
    backgroundColor = Color(0xFF1E1E1E),
    borderColor = Color(0xFF3A3A3A),
    selectedIconColor = Color(0xFF00D9FF),
    unSelectedIconColor = Color(0xFF666666),
    selectedTextColor = Color(0xFF00D9FF),
    unSelectedTextColor = Color(0xFF999999),
    selectedRippleColor = Color(0x3300D9FF)
)

BottomNavigation(
    items = navItems,
    selectedId = selectedId,
    onItemSelected = { /* ... */ },
    config = NavConfig(navColor = customColors)
)

๐ŸŽญ Item Types

FlowTab supports three item types:

sealed class NavItemType {
    // Regular navigation item
    data object Standard : NavItemType()
    
    // Expandable search bar
    data object Search : NavItemType()
    
    // Modal/dialog trigger (doesn't change selectedId)
    data class Isolated(val rotation: Float = 0f) : NavItemType()
}

๐ŸŽฏ Indicator Types

FlowTab offers three selection indicator styles:

sealed class NavIndicator {
    // Full-width background highlight
    data class Ripple(
        val color: Color = Color.Red,
        val indicatorPadding: Dp = 4.dp
    ) : NavIndicator()
    
    // Small circular indicator
    data class Dot(
        val size: Dp = 8.dp,
        val color: Color = Color.Red,
        val indicatorPadding: Dp = 4.dp
    ) : NavIndicator()
    
    // Horizontal line indicator
    data class Line(
        val height: Dp = 2.dp,
        val width: Dp = 40.dp,
        val color: Color = Color.Red,
        val indicatorPadding: Dp = 4.dp
    ) : NavIndicator()
}

๐Ÿ“– Configuration Reference

NavConfig Parameters

Parameter Type Default Description
height Dp 60.dp Height of the navigation bar
cornerRadius Dp 60.dp Corner radius for rounded edges
maxWidth Dp 460.dp Maximum width (useful for tablets)
iconsSize Dp 20.dp Size of navigation icons
animationDuration Int 250 Animation duration in milliseconds
enableBlur Boolean true Enable glassmorphism blur effect
blurIntensity Float 0.95f Blur intensity (0.0 to 1.0)
showLabels Boolean true Show text labels below icons
hideLabelsOnSearchExpand Boolean true Hide labels when search expands
showBorder Boolean true Show border around navigation bar
elevation Dp 0.dp Shadow elevation
navColor NavColor NavColor() Color configuration
navIndicator NavIndicator NavIndicator.Dot() Selection indicator style

๐Ÿ’ก Best Practices

โœ… Do

// โœ… Keep NavItems stable with remember
val navItems = remember {
    listOf(
        NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
        NavItem(id = "search", label = "Search", icon = Icons.Default.Search)
    )
}

// โœ… Use descriptive, unique IDs
NavItem(id = "user_profile", label = "Profile", icon = Icons.Default.Person)
NavItem(id = "settings_screen", label = "Settings", icon = Icons.Default.Settings)

// โœ… Handle search callbacks appropriately
onQueryChange = { query -> viewModel.updateSearchQuery(query) }
onSearch = { query -> viewModel.performSearch(query) }

// โœ… Match indicator style to your design language
navIndicator = NavIndicator.Line()  // Material Design 3
navIndicator = NavIndicator.Dot()   // Minimal/Instagram style
navIndicator = NavIndicator.Ripple() // Bold/high contrast

โŒ Don't

// โŒ Don't recreate items every composition
val navItems = listOf(NavItem(...))  // Missing remember!

// โŒ Don't use generic or duplicate IDs
NavItem(id = "1", ...)
NavItem(id = "screen", ...)

// โŒ Don't ignore the difference between onQueryChange and onSearch
onQueryChange = { query -> performExpensiveSearch(query) }  // Too frequent!

๐Ÿงช Testing

Example unit test:

@Test
fun `bottom navigation handles item selection correctly`() {
    var selectedId = "home"

    composeTestRule.setContent {
        BottomNavigation(
            items = listOf(
                NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
                NavItem(id = "profile", label = "Profile", icon = Icons.Default.Person)
            ),
            selectedId = selectedId,
            onItemSelected = { item -> selectedId = item.id }
        )
    }

    composeTestRule.onNodeWithText("Profile").performClick()
    assertEquals("profile", selectedId)
}

๐Ÿค Contributing

Contributions are welcome! Please follow these steps:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Please read our Contributing Guide for more details.


๐Ÿ“„ License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.

Copyright 2025 Alim

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

๐Ÿ™ Acknowledgments


๐Ÿ“ž Support & Community


๐ŸŒŸ Showcase

Using FlowTab in your app? We'd love to feature it! Open an issue with the showcase label.


Made with โค๏ธ by Alim

โญ Star this repo if you find it helpful! โญ

โฌ† Back to top