blob: b4caf0275b5e43e098905dad7e547310b769d7c3 [file] [log] [blame]
/*
* Copyright 2020 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
*
* http://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 androidx.compose.foundation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapAndPress
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
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.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sign
/**
* [CompositionLocal] used to pass [ScrollbarStyle] down the tree.
* This value is typically set in some "Theme" composable function
* (DesktopTheme, MaterialTheme)
*/
val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
/**
* Defines visual style of scrollbars (thickness, shapes, colors, etc).
* Can be passed as a parameter of scrollbar through [LocalScrollbarStyle]
*/
@Immutable
data class ScrollbarStyle(
val minimalHeight: Dp,
val thickness: Dp,
val shape: Shape,
val hoverDurationMillis: Int,
val unhoverColor: Color,
val hoverColor: Color
)
/**
* Simple default [ScrollbarStyle] without applying MaterialTheme.
*/
fun defaultScrollbarStyle() = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
hoverColor = Color.Black.copy(alpha = 0.50f)
)
/**
* Vertical scrollbar that can be attached to some scrollable
* component (ScrollableColumn, LazyColumn) and share common state with it.
*
* Can be placed independently.
*
* Example:
* val state = rememberScrollState(0f)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* VerticalScrollbar(
* Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* rememberScrollbarAdapter(state)
* )
* }
*
* @param adapter [ScrollbarAdapter] that will be used to communicate with scrollable component
* @param modifier the modifier to apply to this layout
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
* will be at the bottom of the container.
* It is usually used in pair with `LazyColumn(reverseLayout = true)`
* @param style [ScrollbarStyle] to define visual style of scrollbar
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [DragInteraction.Start] when this Scrollbar is being dragged.
*/
@Composable
fun VerticalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) = Scrollbar(
adapter,
modifier,
reverseLayout,
style,
interactionSource,
isVertical = true
)
/**
* Horizontal scrollbar that can be attached to some scrollable
* component (Modifier.verticalScroll(), LazyRow) and share common state with it.
*
* Can be placed independently.
*
* Example:
* val state = rememberScrollState(0f)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* HorizontalScrollbar(
* Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
* rememberScrollbarAdapter(state)
* )
* }
*
* @param adapter [ScrollbarAdapter] that will be used to communicate with scrollable component
* @param modifier the modifier to apply to this layout
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
* will be at the end of the container.
* It is usually used in pair with `LazyRow(reverseLayout = true)`
* @param style [ScrollbarStyle] to define visual style of scrollbar
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [DragInteraction.Start] when this Scrollbar is being dragged.
*/
@Composable
fun HorizontalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) = Scrollbar(
adapter,
modifier,
reverseLayout,
style,
interactionSource,
isVertical = false
)
// TODO(demin): do we need to stop dragging if cursor is beyond constraints?
@Composable
private fun Scrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean,
style: ScrollbarStyle,
interactionSource: MutableInteractionSource,
isVertical: Boolean
) = with(LocalDensity.current) {
val dragInteraction = remember { mutableStateOf<DragInteraction.Start?>(null) }
DisposableEffect(interactionSource) {
onDispose {
dragInteraction.value?.let { interaction ->
interactionSource.tryEmit(DragInteraction.Cancel(interaction))
dragInteraction.value = null
}
}
}
var containerSize by remember { mutableStateOf(0) }
val isHovered by interactionSource.collectIsHoveredAsState()
val isHighlighted by remember {
derivedStateOf {
isHovered || dragInteraction.value is DragInteraction.Start
}
}
val minimalHeight = style.minimalHeight.toPx()
val sliderAdapter = remember(adapter, containerSize, minimalHeight, reverseLayout) {
SliderAdapter(adapter, containerSize, minimalHeight, reverseLayout)
}
val scrollThickness = style.thickness.roundToPx()
val measurePolicy = if (isVertical) {
remember(sliderAdapter, scrollThickness) {
verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
}
} else {
remember(sliderAdapter, scrollThickness) {
horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
}
}
val color by animateColorAsState(
if (isHighlighted) style.hoverColor else style.unhoverColor,
animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis)
)
val isVisible = sliderAdapter.size < containerSize
Layout(
{
Box(
Modifier
.background(if (isVisible) color else Color.Transparent, style.shape)
.scrollbarDrag(interactionSource, dragInteraction) { offset ->
sliderAdapter.position += if (isVertical) offset.y else offset.x
}
)
},
modifier
.hoverable(interactionSource = interactionSource)
.scrollOnPressOutsideSlider(isVertical, sliderAdapter, adapter, containerSize),
measurePolicy
)
}
private fun Modifier.scrollbarDrag(
interactionSource: MutableInteractionSource,
draggedInteraction: MutableState<DragInteraction.Start?>,
onDelta: (Offset) -> Unit
): Modifier = composed {
val currentInteractionSource by rememberUpdatedState(interactionSource)
val currentDraggedInteraction by rememberUpdatedState(draggedInteraction)
val currentOnDelta by rememberUpdatedState(onDelta)
pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown(requireUnconsumed = false)
val interaction = DragInteraction.Start()
currentInteractionSource.tryEmit(interaction)
currentDraggedInteraction.value = interaction
val isSuccess = drag(down.id) { change ->
currentOnDelta.invoke(change.positionChange())
change.consumePositionChange()
}
val finishInteraction = if (isSuccess) {
DragInteraction.Stop(interaction)
} else {
DragInteraction.Cancel(interaction)
}
currentInteractionSource.tryEmit(finishInteraction)
currentDraggedInteraction.value = null
}
}
}
}
private fun Modifier.scrollOnPressOutsideSlider(
isVertical: Boolean,
sliderAdapter: SliderAdapter,
scrollbarAdapter: ScrollbarAdapter,
containerSize: Int
) = composed {
var targetOffset: Offset? by remember { mutableStateOf(null) }
if (targetOffset != null) {
val targetPosition = if (isVertical) targetOffset!!.y else targetOffset!!.x
LaunchedEffect(targetPosition) {
var delay = PressTimeoutMillis * 3
while (targetPosition !in sliderAdapter.bounds) {
val oldSign = sign(targetPosition - sliderAdapter.position)
scrollbarAdapter.scrollTo(
containerSize,
scrollbarAdapter.scrollOffset + oldSign * containerSize
)
val newSign = sign(targetPosition - sliderAdapter.position)
if (oldSign != newSign) {
break
}
delay(delay)
delay = PressTimeoutMillis
}
}
}
Modifier.pointerInput(Unit) {
detectTapAndPress(
onPress = { offset ->
targetOffset = offset
tryAwaitRelease()
targetOffset = null
},
onTap = {}
)
}
}
/**
* Create and [remember] [ScrollbarAdapter] for scrollable container and current instance of
* [scrollState]
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: ScrollState
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* Create and [remember] [ScrollbarAdapter] for lazy scrollable container and current instance of
* [scrollState]
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyListState,
): ScrollbarAdapter {
return remember(scrollState) {
ScrollbarAdapter(scrollState)
}
}
/**
* ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll
*
* [scrollState] is instance of [ScrollState] which is used by scrollable component
*
* Example:
* val state = rememberScrollState(0f)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* VerticalScrollbar(
* Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* rememberScrollbarAdapter(state)
* )
* }
*/
fun ScrollbarAdapter(
scrollState: ScrollState
): ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState)
private class ScrollableScrollbarAdapter(
private val scrollState: ScrollState
) : ScrollbarAdapter {
override val scrollOffset: Float get() = scrollState.value.toFloat()
override suspend fun scrollTo(containerSize: Int, scrollOffset: Float) {
scrollState.scrollTo(scrollOffset.roundToInt())
}
override fun maxScrollOffset(containerSize: Int) =
scrollState.maxValue.toFloat()
}
/**
* ScrollbarAdapter for lazy lists.
*
* [scrollState] is instance of [LazyListState] which is used by scrollable component
*
* Scrollbar size and position will be dynamically changed on the current visible content.
*
* Example:
* Box(Modifier.fillMaxSize()) {
* val state = rememberLazyListState()
*
* LazyColumn(state = state) {
* ...
* }
*
* VerticalScrollbar(
* Modifier.align(Alignment.CenterEnd),
* rememberScrollbarAdapter(state)
* )
* }
*/
fun ScrollbarAdapter(
scrollState: LazyListState
): ScrollbarAdapter = LazyScrollbarAdapter(
scrollState
)
private class LazyScrollbarAdapter(
private val scrollState: LazyListState
) : ScrollbarAdapter {
override val scrollOffset: Float
get() = scrollState.firstVisibleItemIndex * averageItemSize +
scrollState.firstVisibleItemScrollOffset
override suspend fun scrollTo(containerSize: Int, scrollOffset: Float) {
val distance = scrollOffset - this@LazyScrollbarAdapter.scrollOffset
// if we scroll less than containerSize we need to use scrollBy function to avoid
// undesirable scroll jumps (when an item size is different)
//
// if we scroll more than containerSize we should immediately jump to this position
// without recreating all items between the current and the new position
if (abs(distance) <= containerSize) {
scrollState.scrollBy(distance)
} else {
snapTo(containerSize, scrollOffset)
}
}
private suspend fun snapTo(containerSize: Int, scrollOffset: Float) {
// In case of very big values, we can catch an overflow, so convert values to double and
// coerce them
// val averageItemSize = 26.000002f
// val scrollOffsetCoerced = 2.54490608E8.toFloat()
// val index = (scrollOffsetCoerced / averageItemSize).toInt() // 9788100
// val offset = (scrollOffsetCoerced - index * averageItemSize) // -16.0
// println(offset)
val maximumValue = maxScrollOffset(containerSize).toDouble()
val scrollOffsetCoerced = scrollOffset.toDouble().coerceIn(0.0, maximumValue)
val averageItemSize = averageItemSize.toDouble()
val index = (scrollOffsetCoerced / averageItemSize)
.toInt()
.coerceAtLeast(0)
.coerceAtMost(itemCount - 1)
val offset = (scrollOffsetCoerced - index * averageItemSize)
.toInt()
.coerceAtLeast(0)
scrollState.scrollToItem(index = index, scrollOffset = offset)
}
override fun maxScrollOffset(containerSize: Int) =
(averageItemSize * itemCount - containerSize).coerceAtLeast(0f)
private val itemCount get() = scrollState.layoutInfo.totalItemsCount
private val averageItemSize by derivedStateOf {
scrollState
.layoutInfo
.visibleItemsInfo
.asSequence()
.map { it.size }
.average()
.toFloat()
}
}
/**
* Defines how to scroll the scrollable component
*/
interface ScrollbarAdapter {
/**
* Scroll offset of the content inside the scrollable component.
* Offset "100" means that the content is scrolled by 100 pixels from the start.
*/
val scrollOffset: Float
/**
* Instantly jump to [scrollOffset] in pixels
*
* @param containerSize size of the scrollable container
* (for example, it is height of ScrollableColumn if we use VerticalScrollbar)
* @param scrollOffset target value in pixels to jump to,
* value will be coerced to 0..maxScrollOffset
*/
suspend fun scrollTo(containerSize: Int, scrollOffset: Float)
/**
* Maximum scroll offset of the content inside the scrollable component
*
* @param containerSize size of the scrollable component
* (for example, it is height of ScrollableColumn if we use VerticalScrollbar)
*/
fun maxScrollOffset(containerSize: Int): Float
}
private class SliderAdapter(
val adapter: ScrollbarAdapter,
val containerSize: Int,
val minHeight: Float,
val reverseLayout: Boolean
) {
private val contentSize get() = adapter.maxScrollOffset(containerSize) + containerSize
private val visiblePart get() = containerSize.toFloat() / contentSize
val size
get() = (containerSize * visiblePart)
.coerceAtLeast(minHeight)
.coerceAtMost(containerSize.toFloat())
private val scrollScale: Float
get() {
val extraScrollbarSpace = containerSize - size
val extraContentSpace = contentSize - containerSize
return if (extraContentSpace == 0f) 1f else extraScrollbarSpace / extraContentSpace
}
private var rawPosition: Float
get() = scrollScale * adapter.scrollOffset
set(value) {
runBlocking {
adapter.scrollTo(containerSize, value / scrollScale)
}
}
var position: Float
get() = if (reverseLayout) containerSize - size - rawPosition else rawPosition
set(value) {
rawPosition = if (reverseLayout) {
containerSize - size - value
} else {
value
}
}
val bounds get() = position..position + size
}
private fun verticalMeasurePolicy(
sliderAdapter: SliderAdapter,
setContainerSize: (Int) -> Unit,
scrollThickness: Int
) = MeasurePolicy { measurables, constraints ->
setContainerSize(constraints.maxHeight)
val height = sliderAdapter.size.toInt()
val placeable = measurables.first().measure(
Constraints.fixed(
constraints.constrainWidth(scrollThickness),
height
)
)
layout(placeable.width, constraints.maxHeight) {
placeable.place(0, sliderAdapter.position.toInt())
}
}
private fun horizontalMeasurePolicy(
sliderAdapter: SliderAdapter,
setContainerSize: (Int) -> Unit,
scrollThickness: Int
) = MeasurePolicy { measurables, constraints ->
setContainerSize(constraints.maxWidth)
val width = sliderAdapter.size.toInt()
val placeable = measurables.first().measure(
Constraints.fixed(
width,
constraints.constrainHeight(scrollThickness)
)
)
layout(constraints.maxWidth, placeable.height) {
placeable.place(sliderAdapter.position.toInt(), 0)
}
}
/**
* The time that must elapse before a tap gesture sends onTapDown, if there's
* any doubt that the gesture is a tap.
*/
private const val PressTimeoutMillis: Long = 100L