diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt index fa913cb27..beece9071 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -66,7 +67,7 @@ private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L @Composable fun ScrollableState.DraggableScrollbar( modifier: Modifier = Modifier, - state: ScrollbarState, + state: State, orientation: Orientation, onThumbMoved: (Float) -> Unit, ) { @@ -96,7 +97,7 @@ fun ScrollableState.DraggableScrollbar( @Composable fun ScrollableState.DecorativeScrollbar( modifier: Modifier = Modifier, - state: ScrollbarState, + state: State, orientation: Orientation, ) { val interactionSource = remember { MutableInteractionSource() } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt index c1281a4c0..b147ac2d2 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt @@ -16,8 +16,9 @@ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.Orientation.Horizontal +import androidx.compose.foundation.gestures.Orientation.Vertical import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures @@ -28,31 +29,30 @@ import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.compose.ui.util.packFloats import androidx.compose.ui.util.unpackFloat1 import androidx.compose.ui.util.unpackFloat2 @@ -61,6 +61,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt /** * The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll @@ -191,7 +192,7 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { fun Scrollbar( modifier: Modifier = Modifier, orientation: Orientation, - state: ScrollbarState, + state: State, minThumbSize: Dp = 40.dp, interactionSource: MutableInteractionSource? = null, thumb: @Composable () -> Unit, @@ -210,23 +211,6 @@ fun Scrollbar( var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) } - val thumbTravelPercent = when { - interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent - else -> interactionThumbTravelPercent - } - val thumbSizePx = max( - a = state.thumbSizePercent * track.size, - b = with(localDensity) { minThumbSize.toPx() }, - ) - val thumbSizeDp by animateDpAsState( - targetValue = with(localDensity) { thumbSizePx.toDp() }, - label = "scrollbar thumb size", - ) - val thumbMovedPx = min( - a = track.size * thumbTravelPercent, - b = track.size - thumbSizePx, - ) - // scrollbar track container Box( modifier = modifier @@ -320,30 +304,67 @@ fun Scrollbar( } }, ) { - val scrollbarThumbMovedDp = max( - a = with(localDensity) { thumbMovedPx.toDp() }, - b = 0.dp, - ) // scrollbar thumb container Box( modifier = Modifier .align(Alignment.TopStart) - .run { - when (orientation) { - Orientation.Horizontal -> width(thumbSizeDp) - Orientation.Vertical -> height(thumbSizeDp) + .layout { measurable, constraints -> + val state = state.value + val thumbSizePx = max( + a = state.thumbSizePercent * track.size, + b = minThumbSize.toPx(), + ) + + val thumbTravelPercent = when { + interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent + else -> interactionThumbTravelPercent + } + + val thumbMovedPx = min( + a = track.size * thumbTravelPercent, + b = track.size - thumbSizePx, + ) + + val scrollbarThumbMovedDp = max( + a = thumbMovedPx, + b = 0f, + ) + + val y = when (orientation) { + Horizontal -> 0 + Vertical -> scrollbarThumbMovedDp + } + val x = when (orientation) { + Horizontal -> scrollbarThumbMovedDp + Vertical -> 0 + } + + val offset = IntOffset(x.toInt(), y.toInt()) + + val constrained = when (orientation) { + Horizontal -> { + Constraints( + minWidth = thumbSizePx.roundToInt(), + maxWidth = thumbSizePx.roundToInt(), + minHeight = constraints.minHeight, + maxHeight = constraints.maxHeight + ) + } + Vertical -> { + Constraints( + minWidth = constraints.minWidth, + maxWidth = constraints.maxWidth, + minHeight = thumbSizePx.roundToInt(), + maxHeight = thumbSizePx.roundToInt() + ) + } + } + + val placeable = measurable.measure(constrained) + layout(placeable.width, placeable.height) { + placeable.place(offset) } } - .offset( - y = when (orientation) { - Orientation.Horizontal -> 0.dp - Orientation.Vertical -> scrollbarThumbMovedDp - }, - x = when (orientation) { - Orientation.Horizontal -> scrollbarThumbMovedDp - Orientation.Vertical -> 0.dp - }, - ), ) { thumb() } @@ -351,53 +372,55 @@ fun Scrollbar( if (onThumbMoved == null) return - // State that will be read inside the effects that follow - // but will not cause re-triggering of them - val updatedState by rememberUpdatedState(state) - // Process presses - LaunchedEffect(pressedOffset) { - // Press ended, reset interactionThumbTravelPercent - if (pressedOffset == Offset.Unspecified) { - interactionThumbTravelPercent = Float.NaN - return@LaunchedEffect - } + LaunchedEffect(Unit) { + snapshotFlow { pressedOffset }.collect { pressedOffset -> + // Press ended, reset interactionThumbTravelPercent + if (pressedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@collect + } - var currentThumbMovedPercent = updatedState.thumbMovedPercent - val destinationThumbMovedPercent = track.thumbPosition( - dimension = orientation.valueOf(pressedOffset), - ) - val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent - val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f - - while (currentThumbMovedPercent != destinationThumbMovedPercent) { - currentThumbMovedPercent = when { - isPositive -> min( - a = currentThumbMovedPercent + delta, - b = destinationThumbMovedPercent, - ) + var currentThumbMovedPercent = state.value.thumbMovedPercent + val destinationThumbMovedPercent = track.thumbPosition( + dimension = orientation.valueOf(pressedOffset), + ) + val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent + val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f + + while (currentThumbMovedPercent != destinationThumbMovedPercent) { + currentThumbMovedPercent = when { + isPositive -> min( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) - else -> max( - a = currentThumbMovedPercent + delta, - b = destinationThumbMovedPercent, - ) + else -> max( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) + } + onThumbMoved(currentThumbMovedPercent) + interactionThumbTravelPercent = currentThumbMovedPercent + delay(SCROLLBAR_PRESS_DELAY_MS) } - onThumbMoved(currentThumbMovedPercent) - interactionThumbTravelPercent = currentThumbMovedPercent - delay(SCROLLBAR_PRESS_DELAY_MS) + + } } // Process drags - LaunchedEffect(draggedOffset) { - if (draggedOffset == Offset.Unspecified) { - interactionThumbTravelPercent = Float.NaN - return@LaunchedEffect + LaunchedEffect(Unit) { + snapshotFlow { draggedOffset }.collect { draggedOffset -> + if (draggedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@collect + } + val currentTravel = track.thumbPosition( + dimension = orientation.valueOf(draggedOffset), + ) + onThumbMoved(currentTravel) + interactionThumbTravelPercent = currentTravel } - val currentTravel = track.thumbPosition( - dimension = orientation.valueOf(draggedOffset), - ) - onThumbMoved(currentTravel) - interactionThumbTravelPercent = currentTravel } } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt index 7a0282bf7..67abce1b7 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.produceState import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -40,7 +41,7 @@ import kotlin.math.min fun LazyListState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, -): ScrollbarState = produceState( +): State = produceState( initialValue = ScrollbarState.FULL, key1 = this, key2 = itemsAvailable, @@ -91,7 +92,7 @@ fun LazyListState.scrollbarState( .filterNotNull() .distinctUntilChanged() .collect { value = it } -}.value +} /** * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState] @@ -103,7 +104,7 @@ fun LazyListState.scrollbarState( fun LazyGridState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, -): ScrollbarState = produceState( +): State = produceState( initialValue = ScrollbarState.FULL, key1 = this, key2 = itemsAvailable, @@ -164,7 +165,7 @@ fun LazyGridState.scrollbarState( .filterNotNull() .distinctUntilChanged() .collect { value = it } -}.value +} /** * Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState] @@ -177,7 +178,7 @@ fun LazyGridState.scrollbarState( fun LazyStaggeredGridState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index, -): ScrollbarState = produceState( +): State = produceState( initialValue = ScrollbarState.FULL, key1 = this, key2 = itemsAvailable, @@ -227,7 +228,7 @@ fun LazyStaggeredGridState.scrollbarState( .filterNotNull() .distinctUntilChanged() .collect { value = it } -}.value +} private inline fun List.floatSumOf(selector: (T) -> Float): Float { var sum = 0f