Move state into a state holder and remove redundant Box

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

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

@ -32,9 +32,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.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.setValue import androidx.compose.runtime.setValue
@ -44,10 +44,10 @@ 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.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.Constraints 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
@ -75,21 +75,53 @@ 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 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 ScrollbarStateValue 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
@ -106,54 +138,23 @@ private value class ScrollbarTrack(
} }
/** /**
* Creates a [ScrollbarState] with the listed properties * Creates a [ScrollbarStateValue] 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 ScrollbarState( fun scrollbarStateValue(
thumbSizePercent: Float, thumbSizePercent: Float,
thumbMovedPercent: Float, thumbMovedPercent: Float,
) = ScrollbarState( ) = ScrollbarStateValue(
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]
*/ */
@ -192,14 +193,12 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {
fun Scrollbar( fun Scrollbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
orientation: Orientation, orientation: Orientation,
state: State<ScrollbarState>, state: ScrollbarState,
minThumbSize: Dp = 40.dp, minThumbSize: Dp = 40.dp,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
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) }
@ -305,68 +304,57 @@ fun Scrollbar(
}, },
) { ) {
// scrollbar thumb container // scrollbar thumb container
Box( Layout(content = { thumb() }) { measurables, constraints ->
modifier = Modifier val measurable = measurables.first()
.align(Alignment.TopStart)
.layout { measurable, constraints ->
val state = state.value
val thumbSizePx = max(
a = state.thumbSizePercent * track.size,
b = minThumbSize.toPx(),
)
val thumbTravelPercent = when { val thumbSizePx = max(
interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent a = state.thumbSizePercent * track.size,
else -> interactionThumbTravelPercent b = minThumbSize.toPx(),
} )
val thumbMovedPx = min(
a = track.size * thumbTravelPercent,
b = track.size - thumbSizePx,
)
val scrollbarThumbMovedDp = max( val thumbTravelPercent = when {
a = thumbMovedPx, interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
b = 0f, else -> interactionThumbTravelPercent
) }
val y = when (orientation) { val thumbMovedPx = min(
Horizontal -> 0 a = track.size * thumbTravelPercent,
Vertical -> scrollbarThumbMovedDp b = track.size - thumbSizePx,
} )
val x = when (orientation) {
Horizontal -> scrollbarThumbMovedDp
Vertical -> 0
}
val offset = IntOffset(x.toInt(), y.toInt()) val scrollbarThumbMovedPx = max(
a = thumbMovedPx.roundToInt(),
b = 0,
)
val constrained = when (orientation) { val y = when (orientation) {
Horizontal -> { Horizontal -> 0
Constraints( Vertical -> scrollbarThumbMovedPx
minWidth = thumbSizePx.roundToInt(), }
maxWidth = thumbSizePx.roundToInt(), val x = when (orientation) {
minHeight = constraints.minHeight, Horizontal -> scrollbarThumbMovedPx
maxHeight = constraints.maxHeight Vertical -> 0
) }
}
Vertical -> {
Constraints(
minWidth = constraints.minWidth,
maxWidth = constraints.maxWidth,
minHeight = thumbSizePx.roundToInt(),
maxHeight = thumbSizePx.roundToInt()
)
}
}
val placeable = measurable.measure(constrained) val updatedConstraints = when (orientation) {
layout(placeable.width, placeable.height) { Horizontal -> {
placeable.place(offset) constraints.copy(
} minWidth = thumbSizePx.roundToInt(),
maxWidth = thumbSizePx.roundToInt()
)
} }
) { Vertical -> {
thumb() constraints.copy(
minHeight = thumbSizePx.roundToInt(),
maxHeight = thumbSizePx.roundToInt()
)
}
}
val placeable = measurable.measure(updatedConstraints)
layout(placeable.width, placeable.height) {
placeable.place(x, y)
}
} }
} }
@ -381,7 +369,7 @@ fun Scrollbar(
return@collect return@collect
} }
var currentThumbMovedPercent = state.value.thumbMovedPercent var currentThumbMovedPercent = state.thumbMovedPercent
val destinationThumbMovedPercent = track.thumbPosition( val destinationThumbMovedPercent = track.thumbPosition(
dimension = orientation.valueOf(pressedOffset), dimension = orientation.valueOf(pressedOffset),
) )

