Skip to content

Commit 99775a1

Browse files
committed
Add fast scroll
1 parent c2eddfa commit 99775a1

1 file changed

Lines changed: 148 additions & 0 deletions

File tree

  • composeApp/src/commonMain/kotlin/com/linuxcommandlibrary/app/ui/composables
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package com.linuxcommandlibrary.app.ui.composables
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.gestures.detectDragGestures
5+
import androidx.compose.foundation.gestures.detectTapGestures
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.fillMaxHeight
8+
import androidx.compose.foundation.layout.height
9+
import androidx.compose.foundation.layout.offset
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.size
12+
import androidx.compose.foundation.layout.width
13+
import androidx.compose.foundation.shape.CircleShape
14+
import androidx.compose.foundation.lazy.LazyListState
15+
import androidx.compose.material.MaterialTheme
16+
import androidx.compose.ui.geometry.Size
17+
import androidx.compose.ui.graphics.Outline
18+
import androidx.compose.ui.graphics.Path
19+
import androidx.compose.ui.graphics.Shape
20+
import androidx.compose.ui.unit.Density
21+
import androidx.compose.ui.unit.LayoutDirection
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.derivedStateOf
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.rememberCoroutineScope
28+
import androidx.compose.runtime.setValue
29+
import androidx.compose.ui.Alignment
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.input.pointer.pointerInput
32+
import androidx.compose.ui.layout.onSizeChanged
33+
import androidx.compose.ui.unit.IntOffset
34+
import androidx.compose.ui.unit.dp
35+
import kotlinx.coroutines.launch
36+
37+
private val BUBBLE_HEIGHT_DP = 56.dp
38+
private val BUBBLE_WIDTH_DP = 24.dp
39+
private val TOUCH_TARGET_WIDTH_DP = 48.dp
40+
41+
private val BubbleShape = object : Shape {
42+
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
43+
val w = size.width
44+
val h = size.height
45+
46+
val path = Path().apply {
47+
// Top point (right side)
48+
moveTo(w, 0f)
49+
// Left bulge curve from top to bottom
50+
cubicTo(w, h * 0.15f, 0f, h * 0.2f, 0f, h * 0.5f)
51+
cubicTo(0f, h * 0.8f, w, h * 0.85f, w, h)
52+
// Right edge (straight line back to top)
53+
close()
54+
}
55+
return Outline.Generic(path)
56+
}
57+
}
58+
59+
@Composable
60+
fun FastScrollBar(
61+
listState: LazyListState,
62+
itemCount: Int,
63+
modifier: Modifier = Modifier,
64+
) {
65+
if (itemCount == 0) return
66+
67+
val coroutineScope = rememberCoroutineScope()
68+
var trackHeightPx by remember { mutableStateOf(0f) }
69+
var thumbHeightPx by remember { mutableStateOf(0f) }
70+
var isDragging by remember { mutableStateOf(false) }
71+
72+
val scrollableRange by remember {
73+
derivedStateOf { (trackHeightPx - thumbHeightPx).coerceAtLeast(1f) }
74+
}
75+
76+
val thumbOffsetY by remember {
77+
derivedStateOf {
78+
if (itemCount <= 0) return@derivedStateOf 0f
79+
val firstVisible = listState.firstVisibleItemIndex
80+
val fraction = firstVisible.toFloat() / itemCount
81+
fraction * scrollableRange
82+
}
83+
}
84+
85+
fun scrollToFraction(y: Float) {
86+
val fraction = ((y - thumbHeightPx / 2f) / scrollableRange).coerceIn(0f, 1f)
87+
val targetIndex = (fraction * itemCount).toInt().coerceIn(0, (itemCount - 1).coerceAtLeast(0))
88+
coroutineScope.launch {
89+
listState.scrollToItem(targetIndex)
90+
}
91+
}
92+
93+
Box(
94+
modifier = modifier
95+
.fillMaxHeight()
96+
.width(TOUCH_TARGET_WIDTH_DP)
97+
.onSizeChanged { trackHeightPx = it.height.toFloat() }
98+
.pointerInput(itemCount) {
99+
detectTapGestures { offset ->
100+
scrollToFraction(offset.y)
101+
}
102+
}
103+
.pointerInput(itemCount) {
104+
detectDragGestures(
105+
onDragStart = { offset ->
106+
isDragging = true
107+
scrollToFraction(offset.y)
108+
},
109+
onDragEnd = { isDragging = false },
110+
onDragCancel = { isDragging = false },
111+
onDrag = { change, _ ->
112+
change.consume()
113+
scrollToFraction(change.position.y)
114+
},
115+
)
116+
},
117+
contentAlignment = Alignment.TopEnd,
118+
) {
119+
// Bubble thumb
120+
Box(
121+
modifier = Modifier
122+
.offset { IntOffset(0, thumbOffsetY.toInt()) }
123+
.width(BUBBLE_WIDTH_DP)
124+
.height(BUBBLE_HEIGHT_DP)
125+
.onSizeChanged { thumbHeightPx = it.height.toFloat() }
126+
.background(
127+
color = MaterialTheme.colors.secondary.copy(
128+
alpha = if (isDragging) 0.4f else 0.15f,
129+
),
130+
shape = BubbleShape,
131+
),
132+
contentAlignment = Alignment.CenterEnd,
133+
) {
134+
// Circle handle
135+
Box(
136+
modifier = Modifier
137+
.padding(end = 2.dp)
138+
.size(16.dp)
139+
.background(
140+
color = MaterialTheme.colors.secondary.copy(
141+
alpha = if (isDragging) 0.6f else 0.3f,
142+
),
143+
shape = CircleShape,
144+
),
145+
)
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)