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