Move scrollbar changes out of composition

pull/1063/head
Ben Trengrove 1 year ago
parent a7fb019bc3
commit 01928d7df5

@ -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<ScrollbarState>,
orientation: Orientation,
onThumbMoved: (Float) -> Unit,
) {
@ -96,7 +97,7 @@ fun ScrollableState.DraggableScrollbar(
@Composable
fun ScrollableState.DecorativeScrollbar(
modifier: Modifier = Modifier,
state: ScrollbarState,
state: State<ScrollbarState>,
orientation: Orientation,
) {
val interactionSource = remember { MutableInteractionSource() }

@ -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<ScrollbarState>,
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
}
}

@ -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<ScrollbarState> = 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<ScrollbarState> = 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<ScrollbarState> = produceState(
initialValue = ScrollbarState.FULL,
key1 = this,
key2 = itemsAvailable,
@ -227,7 +228,7 @@ fun LazyStaggeredGridState.scrollbarState(
.filterNotNull()
.distinctUntilChanged()
.collect { value = it }
}.value
}
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float {
var sum = 0f

Loading…
Cancel
Save