@@ -3,7 +3,9 @@ package `in`.hridayan.ashell.core.presentation.components.scrollbar
33import androidx.compose.animation.core.Spring
44import androidx.compose.animation.core.animateFloatAsState
55import androidx.compose.animation.core.spring
6+ import androidx.compose.foundation.ScrollState
67import androidx.compose.foundation.gestures.detectVerticalDragGestures
8+ import androidx.compose.foundation.gestures.scrollBy
79import androidx.compose.foundation.layout.Box
810import androidx.compose.foundation.layout.fillMaxHeight
911import androidx.compose.foundation.layout.size
@@ -17,6 +19,7 @@ import androidx.compose.runtime.getValue
1719import androidx.compose.runtime.mutableFloatStateOf
1820import androidx.compose.runtime.mutableStateOf
1921import androidx.compose.runtime.remember
22+ import androidx.compose.runtime.rememberCoroutineScope
2023import androidx.compose.runtime.setValue
2124import androidx.compose.ui.Modifier
2225import androidx.compose.ui.draw.drawWithContent
@@ -27,52 +30,122 @@ import androidx.compose.ui.layout.onSizeChanged
2730import androidx.compose.ui.platform.LocalDensity
2831import androidx.compose.ui.unit.dp
2932import kotlinx.coroutines.delay
33+ import kotlinx.coroutines.launch
3034
3135/* *
3236 * A draggable circular scroll thumb that enables smooth fast-scrolling on a [LazyListState].
33- *
34- * Uses [dispatchRawDelta] for synchronous, jitter-free scrolling during drag.
3537 */
3638@Composable
3739fun DraggableScrollThumb (
3840 listState : LazyListState ,
3941 modifier : Modifier = Modifier ,
4042 thumbSize : Int = 48
4143) {
42- val thumbSizeDp = thumbSize.dp
43- val density = LocalDensity .current
44- val thumbSizePx = with (density) { thumbSizeDp.toPx() }
45-
46- var isDragging by remember { mutableStateOf(false ) }
47- var trackHeightPx by remember { mutableFloatStateOf(0f ) }
48- var showThumb by remember { mutableStateOf(false ) }
49- var capturedScrollableRange by remember { mutableFloatStateOf(0f ) }
44+ var stableAvgSize by remember { mutableFloatStateOf(0f ) }
5045
51- // Improved scroll progress calculation for LazyList
5246 val scrollProgress by remember {
5347 derivedStateOf {
5448 val info = listState.layoutInfo
5549 if (info.totalItemsCount == 0 || info.visibleItemsInfo.isEmpty()) 0f
5650 else {
5751 val firstVisibleItem = info.visibleItemsInfo.first()
58- val lastVisibleItem = info.visibleItemsInfo.last()
5952 val totalItemsCount = info.totalItemsCount
53+ val viewportHeight = info.viewportEndOffset - info.viewportStartOffset
54+
55+ val visibleItems = info.visibleItemsInfo
56+ val currentAvg = visibleItems.map { it.size }.average().toFloat()
57+
58+ if (stableAvgSize == 0f ) stableAvgSize = currentAvg
59+
60+ val estimatedTotalHeight = stableAvgSize * totalItemsCount
61+ val currentScrollOffset = (firstVisibleItem.index * stableAvgSize) - firstVisibleItem.offset
62+ val totalScrollableRange = (estimatedTotalHeight - viewportHeight).coerceAtLeast(1f )
63+ (currentScrollOffset / totalScrollableRange).coerceIn(0f , 1f )
64+ }
65+ }
66+ }
6067
61- val firstIndex = firstVisibleItem.index
62- val lastIndex = lastVisibleItem.index
63- val visibleCount = lastIndex - firstIndex + 1
64-
65- val offset = - firstVisibleItem.offset.toFloat()
66- val size = firstVisibleItem.size.toFloat()
67- val fraction = if (size > 0 ) offset / size else 0f
68+ LaunchedEffect (listState.isScrollInProgress) {
69+ if (listState.isScrollInProgress) {
70+ val info = listState.layoutInfo
71+ if (info.visibleItemsInfo.isNotEmpty()) {
72+ val currentAvg = info.visibleItemsInfo.map { it.size }.average().toFloat()
73+ stableAvgSize = if (stableAvgSize == 0f ) currentAvg else stableAvgSize * 0.95f + currentAvg * 0.05f
74+ }
75+ }
76+ }
6877
69- val progress =
70- (firstIndex + fraction) / (totalItemsCount - visibleCount + 1 ).coerceAtLeast(1 )
71- progress.coerceIn(0f , 1f )
78+ DraggableScrollThumbImpl (
79+ scrollProgress = scrollProgress,
80+ isScrollInProgress = listState.isScrollInProgress,
81+ onDrag = { deltaPx, trackRange ->
82+ val info = listState.layoutInfo
83+ val vis = info.visibleItemsInfo
84+ if (vis.isNotEmpty()) {
85+ val avgSize = vis.map { it.size }.average().toFloat()
86+ val totalContentHeight = avgSize * info.totalItemsCount
87+ val viewportHeight = (info.viewportEndOffset - info.viewportStartOffset).toFloat()
88+ val scrollableRange = (totalContentHeight - viewportHeight).coerceAtLeast(1f )
89+
90+ val scrollDelta = deltaPx * (scrollableRange / trackRange)
91+ listState.dispatchRawDelta(scrollDelta)
7292 }
93+ },
94+ modifier = modifier,
95+ thumbSize = thumbSize
96+ )
97+ }
98+
99+ /* *
100+ * A draggable circular scroll thumb for [ScrollState] (regular Column/Row).
101+ */
102+ @Composable
103+ fun DraggableScrollThumb (
104+ scrollState : ScrollState ,
105+ modifier : Modifier = Modifier ,
106+ thumbSize : Int = 48
107+ ) {
108+ val scrollProgress by remember {
109+ derivedStateOf {
110+ if (scrollState.maxValue == 0 ) 0f
111+ else (scrollState.value.toFloat() / scrollState.maxValue).coerceIn(0f , 1f )
73112 }
74113 }
75114
115+ val coroutineScope = rememberCoroutineScope()
116+
117+ DraggableScrollThumbImpl (
118+ scrollProgress = scrollProgress,
119+ isScrollInProgress = scrollState.isScrollInProgress,
120+ onDrag = { deltaPx, trackRange ->
121+ if (scrollState.maxValue > 0 ) {
122+ val scrollDelta = deltaPx * (scrollState.maxValue.toFloat() / trackRange)
123+ coroutineScope.launch {
124+ scrollState.scrollBy(scrollDelta)
125+ }
126+ }
127+ },
128+ modifier = modifier,
129+ thumbSize = thumbSize
130+ )
131+ }
132+
133+ @Composable
134+ private fun DraggableScrollThumbImpl (
135+ scrollProgress : Float ,
136+ isScrollInProgress : Boolean ,
137+ onDrag : (deltaPx: Float , trackRange: Float ) -> Unit ,
138+ modifier : Modifier = Modifier ,
139+ thumbSize : Int = 48
140+ ) {
141+ val thumbSizeDp = thumbSize.dp
142+ val density = LocalDensity .current
143+ val thumbSizePx = with (density) { thumbSizeDp.toPx() }
144+
145+ var isDragging by remember { mutableStateOf(false ) }
146+ var trackHeightPx by remember { mutableFloatStateOf(0f ) }
147+ var showThumb by remember { mutableStateOf(false ) }
148+
76149 var dragProgress by remember { mutableFloatStateOf(0f ) }
77150 LaunchedEffect (scrollProgress, isDragging) {
78151 if (! isDragging) {
@@ -89,21 +162,21 @@ fun DraggableScrollThumb(
89162 label = " thumbProgress"
90163 )
91164
92- val thumbOffsetY = remember(animatedProgress, trackHeightPx) {
93- (animatedProgress * (trackHeightPx - thumbSizePx)).coerceIn(
165+ val effectiveProgress = if (isDragging) dragProgress else animatedProgress
166+
167+ val thumbOffsetY = remember(effectiveProgress, trackHeightPx) {
168+ (effectiveProgress * (trackHeightPx - thumbSizePx)).coerceIn(
94169 0f ,
95170 (trackHeightPx - thumbSizePx).coerceAtLeast(0f )
96171 )
97172 }
98173
99- // Horizontal slide animation
100174 val thumbOffsetX by animateFloatAsState(
101175 targetValue = if (showThumb || isDragging) 0f else thumbSizePx,
102176 animationSpec = spring(stiffness = Spring .StiffnessLow ),
103177 label = " thumbOffsetX"
104178 )
105179
106- // Scale animation on drag
107180 val thumbScale by animateFloatAsState(
108181 targetValue = if (isDragging) 0.8f else 1f ,
109182 animationSpec = spring(stiffness = Spring .StiffnessMedium ),
@@ -116,8 +189,8 @@ fun DraggableScrollThumb(
116189 label = " thumbAlpha"
117190 )
118191
119- LaunchedEffect (listState. isScrollInProgress, isDragging) {
120- if (listState. isScrollInProgress || isDragging) {
192+ LaunchedEffect (isScrollInProgress, isDragging) {
193+ if (isScrollInProgress || isDragging) {
121194 showThumb = true
122195 } else {
123196 delay(1500 )
@@ -145,36 +218,17 @@ fun DraggableScrollThumb(
145218 }
146219 .pointerInput(trackHeightPx) {
147220 detectVerticalDragGestures(
148- onDragStart = {
149- isDragging = true
150- // Lock the scroll range at start of drag to prevent jumping
151- val info = listState.layoutInfo
152- val vis = info.visibleItemsInfo
153- if (vis.isNotEmpty()) {
154- val avgSize = vis.map { it.size }.average().toFloat()
155- val totalContentHeight = avgSize * info.totalItemsCount
156- val viewportHeight =
157- (info.viewportEndOffset - info.viewportStartOffset).toFloat()
158- capturedScrollableRange =
159- (totalContentHeight - viewportHeight).coerceAtLeast(1f )
160- }
161- },
221+ onDragStart = { isDragging = true },
162222 onDragEnd = { isDragging = false },
163223 onDragCancel = { isDragging = false },
164224 onVerticalDrag = { change, dragAmount ->
165225 change.consume()
166226
167227 val trackRange = (trackHeightPx - thumbSizePx).coerceAtLeast(1f )
168- val newProgress =
169- (dragProgress + dragAmount / trackRange).coerceIn(0f , 1f )
228+ val newProgress = (dragProgress + dragAmount / trackRange).coerceIn(0f , 1f )
170229 dragProgress = newProgress
171230
172- // Map track movement to scroll movement using the locked range
173- if (capturedScrollableRange > 0 ) {
174- val scrollDelta =
175- dragAmount * (capturedScrollableRange / trackRange)
176- listState.dispatchRawDelta(scrollDelta)
177- }
231+ onDrag(dragAmount, trackRange)
178232 }
179233 )
180234 }
0 commit comments