Skip to content

Commit ba7b46b

Browse files
committed
feat: introduce maxScrollThreshold for finer-grained auto-scroll control during drag events
1 parent 29b7976 commit ba7b46b

1 file changed

Lines changed: 77 additions & 63 deletions

File tree

  • compose-dnd/src/commonMain/kotlin/com/mohamedrejeb/compose/dnd/scroll

compose-dnd/src/commonMain/kotlin/com/mohamedrejeb/compose/dnd/scroll/DragAutoScroll.kt

Lines changed: 77 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,22 @@ import com.mohamedrejeb.compose.dnd.DragAndDropState
4141
import com.mohamedrejeb.compose.dnd.annotation.ExperimentalDndApi
4242
import kotlinx.coroutines.flow.collectLatest
4343
import kotlinx.coroutines.flow.distinctUntilChanged
44-
import kotlinx.coroutines.flow.filter
4544

4645
/**
4746
* Configuration for auto-scroll behavior during drag.
4847
*
4948
* @param minScrollThreshold Minimum distance from the edge of the scrollable container
5049
* within which auto-scroll activates. The actual threshold is the larger of this value
51-
* and the size of the first/last visible item (when available).
50+
* and the size of the first/last visible item (when available), capped by [maxScrollThreshold].
51+
* @param maxScrollThreshold Maximum distance from the edge for auto-scroll activation.
52+
* Prevents the threshold from becoming too large for tall items or containers.
53+
* The effective threshold is: `clamp(itemSize, minScrollThreshold, maxScrollThreshold)`.
5254
* @param maxScrollSpeed Maximum scroll speed in pixels per second when the
5355
* dragged item is at the very edge.
5456
*/
5557
data class DragAutoScrollConfig(
5658
val minScrollThreshold: Dp = 48.dp,
59+
val maxScrollThreshold: Dp = 160.dp,
5760
val maxScrollSpeed: Float = 1500f,
5861
)
5962

