parent
0c542b42f9
commit
31b4841cb2
@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.Orientation.Horizontal
|
||||
import androidx.compose.foundation.gestures.Orientation.Vertical
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.width
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A [Scrollbar] that allows for fast scrolling of content.
|
||||
* Its thumb disappears when the scrolling container is dormant.
|
||||
* @param modifier a [Modifier] for the [Scrollbar]
|
||||
* @param state the driving state for the [Scrollbar]
|
||||
* @param scrollInProgress a flag indicating if the scrolling container for the scrollbar is
|
||||
* currently scrolling
|
||||
* @param orientation the orientation of the scrollbar
|
||||
* @param onThumbMoved the fast scroll implementation
|
||||
*/
|
||||
@Composable
|
||||
fun FastScrollbar(
|
||||
modifier: Modifier = Modifier,
|
||||
state: ScrollbarState,
|
||||
scrollInProgress: Boolean,
|
||||
orientation: Orientation,
|
||||
onThumbMoved: (Float) -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Scrollbar(
|
||||
modifier = modifier,
|
||||
orientation = orientation,
|
||||
interactionSource = interactionSource,
|
||||
state = state,
|
||||
thumb = {
|
||||
FastScrollbarThumb(
|
||||
scrollInProgress = scrollInProgress,
|
||||
interactionSource = interactionSource,
|
||||
orientation = orientation,
|
||||
)
|
||||
},
|
||||
onThumbMoved = onThumbMoved,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple [Scrollbar].
|
||||
* Its thumb disappears when the scrolling container is dormant.
|
||||
* @param modifier a [Modifier] for the [Scrollbar]
|
||||
* @param state the driving state for the [Scrollbar]
|
||||
* @param scrollInProgress a flag indicating if the scrolling container for the scrollbar is
|
||||
* currently scrolling
|
||||
* @param orientation the orientation of the scrollbar
|
||||
*/
|
||||
@Composable
|
||||
fun DecorativeScrollbar(
|
||||
modifier: Modifier = Modifier,
|
||||
state: ScrollbarState,
|
||||
scrollInProgress: Boolean,
|
||||
orientation: Orientation,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Scrollbar(
|
||||
modifier = modifier,
|
||||
orientation = orientation,
|
||||
interactionSource = interactionSource,
|
||||
state = state,
|
||||
thumb = {
|
||||
DecorativeScrollbarThumb(
|
||||
interactionSource = interactionSource,
|
||||
scrollInProgress = scrollInProgress,
|
||||
orientation = orientation,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A scrollbar thumb that is intended to also be a touch target for fast scrolling.
|
||||
*/
|
||||
@Composable
|
||||
private fun FastScrollbarThumb(
|
||||
scrollInProgress: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
orientation: Orientation,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.run {
|
||||
when (orientation) {
|
||||
Vertical -> width(12.dp).fillMaxHeight()
|
||||
Horizontal -> height(12.dp).fillMaxWidth()
|
||||
}
|
||||
}
|
||||
.background(
|
||||
color = scrollbarThumbColor(
|
||||
scrollInProgress = scrollInProgress,
|
||||
interactionSource = interactionSource,
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorative scrollbar thumb for communicating a user's position in a list solely.
|
||||
*/
|
||||
@Composable
|
||||
private fun DecorativeScrollbarThumb(
|
||||
scrollInProgress: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
orientation: Orientation,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.run {
|
||||
when (orientation) {
|
||||
Vertical -> width(2.dp).fillMaxHeight()
|
||||
Horizontal -> height(2.dp).fillMaxWidth()
|
||||
}
|
||||
}
|
||||
.background(
|
||||
color = scrollbarThumbColor(
|
||||
scrollInProgress = scrollInProgress,
|
||||
interactionSource = interactionSource,
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The color of the scrollbar thumb as a function of its interaction state.
|
||||
* @param scrollInProgress if the scrolling container is currently scrolling
|
||||
* @param interactionSource source of interactions in the scrolling container
|
||||
*/
|
||||
@Composable
|
||||
private fun scrollbarThumbColor(
|
||||
scrollInProgress: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
): Color {
|
||||
var state by remember { mutableStateOf(Active) }
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val hovered by interactionSource.collectIsHoveredAsState()
|
||||
val dragged by interactionSource.collectIsDraggedAsState()
|
||||
val active = pressed || hovered || dragged || scrollInProgress
|
||||
|
||||
val color by animateColorAsState(
|
||||
targetValue = when (state) {
|
||||
Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
|
||||
Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||
Dormant -> Color.Transparent
|
||||
},
|
||||
animationSpec = SpringSpec(
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
label = "Scrollbar thumb color",
|
||||
)
|
||||
LaunchedEffect(active) {
|
||||
when (active) {
|
||||
true -> state = Active
|
||||
false -> {
|
||||
state = Inactive
|
||||
delay(2_000)
|
||||
state = Dormant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
private enum class ThumbState {
|
||||
Active, Inactive, Dormant
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Calculates the [ScrollbarState] for lazy layouts.
|
||||
* @param itemsAvailable the total amount of items available to scroll in the layout.
|
||||
* @param visibleItems a list of items currently visible in the layout.
|
||||
* @param firstItemIndex a function for interpolating the first visible index in the lazy layout
|
||||
* as scrolling progresses for smooth and linear scrollbar thumb progression.
|
||||
* [itemsAvailable].
|
||||
* @param reverseLayout if the items in the backing lazy layout are laid out in reverse order.
|
||||
* */
|
||||
@Composable
|
||||
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.scrollbarState(
|
||||
itemsAvailable: Int,
|
||||
crossinline visibleItems: LazyState.() -> List<LazyStateItem>,
|
||||
crossinline firstItemIndex: LazyState.(List<LazyStateItem>) -> Float,
|
||||
crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float,
|
||||
crossinline reverseLayout: LazyState.() -> Boolean,
|
||||
): ScrollbarState {
|
||||
var state by remember { mutableStateOf(ScrollbarState.FULL) }
|
||||
|
||||
LaunchedEffect(
|
||||
key1 = this,
|
||||
key2 = itemsAvailable,
|
||||
) {
|
||||
snapshotFlow {
|
||||
if (itemsAvailable == 0) return@snapshotFlow null
|
||||
|
||||
val visibleItemsInfo = visibleItems(this@scrollbarState)
|
||||
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
|
||||
|
||||
// Add the item offset for interpolation between scroll indices
|
||||
val firstIndex = min(
|
||||
a = firstItemIndex(visibleItemsInfo),
|
||||
b = itemsAvailable.toFloat(),
|
||||
)
|
||||
if (firstIndex.isNaN()) return@snapshotFlow null
|
||||
|
||||
val itemsVisible = visibleItemsInfo.sumOf {
|
||||
itemPercentVisible(it).toDouble()
|
||||
}.toFloat()
|
||||
|
||||
val thumbTravelPercent = min(
|
||||
a = firstIndex / itemsAvailable,
|
||||
b = 1f,
|
||||
)
|
||||
val thumbSizePercent = min(
|
||||
a = itemsVisible / itemsAvailable,
|
||||
b = 1f,
|
||||
)
|
||||
|
||||
ScrollbarState(
|
||||
thumbSizePercent = thumbSizePercent,
|
||||
thumbTravelPercent = when {
|
||||
reverseLayout() -> 1f - thumbTravelPercent
|
||||
else -> thumbTravelPercent
|
||||
},
|
||||
)
|
||||
}
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { state = it }
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar
|
||||
* progression.
|
||||
* @param visibleItems a list of items currently visible in the layout.
|
||||
* @param itemSize a lookup function for the size of an item in the layout.
|
||||
* @param offset a lookup function for the offset of an item relative to the start of the view port.
|
||||
* @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction
|
||||
* of the scroll.
|
||||
* @param itemIndex a lookup function for index of an item in the layout relative to
|
||||
* the total amount of items available.
|
||||
* */
|
||||
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(
|
||||
visibleItems: List<LazyStateItem>,
|
||||
crossinline itemSize: LazyState.(LazyStateItem) -> Int,
|
||||
crossinline offset: LazyState.(LazyStateItem) -> Int,
|
||||
crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?,
|
||||
crossinline itemIndex: (LazyStateItem) -> Int,
|
||||
): Float {
|
||||
if (visibleItems.isEmpty()) return 0f
|
||||
|
||||
val firstItem = visibleItems.first()
|
||||
val firstItemIndex = itemIndex(firstItem)
|
||||
|
||||
if (firstItemIndex < 0) return Float.NaN
|
||||
|
||||
val itemOffset = offset(firstItem).toFloat()
|
||||
val offsetPercentage = abs(itemOffset) / itemSize(firstItem)
|
||||
|
||||
val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage
|
||||
|
||||
val nextItemIndex = itemIndex(nextItem)
|
||||
|
||||
return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of an item that is currently visible in the view port.
|
||||
* @param itemSize the size of the item
|
||||
* @param itemStartOffset the start offset of the item relative to the view port start
|
||||
* @param viewportStartOffset the start offset of the view port
|
||||
* @param viewportEndOffset the end offset of the view port
|
||||
*/
|
||||
internal fun itemVisibilityPercentage(
|
||||
itemSize: Int,
|
||||
itemStartOffset: Int,
|
||||
viewportStartOffset: Int,
|
||||
viewportEndOffset: Int,
|
||||
): Float {
|
||||
if (itemSize == 0) return 0f
|
||||
val itemEnd = itemStartOffset + itemSize
|
||||
val startOffset = when {
|
||||
itemStartOffset > viewportStartOffset -> 0
|
||||
else -> abs(abs(viewportStartOffset) - abs(itemStartOffset))
|
||||
}
|
||||
val endOffset = when {
|
||||
itemEnd < viewportEndOffset -> 0
|
||||
else -> abs(abs(itemEnd) - abs(viewportEndOffset))
|
||||
}
|
||||
val size = itemSize.toFloat()
|
||||
return (size - startOffset - endOffset) / size
|
||||
}
|
@ -0,0 +1,340 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Class definition for the core properties of a scroll bar
|
||||
*/
|
||||
@Immutable
|
||||
@JvmInline
|
||||
value class ScrollbarState internal constructor(
|
||||
internal val packedValue: Long,
|
||||
) {
|
||||
companion object {
|
||||
val FULL = ScrollbarState(
|
||||
thumbSizePercent = 1f,
|
||||
thumbTravelPercent = 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class definition for the core properties of a scroll bar track
|
||||
*/
|
||||
@Immutable
|
||||
@JvmInline
|
||||
private value class ScrollbarTrack(
|
||||
val packedValue: Long,
|
||||
) {
|
||||
constructor(
|
||||
max: Float,
|
||||
min: Float,
|
||||
) : this(packFloats(max, min))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scrollbar state with the listed properties
|
||||
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size
|
||||
* @param thumbTravelPercent the distance the thumb has traveled as a percentage of total track size
|
||||
*/
|
||||
fun ScrollbarState(
|
||||
thumbSizePercent: Float,
|
||||
thumbTravelPercent: Float,
|
||||
) = ScrollbarState(
|
||||
packFloats(
|
||||
val1 = thumbSizePercent,
|
||||
val2 = thumbTravelPercent,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.thumbTravelPercent
|
||||
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]
|
||||
*/
|
||||
internal fun Orientation.valueOf(offset: Offset) = when (this) {
|
||||
Orientation.Horizontal -> offset.x
|
||||
Orientation.Vertical -> offset.y
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of [intSize] along the axis specified by [this]
|
||||
*/
|
||||
internal fun Orientation.valueOf(intSize: IntSize) = when (this) {
|
||||
Orientation.Horizontal -> intSize.width
|
||||
Orientation.Vertical -> intSize.height
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of [intOffset] along the axis specified by [this]
|
||||
*/
|
||||
internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {
|
||||
Orientation.Horizontal -> intOffset.x
|
||||
Orientation.Vertical -> intOffset.y
|
||||
}
|
||||
|
||||
/**
|
||||
* A Composable for drawing a Scrollbar
|
||||
* @param orientation the scroll direction of the scrollbar
|
||||
* @param state the state describing the position of the scrollbar
|
||||
* @param minThumbSize the minimum size of the scrollbar thumb
|
||||
* @param interactionSource allows for observing the state of the scroll bar
|
||||
* @param thumb a composable for drawing the scrollbar thumb
|
||||
* @param onThumbMoved an function for reacting to scroll bar interactions, for example implementing
|
||||
* a fast scroll
|
||||
*/
|
||||
@Composable
|
||||
fun Scrollbar(
|
||||
modifier: Modifier = Modifier,
|
||||
orientation: Orientation,
|
||||
state: ScrollbarState,
|
||||
minThumbSize: Dp = 40.dp,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
thumb: @Composable () -> Unit,
|
||||
onThumbMoved: ((Float) -> Unit)? = null,
|
||||
) {
|
||||
val localDensity = LocalDensity.current
|
||||
var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) }
|
||||
var pressedOffset by remember { mutableStateOf(Offset.Unspecified) }
|
||||
var draggedOffset by remember { mutableStateOf(Offset.Unspecified) }
|
||||
|
||||
var track by remember { mutableStateOf(ScrollbarTrack(0)) }
|
||||
val updatedState by rememberUpdatedState(state)
|
||||
val updatedTrack by rememberUpdatedState(track)
|
||||
|
||||
val thumbSizePercent = state.thumbSizePercent
|
||||
val thumbTravelPercent = when {
|
||||
interactionThumbTravelPercent.isNaN() -> state.thumbTravelPercent
|
||||
else -> interactionThumbTravelPercent
|
||||
}
|
||||
val thumbSizePx = max(
|
||||
a = thumbSizePercent * track.size,
|
||||
b = with(localDensity) { minThumbSize.toPx() },
|
||||
)
|
||||
|
||||
val thumbSizeDp by animateDpAsState(
|
||||
targetValue = with(localDensity) { thumbSizePx.toDp() },
|
||||
label = "thumb size",
|
||||
)
|
||||
|
||||
val thumbTravelPx = min(
|
||||
a = track.size * thumbTravelPercent,
|
||||
b = track.size - thumbSizePx,
|
||||
)
|
||||
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
if (draggedOffset == Offset.Unspecified) return@rememberDraggableState
|
||||
|
||||
draggedOffset = when (orientation) {
|
||||
Orientation.Vertical -> draggedOffset.copy(
|
||||
y = draggedOffset.y + delta,
|
||||
)
|
||||
|
||||
Orientation.Horizontal -> draggedOffset.copy(
|
||||
x = draggedOffset.x + delta,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.run {
|
||||
when (orientation) {
|
||||
Orientation.Vertical -> fillMaxHeight()
|
||||
Orientation.Horizontal -> fillMaxWidth()
|
||||
}
|
||||
}
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val position = orientation.valueOf(coordinates.positionInRoot())
|
||||
track = ScrollbarTrack(
|
||||
max = position,
|
||||
min = position + orientation.valueOf(coordinates.size),
|
||||
)
|
||||
}
|
||||
// Process scrollbar presses
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = { offset ->
|
||||
val initialPress = PressInteraction.Press(offset)
|
||||
|
||||
interactionSource?.tryEmit(initialPress)
|
||||
pressedOffset = offset
|
||||
|
||||
interactionSource?.tryEmit(
|
||||
if (tryAwaitRelease()) {
|
||||
PressInteraction.Release(initialPress)
|
||||
} else {
|
||||
PressInteraction.Cancel(initialPress)
|
||||
},
|
||||
)
|
||||
pressedOffset = Offset.Unspecified
|
||||
},
|
||||
)
|
||||
}
|
||||
// Process scrollbar drags
|
||||
.draggable(
|
||||
state = draggableState,
|
||||
orientation = orientation,
|
||||
interactionSource = interactionSource,
|
||||
onDragStarted = { startedPosition: Offset ->
|
||||
draggedOffset = startedPosition
|
||||
},
|
||||
onDragStopped = {
|
||||
draggedOffset = Offset.Unspecified
|
||||
},
|
||||
),
|
||||
) {
|
||||
val offset = max(
|
||||
a = with(localDensity) { thumbTravelPx.toDp() },
|
||||
b = 0.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.run {
|
||||
when (orientation) {
|
||||
Orientation.Horizontal -> width(thumbSizeDp)
|
||||
Orientation.Vertical -> height(thumbSizeDp)
|
||||
}
|
||||
}
|
||||
.offset(
|
||||
y = when (orientation) {
|
||||
Orientation.Horizontal -> 0.dp
|
||||
Orientation.Vertical -> offset
|
||||
},
|
||||
x = when (orientation) {
|
||||
Orientation.Horizontal -> offset
|
||||
Orientation.Vertical -> 0.dp
|
||||
},
|
||||
),
|
||||
) {
|
||||
thumb()
|
||||
}
|
||||
}
|
||||
|
||||
if (onThumbMoved == null) return
|
||||
|
||||
// Process presses
|
||||
LaunchedEffect(pressedOffset) {
|
||||
if (pressedOffset == Offset.Unspecified) {
|
||||
interactionThumbTravelPercent = Float.NaN
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var currentTravel = updatedState.thumbTravelPercent
|
||||
val destinationTravel = updatedTrack.thumbPosition(
|
||||
dimension = orientation.valueOf(pressedOffset),
|
||||
)
|
||||
val isPositive = currentTravel < destinationTravel
|
||||
// TODO: Come up with a better heuristic for jumps
|
||||
val delta = if (isPositive) 0.1f else -0.1f
|
||||
|
||||
while (currentTravel != destinationTravel) {
|
||||
currentTravel =
|
||||
if (isPositive) {
|
||||
min(currentTravel + delta, destinationTravel)
|
||||
} else {
|
||||
max(currentTravel + delta, destinationTravel)
|
||||
}
|
||||
onThumbMoved(currentTravel)
|
||||
interactionThumbTravelPercent = currentTravel
|
||||
// TODO: Define this more thoroughly
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
// Process drags
|
||||
LaunchedEffect(draggedOffset) {
|
||||
if (draggedOffset == Offset.Unspecified) {
|
||||
interactionThumbTravelPercent = Float.NaN
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val currentTravel = updatedTrack.thumbPosition(
|
||||
dimension = orientation.valueOf(draggedOffset),
|
||||
)
|
||||
onThumbMoved(currentTravel)
|
||||
interactionThumbTravelPercent = currentTravel
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Calculates a [ScrollbarState] driven by the changes in a [LazyListState].
|
||||
*
|
||||
* @param itemsAvailable the total amount of items available to scroll in the lazy list.
|
||||
* @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable].
|
||||
*/
|
||||
@Composable
|
||||
fun LazyListState.scrollbarState(
|
||||
itemsAvailable: Int,
|
||||
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
|
||||
): ScrollbarState =
|
||||
scrollbarState(
|
||||
itemsAvailable = itemsAvailable,
|
||||
visibleItems = { layoutInfo.visibleItemsInfo },
|
||||
firstItemIndex = { visibleItems ->
|
||||
interpolateFirstItemIndex(
|
||||
visibleItems = visibleItems,
|
||||
itemSize = { it.size },
|
||||
offset = { it.offset },
|
||||
nextItemOnMainAxis = { first -> visibleItems.find { it != first } },
|
||||
itemIndex = itemIndex,
|
||||
)
|
||||
},
|
||||
itemPercentVisible = itemPercentVisible@{ itemInfo ->
|
||||
itemVisibilityPercentage(
|
||||
itemSize = itemInfo.size,
|
||||
itemStartOffset = itemInfo.offset,
|
||||
viewportStartOffset = layoutInfo.viewportStartOffset,
|
||||
viewportEndOffset = layoutInfo.viewportEndOffset,
|
||||
)
|
||||
},
|
||||
reverseLayout = { layoutInfo.reverseLayout },
|
||||
)
|
||||
|
||||
/**
|
||||
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
|
||||
*
|
||||
* @param itemsAvailable the total amount of items available to scroll in the grid.
|
||||
* @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable].
|
||||
*/
|
||||
@Composable
|
||||
fun LazyGridState.scrollbarState(
|
||||
itemsAvailable: Int,
|
||||
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
|
||||
): ScrollbarState =
|
||||
scrollbarState(
|
||||
itemsAvailable = itemsAvailable,
|
||||
visibleItems = { layoutInfo.visibleItemsInfo },
|
||||
firstItemIndex = { visibleItems ->
|
||||
interpolateFirstItemIndex(
|
||||
visibleItems = visibleItems,
|
||||
itemSize = {
|
||||
layoutInfo.orientation.valueOf(it.size)
|
||||
},
|
||||
offset = { layoutInfo.orientation.valueOf(it.offset) },
|
||||
nextItemOnMainAxis = { first ->
|
||||
when (layoutInfo.orientation) {
|
||||
Orientation.Vertical -> visibleItems.find {
|
||||
it != first && it.row != first.row
|
||||
}
|
||||
|
||||
Orientation.Horizontal -> visibleItems.find {
|
||||
it != first && it.column != first.column
|
||||
}
|
||||
}
|
||||
},
|
||||
itemIndex = itemIndex,
|
||||
)
|
||||
},
|
||||
itemPercentVisible = itemPercentVisible@{ itemInfo ->
|
||||
itemVisibilityPercentage(
|
||||
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
|
||||
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
|
||||
viewportStartOffset = layoutInfo.viewportStartOffset,
|
||||
viewportEndOffset = layoutInfo.viewportEndOffset,
|
||||
)
|
||||
},
|
||||
reverseLayout = { layoutInfo.reverseLayout },
|
||||
)
|
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Remembers a function to react to [Scrollbar] thumb position movements for a [LazyListState]
|
||||
* @param itemsAvailable the amount of items in the list.
|
||||
*/
|
||||
@Composable
|
||||
fun LazyListState.rememberThumbInteractions(
|
||||
itemsAvailable: Int,
|
||||
): (Float) -> Unit = rememberThumbInteractions(
|
||||
itemsAvailable = itemsAvailable,
|
||||
scroll = ::scrollToItem,
|
||||
)
|
||||
|
||||
/**
|
||||
* Remembers a function to react to [Scrollbar] thumb position movements for a [LazyGridState]
|
||||
* @param itemsAvailable the amount of items in the grid.
|
||||
*/
|
||||
@Composable
|
||||
fun LazyGridState.rememberThumbInteractions(
|
||||
itemsAvailable: Int,
|
||||
): (Float) -> Unit = rememberThumbInteractions(
|
||||
itemsAvailable = itemsAvailable,
|
||||
scroll = ::scrollToItem,
|
||||
)
|
||||
|
||||
/**
|
||||
* Generic function to react to [Scrollbar] thumb interactions in a lazy layout.
|
||||
* @param itemsAvailable the total amount of items available to scroll in the layout.
|
||||
* @param scroll a function to be invoked when an index has been identified to scroll to.
|
||||
*/
|
||||
@Composable
|
||||
private inline fun rememberThumbInteractions(
|
||||
itemsAvailable: Int,
|
||||
crossinline scroll: suspend (index: Int) -> Unit,
|
||||
): (Float) -> Unit {
|
||||
var percentage by remember { mutableStateOf(Float.NaN) }
|
||||
val itemCount by rememberUpdatedState(itemsAvailable)
|
||||
|
||||
LaunchedEffect(percentage) {
|
||||
if (percentage.isNaN()) return@LaunchedEffect
|
||||
val indexToFind = (itemCount * percentage).toInt()
|
||||
scroll(indexToFind)
|
||||
}
|
||||
return remember {
|
||||
{ percentage = it }
|
||||
}
|
||||
}
|
Loading…
Reference in new issue