@ -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,28 @@ 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.getValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
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. ui.Alignment
import androidx.compose. runtime.snapshotFlow
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.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.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 +59,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
@ -74,21 +73,59 @@ private const val SCROLLBAR_PRESS_DELAY_MS = 10L
* /
* /
private const val SCROLLBAR _PRESS _DELTA _PCT = 0.02f
private const val SCROLLBAR _PRESS _DELTA _PCT = 0.02f
class ScrollbarState {
private var packedValue by mutableLongStateOf ( 0L )
internal fun onScroll ( stateValue : ScrollbarStateValue ) {
packedValue = stateValue . packedValue
}
/ * *
* Returns the thumb size of the scrollbar as a percentage of the total track size
* /
val thumbSizePercent
get ( ) = unpackFloat1 ( packedValue )
/ * *
* Returns the distance the thumb has traveled as a percentage of total track size
* /
val thumbMovedPercent
get ( ) = unpackFloat2 ( packedValue )
/ * *
* Returns the max distance the thumb can travel as a percentage of total track size
* /
val thumbTrackSizePercent
get ( ) = 1f - thumbSizePercent
}
/ * *
* Returns the size of the scrollbar track in pixels
* /
private val ScrollbarTrack . size
get ( ) = unpackFloat2 ( packedValue ) - unpackFloat1 ( packedValue )
/ * *
* Returns the position of the scrollbar thumb on the track as a percentage
* /
private fun ScrollbarTrack . thumbPosition (
dimension : Float ,
) : Float = max (
a = min (
a = dimension / size ,
b = 1f ,
) ,
b = 0f ,
)
/ * *
/ * *
* Class definition for the core properties of a scroll bar
* Class definition for the core properties of a scroll bar
* /
* /
@Immutable
@Immutable
@JvmInline
@JvmInline
value class ScrollbarState internal constructor (
value class ScrollbarState Value internal constructor (
internal val packedValue : Long ,
internal val packedValue : Long ,
) {
)
companion object {
val FULL = ScrollbarState (
thumbSizePercent = 1f ,
thumbMovedPercent = 0f ,
)
}
}
/ * *
/ * *
* Class definition for the core properties of a scroll bar track
* Class definition for the core properties of a scroll bar track
@ -105,54 +142,23 @@ private value class ScrollbarTrack(
}
}
/ * *
/ * *
* Creates a [ ScrollbarState ] with the listed properties
* Creates a [ ScrollbarState Value ] with the listed properties
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size .
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size .
* Refers to either the thumb width ( for horizontal scrollbars )
* Refers to either the thumb width ( for horizontal scrollbars )
* or height ( for vertical scrollbars ) .
* or height ( for vertical scrollbars ) .
* @param thumbMovedPercent the distance the thumb has traveled as a percentage of total
* @param thumbMovedPercent the distance the thumb has traveled as a percentage of total
* track size .
* track size .
* /
* /
fun ScrollbarStat e(
fun scrollbarStateValu e(
thumbSizePercent : Float ,
thumbSizePercent : Float ,
thumbMovedPercent : Float ,
thumbMovedPercent : Float ,
) = ScrollbarState (
) = ScrollbarState Value (
packFloats (
packFloats (
val1 = thumbSizePercent ,
val1 = thumbSizePercent ,
val2 = thumbMovedPercent ,
val2 = thumbMovedPercent ,
) ,
) ,
)
)
/ * *
* Returns the thumb size of the scrollbar as a percentage of the total track size
* /
val ScrollbarState . thumbSizePercent
get ( ) = unpackFloat1 ( packedValue )
/ * *
* Returns the distance the thumb has traveled as a percentage of total track size
* /
val ScrollbarState . thumbMovedPercent
get ( ) = unpackFloat2 ( packedValue )
/ * *
* Returns the size of the scrollbar track in pixels
* /
private val ScrollbarTrack . size
get ( ) = unpackFloat2 ( packedValue ) - unpackFloat1 ( packedValue )
/ * *
* Returns the position of the scrollbar thumb on the track as a percentage
* /
private fun ScrollbarTrack . thumbPosition (
dimension : Float ,
) : Float = max (
a = min (
a = dimension / size ,
b = 1f ,
) ,
b = 0f ,
)
/ * *
/ * *
* Returns the value of [ offset ] along the axis specified by [ this ]
* Returns the value of [ offset ] along the axis specified by [ this ]
* /
* /
@ -197,8 +203,6 @@ fun Scrollbar(
thumb : @Composable ( ) -> Unit ,
thumb : @Composable ( ) -> Unit ,
onThumbMoved : ( ( Float ) -> Unit ) ? = null ,
onThumbMoved : ( ( Float ) -> Unit ) ? = null ,
) {
) {
val localDensity = LocalDensity . current
// Using Offset.Unspecified and Float.NaN instead of null
// Using Offset.Unspecified and Float.NaN instead of null
// to prevent unnecessary boxing of primitives
// to prevent unnecessary boxing of primitives
var pressedOffset by remember { mutableStateOf ( Offset . Unspecified ) }
var pressedOffset by remember { mutableStateOf ( Offset . Unspecified ) }
@ -210,23 +214,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,84 +307,113 @@ fun Scrollbar(
}
}
} ,
} ,
) {
) {
val scrollbarThumbMovedDp = max (
a = with ( localDensity ) { thumbMovedPx . toDp ( ) } ,
b = 0. dp ,
)
// scrollbar thumb container
// scrollbar thumb container
Box (
Layout ( content = { thumb ( ) } ) { measurables , constraints ->
modifier = Modifier
val measurable = measurables . first ( )
. align ( Alignment . TopStart )
. run {
val thumbSizePx = max (
when ( orientation ) {
a = state . thumbSizePercent * track . size ,
Orientation . Horizontal -> width ( thumbSizeDp )
b = minThumbSize . toPx ( ) ,
Orientation . Vertical -> height ( thumbSizeDp )
)
}
}
val trackSizePx = when ( state . thumbTrackSizePercent ) {
. offset (
0f -> track . size
y = when ( orientation ) {
else -> ( track . size - thumbSizePx ) / state . thumbTrackSizePercent
Orientation . Horizontal -> 0. dp
}
Orientation . Vertical -> scrollbarThumbMovedDp
} ,
val thumbTravelPercent = max (
x = when ( orientation ) {
a = min (
Orientation . Horizontal -> scrollbarThumbMovedDp
a = when {
Orientation . Vertical -> 0. dp
interactionThumbTravelPercent . isNaN ( ) -> state . thumbMovedPercent
else -> interactionThumbTravelPercent
} ,
} ,
b = state . thumbTrackSizePercent ,
) ,
) ,
) {
b = 0f ,
thumb ( )
)
val thumbMovedPx = trackSizePx * thumbTravelPercent
val y = when ( orientation ) {
Horizontal -> 0
Vertical -> thumbMovedPx . roundToInt ( )
}
val x = when ( orientation ) {
Horizontal -> thumbMovedPx . roundToInt ( )
Vertical -> 0
}
val updatedConstraints = when ( orientation ) {
Horizontal -> {
constraints . copy (
minWidth = thumbSizePx . roundToInt ( ) ,
maxWidth = thumbSizePx . roundToInt ( ) ,
)
}
Vertical -> {
constraints . copy (
minHeight = thumbSizePx . roundToInt ( ) ,
maxHeight = thumbSizePx . roundToInt ( ) ,
)
}
}
val placeable = measurable . measure ( updatedConstraints )
layout ( placeable . width , placeable . height ) {
placeable . place ( x , y )
}
}
}
}
}
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 = s tate. 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
}
}
}
}