Implement floating pill-shaped navigation bar

Replace the standard miuix NavigationBar with a custom floating
pill-shaped bar built from scratch. The miuix NavigationBar draws its
own background internally which defeats external clipping, so the
floating bar uses a Row with custom FloatingNavItem composables instead.
The bar has RoundedCornerShape(28dp), shadow elevation, 24dp horizontal
margin, and sits above the system gesture bar. Add bottom content
padding to all 5 main tab screens to account for the overlay.

Made-with: Cursor
This commit is contained in:
LoveSy
2026-03-03 14:20:22 +08:00
parent 69d8024ee7
commit 2b8eb54dd4
6 changed files with 128 additions and 48 deletions

View File

@@ -1,52 +1,63 @@
package com.topjohnwu.magisk.ui
import android.net.Uri
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
import com.topjohnwu.magisk.arch.VMFactory
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.model.module.LocalModule
import com.topjohnwu.magisk.ui.deny.DenyListScreen
import com.topjohnwu.magisk.ui.deny.DenyListViewModel
import com.topjohnwu.magisk.ui.flash.FlashScreen
import com.topjohnwu.magisk.ui.flash.FlashViewModel
import com.topjohnwu.magisk.ui.home.HomeScreen
import com.topjohnwu.magisk.ui.home.HomeViewModel
import com.topjohnwu.magisk.ui.install.InstallScreen
import com.topjohnwu.magisk.ui.install.InstallViewModel
import com.topjohnwu.magisk.ui.log.LogScreen
import com.topjohnwu.magisk.ui.log.LogViewModel
import com.topjohnwu.magisk.ui.module.ActionScreen
import com.topjohnwu.magisk.ui.module.ActionViewModel
import com.topjohnwu.magisk.ui.module.ModuleScreen
import com.topjohnwu.magisk.ui.module.ModuleViewModel
import com.topjohnwu.magisk.ui.navigation.CollectNavEvents
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
import com.topjohnwu.magisk.ui.navigation.Navigator
import com.topjohnwu.magisk.ui.navigation.ObserveViewEvents
import com.topjohnwu.magisk.ui.navigation.Route
import com.topjohnwu.magisk.ui.settings.SettingsScreen
import com.topjohnwu.magisk.ui.settings.SettingsViewModel
import com.topjohnwu.magisk.ui.superuser.SuperuserScreen
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.NavigationBar
import top.yukonga.miuix.kmp.basic.NavigationBarItem
import top.yukonga.miuix.kmp.basic.NavigationItem
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
enum class Tab(val titleRes: Int, val iconRes: Int) {
@@ -61,19 +72,11 @@ enum class Tab(val titleRes: Int, val iconRes: Int) {
fun MainScreen(initialTab: Int = 0) {
val navigator = LocalNavigator.current
val pagerState = rememberPagerState(initialPage = initialTab, pageCount = { Tab.entries.size })
val scope = rememberCoroutineScope()
val items = Tab.entries.map { tab ->
NavigationItem(
label = stringResource(tab.titleRes),
icon = ImageVector.vectorResource(tab.iconRes),
)
}
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = Tab.entries.size - 1,
userScrollEnabled = true,
) { page ->
@@ -109,25 +112,95 @@ fun MainScreen(initialTab: Int = 0) {
}
}
NavigationBar {
items.forEachIndexed { index, item ->
val tab = Tab.entries[index]
val enabled = when (tab) {
Tab.SUPERUSER -> Info.showSuperUser
Tab.MODULES -> Info.env.isActive && LocalModule.loaded()
else -> true
}
NavigationBarItem(
modifier = Modifier.weight(1f),
icon = item.icon,
label = item.label,
selected = pagerState.currentPage == index,
enabled = enabled,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
}
)
FloatingNavigationBar(
pagerState = pagerState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
@Composable
private fun FloatingNavigationBar(
pagerState: PagerState,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
val shape = RoundedCornerShape(28.dp)
val navBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
Row(
modifier = modifier
.padding(bottom = navBarInset + 12.dp, start = 24.dp, end = 24.dp)
.shadow(elevation = 6.dp, shape = shape)
.clip(shape)
.background(MiuixTheme.colorScheme.surfaceContainer)
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Tab.entries.forEachIndexed { index, tab ->
val selected = pagerState.currentPage == index
val enabled = when (tab) {
Tab.SUPERUSER -> Info.showSuperUser
Tab.MODULES -> Info.env.isActive && LocalModule.loaded()
else -> true
}
FloatingNavItem(
icon = ImageVector.vectorResource(tab.iconRes),
label = stringResource(tab.titleRes),
selected = selected,
enabled = enabled,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
private fun FloatingNavItem(
icon: ImageVector,
label: String,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val contentColor by animateColorAsState(
targetValue = when {
!enabled -> MiuixTheme.colorScheme.disabledOnSecondaryVariant
selected -> MiuixTheme.colorScheme.primary
else -> MiuixTheme.colorScheme.onSurfaceVariantActions
},
animationSpec = tween(200),
label = "navItemColor"
)
Column(
modifier = modifier
.clickable(
enabled = enabled,
indication = null,
interactionSource = remember { MutableInteractionSource() },
role = Role.Tab,
onClick = onClick,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(24.dp),
tint = contentColor,
)
Spacer(Modifier.height(2.dp))
Text(
text = label,
fontSize = 11.sp,
color = contentColor,
)
}
}

View File

@@ -59,7 +59,7 @@ fun HomeScreen(viewModel: HomeViewModel) {
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
.padding(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (uiState.isNoticeVisible) {

View File

@@ -3,6 +3,7 @@ package com.topjohnwu.magisk.ui.log
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -115,6 +116,7 @@ private fun SuLogTab(logs: List<SuLog>, onClear: () -> Unit) {
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier.height(4.dp)) }
@@ -217,6 +219,7 @@ private fun MagiskLogTab(log: String, onSave: () -> Unit, onClear: () -> Unit) {
.horizontalScroll(rememberScrollState())
.verticalScroll(rememberScrollState())
.padding(12.dp)
.padding(bottom = 76.dp)
) {
Text(
text = log,

View File

@@ -1,6 +1,7 @@
package com.topjohnwu.magisk.ui.module
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -61,6 +62,7 @@ fun ModuleScreen(viewModel: ModuleViewModel) {
.fillMaxSize()
.padding(padding)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier.height(4.dp)) }

View File

@@ -63,7 +63,7 @@ fun SettingsScreen(viewModel: SettingsViewModel) {
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 12.dp)
.padding(bottom = 16.dp)
.padding(bottom = 88.dp)
) {
CustomizationSection(viewModel)
Spacer(Modifier.height(12.dp))

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -85,6 +86,7 @@ fun SuperuserScreen(viewModel: SuperuserViewModel) {
.fillMaxSize()
.padding(padding)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier.height(4.dp)) }