@ -24,8 +24,8 @@ 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.LaunchedEffect
import androidx.compose.runtime.produceState import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@ -41,57 +41,57 @@ import kotlin.math.min
fun LazyListState.scrollbarState( fun LazyListState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): State<ScrollbarState> = produceState( ): ScrollbarState {
initialValue = ScrollbarState.FULL, val state = remember { ScrollbarState() }
key1 = this, LaunchedEffect(this, itemsAvailable) {
key2 = itemsAvailable, snapshotFlow {
) { if (itemsAvailable == 0) return@snapshotFlow null
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null val firstIndex = min(
a = interpolateFirstItemIndex(
val firstIndex = min( visibleItems = visibleItemsInfo,
a = interpolateFirstItemIndex( itemSize = { it.size },
visibleItems = visibleItemsInfo, offset = { it.offset },
itemSize = { it.size }, nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
offset = { it.offset }, itemIndex = itemIndex,
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } }, ),
itemIndex = itemIndex, b = itemsAvailable.toFloat(),
), )
b = itemsAvailable.toFloat(), if (firstIndex.isNaN()) return@snapshotFlow null
)
if (firstIndex.isNaN()) return@snapshotFlow null val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo -> itemSize = itemInfo.size,
itemVisibilityPercentage( itemStartOffset = itemInfo.offset,
itemSize = itemInfo.size, viewportStartOffset = layoutInfo.viewportStartOffset,
itemStartOffset = itemInfo.offset, viewportEndOffset = layoutInfo.viewportEndOffset,
viewportStartOffset = layoutInfo.viewportStartOffset, )
viewportEndOffset = layoutInfo.viewportEndOffset, }
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
) )
} }
.filterNotNull()
val thumbTravelPercent = min( .distinctUntilChanged()
a = firstIndex / itemsAvailable, .collect { state.onScroll(it) }
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
} }
.filterNotNull() return state
.distinctUntilChanged()
.collect { value = it }
} }
/** /**
@ -104,67 +104,67 @@ fun LazyListState.scrollbarState(
fun LazyGridState.scrollbarState( fun LazyGridState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
): State<ScrollbarState> = produceState( ): ScrollbarState {
initialValue = ScrollbarState.FULL, val state = remember { ScrollbarState() }
key1 = this, LaunchedEffect(this, itemsAvailable) {
key2 = itemsAvailable, snapshotFlow {
) { if (itemsAvailable == 0) return@snapshotFlow null
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null val firstIndex = min(
a = interpolateFirstItemIndex(
val firstIndex = min( visibleItems = visibleItemsInfo,
a = interpolateFirstItemIndex( itemSize = { layoutInfo.orientation.valueOf(it.size) },
visibleItems = visibleItemsInfo, offset = { layoutInfo.orientation.valueOf(it.offset) },
itemSize = { layoutInfo.orientation.valueOf(it.size) }, nextItemOnMainAxis = { first ->
offset = { layoutInfo.orientation.valueOf(it.offset) }, when (layoutInfo.orientation) {
nextItemOnMainAxis = { first -> Orientation.Vertical -> visibleItemsInfo.find {
when (layoutInfo.orientation) { it != first && it.row != first.row
Orientation.Vertical -> visibleItemsInfo.find { }
it != first && it.row != first.row
} Orientation.Horizontal -> visibleItemsInfo.find {
it != first && it.column != first.column
Orientation.Horizontal -> visibleItemsInfo.find { }
it != first && it.column != first.column
} }
} },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
}, },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
) )
} }
.filterNotNull()
val thumbTravelPercent = min( .distinctUntilChanged()
a = firstIndex / itemsAvailable, .collect { state.onScroll(it) }
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
} }
.filterNotNull() return state
.distinctUntilChanged()
.collect { value = it }
} }
/** /**
@ -178,56 +178,56 @@ fun LazyGridState.scrollbarState(
fun LazyStaggeredGridState.scrollbarState( fun LazyStaggeredGridState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index, itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index,
): State<ScrollbarState> = produceState( ): ScrollbarState {
initialValue = ScrollbarState.FULL, val state = remember { ScrollbarState() }
key1 = this, LaunchedEffect(this, itemsAvailable) {
key2 = itemsAvailable, snapshotFlow {
) { if (itemsAvailable == 0) return@snapshotFlow null
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null val firstIndex = min(
a = interpolateFirstItemIndex(
val firstIndex = min( visibleItems = visibleItemsInfo,
a = interpolateFirstItemIndex( itemSize = { layoutInfo.orientation.valueOf(it.size) },
visibleItems = visibleItemsInfo, offset = { layoutInfo.orientation.valueOf(it.offset) },
itemSize = { layoutInfo.orientation.valueOf(it.size) }, nextItemOnMainAxis = { first ->
offset = { layoutInfo.orientation.valueOf(it.offset) }, visibleItemsInfo.find { it != first && it.lane == first.lane }
nextItemOnMainAxis = { first -> },
visibleItemsInfo.find { it != first && it.lane == first.lane } itemIndex = itemIndex,
}, ),
itemIndex = itemIndex, b = itemsAvailable.toFloat(),
), )
b = itemsAvailable.toFloat(), if (firstIndex.isNaN()) return@snapshotFlow null
)
if (firstIndex.isNaN()) return@snapshotFlow null val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo -> itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemVisibilityPercentage( itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
itemSize = layoutInfo.orientation.valueOf(itemInfo.size), viewportStartOffset = layoutInfo.viewportStartOffset,
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), viewportEndOffset = layoutInfo.viewportEndOffset,
viewportStartOffset = layoutInfo.viewportStartOffset, )
viewportEndOffset = layoutInfo.viewportEndOffset, }
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = thumbTravelPercent,
) )
} }
.filterNotNull()
val thumbTravelPercent = min( .distinctUntilChanged()
a = firstIndex / itemsAvailable, .collect { state.onScroll(it) }
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = thumbTravelPercent,
)
} }
.filterNotNull() return state
.distinctUntilChanged()
.collect { value = it }
} }
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float { private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float {

Loading…
Cancel
Save