From e09ba1fb13357b32cb93bbb2cf8944d01558667d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 28 Sep 2026 18:09:45 +0200 Subject: [PATCH 1/2] 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, ) } } From ad049429adf492319f59c4b19534f87c99b39942 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Thu, 1 Oct 2026 23:14:07 +0200 Subject: [PATCH 2/2] fix: keep nav bar hidden across item view and simplify collapse wiring Co-Authored-By: Claude Opus 5.5 --- .../component/NavigationWrapper.kt | 1 + .../core/ui/components/KeyGoLazyColumn.kt | 10 +- .../ui/composition/NavigationBarCollapse.kt | 79 +++------ .../NavigationBarCollapseStateTest.kt | 152 ++++++++++++++++++ .../components/ItemListContent.kt | 29 ++-- 5 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 core/ui/src/test/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapseStateTest.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 e87962198..b9f9478b5 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 @@ -145,6 +145,7 @@ fun KeyGoNavigationWrapper( layoutType == NavigationSuiteType.NavigationBar && !touchExplorationEnabled val collapseState = remember { NavigationBarCollapseState() } + LaunchedEffect(selectedRoute, hidesOnScroll) { collapseState.reset() } val showNavigation = showChrome && !(hidesOnScroll && collapseState.isCollapsed) LaunchedEffect(showNavigation) { 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 b03eeeb23..9356d7b90 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 @@ -82,7 +82,7 @@ fun KeyGoColumn( contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp), openedItemId: ID? = null, selectedItemIds: Set = emptySet(), - listState: LazyListState = rememberLazyListState(), + state: LazyListState = rememberLazyListState(), ) { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current @@ -99,14 +99,14 @@ fun KeyGoColumn( // The first item reaching below the anchor owns the sticky header. With no status bar to // avoid this is exactly the first visible item. - val anchor by remember(listState, stickyTop) { + val anchor by remember(state, stickyTop) { derivedStateOf { - val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { + val info = state.layoutInfo.visibleItemsInfo.firstOrNull { it.offset + it.size > stickyTop } StickyAnchor( - index = info?.index ?: listState.firstVisibleItemIndex, + index = info?.index ?: state.firstVisibleItemIndex, top = info?.offset ?: 0, ) } @@ -124,7 +124,7 @@ fun KeyGoColumn( ) { LazyColumn( modifier = Modifier.fillMaxSize(), - state = listState, + state = state, contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(ItemVerticalPadding), ) { 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 index 26d97c672..e721a9b1b 100644 --- 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 @@ -1,20 +1,15 @@ package de.davis.keygo.core.ui.composition import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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 { @@ -25,10 +20,17 @@ class NavigationBarCollapseState { private var scrolledForward by mutableStateOf(false) + private var leftScrolledAway 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 + get() = scrolledForward && (follower?.state?.canScrollBackward ?: leftScrolledAway) + + fun reset() { + scrolledForward = false + leftScrolledAway = false + } internal suspend fun follow(state: ScrollableState) { val current = Follower(state) @@ -42,61 +44,22 @@ class NavigationBarCollapseState { state.lastScrolledBackward -> false else -> null } - }.filterNotNull().collect { scrolledForward = it } + }.filterNotNull().collect { if (follower === current) scrolledForward = it } } finally { - if (follower === current) follower = null + // The chrome comes back a frame before a list returning from an item view does, so + // the gap keeps the position the list left at instead of flashing the bar. + if (follower === current) { + leftScrolledAway = state.canScrollBackward + 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) } - } +@Composable +fun NavigationBarCollapseEffect(state: ScrollableState, enabled: Boolean = true) { + val collapseState = LocalNavigationBarCollapseState.current ?: return + if (enabled) LaunchedEffect(collapseState, state) { collapseState.follow(state) } } diff --git a/core/ui/src/test/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapseStateTest.kt b/core/ui/src/test/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapseStateTest.kt new file mode 100644 index 000000000..5edf8d38e --- /dev/null +++ b/core/ui/src/test/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapseStateTest.kt @@ -0,0 +1,152 @@ +package de.davis.keygo.core.ui.composition + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NavigationBarCollapseStateTest { + + private class FakeScrollableState : ScrollableState { + override var canScrollBackward by mutableStateOf(false) + override var lastScrolledForward by mutableStateOf(false) + override var lastScrolledBackward by mutableStateOf(false) + override val isScrollInProgress = false + + override fun dispatchRawDelta(delta: Float) = 0f + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit, + ) = Unit + + fun scrolled(forward: Boolean?) { + lastScrolledForward = forward == true + lastScrolledBackward = forward == false + } + } + + private val collapseState = NavigationBarCollapseState() + private val list = FakeScrollableState() + + private fun TestScope.settle() { + Snapshot.sendApplyNotifications() + runCurrent() + } + + private fun TestScope.scrollDownAwayFromTop(list: FakeScrollableState) { + list.canScrollBackward = true + list.scrolled(forward = true) + settle() + } + + @Test + fun `collapses once the list scrolls forward away from its top`() = runTest { + backgroundScope.launch { collapseState.follow(list) } + settle() + assertFalse(collapseState.isCollapsed) + + scrollDownAwayFromTop(list) + + assertTrue(collapseState.isCollapsed) + } + + @Test + fun `expands on a backward scroll`() = runTest { + backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + + list.scrolled(forward = false) + settle() + + assertFalse(collapseState.isCollapsed) + } + + @Test + fun `expands when the list stops reaching back without a scroll`() = runTest { + backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + + list.canScrollBackward = false + settle() + + assertFalse(collapseState.isCollapsed) + } + + @Test + fun `a scroll that moved nothing keeps the last direction`() = runTest { + backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + + list.scrolled(forward = null) + settle() + + assertTrue(collapseState.isCollapsed) + } + + @Test + fun `stays collapsed between a list leaving scrolled away and coming back`() = runTest { + val job = backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + + job.cancel() + settle() + assertTrue(collapseState.isCollapsed) + + backgroundScope.launch { collapseState.follow(list) } + settle() + assertTrue(collapseState.isCollapsed) + } + + @Test + fun `expands once a list leaves at its top`() = runTest { + val job = backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + list.canScrollBackward = false + settle() + + job.cancel() + settle() + + assertFalse(collapseState.isCollapsed) + } + + @Test + fun `reset expands with no list followed`() = runTest { + val job = backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + job.cancel() + settle() + + collapseState.reset() + + assertFalse(collapseState.isCollapsed) + } + + @Test + fun `a superseded list no longer steers the bar`() = runTest { + backgroundScope.launch { collapseState.follow(list) } + scrollDownAwayFromTop(list) + + val newer = FakeScrollableState() + backgroundScope.launch { collapseState.follow(newer) } + scrollDownAwayFromTop(newer) + + list.scrolled(forward = false) + settle() + + assertTrue(collapseState.isCollapsed) + } +} 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 975650f93..6abb5a73c 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 @@ -2,6 +2,7 @@ package de.davis.keygo.feature.list_screen.presentation.components import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animate import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues @@ -20,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarScrollBehavior +import androidx.compose.material3.SearchBarScrollState import androidx.compose.material3.SearchBarState import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -50,8 +52,7 @@ 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.core.ui.composition.NavigationBarCollapseEffect 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 @@ -60,6 +61,7 @@ import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState import de.davis.keygo.feature.list_screen.presentation.model.ListItemState import de.davis.keygo.feature.list_screen.presentation.model.SearchState import de.davis.keygo.feature.vault.presentation.VaultFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -185,7 +187,7 @@ internal fun ItemListContent( ) { isEmpty -> LaunchedEffect(isEmpty) { // Nothing here scrolls, so a hidden search bar could never be dragged back. - if (isEmpty) scrollBehavior.scrollState.scrollOffset = 0f + if (isEmpty) scrollBehavior.scrollState.reveal() } when (isEmpty) { @@ -247,9 +249,15 @@ 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 + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollBackward }.collectLatest { + if (!it) scrollBehavior.scrollState.reveal() + } } + NavigationBarCollapseEffect( + state = listState, + enabled = collapsesNavigationBar, + ) KeyGoColumn( items = items, @@ -262,11 +270,7 @@ internal fun ItemListContent( ), openedItemId = openedItemId, selectedItemIds = uiState.selectedItemIds, - modifier = Modifier.collapsesNavigationBar( - state = listState, - enabled = collapsesNavigationBar, - ), - listState = listState, + state = listState, ) } } @@ -274,6 +278,11 @@ internal fun ItemListContent( } } +@OptIn(ExperimentalMaterial3Api::class) +private suspend fun SearchBarScrollState.reveal() { + if (scrollOffset != 0f) animate(scrollOffset, 0f) { value, _ -> scrollOffset = value } +} + @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable