Skip to content

Commit c9d47c2

Browse files
Auto scroll for editor (#580)
1 parent fc7cee0 commit c9d47c2

2 files changed

Lines changed: 139 additions & 2 deletions

File tree

writeopia_ui/src/commonMain/kotlin/io/writeopia/ui/WriteopiaEditor.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package io.writeopia.ui
33
import androidx.compose.animation.core.tween
44
import androidx.compose.foundation.layout.Box
55
import androidx.compose.foundation.layout.PaddingValues
6-
import androidx.compose.foundation.lazy.LazyColumn
6+
import io.writeopia.ui.components.AutoScrollLazyColumn
77
import androidx.compose.foundation.lazy.LazyListState
88
import androidx.compose.foundation.lazy.items
99
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -35,7 +35,7 @@ fun WriteopiaEditor(
3535
}
3636

3737
DraggableScreen(modifier = modifier) {
38-
LazyColumn(
38+
AutoScrollLazyColumn(
3939
modifier = modifier,
4040
contentPadding = contentPadding,
4141
state = listState,
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package io.writeopia.ui.components
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.PaddingValues
5+
import androidx.compose.foundation.lazy.LazyColumn
6+
import androidx.compose.foundation.lazy.LazyListScope
7+
import androidx.compose.foundation.lazy.LazyListState
8+
import androidx.compose.foundation.lazy.rememberLazyListState
9+
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.LaunchedEffect
11+
import androidx.compose.runtime.getValue
12+
import androidx.compose.runtime.mutableStateOf
13+
import androidx.compose.runtime.remember
14+
import androidx.compose.runtime.setValue
15+
import androidx.compose.runtime.snapshotFlow
16+
import androidx.compose.ui.Modifier
17+
import androidx.compose.ui.layout.boundsInWindow
18+
import androidx.compose.ui.layout.onGloballyPositioned
19+
import androidx.compose.ui.unit.Dp
20+
import androidx.compose.ui.unit.dp
21+
import io.writeopia.ui.draganddrop.target.DragTargetInfo
22+
import io.writeopia.ui.draganddrop.target.LocalDragTargetInfo
23+
import kotlinx.coroutines.delay
24+
import kotlinx.coroutines.flow.collectLatest
25+
import kotlinx.coroutines.isActive
26+
27+
/**
28+
* A LazyColumn that automatically scrolls when the user is dragging an item
29+
* and approaches the top or bottom edges.
30+
*
31+
* @param modifier The modifier to be applied to the LazyColumn
32+
* @param state The LazyListState used to control scrolling
33+
* @param contentPadding Padding around the content
34+
* @param edgeThreshold The distance from the edge at which auto-scrolling begins
35+
* @param scrollSpeed The speed at which to scroll (pixels per frame)
36+
* @param content The LazyListScope content builder
37+
*/
38+
@Composable
39+
fun AutoScrollLazyColumn(
40+
modifier: Modifier = Modifier,
41+
state: LazyListState = rememberLazyListState(),
42+
contentPadding: PaddingValues = PaddingValues(0.dp),
43+
edgeThreshold: Dp = 80.dp,
44+
scrollSpeed: Float = 15f,
45+
content: LazyListScope.() -> Unit
46+
) {
47+
val dragInfo: DragTargetInfo = LocalDragTargetInfo.current
48+
49+
var columnTop by remember { mutableStateOf(0f) }
50+
var columnBottom by remember { mutableStateOf(0f) }
51+
52+
AutoScrollEffect(
53+
dragInfo = dragInfo,
54+
listState = state,
55+
columnTop = columnTop,
56+
columnBottom = columnBottom,
57+
edgeThreshold = edgeThreshold,
58+
scrollSpeed = scrollSpeed
59+
)
60+
61+
Box(
62+
modifier = Modifier.onGloballyPositioned { coordinates ->
63+
val bounds = coordinates.boundsInWindow()
64+
columnTop = bounds.top
65+
columnBottom = bounds.bottom
66+
}
67+
) {
68+
LazyColumn(
69+
modifier = modifier,
70+
state = state,
71+
contentPadding = contentPadding,
72+
content = content
73+
)
74+
}
75+
}
76+
77+
@Composable
78+
private fun AutoScrollEffect(
79+
dragInfo: DragTargetInfo,
80+
listState: LazyListState,
81+
columnTop: Float,
82+
columnBottom: Float,
83+
edgeThreshold: Dp,
84+
scrollSpeed: Float
85+
) {
86+
LaunchedEffect(dragInfo, listState, columnTop, columnBottom, edgeThreshold, scrollSpeed) {
87+
snapshotFlow {
88+
AutoScrollData(
89+
isDragging = dragInfo.isDragging,
90+
dragY = (dragInfo.dragPosition + dragInfo.dragOffset).y
91+
)
92+
}.collectLatest { data ->
93+
if (data.isDragging && columnBottom > columnTop) {
94+
val thresholdPx = edgeThreshold.value * 2.5f // Approximate px conversion
95+
96+
while (isActive && dragInfo.isDragging) {
97+
val currentDragY = (dragInfo.dragPosition + dragInfo.dragOffset).y
98+
val distanceFromTop = currentDragY - columnTop
99+
val distanceFromBottom = columnBottom - currentDragY
100+
101+
val scrollAmount = when {
102+
distanceFromTop < thresholdPx -> {
103+
// Near top - scroll up (negative)
104+
// Intensity goes from 0 (at threshold) to 1 (at edge)
105+
val intensity = 1f - (distanceFromTop / thresholdPx).coerceIn(0f, 1f)
106+
// Accelerate: scroll much faster when very close to the edge
107+
val accelerated = intensity * (1f + intensity * intensity * 4f)
108+
-scrollSpeed * accelerated
109+
}
110+
distanceFromBottom < thresholdPx -> {
111+
// Near bottom - scroll down (positive)
112+
val intensity = 1f - (distanceFromBottom / thresholdPx).coerceIn(0f, 1f)
113+
val accelerated = intensity * (1f + intensity * intensity * 4f)
114+
scrollSpeed * accelerated
115+
}
116+
else -> 0f
117+
}
118+
119+
if (scrollAmount != 0f) {
120+
listState.scrollBy(scrollAmount)
121+
}
122+
123+
delay(16) // ~60fps
124+
}
125+
}
126+
}
127+
}
128+
}
129+
130+
private data class AutoScrollData(
131+
val isDragging: Boolean,
132+
val dragY: Float
133+
)
134+
135+
private suspend fun LazyListState.scrollBy(amount: Float) {
136+
dispatchRawDelta(amount)
137+
}

0 commit comments

Comments
 (0)