@@ -90,6 +93,7 @@ fun <T> Modifier.dragAutoScroll(
9093
): Modifier {
9194
val density = LocalDensity.current
9295
val minThresholdPx = with(density) { config.minScrollThreshold.toPx() }
96+
val maxThresholdPx = with(density) { config.maxScrollThreshold.toPx() }
9397

9498
var containerTopLeft by remember { mutableStateOf(Offset.Zero) }
9599
var containerSize by remember { mutableStateOf(Size.Zero) }
@@ -98,6 +102,7 @@ fun <T> Modifier.dragAutoScroll(
98102
state,
99103
lazyListState,
100104
minThresholdPx,
105+
maxThresholdPx,
101106
config.maxScrollSpeed,
102107
) {
103108
snapshotFlow {
@@ -106,6 +111,11 @@ fun <T> Modifier.dragAutoScroll(
106111
val itemSize = state.currentDraggableItem?.size ?: return@snapshotFlow null
107112
val orientation = lazyListState.layoutInfo.orientation
108113

114+
// Don't scroll if the item isn't within this container on the cross axis
115+
if (!hasCrossAxisOverlap(orientation, dragPosition, itemSize, containerTopLeft, containerSize)) {
116+
return@snapshotFlow null
117+
}
118+
109119
val (itemStart, itemEnd, containerStart, containerEnd) = resolveScrollAxis(
110120
orientation = orientation,
111121
dragPosition = dragPosition,
@@ -114,16 +124,12 @@ fun <T> Modifier.dragAutoScroll(
114124
containerSize = containerSize,
115125
)
116126

117-
// Dynamic threshold based on first/last visible item size
127+
// Dynamic threshold: clamp(itemSize, min, max)
118128
val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
119-
val startThresholdPx = maxOf(
120-
minThresholdPx,
121-
visibleItems.firstOrNull()?.size?.toFloat() ?: minThresholdPx,
122-
)
123-
val endThresholdPx = maxOf(
124-
minThresholdPx,
125-
visibleItems.lastOrNull()?.size?.toFloat() ?: minThresholdPx,
126-
)
129+
val startThresholdPx = (visibleItems.firstOrNull()?.size?.toFloat() ?: minThresholdPx)
130+
.coerceIn(minThresholdPx, maxThresholdPx)
131+
val endThresholdPx = (visibleItems.lastOrNull()?.size?.toFloat() ?: minThresholdPx)
132+
.coerceIn(minThresholdPx, maxThresholdPx)
127133

128134
computeScrollSpeed(
129135
itemStart = itemStart,
@@ -149,27 +155,12 @@ fun <T> Modifier.dragAutoScroll(
149155
}
150156
}
151157

152-
// Workaround to fix scroll jump when dragging the first items.
153-
// When the first visible item is at index 0 or 1 during a drag, LazyList's internal
154-
// scroll anchoring can cause jumps. Pinning the scroll position prevents this.
155-
LaunchedEffect(state, lazyListState) {
156-
snapshotFlow { state.hoveredDropTargetKey }
157-
.filter { it != null }
158-
.distinctUntilChanged()
159-
.collect {
160-
if (lazyListState.firstVisibleItemIndex == 0 || lazyListState.firstVisibleItemIndex == 1) {
161-
lazyListState.scrollToItem(
162-
lazyListState.firstVisibleItemIndex,
163-
lazyListState.firstVisibleItemScrollOffset,
164-
)
165-
}
166-
}
167-
}
168-
169-
return this.onPlaced { coordinates ->
170-
containerTopLeft = coordinates.positionInRoot()
171-
containerSize = coordinates.size.toSize()
172-
}
158+
return this
159+
.dragScrollPin(state = state, lazyListState = lazyListState)
160+
.onPlaced { coordinates ->
161+
containerTopLeft = coordinates.positionInRoot()
162+
containerSize = coordinates.size.toSize()
163+
}
173164
}
174165

175166
/**
@@ -193,6 +184,7 @@ fun <T> Modifier.dragAutoScroll(
193184
): Modifier {
194185
val density = LocalDensity.current
195186
val minThresholdPx = with(density) { config.minScrollThreshold.toPx() }
187+
val maxThresholdPx = with(density) { config.maxScrollThreshold.toPx() }
196188

197189
var containerTopLeft by remember { mutableStateOf(Offset.Zero) }
198190
var containerSize by remember { mutableStateOf(Size.Zero) }
@@ -201,6 +193,7 @@ fun <T> Modifier.dragAutoScroll(
201193
state,
202194
lazyGridState,
203195
minThresholdPx,
196+
maxThresholdPx,
204197
config.maxScrollSpeed,
205198
) {
206199
snapshotFlow {
@@ -209,6 +202,10 @@ fun <T> Modifier.dragAutoScroll(
209202
val itemSize = state.currentDraggableItem?.size ?: return@snapshotFlow null
210203
val orientation = lazyGridState.layoutInfo.orientation
211204

205+
if (!hasCrossAxisOverlap(orientation, dragPosition, itemSize, containerTopLeft, containerSize)) {
206+
return@snapshotFlow null
207+
}
208+
212209
val (itemStart, itemEnd, containerStart, containerEnd) = resolveScrollAxis(
213210
orientation = orientation,
214211
dragPosition = dragPosition,
@@ -217,7 +214,7 @@ fun <T> Modifier.dragAutoScroll(
217214
containerSize = containerSize,
218215
)
219216

220-
// Dynamic threshold based on first/last visible item size along the scroll axis
217+
// Dynamic threshold: clamp(itemSize, min, max)
221218
val visibleItems = lazyGridState.layoutInfo.visibleItemsInfo
222219
val firstItemMainAxisSize = visibleItems.firstOrNull()?.let {
223220
if (orientation == Orientation.Vertical) it.size.height else it.size.width
@@ -226,14 +223,10 @@ fun <T> Modifier.dragAutoScroll(
226223
if (orientation == Orientation.Vertical) it.size.height else it.size.width
227224
}
228225

229-
val startThresholdPx = maxOf(
230-
minThresholdPx,
231-
firstItemMainAxisSize?.toFloat() ?: minThresholdPx,
232-
)
233-
val endThresholdPx = maxOf(
234-
minThresholdPx,
235-
lastItemMainAxisSize?.toFloat() ?: minThresholdPx,
236-
)
226+
val startThresholdPx = (firstItemMainAxisSize?.toFloat() ?: minThresholdPx)
227+
.coerceIn(minThresholdPx, maxThresholdPx)
228+
val endThresholdPx = (lastItemMainAxisSize?.toFloat() ?: minThresholdPx)
229+
.coerceIn(minThresholdPx, maxThresholdPx)
237230

238231
computeScrollSpeed(
239232
itemStart = itemStart,
@@ -259,25 +252,12 @@ fun <T> Modifier.dragAutoScroll(
259252
}
260253
}
261254

262-
// Same first-item workaround as LazyList
263-
LaunchedEffect(state, lazyGridState) {
264-
snapshotFlow { state.hoveredDropTargetKey }
265-
.filter { it != null }
266-
.distinctUntilChanged()
267-
.collect {
268-
if (lazyGridState.firstVisibleItemIndex == 0 || lazyGridState.firstVisibleItemIndex == 1) {
269-
lazyGridState.scrollToItem(
270-
lazyGridState.firstVisibleItemIndex,
271-
lazyGridState.firstVisibleItemScrollOffset,
272-
)
273-
}
274-
}
275-
}
276-
277-
return this.onPlaced { coordinates ->
278-
containerTopLeft = coordinates.positionInRoot()
279-
containerSize = coordinates.size.toSize()
280-
}
255+
return this
256+
.dragScrollPin(state = state, lazyGridState = lazyGridState)
257+
.onPlaced { coordinates ->
258+
containerTopLeft = coordinates.positionInRoot()
259+
containerSize = coordinates.size.toSize()
260+
}
281261
}
282262

283263
/**
@@ -301,6 +281,7 @@ fun <T> Modifier.dragAutoScroll(
301281
): Modifier {
302282
val density = LocalDensity.current
303283
val minThresholdPx = with(density) { config.minScrollThreshold.toPx() }
284+
val maxThresholdPx = with(density) { config.maxScrollThreshold.toPx() }
304285

305286
var containerTopLeft by remember { mutableStateOf(Offset.Zero) }
306287
var containerSize by remember { mutableStateOf(Size.Zero) }
@@ -310,6 +291,7 @@ fun <T> Modifier.dragAutoScroll(
310291
scrollState,
311292
orientation,
312293
minThresholdPx,
294+
maxThresholdPx,
313295
config.maxScrollSpeed,
314296
) {
315297
// Unlike LazyList/Grid where containerTopLeft is stable during scroll,
@@ -333,6 +315,10 @@ fun <T> Modifier.dragAutoScroll(
333315
val itemSize = state.currentDraggableItem?.size
334316
?: continue
335317

318+
if (!hasCrossAxisOverlap(orientation, dragPosition, itemSize, containerTopLeft, containerSize)) {
319+
continue
320+
}
321+
336322
val viewportPx = scrollState.viewportSize.toFloat()
337323
val isVertical = orientation == Orientation.Vertical
338324
val reportedAxisSize =
@@ -359,9 +345,10 @@ fun <T> Modifier.dragAutoScroll(
359345
containerSize = viewportSize,
360346
)
361347

362-
// ScrollState has no visible items — use 20% of viewport as
363-
// threshold (similar to dnd-kit), with minThreshold as floor
364-
val thresholdPx = maxOf(minThresholdPx, viewportPx * 0.2f)
348+
// ScrollState has no visible items — use 20% of viewport,
349+
// clamped between min and max threshold
350+
val thresholdPx = (viewportPx * 0.2f)
351+
.coerceIn(minThresholdPx, maxThresholdPx)
365352

366353
val scrollSpeed = computeScrollSpeed(
367354
itemStart = itemStart,
@@ -400,6 +387,33 @@ private data class ScrollAxisBounds(
400387
val containerEnd: Float,
401388
)
402389

390+
/**
391+
* Returns true if the dragged item overlaps with the container on the cross axis.
392+
* For vertical scroll, checks horizontal overlap. For horizontal scroll, checks vertical overlap.
393+
* This prevents auto-scroll from triggering in sibling containers (e.g., other Kanban columns).
394+
*/
395+
private fun hasCrossAxisOverlap(
396+
orientation: Orientation,
397+
dragPosition: Offset,
398+
itemSize: Size,
399+
containerTopLeft: Offset,
400+
containerSize: Size,
401+
): Boolean = if (orientation == Orientation.Vertical) {
402+
// Vertical scroll — check horizontal overlap
403+
val itemLeft = dragPosition.x
404+
val itemRight = dragPosition.x + itemSize.width
405+
val containerLeft = containerTopLeft.x
406+
val containerRight = containerTopLeft.x + containerSize.width
407+
itemRight > containerLeft && itemLeft < containerRight
408+
} else {
409+
// Horizontal scroll — check vertical overlap
410+
val itemTop = dragPosition.y
411+
val itemBottom = dragPosition.y + itemSize.height
412+
val containerTop = containerTopLeft.y
413+
val containerBottom = containerTopLeft.y + containerSize.height
414+
itemBottom > containerTop && itemTop < containerBottom
415+
}
416+
403417
private fun resolveScrollAxis(
404418
orientation: Orientation,
405419
dragPosition: Offset,

0 commit comments

Comments
 (0)