Move scrollbar changes out of composition

pull/1063/head
Ben Trengrove 9 months ago
parent a7fb019bc3
commit 01928d7df5

@ -38,6 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -66,7 +67,7 @@ private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L
@Composable @Composable
fun ScrollableState.DraggableScrollbar( fun ScrollableState.DraggableScrollbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: ScrollbarState, state: State<ScrollbarState>,
orientation: Orientation, orientation: Orientation,
onThumbMoved: (Float) -> Unit, onThumbMoved: (Float) -> Unit,
) { ) {
@ -96,7 +97,7 @@ fun ScrollableState.DraggableScrollbar(
@Composable @Composable
fun ScrollableState.DecorativeScrollbar( fun ScrollableState.DecorativeScrollbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: ScrollbarState, state: State<ScrollbarState>,
orientation: Orientation, orientation: Orientation,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }

@ -16,8 +16,9 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar 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
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.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures 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.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth 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.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.util.packFloats import androidx.compose.ui.util.packFloats
import androidx.compose.ui.util.unpackFloat1 import androidx.compose.ui.util.unpackFloat1
import androidx.compose.ui.util.unpackFloat2 import androidx.compose.ui.util.unpackFloat2
@ -61,6 +61,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlin.math.max import kotlin.math.max
import kotlin.math.min 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 * 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( fun Scrollbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
orientation: Orientation, orientation: Orientation,
state: ScrollbarState, state: State<ScrollbarState>,
minThumbSize: Dp = 40.dp, minThumbSize: Dp = 40.dp,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
thumb: @Composable () -> Unit, thumb: @Composable () -> Unit,
@ -210,23 +211,6 @@ fun Scrollbar(
var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) } 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 // scrollbar track container
Box( Box(
modifier = modifier modifier = modifier
@ -320,30 +304,67 @@ fun Scrollbar(
} }
}, },
) { ) {
val scrollbarThumbMovedDp = max(
a = with(localDensity) { thumbMovedPx.toDp() },
b = 0.dp,
)
// scrollbar thumb container // scrollbar thumb container
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.run { .layout { measurable, constraints ->
when (orientation) { val state = state.value
Orientation.Horizontal -> width(thumbSizeDp) val thumbSizePx = max(
Orientation.Vertical -> height(thumbSizeDp) 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() thumb()
} }
@ -351,53 +372,55 @@ fun Scrollbar(
if (onThumbMoved == null) return 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 // Process presses
LaunchedEffect(pressedOffset) { LaunchedEffect(Unit) {
// Press ended, reset interactionThumbTravelPercent snapshotFlow { pressedOffset }.collect { pressedOffset ->
if (pressedOffset == Offset.Unspecified) { // Press ended, reset interactionThumbTravelPercent
interactionThumbTravelPercent = Float.NaN if (pressedOffset == Offset.Unspecified) {
return@LaunchedEffect interactionThumbTravelPercent = Float.NaN
} return@collect
}
var currentThumbMovedPercent = updatedState.thumbMovedPercent var currentThumbMovedPercent = state.value.thumbMovedPercent
val destinationThumbMovedPercent = track.thumbPosition( val destinationThumbMovedPercent = track.thumbPosition(
dimension = orientation.valueOf(pressedOffset), dimension = orientation.valueOf(pressedOffset),
) )
val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent
val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f
while (currentThumbMovedPercent != destinationThumbMovedPercent) { while (currentThumbMovedPercent != destinationThumbMovedPercent) {
currentThumbMovedPercent = when { currentThumbMovedPercent = when {
isPositive -> min( isPositive -> min(
a = currentThumbMovedPercent + delta, a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent, b = destinationThumbMovedPercent,
) )
else -> max( else -> max(
a = currentThumbMovedPercent + delta, a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent, b = destinationThumbMovedPercent,
) )
}
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
} }
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
} }
} }
// Process drags // Process drags
LaunchedEffect(draggedOffset) { LaunchedEffect(Unit) {
if (draggedOffset == Offset.Unspecified) { snapshotFlow { draggedOffset }.collect { draggedOffset ->
interactionThumbTravelPercent = Float.NaN if (draggedOffset == Offset.Unspecified) {
return@LaunchedEffect 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
} }
} }

@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -40,7 +41,7 @@ import kotlin.math.min
fun LazyListState.scrollbarState( fun LazyListState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): ScrollbarState = produceState( ): State<ScrollbarState> = produceState(
initialValue = ScrollbarState.FULL, initialValue = ScrollbarState.FULL,
key1 = this, key1 = this,
key2 = itemsAvailable, key2 = itemsAvailable,
@ -91,7 +92,7 @@ fun LazyListState.scrollbarState(
.filterNotNull() .filterNotNull()
.distinctUntilChanged() .distinctUntilChanged()
.collect { value = it } .collect { value = it }
}.value }
/** /**
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState] * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
@ -103,7 +104,7 @@ fun LazyListState.scrollbarState(
fun LazyGridState.scrollbarState( fun LazyGridState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
): ScrollbarState = produceState( ): State<ScrollbarState> = produceState(
initialValue = ScrollbarState.FULL, initialValue = ScrollbarState.FULL,
key1 = this, key1 = this,
key2 = itemsAvailable, key2 = itemsAvailable,
@ -164,7 +165,7 @@ fun LazyGridState.scrollbarState(
.filterNotNull() .filterNotNull()
.distinctUntilChanged() .distinctUntilChanged()
.collect { value = it } .collect { value = it }
}.value }
/** /**
* Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState] * Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState]
@ -177,7 +178,7 @@ fun LazyGridState.scrollbarState(
fun LazyStaggeredGridState.scrollbarState( fun LazyStaggeredGridState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index, itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index,
): ScrollbarState = produceState( ): State<ScrollbarState> = produceState(
initialValue = ScrollbarState.FULL, initialValue = ScrollbarState.FULL,
key1 = this, key1 = this,
key2 = itemsAvailable, key2 = itemsAvailable,
@ -227,7 +228,7 @@ fun LazyStaggeredGridState.scrollbarState(
.filterNotNull() .filterNotNull()
.distinctUntilChanged() .distinctUntilChanged()
.collect { value = it } .collect { value = it }
}.value }
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float { private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float {
var sum = 0f var sum = 0f

Loading…
Cancel
Save