From c4492243d9c2c8ec1de8e8f29ac1e7ed3ba41fb0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 10:48:09 -0400 Subject: [PATCH] fix(nav): stop the faded-out tab bar taking taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanding a token card on the wallet fades the tab bar out with `alpha = 1f - cardExpansion.progress.value`. Alpha doesn't gate pointer input, so the invisible bar still took taps and switched tabs — and that tap was unrecoverable: `CardExpansionController` is owned at the app root so its fly-state survives a push, but the only code that returns `progress` to 0 is `collapse` in `CardExpandHost`, which lives inside the wallet nav entry. Leaving the wallet mid-expansion left nothing able to collapse it, so `progress` stuck at 1 and the bar stayed invisible on every tab for the rest of the Activity's life. Two changes, both needed — dropping the bar from the tree alone would leave a stuck expansion with no visible *and* no tappable bar, which is worse: - Scope the fade to the wallet tab, so a route change can't strand the bar faded out with nothing left to bring it back. - Drop `NavigationBar` from the tree once the fade completes, inside the existing `AnimatedVisibility` content so the slide-out doesn't also play and the return is still a fade in place. `derivedStateOf` keeps that to the two frames the boolean flips on rather than one recomposition per frame of the expansion. The fade itself moves from the `AppContent` call site into `AppNavigationBar`, which now takes the controller rather than a Float — reading `progress` inside the `graphicsLayer` keeps a frame of the expansion from recomposing the bar. --- .../app/internal/ui/AppNavigationBar.kt | 65 ++++++++++++++----- .../app/internal/ui/navigation/AppContent.kt | 9 ++- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index d299271fe..a7d69bd70 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -10,12 +10,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager import dev.chrisbanes.haze.HazeState @@ -52,6 +55,10 @@ internal fun AppNavigationBar( // A tab home taking over the whole screen without leaving its route (the You tab's tip card // expanding in place). See TabBarVisibilityController. forceHidden: Boolean = false, + // The wallet's card expansion, which fades this bar out as a card opens over the deck. Passed as + // the controller rather than a Float so the progress is read inside a graphicsLayer and a frame of + // the expansion doesn't recompose the bar. + cardExpansion: CardExpansionController? = null, ) { // Selection follows the base of the backstack (the tab "home"), so it stays correct while a // sheet/modal sits on top and is right on launch. The top route only gates visibility. @@ -78,9 +85,26 @@ internal fun AppNavigationBar( val avatar = rememberProfileAvatar() + // The expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so + // scope the fade to that tab. A route change that leaves the wallet mid-expansion would otherwise + // strand the bar faded out with nothing left to bring it back. + val expansion = cardExpansion?.takeIf { selectedTab == NavBarButton.Wallet } + val fadeProgress = { expansion?.progress?.value ?: 0f } + + // Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the + // bar hit-testable, so an invisible bar still took taps and switched tabs. Drop it from the tree at + // the end of the fade instead. derivedStateOf keeps that to the two frames the boolean flips on, + // rather than one recomposition per frame of the expansion. + val fadedOut by remember(expansion) { + derivedStateOf { fadeProgress() >= 1f } + } + Box( modifier = Modifier - .then(modifier), + .then(modifier) + // Fades out with the expansion and back in with the collapse (no abrupt snap on return), + // like iOS's tab bar. At rest progress is 0, so it's fully shown. + .graphicsLayer { alpha = 1f - fadeProgress() }, contentAlignment = Alignment.BottomCenter, ) { AnimatedVisibility( @@ -88,23 +112,28 @@ internal fun AppNavigationBar( enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { - val state = rememberNavigationBarState( - selectedTab = selectedTab ?: NavBarButton.Wallet, - tipUnreadCount = tipUnreadCount, - ) - NavigationBar( - modifier = Modifier - .navigationBarsPadding() - .padding(horizontal = CodeTheme.dimens.grid.x8) - .padding(bottom = CodeTheme.dimens.grid.x3), - state = state, - onButtonClick = { button -> - // Tab bar semantics: swap the current screen (single backstack). - navigator.replaceAll(button.destinationRoute()) - }, - hazeState = hazeState, - avatar = avatar, - ) + // Inside the AnimatedVisibility, not part of its `visible`: the bar is already at alpha 0 + // by the time this drops it, so it must not also play the slide-out — and on the way back + // it reappears where it stood and fades up, as before. + if (!fadedOut) { + val state = rememberNavigationBarState( + selectedTab = selectedTab ?: NavBarButton.Wallet, + tipUnreadCount = tipUnreadCount, + ) + NavigationBar( + modifier = Modifier + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.grid.x8) + .padding(bottom = CodeTheme.dimens.grid.x3), + state = state, + onButtonClick = { button -> + // Tab bar semantics: swap the current screen (single backstack). + navigator.replaceAll(button.destinationRoute()) + }, + hazeState = hazeState, + avatar = avatar, + ) + } } } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index d6a7e75e0..114de5baf 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import dev.chrisbanes.haze.hazeSource @@ -194,12 +193,12 @@ internal fun AppContent( navigator = codeNavigator, hazeState = hazeState, forceHidden = tabBarVisibility.isHidden, + // The bar fades itself out with the wallet's card expansion — and drops out of the tree at + // the end of the fade, so an invisible bar can't be tapped. See AppNavigationBar. + cardExpansion = cardExpansion, modifier = Modifier .align(Alignment.BottomCenter) - .measured { if (it.height > tabBarHeight.value) tabBarHeight.value = it.height } - // Fades out with the expansion and back in with the collapse (no abrupt snap on return), - // like iOS's tab bar. At rest progress is 0, so it's fully shown. - .graphicsLayer { alpha = 1f - cardExpansion.progress.value }, + .measured { if (it.height > tabBarHeight.value) tabBarHeight.value = it.height }, ) // The expanded currency-info overlay is hosted INSIDE the wallet nav entry (see CardExpandHost),