@@ -41,19 +41,22 @@ import com.mohamedrejeb.compose.dnd.DragAndDropState
4141import com.mohamedrejeb.compose.dnd.annotation.ExperimentalDndApi
4242import kotlinx.coroutines.flow.collectLatest
4343import 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 */
5557data 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+
403417private fun resolveScrollAxis (
404418 orientation : Orientation ,
405419 dragPosition : Offset ,
0 commit comments