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..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 @@ -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,10 @@ fun KeyGoNavigationWrapper( val hidesOnScroll = layoutType == NavigationSuiteType.NavigationBar && !touchExplorationEnabled - var hiddenByScroll by remember { mutableStateOf(false) } + val collapseState = remember { NavigationBarCollapseState() } + LaunchedEffect(selectedRoute, hidesOnScroll) { collapseState.reset() } - 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 +296,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 +571,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 +604,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 0f3cbe668..b2916115f 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 @@ -87,8 +88,8 @@ fun KeyGoColumn( contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp), openedItemId: ID? = null, selectedItemIds: Set = emptySet(), + state: LazyListState = rememberLazyListState(), ) { - val listState = rememberLazyListState() val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val headerStart = HeaderStartPadding + contentPadding.calculateStartPadding(layoutDirection) @@ -104,14 +105,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, ) } @@ -129,7 +130,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 new file mode 100644 index 000000000..e721a9b1b --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/composition/NavigationBarCollapse.kt @@ -0,0 +1,65 @@ +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 kotlinx.coroutines.flow.filterNotNull + +@Stable +class NavigationBarCollapseState { + + private class Follower(val state: ScrollableState) + + private var follower by mutableStateOf(null) + + 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 ?: leftScrolledAway) + + fun reset() { + scrolledForward = false + leftScrolledAway = false + } + + 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 { if (follower === current) scrolledForward = it } + } finally { + // 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 } + +@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/ItemListScreen.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt index df3526c1d..6bd856611 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) @@ -133,6 +134,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 9db21da65..e8c0d435c 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.Column import androidx.compose.foundation.layout.ColumnScope @@ -9,6 +10,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 @@ -20,14 +22,17 @@ 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 import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.input.nestedscroll.nestedScroll @@ -49,6 +54,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.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 @@ -57,6 +63,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 import de.davis.keygo.feature.list_screen.R as ListScreenR @@ -90,7 +97,8 @@ internal fun ItemListContent( onVaultSelectorClick: () -> Unit, onDismissVaultFlow: () -> Unit, scrollBehavior: SearchBarScrollBehavior, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + collapsesNavigationBar: Boolean = false, ) { val searchInputField = @Composable { ListSearchTextField( @@ -189,6 +197,11 @@ internal fun ItemListContent( .padding(innerPadding) .padding(top = 4.dp) ) { target -> + LaunchedEffect(target) { + // Nothing here scrolls, so a hidden search bar could never be dragged back. + if (target != ListBody.Items) scrollBehavior.scrollState.reveal() + } + when (target) { ListBody.NoFilterMatches, ListBody.CreateCard, ListBody.NotFound -> { Box( @@ -259,6 +272,19 @@ 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. + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollBackward }.collectLatest { + if (!it) scrollBehavior.scrollState.reveal() + } + } + NavigationBarCollapseEffect( + state = listState, + enabled = collapsesNavigationBar, + ) + KeyGoColumn( items = items, onItemClick = { onItemClick(it, false) }, @@ -269,7 +295,8 @@ internal fun ItemListContent( bottom = 96.dp, ), openedItemId = openedItemId, - selectedItemIds = uiState.selectedItemIds + selectedItemIds = uiState.selectedItemIds, + state = listState, ) } } @@ -277,6 +304,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