From e09ba1fb13357b32cb93bbb2cf8944d01558667d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 18:09:45 +0200 Subject: [PATCH] fix: collapse nav bar from list position Ports #103 from fix/hotfix-2.0.4 onto the Navigation 3 shell. v2 keeps its scaffold state and TalkBack exemption, but hiding now follows the opted-in list's position via NavigationBarCollapseState instead of a nested-scroll delta counter, so a delete or filter can no longer leave the bar stuck hidden. The state is held in remember: it has no Saver, and the wrapper already outlives item view. Also resets the search bar when the list reaches its top or empties without a scroll. Co-Authored-By: Claude Opus 5.5 --- .../component/NavigationWrapper.kt | 84 ++------------- .../presentation/DashboardEntries.kt | 1 + .../core/ui/components/KeyGoLazyColumn.kt | 3 +- .../ui/composition/NavigationBarCollapse.kt | 102 ++++++++++++++++++ .../presentation/ItemListScreen.kt | 4 +- .../components/ItemListContent.kt | 27 ++++- 6 files changed, 142 insertions(+), 79 deletions(-) create mode 100644 core/ui/src/main/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapse.kt diff --git a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt index a74b0d721..e87962198 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/presentation/component/NavigationWrapper.kt @@ -63,6 +63,7 @@ import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -77,12 +78,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -99,8 +96,9 @@ import de.davis.keygo.R import de.davis.keygo.app.presentation.AppDestinations import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation +import de.davis.keygo.core.ui.composition.LocalNavigationBarCollapseState +import de.davis.keygo.core.ui.composition.NavigationBarCollapseState import kotlinx.coroutines.launch -import kotlin.math.sign import de.davis.keygo.core.ui.R as CoreUiR @@ -146,27 +144,9 @@ fun KeyGoNavigationWrapper( val hidesOnScroll = layoutType == NavigationSuiteType.NavigationBar && !touchExplorationEnabled - var hiddenByScroll by remember { mutableStateOf(false) } + val collapseState = remember { NavigationBarCollapseState() } - val density = LocalDensity.current - val scrollConnection = remember(density) { - NavigationScrollConnection( - thresholdPx = with(density) { NavigationScrollThreshold.toPx() }, - onVisibilityChange = { visible -> hiddenByScroll = !visible }, - ) - } - - // A newly selected top level destination shows its own content from the top, and a layout - // type that does not hide leaves nothing to come back from, so both start the component - // visible again. The run behind the flag is cleared with it: left standing at the threshold - // it had reached, the next scroll of a single pixel in the same direction would hide the - // component again without the distance ever being travelled. - LaunchedEffect(selectedRoute, hidesOnScroll) { - hiddenByScroll = false - scrollConnection.reset() - } - - val showNavigation = showChrome && !(hidesOnScroll && hiddenByScroll) + val showNavigation = showChrome && !(hidesOnScroll && collapseState.isCollapsed) LaunchedEffect(showNavigation) { if (showNavigation) scaffoldState.show() else scaffoldState.hide() } @@ -315,12 +295,11 @@ fun KeyGoNavigationWrapper( else -> WindowInsets(0, 0, 0, 0) } ) - .then( - if (hidesOnScroll) Modifier.nestedScroll(scrollConnection) - else Modifier - ) ) { - content() + CompositionLocalProvider( + LocalNavigationBarCollapseState provides collapseState, + content = content, + ) // This slot ends where the navigation component starts, so a bottom // aligned host clears the component on its own and follows it as it @@ -591,48 +570,6 @@ private fun navigationInsets( else -> WindowInsets(0, 0, 0, 0) } -/** - * Hides the navigation component once the content has been scrolled [thresholdPx] down, and brings - * it back on the same distance scrolled up. - * - * Only the distance the content actually consumed counts, so overscrolling at either end of a list - * does not move the component, and content that cannot scroll at all never hides it. - */ -private class NavigationScrollConnection( - private val thresholdPx: Float, - private val onVisibilityChange: (visible: Boolean) -> Unit, -) : NestedScrollConnection { - - private var accumulated = 0f - - /** Starts a new run, so the next scroll has to travel the whole threshold to decide again. */ - fun reset() { - accumulated = 0f - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset { - // A scroll that moved the content nowhere, a horizontal one included, leaves the run - // it interrupted intact. - val delta = consumed.y - if (delta != 0f) { - // A change of direction starts a new run, so scrolling back reverses the decision - // after one threshold instead of first having to undo the whole distance travelled. - if (delta.sign != accumulated.sign) accumulated = 0f - accumulated = (accumulated + delta).coerceIn(-thresholdPx, thresholdPx) - - if (accumulated <= -thresholdPx) onVisibilityChange(false) - else if (accumulated >= thresholdPx) onVisibilityChange(true) - } - - // Nothing is consumed here: the scroll belongs to the content, this only watches it. - return super.onPostScroll(consumed, available, source) - } -} - /** * Whether an accessibility service that uses touch exploration, such as TalkBack, is running. * @@ -666,9 +603,6 @@ private fun rememberTouchExplorationEnabled(): Boolean { /** The padding [NavigationSuiteScaffoldLayout] places around the primary action content. */ private val PrimaryActionContentPadding = 16.dp -/** How far the content has to be scrolled before the navigation component follows it away. */ -private val NavigationScrollThreshold = 24.dp - @Suppress("VisualLintOverlap") @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Preview(name = "Default") diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt index cbb6df8f6..611174ac5 100644 --- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt +++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardEntries.kt @@ -45,6 +45,7 @@ fun EntryProviderScope.dashboardEntries(navigator: AppNavigator) { dockedSearchResults = listPaneVisible, enableDeletion = true, enableSelection = true, + collapsesNavigationBar = true, ) } diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt index 1c3299df1..b03eeeb23 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -81,8 +82,8 @@ fun KeyGoColumn( contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp), openedItemId: ID? = null, selectedItemIds: Set = emptySet(), + listState: LazyListState = rememberLazyListState(), ) { - val listState = rememberLazyListState() val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val headerStart = HeaderStartPadding + contentPadding.calculateStartPadding(layoutDirection) diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapse.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapse.kt new file mode 100644 index 000000000..26d97c672 --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapse.kt @@ -0,0 +1,102 @@ +package de.davis.keygo.core.ui.composition + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.platform.InspectorInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch + +@Stable +class NavigationBarCollapseState { + + private class Follower(val state: ScrollableState) + + private var follower by mutableStateOf(null) + + private var scrolledForward by mutableStateOf(false) + + // Derived from the list's position rather than accumulated from scroll deltas, so content + // that changes without a scroll (a delete, a filter) can never leave the bar stuck hidden. + val isCollapsed: Boolean + get() = scrolledForward && follower?.state?.canScrollBackward == true + + internal suspend fun follow(state: ScrollableState) { + val current = Follower(state) + follower = current + try { + // A fling that runs into the end keeps scrolling by zero, which clears both flags. + // Only a scroll that moved the list changes the direction. + snapshotFlow { + when { + state.lastScrolledForward -> true + state.lastScrolledBackward -> false + else -> null + } + }.filterNotNull().collect { scrolledForward = it } + } finally { + if (follower === current) follower = null + } + } +} + +val LocalNavigationBarCollapseState = staticCompositionLocalOf { null } + +fun Modifier.collapsesNavigationBar(state: ScrollableState, enabled: Boolean = true): Modifier = + this then CollapsesNavigationBarElement(state, enabled) + +private data class CollapsesNavigationBarElement( + val state: ScrollableState, + val enabled: Boolean, +) : ModifierNodeElement() { + + override fun create() = CollapsesNavigationBarNode(state, enabled) + + override fun update(node: CollapsesNavigationBarNode) = node.update(state, enabled) + + override fun InspectorInfo.inspectableProperties() { + name = "collapsesNavigationBar" + properties["state"] = state + properties["enabled"] = enabled + } +} + +private class CollapsesNavigationBarNode( + private var state: ScrollableState, + private var enabled: Boolean, +) : Modifier.Node(), CompositionLocalConsumerModifierNode { + + private var job: Job? = null + + override fun onAttach() = follow() + + override fun onDetach() { + job = null + } + + fun update(state: ScrollableState, enabled: Boolean) { + if (this.state == state && this.enabled == enabled) return + + this.state = state + this.enabled = enabled + if (isAttached) follow() + } + + private fun follow() { + job?.cancel() + job = null + if (!enabled) return + + val collapseState = currentValueOf(LocalNavigationBarCollapseState) ?: return + job = coroutineScope.launch { collapseState.follow(state) } + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt index 517a0f2a7..b2b1c7082 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt @@ -46,6 +46,7 @@ fun ItemListScreen( enableSelection: Boolean = true, dockedSearchResults: Boolean = false, scrollBehavior: SearchBarScrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior(), + collapsesNavigationBar: Boolean = false, ) { val viewModel = koinViewModel { parametersOf(enableSelection, restrictedItemType) @@ -131,6 +132,7 @@ fun ItemListScreen( scrollBehavior = scrollBehavior, onVaultSelectorClick = viewModel::onVaultSelectorClick, onDismissVaultFlow = viewModel::onDismissVaultFlow, - modifier = modifier + modifier = modifier, + collapsesNavigationBar = collapsesNavigationBar, ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt index 226fdc008..975650f93 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.AppBarWithSearch @@ -24,12 +25,14 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -47,6 +50,8 @@ import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.core.ui.components.KeyGoColumn import de.davis.keygo.core.ui.components.KeyGoColumnItem +import de.davis.keygo.core.ui.composition.collapsesNavigationBar +import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.feature.list_screen.domain.model.SortDirection import de.davis.keygo.feature.list_screen.presentation.NoItemStrategy import de.davis.keygo.feature.list_screen.presentation.model.FilterAction @@ -85,7 +90,8 @@ internal fun ItemListContent( onVaultSelectorClick: () -> Unit, onDismissVaultFlow: () -> Unit, scrollBehavior: SearchBarScrollBehavior, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + collapsesNavigationBar: Boolean = false, ) { var showFilterSheet by rememberSaveable { mutableStateOf(false) } @@ -177,6 +183,11 @@ internal fun ItemListContent( .padding(innerPadding) .padding(top = 4.dp) ) { isEmpty -> + LaunchedEffect(isEmpty) { + // Nothing here scrolls, so a hidden search bar could never be dragged back. + if (isEmpty) scrollBehavior.scrollState.scrollOffset = 0f + } + when (isEmpty) { true -> { Box( @@ -233,6 +244,13 @@ internal fun ItemListContent( } } + val listState = rememberLazyListState() + // A list back at its top shows the search bar even when it got there without a + // scroll, like after a delete. + ObserveAsEvents(snapshotFlow { listState.canScrollBackward }, listState) { + if (!it) scrollBehavior.scrollState.scrollOffset = 0f + } + KeyGoColumn( items = items, onItemClick = { onItemClick(it, false) }, @@ -243,7 +261,12 @@ internal fun ItemListContent( bottom = 96.dp, ), openedItemId = openedItemId, - selectedItemIds = uiState.selectedItemIds + selectedItemIds = uiState.selectedItemIds, + modifier = Modifier.collapsesNavigationBar( + state = listState, + enabled = collapsesNavigationBar, + ), + listState = listState, ) } }