Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 1 | /* |
| 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 | |
| 17 | package androidx.compose.foundation |
| 18 | |
Doris Liu | 1ad57c4 | 2021-01-17 23:17:48 -0800 | [diff] [blame] | 19 | import androidx.compose.animation.animateColorAsState |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 20 | import androidx.compose.animation.core.TweenSpec |
Matvei Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 21 | import androidx.compose.foundation.gestures.awaitFirstDown |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 22 | import androidx.compose.foundation.gestures.detectTapAndPress |
Matvei Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 23 | import androidx.compose.foundation.gestures.drag |
George Mount | 32de9dd | 2022-10-05 14:51:06 -0700 | [diff] [blame^] | 24 | import androidx.compose.foundation.gestures.awaitEachGesture |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 25 | import androidx.compose.foundation.gestures.scrollBy |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 26 | import androidx.compose.foundation.interaction.DragInteraction |
| 27 | import androidx.compose.foundation.interaction.MutableInteractionSource |
Igor Demin | e897e07 | 2021-08-25 20:53:19 +0300 | [diff] [blame] | 28 | import androidx.compose.foundation.interaction.collectIsHoveredAsState |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 29 | import androidx.compose.foundation.layout.Box |
| 30 | import androidx.compose.foundation.lazy.LazyListState |
Igor Demin | 4c169c3 | 2021-07-21 09:01:19 +0300 | [diff] [blame] | 31 | import androidx.compose.foundation.shape.RoundedCornerShape |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 32 | import androidx.compose.runtime.Composable |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 33 | import androidx.compose.runtime.CompositionLocal |
| 34 | import androidx.compose.runtime.DisposableEffect |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 35 | import androidx.compose.runtime.Immutable |
| 36 | import androidx.compose.runtime.LaunchedEffect |
Matvei Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 37 | import androidx.compose.runtime.MutableState |
Igor Demin | 76a746c | 2021-05-05 20:08:39 +0300 | [diff] [blame] | 38 | import androidx.compose.runtime.derivedStateOf |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 39 | import androidx.compose.runtime.getValue |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 40 | import androidx.compose.runtime.mutableStateOf |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 41 | import androidx.compose.runtime.remember |
Igor Demin | 59cc44f | 2021-05-05 13:18:07 +0300 | [diff] [blame] | 42 | import androidx.compose.runtime.rememberUpdatedState |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 43 | import androidx.compose.runtime.setValue |
Louis Pullen-Freilich | d42e107 | 2021-01-27 18:20:02 +0000 | [diff] [blame] | 44 | import androidx.compose.runtime.staticCompositionLocalOf |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 45 | import androidx.compose.ui.Modifier |
| 46 | import androidx.compose.ui.composed |
| 47 | import androidx.compose.ui.geometry.Offset |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 48 | import androidx.compose.ui.graphics.Color |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 49 | import androidx.compose.ui.graphics.Shape |
Matvei Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 50 | import androidx.compose.ui.input.pointer.pointerInput |
| 51 | import androidx.compose.ui.input.pointer.positionChange |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 52 | import androidx.compose.ui.layout.Layout |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 53 | import androidx.compose.ui.layout.MeasurePolicy |
Louis Pullen-Freilich | d42e107 | 2021-01-27 18:20:02 +0000 | [diff] [blame] | 54 | import androidx.compose.ui.platform.LocalDensity |
Igor Demin | 2c71f21 | 2022-01-21 19:41:17 +0300 | [diff] [blame] | 55 | import androidx.compose.ui.platform.LocalLayoutDirection |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 56 | import androidx.compose.ui.unit.Constraints |
| 57 | import androidx.compose.ui.unit.Dp |
Igor Demin | 2c71f21 | 2022-01-21 19:41:17 +0300 | [diff] [blame] | 58 | import androidx.compose.ui.unit.LayoutDirection |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 59 | import androidx.compose.ui.unit.constrainHeight |
| 60 | import androidx.compose.ui.unit.constrainWidth |
| 61 | import androidx.compose.ui.unit.dp |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 62 | import kotlinx.coroutines.delay |
| 63 | import kotlinx.coroutines.runBlocking |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 64 | import kotlin.math.abs |
Andrey Kulikov | 78fca42 | 2021-02-08 21:39:23 +0000 | [diff] [blame] | 65 | import kotlin.math.roundToInt |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 66 | import kotlin.math.sign |
| 67 | |
| 68 | /** |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 69 | * [CompositionLocal] used to pass [ScrollbarStyle] down the tree. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 70 | * This value is typically set in some "Theme" composable function |
| 71 | * (DesktopTheme, MaterialTheme) |
| 72 | */ |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 73 | val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() } |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 74 | |
| 75 | /** |
| 76 | * Defines visual style of scrollbars (thickness, shapes, colors, etc). |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 77 | * Can be passed as a parameter of scrollbar through [LocalScrollbarStyle] |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 78 | */ |
| 79 | @Immutable |
| 80 | data 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 Demin | 4c169c3 | 2021-07-21 09:01:19 +0300 | [diff] [blame] | 90 | * Simple default [ScrollbarStyle] without applying MaterialTheme. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 91 | */ |
| 92 | fun defaultScrollbarStyle() = ScrollbarStyle( |
| 93 | minimalHeight = 16.dp, |
| 94 | thickness = 8.dp, |
Igor Demin | 4c169c3 | 2021-07-21 09:01:19 +0300 | [diff] [blame] | 95 | shape = RoundedCornerShape(4.dp), |
| 96 | hoverDurationMillis = 300, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 97 | unhoverColor = Color.Black.copy(alpha = 0.12f), |
Igor Demin | 4c169c3 | 2021-07-21 09:01:19 +0300 | [diff] [blame] | 98 | hoverColor = Color.Black.copy(alpha = 0.50f) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 99 | ) |
| 100 | |
| 101 | /** |
| 102 | * Vertical scrollbar that can be attached to some scrollable |
Andrey Kulikov | 1e8ebd3 | 2020-12-08 22:12:16 +0000 | [diff] [blame] | 103 | * component (ScrollableColumn, LazyColumn) and share common state with it. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 104 | * |
| 105 | * Can be placed independently. |
| 106 | * |
| 107 | * Example: |
| 108 | * val state = rememberScrollState(0f) |
| 109 | * |
| 110 | * Box(Modifier.fillMaxSize()) { |
Igor Demin | dd2ac2e | 2021-05-05 21:25:57 +0300 | [diff] [blame] | 111 | * Box(modifier = Modifier.verticalScroll(state)) { |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 112 | * ... |
| 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 Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 123 | * @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 Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 127 | * @param style [ScrollbarStyle] to define visual style of scrollbar |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 128 | * @param interactionSource [MutableInteractionSource] that will be used to dispatch |
| 129 | * [DragInteraction.Start] when this Scrollbar is being dragged. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 130 | */ |
| 131 | @Composable |
| 132 | fun VerticalScrollbar( |
| 133 | adapter: ScrollbarAdapter, |
| 134 | modifier: Modifier = Modifier, |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 135 | reverseLayout: Boolean = false, |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 136 | style: ScrollbarStyle = LocalScrollbarStyle.current, |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 137 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 138 | ) = Scrollbar( |
| 139 | adapter, |
| 140 | modifier, |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 141 | reverseLayout, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 142 | style, |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 143 | interactionSource, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 144 | isVertical = true |
| 145 | ) |
| 146 | |
| 147 | /** |
| 148 | * Horizontal scrollbar that can be attached to some scrollable |
Igor Demin | dd2ac2e | 2021-05-05 21:25:57 +0300 | [diff] [blame] | 149 | * component (Modifier.verticalScroll(), LazyRow) and share common state with it. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 150 | * |
| 151 | * Can be placed independently. |
| 152 | * |
| 153 | * Example: |
| 154 | * val state = rememberScrollState(0f) |
| 155 | * |
| 156 | * Box(Modifier.fillMaxSize()) { |
Igor Demin | dd2ac2e | 2021-05-05 21:25:57 +0300 | [diff] [blame] | 157 | * Box(modifier = Modifier.verticalScroll(state)) { |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 158 | * ... |
| 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 Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 169 | * @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 Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 173 | * @param style [ScrollbarStyle] to define visual style of scrollbar |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 174 | * @param interactionSource [MutableInteractionSource] that will be used to dispatch |
| 175 | * [DragInteraction.Start] when this Scrollbar is being dragged. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 176 | */ |
| 177 | @Composable |
| 178 | fun HorizontalScrollbar( |
| 179 | adapter: ScrollbarAdapter, |
| 180 | modifier: Modifier = Modifier, |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 181 | reverseLayout: Boolean = false, |
Igor Demin | e9ef17e | 2021-04-22 16:46:17 +0300 | [diff] [blame] | 182 | style: ScrollbarStyle = LocalScrollbarStyle.current, |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 183 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 184 | ) = Scrollbar( |
| 185 | adapter, |
| 186 | modifier, |
Igor Demin | 2c71f21 | 2022-01-21 19:41:17 +0300 | [diff] [blame] | 187 | if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 188 | style, |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 189 | interactionSource, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 190 | isVertical = false |
| 191 | ) |
| 192 | |
| 193 | // TODO(demin): do we need to stop dragging if cursor is beyond constraints? |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 194 | @Composable |
| 195 | private fun Scrollbar( |
| 196 | adapter: ScrollbarAdapter, |
| 197 | modifier: Modifier = Modifier, |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 198 | reverseLayout: Boolean, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 199 | style: ScrollbarStyle, |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 200 | interactionSource: MutableInteractionSource, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 201 | isVertical: Boolean |
Louis Pullen-Freilich | d42e107 | 2021-01-27 18:20:02 +0000 | [diff] [blame] | 202 | ) = with(LocalDensity.current) { |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 203 | val dragInteraction = remember { mutableStateOf<DragInteraction.Start?>(null) } |
| 204 | DisposableEffect(interactionSource) { |
Leland Richardson | 66b9205c | 2021-01-12 10:41:48 -0800 | [diff] [blame] | 205 | onDispose { |
Louis Pullen-Freilich | 2ae4491 | 2021-02-06 21:48:44 +0000 | [diff] [blame] | 206 | dragInteraction.value?.let { interaction -> |
| 207 | interactionSource.tryEmit(DragInteraction.Cancel(interaction)) |
| 208 | dragInteraction.value = null |
| 209 | } |
Leland Richardson | 66b9205c | 2021-01-12 10:41:48 -0800 | [diff] [blame] | 210 | } |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 211 | } |
| 212 | |
| 213 | var containerSize by remember { mutableStateOf(0) } |
Igor Demin | e897e07 | 2021-08-25 20:53:19 +0300 | [diff] [blame] | 214 | val isHovered by interactionSource.collectIsHoveredAsState() |
Igor Demin | 76a746c | 2021-05-05 20:08:39 +0300 | [diff] [blame] | 215 | |
| 216 | val isHighlighted by remember { |
| 217 | derivedStateOf { |
| 218 | isHovered || dragInteraction.value is DragInteraction.Start |
| 219 | } |
| 220 | } |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 221 | |
| 222 | val minimalHeight = style.minimalHeight.toPx() |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 223 | val sliderAdapter = remember(adapter, containerSize, minimalHeight, reverseLayout) { |
| 224 | SliderAdapter(adapter, containerSize, minimalHeight, reverseLayout) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 225 | } |
| 226 | |
Mihai Popa | 53f5bac | 2021-01-20 18:48:00 +0000 | [diff] [blame] | 227 | val scrollThickness = style.thickness.roundToPx() |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 228 | val measurePolicy = if (isVertical) { |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 229 | remember(sliderAdapter, scrollThickness) { |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 230 | verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 231 | } |
| 232 | } else { |
| 233 | remember(sliderAdapter, scrollThickness) { |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 234 | horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 235 | } |
| 236 | } |
| 237 | |
Doris Liu | 1ad57c4 | 2021-01-17 23:17:48 -0800 | [diff] [blame] | 238 | val color by animateColorAsState( |
Igor Demin | 76a746c | 2021-05-05 20:08:39 +0300 | [diff] [blame] | 239 | if (isHighlighted) style.hoverColor else style.unhoverColor, |
Doris Liu | 05c20be | 2020-12-15 22:36:45 -0800 | [diff] [blame] | 240 | animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 241 | ) |
| 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 Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 250 | .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 Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 258 | ) |
| 259 | }, |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 260 | modifier |
Igor Demin | e897e07 | 2021-08-25 20:53:19 +0300 | [diff] [blame] | 261 | .hoverable(interactionSource = interactionSource) |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 262 | .scrollOnPressOutsideSlider(isVertical, sliderAdapter, adapter, containerSize), |
| 263 | measurePolicy |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 264 | ) |
| 265 | } |
| 266 | |
Matvei Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 267 | private fun Modifier.scrollbarDrag( |
| 268 | interactionSource: MutableInteractionSource, |
| 269 | draggedInteraction: MutableState<DragInteraction.Start?>, |
Aleksandr Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 270 | onDelta: (Offset) -> Unit, |
| 271 | onFinished: () -> Unit |
Igor Demin | 59cc44f | 2021-05-05 13:18:07 +0300 | [diff] [blame] | 272 | ): Modifier = composed { |
| 273 | val currentInteractionSource by rememberUpdatedState(interactionSource) |
| 274 | val currentDraggedInteraction by rememberUpdatedState(draggedInteraction) |
| 275 | val currentOnDelta by rememberUpdatedState(onDelta) |
Aleksandr Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 276 | val currentOnFinished by rememberUpdatedState(onFinished) |
Igor Demin | 59cc44f | 2021-05-05 13:18:07 +0300 | [diff] [blame] | 277 | pointerInput(Unit) { |
George Mount | 32de9dd | 2022-10-05 14:51:06 -0700 | [diff] [blame^] | 278 | 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 Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 286 | } |
George Mount | 32de9dd | 2022-10-05 14:51:06 -0700 | [diff] [blame^] | 287 | 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 Malkov | 49bd56a | 2021-03-24 19:19:28 +0000 | [diff] [blame] | 295 | } |
| 296 | } |
| 297 | } |
| 298 | |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 299 | private 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 Mount | 8c41a23 | 2021-01-11 21:13:04 +0000 | [diff] [blame] | 311 | var delay = PressTimeoutMillis * 3 |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 312 | 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 Mount | 8c41a23 | 2021-01-11 21:13:04 +0000 | [diff] [blame] | 324 | delay(delay) |
| 325 | delay = PressTimeoutMillis |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 326 | } |
| 327 | } |
| 328 | } |
Matvei Malkov | 2f2b9d8 | 2021-03-18 15:20:32 +0000 | [diff] [blame] | 329 | Modifier.pointerInput(Unit) { |
| 330 | detectTapAndPress( |
| 331 | onPress = { offset -> |
| 332 | targetOffset = offset |
| 333 | tryAwaitRelease() |
| 334 | targetOffset = null |
| 335 | }, |
| 336 | onTap = {} |
| 337 | ) |
| 338 | } |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 339 | } |
| 340 | |
| 341 | /** |
| 342 | * Create and [remember] [ScrollbarAdapter] for scrollable container and current instance of |
| 343 | * [scrollState] |
| 344 | */ |
| 345 | @Composable |
| 346 | fun 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 Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 354 | * [scrollState] |
| 355 | */ |
| 356 | @Composable |
| 357 | fun rememberScrollbarAdapter( |
| 358 | scrollState: LazyListState, |
| 359 | ): ScrollbarAdapter { |
| 360 | return remember(scrollState) { |
| 361 | ScrollbarAdapter(scrollState) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 362 | } |
| 363 | } |
| 364 | |
| 365 | /** |
Igor Demin | dd2ac2e | 2021-05-05 21:25:57 +0300 | [diff] [blame] | 366 | * ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 367 | * |
| 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 Demin | dd2ac2e | 2021-05-05 21:25:57 +0300 | [diff] [blame] | 374 | * Box(modifier = Modifier.verticalScroll(state)) { |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 375 | * ... |
| 376 | * } |
| 377 | * |
| 378 | * VerticalScrollbar( |
| 379 | * Modifier.align(Alignment.CenterEnd).fillMaxHeight(), |
| 380 | * rememberScrollbarAdapter(state) |
| 381 | * ) |
| 382 | * } |
| 383 | */ |
| 384 | fun ScrollbarAdapter( |
| 385 | scrollState: ScrollState |
| 386 | ): ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState) |
| 387 | |
| 388 | private class ScrollableScrollbarAdapter( |
| 389 | private val scrollState: ScrollState |
| 390 | ) : ScrollbarAdapter { |
Andrey Kulikov | 78fca42 | 2021-02-08 21:39:23 +0000 | [diff] [blame] | 391 | override val scrollOffset: Float get() = scrollState.value.toFloat() |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 392 | |
| 393 | override suspend fun scrollTo(containerSize: Int, scrollOffset: Float) { |
Andrey Kulikov | 78fca42 | 2021-02-08 21:39:23 +0000 | [diff] [blame] | 394 | scrollState.scrollTo(scrollOffset.roundToInt()) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 395 | } |
| 396 | |
| 397 | override fun maxScrollOffset(containerSize: Int) = |
Andrey Kulikov | 78fca42 | 2021-02-08 21:39:23 +0000 | [diff] [blame] | 398 | scrollState.maxValue.toFloat() |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 399 | } |
| 400 | |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 401 | /** |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 402 | * ScrollbarAdapter for lazy lists. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 403 | * |
| 404 | * [scrollState] is instance of [LazyListState] which is used by scrollable component |
| 405 | * |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 406 | * Scrollbar size and position will be dynamically changed on the current visible content. |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 407 | * |
| 408 | * Example: |
| 409 | * Box(Modifier.fillMaxSize()) { |
| 410 | * val state = rememberLazyListState() |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 411 | * |
| 412 | * LazyColumn(state = state) { |
| 413 | * ... |
| 414 | * } |
| 415 | * |
| 416 | * VerticalScrollbar( |
| 417 | * Modifier.align(Alignment.CenterEnd), |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 418 | * rememberScrollbarAdapter(state) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 419 | * ) |
| 420 | * } |
| 421 | */ |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 422 | fun ScrollbarAdapter( |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 423 | scrollState: LazyListState |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 424 | ): ScrollbarAdapter = LazyScrollbarAdapter( |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 425 | scrollState |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 426 | ) |
| 427 | |
| 428 | private class LazyScrollbarAdapter( |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 429 | private val scrollState: LazyListState |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 430 | ) : 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 Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 436 | 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 Demin | bf4afe7 | 2021-05-21 18:01:55 +0300 | [diff] [blame] | 451 | // 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 Demin | 67e4aa2 | 2020-11-19 11:12:43 +0300 | [diff] [blame] | 462 | |
| 463 | val index = (scrollOffsetCoerced / averageItemSize) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 464 | .toInt() |
| 465 | .coerceAtLeast(0) |
| 466 | .coerceAtMost(itemCount - 1) |
| 467 | |
Igor Demin | bf4afe7 | 2021-05-21 18:01:55 +0300 | [diff] [blame] | 468 | val offset = (scrollOffsetCoerced - index * averageItemSize) |
| 469 | .toInt() |
| 470 | .coerceAtLeast(0) |
| 471 | |
| 472 | scrollState.scrollToItem(index = index, scrollOffset = offset) |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 473 | } |
| 474 | |
| 475 | override fun maxScrollOffset(containerSize: Int) = |
Igor Demin | 95d0881 | 2021-08-02 12:38:05 +0300 | [diff] [blame] | 476 | (averageItemSize * itemCount - containerSize).coerceAtLeast(0f) |
Igor Demin | 7c097c4 | 2021-05-21 18:09:59 +0300 | [diff] [blame] | 477 | |
| 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 Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 489 | } |
| 490 | |
| 491 | /** |
| 492 | * Defines how to scroll the scrollable component |
| 493 | */ |
| 494 | interface 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 | |
| 520 | private class SliderAdapter( |
| 521 | val adapter: ScrollbarAdapter, |
| 522 | val containerSize: Int, |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 523 | val minHeight: Float, |
| 524 | val reverseLayout: Boolean |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 525 | ) { |
Igor Demin | 3bc059e | 2020-11-25 21:19:35 +0300 | [diff] [blame] | 526 | private val contentSize get() = adapter.maxScrollOffset(containerSize) + containerSize |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 527 | private val visiblePart get() = containerSize.toFloat() / contentSize |
| 528 | |
| 529 | val size |
| 530 | get() = (containerSize * visiblePart) |
| 531 | .coerceAtLeast(minHeight) |
| 532 | .coerceAtMost(containerSize.toFloat()) |
| 533 | |
Igor Demin | dd2ac2e | 2021-05-05 21:25:57 +0300 | [diff] [blame] | 534 | 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 Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 540 | |
Aleksandr Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 541 | /** |
| 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 Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 554 | get() = scrollScale * adapter.scrollOffset |
| 555 | set(value) { |
| 556 | runBlocking { |
| 557 | adapter.scrollTo(containerSize, value / scrollScale) |
| 558 | } |
| 559 | } |
| 560 | |
Aleksandr Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 561 | /** |
| 562 | * Actual position of a thumb within slider container |
| 563 | */ |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 564 | var position: Float |
Aleksandr Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 565 | get() = if (reverseLayout) containerSize - size - scrollPosition else scrollPosition |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 566 | set(value) { |
Aleksandr Veselov | 793810b | 2022-01-26 00:12:16 +0300 | [diff] [blame] | 567 | scrollPosition = if (reverseLayout) { |
Igor Demin | 64a7689 | 2021-05-21 16:21:13 +0300 | [diff] [blame] | 568 | containerSize - size - value |
| 569 | } else { |
| 570 | value |
| 571 | } |
| 572 | } |
| 573 | |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 574 | val bounds get() = position..position + size |
| 575 | } |
| 576 | |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 577 | private fun verticalMeasurePolicy( |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 578 | sliderAdapter: SliderAdapter, |
| 579 | setContainerSize: (Int) -> Unit, |
| 580 | scrollThickness: Int |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 581 | ) = MeasurePolicy { measurables, constraints -> |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 582 | 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 Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 595 | private fun horizontalMeasurePolicy( |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 596 | sliderAdapter: SliderAdapter, |
| 597 | setContainerSize: (Int) -> Unit, |
| 598 | scrollThickness: Int |
Mihai Popa | 4958475 | 2021-01-25 12:38:07 +0000 | [diff] [blame] | 599 | ) = MeasurePolicy { measurables, constraints -> |
Igor Demin | d7332f8 | 2020-10-23 21:45:51 +0300 | [diff] [blame] | 600 | 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 Malkov | 79be98f | 2021-02-03 21:36:55 +0000 | [diff] [blame] | 611 | } |
| 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 Malkov | 2f2b9d8 | 2021-03-18 15:20:32 +0000 | [diff] [blame] | 617 | private const val PressTimeoutMillis: Long = 100L |