Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ fun EntryProviderScope<NavKey>.dashboardEntries(navigator: AppNavigator) {
dockedSearchResults = listPaneVisible,
enableDeletion = true,
enableSelection = true,
collapsesNavigationBar = true,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,8 +82,8 @@ fun <ID : Any> KeyGoColumn(
contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp),
openedItemId: ID? = null,
selectedItemIds: Set<ID> = emptySet(),
listState: LazyListState = rememberLazyListState(),
) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val headerStart = HeaderStartPadding + contentPadding.calculateStartPadding(layoutDirection)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Follower?>(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<NavigationBarCollapseState?> { 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<CollapsesNavigationBarNode>() {

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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ fun ItemListScreen(
enableSelection: Boolean = true,
dockedSearchResults: Boolean = false,
scrollBehavior: SearchBarScrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior(),
collapsesNavigationBar: Boolean = false,
) {
val viewModel = koinViewModel<ItemListViewModel> {
parametersOf(enableSelection, restrictedItemType)
Expand Down Expand Up @@ -131,6 +132,7 @@ fun ItemListScreen(
scrollBehavior = scrollBehavior,
onVaultSelectorClick = viewModel::onVaultSelectorClick,
onDismissVaultFlow = viewModel::onDismissVaultFlow,
modifier = modifier
modifier = modifier,
collapsesNavigationBar = collapsesNavigationBar,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) }

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) },
Expand All @@ -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,
)
}
}
Expand Down
Loading