blob: 77c547625d99a04151d6900dd3177e1dc8a73176 [file] [log] [blame]
Igor Demind7332f82020-10-23 21:45:51 +03001/*
2 * Copyright 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.compose.foundation
18
Doris Liu1ad57c42021-01-17 23:17:48 -080019import androidx.compose.animation.animateColorAsState
Igor Demind7332f82020-10-23 21:45:51 +030020import androidx.compose.animation.core.TweenSpec
Matvei Malkov49bd56a2021-03-24 19:19:28 +000021import androidx.compose.foundation.gestures.awaitFirstDown
Igor Demine9ef17e2021-04-22 16:46:17 +030022import androidx.compose.foundation.gestures.detectTapAndPress
Matvei Malkov49bd56a2021-03-24 19:19:28 +000023import androidx.compose.foundation.gestures.drag
George Mount32de9dd2022-10-05 14:51:06 -070024import androidx.compose.foundation.gestures.awaitEachGesture
Igor Demin7c097c42021-05-21 18:09:59 +030025import androidx.compose.foundation.gestures.scrollBy
Igor Demine9ef17e2021-04-22 16:46:17 +030026import androidx.compose.foundation.interaction.DragInteraction
27import androidx.compose.foundation.interaction.MutableInteractionSource
Igor Demine897e072021-08-25 20:53:19 +030028import androidx.compose.foundation.interaction.collectIsHoveredAsState
Igor Demine9ef17e2021-04-22 16:46:17 +030029import androidx.compose.foundation.layout.Box
30import androidx.compose.foundation.lazy.LazyListState
Igor Demin4c169c32021-07-21 09:01:19 +030031import androidx.compose.foundation.shape.RoundedCornerShape
Igor Demind7332f82020-10-23 21:45:51 +030032import androidx.compose.runtime.Composable
Igor Demine9ef17e2021-04-22 16:46:17 +030033import androidx.compose.runtime.CompositionLocal
34import androidx.compose.runtime.DisposableEffect
Igor Demind7332f82020-10-23 21:45:51 +030035import androidx.compose.runtime.Immutable
36import androidx.compose.runtime.LaunchedEffect
Matvei Malkov49bd56a2021-03-24 19:19:28 +000037import androidx.compose.runtime.MutableState
Igor Demin76a746c2021-05-05 20:08:39 +030038import androidx.compose.runtime.derivedStateOf
Igor Demine9ef17e2021-04-22 16:46:17 +030039import androidx.compose.runtime.getValue
Igor Demind7332f82020-10-23 21:45:51 +030040import androidx.compose.runtime.mutableStateOf
Igor Demind7332f82020-10-23 21:45:51 +030041import androidx.compose.runtime.remember
Igor Demin59cc44f2021-05-05 13:18:07 +030042import androidx.compose.runtime.rememberUpdatedState
Igor Demind7332f82020-10-23 21:45:51 +030043import androidx.compose.runtime.setValue
Louis Pullen-Freilichd42e1072021-01-27 18:20:02 +000044import androidx.compose.runtime.staticCompositionLocalOf
Igor Demind7332f82020-10-23 21:45:51 +030045import androidx.compose.ui.Modifier
46import androidx.compose.ui.composed
47import androidx.compose.ui.geometry.Offset
Igor Demind7332f82020-10-23 21:45:51 +030048import androidx.compose.ui.graphics.Color
Igor Demind7332f82020-10-23 21:45:51 +030049import androidx.compose.ui.graphics.Shape
Matvei Malkov49bd56a2021-03-24 19:19:28 +000050import androidx.compose.ui.input.pointer.pointerInput
51import androidx.compose.ui.input.pointer.positionChange
Igor Demind7332f82020-10-23 21:45:51 +030052import androidx.compose.ui.layout.Layout
Mihai Popa49584752021-01-25 12:38:07 +000053import androidx.compose.ui.layout.MeasurePolicy
Louis Pullen-Freilichd42e1072021-01-27 18:20:02 +000054import androidx.compose.ui.platform.LocalDensity
Igor Demin2c71f212022-01-21 19:41:17 +030055import androidx.compose.ui.platform.LocalLayoutDirection
Igor Demind7332f82020-10-23 21:45:51 +030056import androidx.compose.ui.unit.Constraints
57import androidx.compose.ui.unit.Dp
Igor Demin2c71f212022-01-21 19:41:17 +030058import androidx.compose.ui.unit.LayoutDirection
Igor Demind7332f82020-10-23 21:45:51 +030059import androidx.compose.ui.unit.constrainHeight
60import androidx.compose.ui.unit.constrainWidth
61import androidx.compose.ui.unit.dp
Igor Demind7332f82020-10-23 21:45:51 +030062import kotlinx.coroutines.delay
63import kotlinx.coroutines.runBlocking
Igor Demin7c097c42021-05-21 18:09:59 +030064import kotlin.math.abs
Andrey Kulikov78fca422021-02-08 21:39:23 +000065import kotlin.math.roundToInt
Igor Demind7332f82020-10-23 21:45:51 +030066import kotlin.math.sign
67
68/**
Igor Demine9ef17e2021-04-22 16:46:17 +030069 * [CompositionLocal] used to pass [ScrollbarStyle] down the tree.
Igor Demind7332f82020-10-23 21:45:51 +030070 * This value is typically set in some "Theme" composable function
71 * (DesktopTheme, MaterialTheme)
72 */
Igor Demine9ef17e2021-04-22 16:46:17 +030073val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
Igor Demind7332f82020-10-23 21:45:51 +030074
75/**
76 * Defines visual style of scrollbars (thickness, shapes, colors, etc).
Igor Demine9ef17e2021-04-22 16:46:17 +030077 * Can be passed as a parameter of scrollbar through [LocalScrollbarStyle]
Igor Demind7332f82020-10-23 21:45:51 +030078 */
79@Immutable
80data class ScrollbarStyle(
81 val minimalHeight: Dp,
82 val thickness: Dp,
83 val shape: Shape,
84 val hoverDurationMillis: Int,
85 val unhoverColor: Color,
86 val hoverColor: Color
87)
88
89/**
Igor Demin4c169c32021-07-21 09:01:19 +030090 * Simple default [ScrollbarStyle] without applying MaterialTheme.
Igor Demind7332f82020-10-23 21:45:51 +030091 */
92fun defaultScrollbarStyle() = ScrollbarStyle(
93 minimalHeight = 16.dp,
94 thickness = 8.dp,
Igor Demin4c169c32021-07-21 09:01:19 +030095 shape = RoundedCornerShape(4.dp),
96 hoverDurationMillis = 300,
Igor Demind7332f82020-10-23 21:45:51 +030097 unhoverColor = Color.Black.copy(alpha = 0.12f),
Igor Demin4c169c32021-07-21 09:01:19 +030098 hoverColor = Color.Black.copy(alpha = 0.50f)
Igor Demind7332f82020-10-23 21:45:51 +030099)
100
101/**
102 * Vertical scrollbar that can be attached to some scrollable
Andrey Kulikov1e8ebd32020-12-08 22:12:16 +0000103 * component (ScrollableColumn, LazyColumn) and share common state with it.
Igor Demind7332f82020-10-23 21:45:51 +0300104 *
105 * Can be placed independently.
106 *
107 * Example:
108 * val state = rememberScrollState(0f)
109 *
110 * Box(Modifier.fillMaxSize()) {
Igor Demindd2ac2e2021-05-05 21:25:57 +0300111 * Box(modifier = Modifier.verticalScroll(state)) {
Igor Demind7332f82020-10-23 21:45:51 +0300112 * ...
113 * }
114 *
115 * VerticalScrollbar(
116 * Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
117 * rememberScrollbarAdapter(state)
118 * )
119 * }
120 *
121 * @param adapter [ScrollbarAdapter] that will be used to communicate with scrollable component
122 * @param modifier the modifier to apply to this layout
Igor Demin64a76892021-05-21 16:21:13 +0300123 * @param reverseLayout reverse the direction of scrolling and layout, when `true`
124 * and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
125 * will be at the bottom of the container.
126 * It is usually used in pair with `LazyColumn(reverseLayout = true)`
Igor Demind7332f82020-10-23 21:45:51 +0300127 * @param style [ScrollbarStyle] to define visual style of scrollbar
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000128 * @param interactionSource [MutableInteractionSource] that will be used to dispatch
129 * [DragInteraction.Start] when this Scrollbar is being dragged.
Igor Demind7332f82020-10-23 21:45:51 +0300130 */
131@Composable
132fun VerticalScrollbar(
133 adapter: ScrollbarAdapter,
134 modifier: Modifier = Modifier,
Igor Demin64a76892021-05-21 16:21:13 +0300135 reverseLayout: Boolean = false,
Igor Demine9ef17e2021-04-22 16:46:17 +0300136 style: ScrollbarStyle = LocalScrollbarStyle.current,
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000137 interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
Igor Demind7332f82020-10-23 21:45:51 +0300138) = Scrollbar(
139 adapter,
140 modifier,
Igor Demin64a76892021-05-21 16:21:13 +0300141 reverseLayout,
Igor Demind7332f82020-10-23 21:45:51 +0300142 style,
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000143 interactionSource,
Igor Demind7332f82020-10-23 21:45:51 +0300144 isVertical = true
145)
146
147/**
148 * Horizontal scrollbar that can be attached to some scrollable
Igor Demindd2ac2e2021-05-05 21:25:57 +0300149 * component (Modifier.verticalScroll(), LazyRow) and share common state with it.
Igor Demind7332f82020-10-23 21:45:51 +0300150 *
151 * Can be placed independently.
152 *
153 * Example:
154 * val state = rememberScrollState(0f)
155 *
156 * Box(Modifier.fillMaxSize()) {
Igor Demindd2ac2e2021-05-05 21:25:57 +0300157 * Box(modifier = Modifier.verticalScroll(state)) {
Igor Demind7332f82020-10-23 21:45:51 +0300158 * ...
159 * }
160 *
161 * HorizontalScrollbar(
162 * Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
163 * rememberScrollbarAdapter(state)
164 * )
165 * }
166 *
167 * @param adapter [ScrollbarAdapter] that will be used to communicate with scrollable component
168 * @param modifier the modifier to apply to this layout
Igor Demin64a76892021-05-21 16:21:13 +0300169 * @param reverseLayout reverse the direction of scrolling and layout, when `true`
170 * and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
171 * will be at the end of the container.
172 * It is usually used in pair with `LazyRow(reverseLayout = true)`
Igor Demind7332f82020-10-23 21:45:51 +0300173 * @param style [ScrollbarStyle] to define visual style of scrollbar
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000174 * @param interactionSource [MutableInteractionSource] that will be used to dispatch
175 * [DragInteraction.Start] when this Scrollbar is being dragged.
Igor Demind7332f82020-10-23 21:45:51 +0300176 */
177@Composable
178fun HorizontalScrollbar(
179 adapter: ScrollbarAdapter,
180 modifier: Modifier = Modifier,
Igor Demin64a76892021-05-21 16:21:13 +0300181 reverseLayout: Boolean = false,
Igor Demine9ef17e2021-04-22 16:46:17 +0300182 style: ScrollbarStyle = LocalScrollbarStyle.current,
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000183 interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
Igor Demind7332f82020-10-23 21:45:51 +0300184) = Scrollbar(
185 adapter,
186 modifier,
Igor Demin2c71f212022-01-21 19:41:17 +0300187 if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout,
Igor Demind7332f82020-10-23 21:45:51 +0300188 style,
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000189 interactionSource,
Igor Demind7332f82020-10-23 21:45:51 +0300190 isVertical = false
191)
192
193// TODO(demin): do we need to stop dragging if cursor is beyond constraints?
Igor Demind7332f82020-10-23 21:45:51 +0300194@Composable
195private fun Scrollbar(
196 adapter: ScrollbarAdapter,
197 modifier: Modifier = Modifier,
Igor Demin64a76892021-05-21 16:21:13 +0300198 reverseLayout: Boolean,
Igor Demind7332f82020-10-23 21:45:51 +0300199 style: ScrollbarStyle,
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000200 interactionSource: MutableInteractionSource,
Igor Demind7332f82020-10-23 21:45:51 +0300201 isVertical: Boolean
Louis Pullen-Freilichd42e1072021-01-27 18:20:02 +0000202) = with(LocalDensity.current) {
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000203 val dragInteraction = remember { mutableStateOf<DragInteraction.Start?>(null) }
204 DisposableEffect(interactionSource) {
Leland Richardson66b9205c2021-01-12 10:41:48 -0800205 onDispose {
Louis Pullen-Freilich2ae44912021-02-06 21:48:44 +0000206 dragInteraction.value?.let { interaction ->
207 interactionSource.tryEmit(DragInteraction.Cancel(interaction))
208 dragInteraction.value = null
209 }
Leland Richardson66b9205c2021-01-12 10:41:48 -0800210 }
Igor Demind7332f82020-10-23 21:45:51 +0300211 }
212
213 var containerSize by remember { mutableStateOf(0) }
Igor Demine897e072021-08-25 20:53:19 +0300214 val isHovered by interactionSource.collectIsHoveredAsState()
Igor Demin76a746c2021-05-05 20:08:39 +0300215
216 val isHighlighted by remember {
217 derivedStateOf {
218 isHovered || dragInteraction.value is DragInteraction.Start
219 }
220 }
Igor Demind7332f82020-10-23 21:45:51 +0300221
222 val minimalHeight = style.minimalHeight.toPx()
Igor Demin64a76892021-05-21 16:21:13 +0300223 val sliderAdapter = remember(adapter, containerSize, minimalHeight, reverseLayout) {
224 SliderAdapter(adapter, containerSize, minimalHeight, reverseLayout)
Igor Demind7332f82020-10-23 21:45:51 +0300225 }
226
Mihai Popa53f5bac2021-01-20 18:48:00 +0000227 val scrollThickness = style.thickness.roundToPx()
Mihai Popa49584752021-01-25 12:38:07 +0000228 val measurePolicy = if (isVertical) {
Igor Demind7332f82020-10-23 21:45:51 +0300229 remember(sliderAdapter, scrollThickness) {
Mihai Popa49584752021-01-25 12:38:07 +0000230 verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
Igor Demind7332f82020-10-23 21:45:51 +0300231 }
232 } else {
233 remember(sliderAdapter, scrollThickness) {
Mihai Popa49584752021-01-25 12:38:07 +0000234 horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
Igor Demind7332f82020-10-23 21:45:51 +0300235 }
236 }
237
Doris Liu1ad57c42021-01-17 23:17:48 -0800238 val color by animateColorAsState(
Igor Demin76a746c2021-05-05 20:08:39 +0300239 if (isHighlighted) style.hoverColor else style.unhoverColor,
Doris Liu05c20be2020-12-15 22:36:45 -0800240 animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis)
Igor Demind7332f82020-10-23 21:45:51 +0300241 )
242
243 val isVisible = sliderAdapter.size < containerSize
244
245 Layout(
246 {
247 Box(
248 Modifier
249 .background(if (isVisible) color else Color.Transparent, style.shape)
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300250 .scrollbarDrag(
251 interactionSource = interactionSource,
252 draggedInteraction = dragInteraction,
253 onDelta = { offset ->
254 sliderAdapter.rawPosition += if (isVertical) offset.y else offset.x
255 },
256 onFinished = { sliderAdapter.rawPosition = sliderAdapter.position }
257 )
Igor Demind7332f82020-10-23 21:45:51 +0300258 )
259 },
Igor Demind7332f82020-10-23 21:45:51 +0300260 modifier
Igor Demine897e072021-08-25 20:53:19 +0300261 .hoverable(interactionSource = interactionSource)
Mihai Popa49584752021-01-25 12:38:07 +0000262 .scrollOnPressOutsideSlider(isVertical, sliderAdapter, adapter, containerSize),
263 measurePolicy
Igor Demind7332f82020-10-23 21:45:51 +0300264 )
265}
266
Matvei Malkov49bd56a2021-03-24 19:19:28 +0000267private fun Modifier.scrollbarDrag(
268 interactionSource: MutableInteractionSource,
269 draggedInteraction: MutableState<DragInteraction.Start?>,
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300270 onDelta: (Offset) -> Unit,
271 onFinished: () -> Unit
Igor Demin59cc44f2021-05-05 13:18:07 +0300272): Modifier = composed {
273 val currentInteractionSource by rememberUpdatedState(interactionSource)
274 val currentDraggedInteraction by rememberUpdatedState(draggedInteraction)
275 val currentOnDelta by rememberUpdatedState(onDelta)
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300276 val currentOnFinished by rememberUpdatedState(onFinished)
Igor Demin59cc44f2021-05-05 13:18:07 +0300277 pointerInput(Unit) {
George Mount32de9dd2022-10-05 14:51:06 -0700278 awaitEachGesture {
279 val down = awaitFirstDown(requireUnconsumed = false)
280 val interaction = DragInteraction.Start()
281 currentInteractionSource.tryEmit(interaction)
282 currentDraggedInteraction.value = interaction
283 val isSuccess = drag(down.id) { change ->
284 currentOnDelta.invoke(change.positionChange())
285 change.consume()
Matvei Malkov49bd56a2021-03-24 19:19:28 +0000286 }
George Mount32de9dd2022-10-05 14:51:06 -0700287 val finishInteraction = if (isSuccess) {
288 DragInteraction.Stop(interaction)
289 } else {
290 DragInteraction.Cancel(interaction)
291 }
292 currentInteractionSource.tryEmit(finishInteraction)
293 currentDraggedInteraction.value = null
294 currentOnFinished.invoke()
Matvei Malkov49bd56a2021-03-24 19:19:28 +0000295 }
296 }
297}
298
Igor Demind7332f82020-10-23 21:45:51 +0300299private fun Modifier.scrollOnPressOutsideSlider(
300 isVertical: Boolean,
301 sliderAdapter: SliderAdapter,
302 scrollbarAdapter: ScrollbarAdapter,
303 containerSize: Int
304) = composed {
305 var targetOffset: Offset? by remember { mutableStateOf(null) }
306
307 if (targetOffset != null) {
308 val targetPosition = if (isVertical) targetOffset!!.y else targetOffset!!.x
309
310 LaunchedEffect(targetPosition) {
George Mount8c41a232021-01-11 21:13:04 +0000311 var delay = PressTimeoutMillis * 3
Igor Demind7332f82020-10-23 21:45:51 +0300312 while (targetPosition !in sliderAdapter.bounds) {
313 val oldSign = sign(targetPosition - sliderAdapter.position)
314 scrollbarAdapter.scrollTo(
315 containerSize,
316 scrollbarAdapter.scrollOffset + oldSign * containerSize
317 )
318 val newSign = sign(targetPosition - sliderAdapter.position)
319
320 if (oldSign != newSign) {
321 break
322 }
323
George Mount8c41a232021-01-11 21:13:04 +0000324 delay(delay)
325 delay = PressTimeoutMillis
Igor Demind7332f82020-10-23 21:45:51 +0300326 }
327 }
328 }
Matvei Malkov2f2b9d82021-03-18 15:20:32 +0000329 Modifier.pointerInput(Unit) {
330 detectTapAndPress(
331 onPress = { offset ->
332 targetOffset = offset
333 tryAwaitRelease()
334 targetOffset = null
335 },
336 onTap = {}
337 )
338 }
Igor Demind7332f82020-10-23 21:45:51 +0300339}
340
341/**
342 * Create and [remember] [ScrollbarAdapter] for scrollable container and current instance of
343 * [scrollState]
344 */
345@Composable
346fun rememberScrollbarAdapter(
347 scrollState: ScrollState
348): ScrollbarAdapter = remember(scrollState) {
349 ScrollbarAdapter(scrollState)
350}
351
352/**
353 * Create and [remember] [ScrollbarAdapter] for lazy scrollable container and current instance of
Igor Demin7c097c42021-05-21 18:09:59 +0300354 * [scrollState]
355 */
356@Composable
357fun rememberScrollbarAdapter(
358 scrollState: LazyListState,
359): ScrollbarAdapter {
360 return remember(scrollState) {
361 ScrollbarAdapter(scrollState)
Igor Demind7332f82020-10-23 21:45:51 +0300362 }
363}
364
365/**
Igor Demindd2ac2e2021-05-05 21:25:57 +0300366 * ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll
Igor Demind7332f82020-10-23 21:45:51 +0300367 *
368 * [scrollState] is instance of [ScrollState] which is used by scrollable component
369 *
370 * Example:
371 * val state = rememberScrollState(0f)
372 *
373 * Box(Modifier.fillMaxSize()) {
Igor Demindd2ac2e2021-05-05 21:25:57 +0300374 * Box(modifier = Modifier.verticalScroll(state)) {
Igor Demind7332f82020-10-23 21:45:51 +0300375 * ...
376 * }
377 *
378 * VerticalScrollbar(
379 * Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
380 * rememberScrollbarAdapter(state)
381 * )
382 * }
383 */
384fun ScrollbarAdapter(
385 scrollState: ScrollState
386): ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState)
387
388private class ScrollableScrollbarAdapter(
389 private val scrollState: ScrollState
390) : ScrollbarAdapter {
Andrey Kulikov78fca422021-02-08 21:39:23 +0000391 override val scrollOffset: Float get() = scrollState.value.toFloat()
Igor Demind7332f82020-10-23 21:45:51 +0300392
393 override suspend fun scrollTo(containerSize: Int, scrollOffset: Float) {
Andrey Kulikov78fca422021-02-08 21:39:23 +0000394 scrollState.scrollTo(scrollOffset.roundToInt())
Igor Demind7332f82020-10-23 21:45:51 +0300395 }
396
397 override fun maxScrollOffset(containerSize: Int) =
Andrey Kulikov78fca422021-02-08 21:39:23 +0000398 scrollState.maxValue.toFloat()
Igor Demind7332f82020-10-23 21:45:51 +0300399}
400
Igor Demind7332f82020-10-23 21:45:51 +0300401/**
Igor Demin7c097c42021-05-21 18:09:59 +0300402 * ScrollbarAdapter for lazy lists.
Igor Demind7332f82020-10-23 21:45:51 +0300403 *
404 * [scrollState] is instance of [LazyListState] which is used by scrollable component
405 *
Igor Demin7c097c42021-05-21 18:09:59 +0300406 * Scrollbar size and position will be dynamically changed on the current visible content.
Igor Demind7332f82020-10-23 21:45:51 +0300407 *
408 * Example:
409 * Box(Modifier.fillMaxSize()) {
410 * val state = rememberLazyListState()
Igor Demind7332f82020-10-23 21:45:51 +0300411 *
412 * LazyColumn(state = state) {
413 * ...
414 * }
415 *
416 * VerticalScrollbar(
417 * Modifier.align(Alignment.CenterEnd),
Igor Demin7c097c42021-05-21 18:09:59 +0300418 * rememberScrollbarAdapter(state)
Igor Demind7332f82020-10-23 21:45:51 +0300419 * )
420 * }
421 */
Igor Demind7332f82020-10-23 21:45:51 +0300422fun ScrollbarAdapter(
Igor Demin7c097c42021-05-21 18:09:59 +0300423 scrollState: LazyListState
Igor Demind7332f82020-10-23 21:45:51 +0300424): ScrollbarAdapter = LazyScrollbarAdapter(
Igor Demin7c097c42021-05-21 18:09:59 +0300425 scrollState
Igor Demind7332f82020-10-23 21:45:51 +0300426)
427
428private class LazyScrollbarAdapter(
Igor Demin7c097c42021-05-21 18:09:59 +0300429 private val scrollState: LazyListState
Igor Demind7332f82020-10-23 21:45:51 +0300430) : ScrollbarAdapter {
431 override val scrollOffset: Float
432 get() = scrollState.firstVisibleItemIndex * averageItemSize +
433 scrollState.firstVisibleItemScrollOffset
434
435 override suspend fun scrollTo(containerSize: Int, scrollOffset: Float) {
Igor Demin7c097c42021-05-21 18:09:59 +0300436 val distance = scrollOffset - this@LazyScrollbarAdapter.scrollOffset
437
438 // if we scroll less than containerSize we need to use scrollBy function to avoid
439 // undesirable scroll jumps (when an item size is different)
440 //
441 // if we scroll more than containerSize we should immediately jump to this position
442 // without recreating all items between the current and the new position
443 if (abs(distance) <= containerSize) {
444 scrollState.scrollBy(distance)
445 } else {
446 snapTo(containerSize, scrollOffset)
447 }
448 }
449
450 private suspend fun snapTo(containerSize: Int, scrollOffset: Float) {
Igor Deminbf4afe72021-05-21 18:01:55 +0300451 // In case of very big values, we can catch an overflow, so convert values to double and
452 // coerce them
453// val averageItemSize = 26.000002f
454// val scrollOffsetCoerced = 2.54490608E8.toFloat()
455// val index = (scrollOffsetCoerced / averageItemSize).toInt() // 9788100
456// val offset = (scrollOffsetCoerced - index * averageItemSize) // -16.0
457// println(offset)
458
459 val maximumValue = maxScrollOffset(containerSize).toDouble()
460 val scrollOffsetCoerced = scrollOffset.toDouble().coerceIn(0.0, maximumValue)
461 val averageItemSize = averageItemSize.toDouble()
Igor Demin67e4aa22020-11-19 11:12:43 +0300462
463 val index = (scrollOffsetCoerced / averageItemSize)
Igor Demind7332f82020-10-23 21:45:51 +0300464 .toInt()
465 .coerceAtLeast(0)
466 .coerceAtMost(itemCount - 1)
467
Igor Deminbf4afe72021-05-21 18:01:55 +0300468 val offset = (scrollOffsetCoerced - index * averageItemSize)
469 .toInt()
470 .coerceAtLeast(0)
471
472 scrollState.scrollToItem(index = index, scrollOffset = offset)
Igor Demind7332f82020-10-23 21:45:51 +0300473 }
474
475 override fun maxScrollOffset(containerSize: Int) =
Igor Demin95d08812021-08-02 12:38:05 +0300476 (averageItemSize * itemCount - containerSize).coerceAtLeast(0f)
Igor Demin7c097c42021-05-21 18:09:59 +0300477
478 private val itemCount get() = scrollState.layoutInfo.totalItemsCount
479
480 private val averageItemSize by derivedStateOf {
481 scrollState
482 .layoutInfo
483 .visibleItemsInfo
484 .asSequence()
485 .map { it.size }
486 .average()
487 .toFloat()
488 }
Igor Demind7332f82020-10-23 21:45:51 +0300489}
490
491/**
492 * Defines how to scroll the scrollable component
493 */
494interface ScrollbarAdapter {
495 /**
496 * Scroll offset of the content inside the scrollable component.
497 * Offset "100" means that the content is scrolled by 100 pixels from the start.
498 */
499 val scrollOffset: Float
500
501 /**
502 * Instantly jump to [scrollOffset] in pixels
503 *
504 * @param containerSize size of the scrollable container
505 * (for example, it is height of ScrollableColumn if we use VerticalScrollbar)
506 * @param scrollOffset target value in pixels to jump to,
507 * value will be coerced to 0..maxScrollOffset
508 */
509 suspend fun scrollTo(containerSize: Int, scrollOffset: Float)
510
511 /**
512 * Maximum scroll offset of the content inside the scrollable component
513 *
514 * @param containerSize size of the scrollable component
515 * (for example, it is height of ScrollableColumn if we use VerticalScrollbar)
516 */
517 fun maxScrollOffset(containerSize: Int): Float
518}
519
520private class SliderAdapter(
521 val adapter: ScrollbarAdapter,
522 val containerSize: Int,
Igor Demin64a76892021-05-21 16:21:13 +0300523 val minHeight: Float,
524 val reverseLayout: Boolean
Igor Demind7332f82020-10-23 21:45:51 +0300525) {
Igor Demin3bc059e2020-11-25 21:19:35 +0300526 private val contentSize get() = adapter.maxScrollOffset(containerSize) + containerSize
Igor Demind7332f82020-10-23 21:45:51 +0300527 private val visiblePart get() = containerSize.toFloat() / contentSize
528
529 val size
530 get() = (containerSize * visiblePart)
531 .coerceAtLeast(minHeight)
532 .coerceAtMost(containerSize.toFloat())
533
Igor Demindd2ac2e2021-05-05 21:25:57 +0300534 private val scrollScale: Float
535 get() {
536 val extraScrollbarSpace = containerSize - size
537 val extraContentSpace = contentSize - containerSize
538 return if (extraContentSpace == 0f) 1f else extraScrollbarSpace / extraContentSpace
539 }
Igor Demind7332f82020-10-23 21:45:51 +0300540
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300541 /**
542 * A position with cumulative offset, may be out of the container when dragging
543 */
544 var rawPosition: Float = position
545 set(value) {
546 field = value
547 position = value
548 }
549
550 /**
551 * Actual scroll of content regarding slider layout
552 */
553 private var scrollPosition: Float
Igor Demind7332f82020-10-23 21:45:51 +0300554 get() = scrollScale * adapter.scrollOffset
555 set(value) {
556 runBlocking {
557 adapter.scrollTo(containerSize, value / scrollScale)
558 }
559 }
560
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300561 /**
562 * Actual position of a thumb within slider container
563 */
Igor Demin64a76892021-05-21 16:21:13 +0300564 var position: Float
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300565 get() = if (reverseLayout) containerSize - size - scrollPosition else scrollPosition
Igor Demin64a76892021-05-21 16:21:13 +0300566 set(value) {
Aleksandr Veselov793810b2022-01-26 00:12:16 +0300567 scrollPosition = if (reverseLayout) {
Igor Demin64a76892021-05-21 16:21:13 +0300568 containerSize - size - value
569 } else {
570 value
571 }
572 }
573
Igor Demind7332f82020-10-23 21:45:51 +0300574 val bounds get() = position..position + size
575}
576
Mihai Popa49584752021-01-25 12:38:07 +0000577private fun verticalMeasurePolicy(
Igor Demind7332f82020-10-23 21:45:51 +0300578 sliderAdapter: SliderAdapter,
579 setContainerSize: (Int) -> Unit,
580 scrollThickness: Int
Mihai Popa49584752021-01-25 12:38:07 +0000581) = MeasurePolicy { measurables, constraints ->
Igor Demind7332f82020-10-23 21:45:51 +0300582 setContainerSize(constraints.maxHeight)
583 val height = sliderAdapter.size.toInt()
584 val placeable = measurables.first().measure(
585 Constraints.fixed(
586 constraints.constrainWidth(scrollThickness),
587 height
588 )
589 )
590 layout(placeable.width, constraints.maxHeight) {
591 placeable.place(0, sliderAdapter.position.toInt())
592 }
593}
594
Mihai Popa49584752021-01-25 12:38:07 +0000595private fun horizontalMeasurePolicy(
Igor Demind7332f82020-10-23 21:45:51 +0300596 sliderAdapter: SliderAdapter,
597 setContainerSize: (Int) -> Unit,
598 scrollThickness: Int
Mihai Popa49584752021-01-25 12:38:07 +0000599) = MeasurePolicy { measurables, constraints ->
Igor Demind7332f82020-10-23 21:45:51 +0300600 setContainerSize(constraints.maxWidth)
601 val width = sliderAdapter.size.toInt()
602 val placeable = measurables.first().measure(
603 Constraints.fixed(
604 width,
605 constraints.constrainHeight(scrollThickness)
606 )
607 )
608 layout(constraints.maxWidth, placeable.height) {
609 placeable.place(sliderAdapter.position.toInt(), 0)
610 }
Matvei Malkov79be98f2021-02-03 21:36:55 +0000611}
612
613/**
614 * The time that must elapse before a tap gesture sends onTapDown, if there's
615 * any doubt that the gesture is a tap.
616 */
Matvei Malkov2f2b9d82021-03-18 15:20:32 +0000617private const val PressTimeoutMillis: Long = 100L