diff --git a/core/src/commonMain/kotlin/ScrollArea.kt b/core/src/commonMain/kotlin/ScrollArea.kt index 186b802..c8725db 100644 --- a/core/src/commonMain/kotlin/ScrollArea.kt +++ b/core/src/commonMain/kotlin/ScrollArea.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.overscroll import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -57,6 +58,7 @@ import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp import kotlin.js.JsName +import kotlin.jvm.JvmInline import kotlin.math.abs import kotlin.math.roundToInt import kotlin.time.Duration @@ -86,105 +88,130 @@ fun rememberScrollAreaState(lazyGridState: LazyGridState): ScrollAreaState = rem LazyGridScrollAreaScrollAreaState(lazyGridState) } +@JvmInline +@Immutable +value class OverscrollSides private constructor(private val id: Int) { + companion object { + val Top = OverscrollSides(0) + val Bottom = OverscrollSides(1) + val Left = OverscrollSides(2) + val Right = OverscrollSides(3) + val Vertical = OverscrollSides(3) + val Horizontal = OverscrollSides(3) + } +} + @Composable fun ScrollArea( state: ScrollAreaState, modifier: Modifier = Modifier, overscrollEffect: OverscrollEffect? = ScrollableDefaults.overscrollEffect(), + overscrollEffectSides: List = listOf(OverscrollSides.Vertical, OverscrollSides.Horizontal), content: @Composable ScrollAreaScope.() -> Unit ) { val scope = rememberCoroutineScope() val scrollEvents = remember { MutableSharedFlow() } - - Box( - modifier.nestedScroll(remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - scope.launch { - scrollEvents.emit(Unit) + NoOverscroll { + Box( + modifier.nestedScroll(remember { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + println("Is scrolling forward = ${isMovingForward(consumed.y)}") + println("Is scrolling backwards = ${isMovingBackwards(consumed.y)}") + if (source == NestedScrollSource.Drag && overscrollEffect != null) { + // they are scrolling past a dead-end + // forward to overscrollEffect's direction they are trying to go + + val isOverscrollTop = isMovingBackwards(available.y) && canScrollBackwards.not() + && overscrollEffectSides.any { it == OverscrollSides.Top || it == OverscrollSides.Vertical } + + val isOverscrollBottom = isMovingForward(available.y) && canScrollForward.not() + && overscrollEffectSides.any { it == OverscrollSides.Bottom || it == OverscrollSides.Vertical } + + val isOverscrollLeft = isMovingBackwards(available.x) && canScrollBackwards.not() + && overscrollEffectSides.any { it == OverscrollSides.Left || it == OverscrollSides.Horizontal } + + val isOverscrollRight = isMovingForward(available.x) && canScrollForward.not() + && overscrollEffectSides.any { it == OverscrollSides.Right || it == OverscrollSides.Horizontal } + + if (isOverscrollTop || isOverscrollBottom || isOverscrollLeft || isOverscrollRight) { + return overscrollEffect.applyToScroll(available, source, noScroll) + } + } + return super.onPostScroll(consumed, available, source) } - if (overscrollEffect == null) return super.onPreScroll(available, source) - - return if ((isStuck(available.toFloat())) && source == NestedScrollSource.Drag) { - return overscrollEffect.applyToScroll(available, source) { remainingOffset -> - performDrag(remainingOffset.toFloat()).toOffset() + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + scope.launch { + scrollEvents.emit(Unit) } - } else { - Offset.Zero - } - } + if (source == NestedScrollSource.Drag && overscrollEffect != null) { + // they have already started scrolling + // forward to overscrollEffect's opposite direction they are trying to go - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (overscrollEffect == null) return super.onPostScroll(consumed, available, source) + val isOverscrollTop = isMovingForward(available.y) && canScrollBackwards.not() + && overscrollEffectSides.any { it == OverscrollSides.Top || it == OverscrollSides.Vertical } - return if (source == NestedScrollSource.Drag) { - performDrag(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } + val isOverscrollBottom = isMovingBackwards(available.y) && canScrollForward.not() + && overscrollEffectSides.any { it == OverscrollSides.Bottom || it == OverscrollSides.Vertical } + + val isOverscrollLeft = isMovingForward(available.x) && canScrollBackwards.not() + && overscrollEffectSides.any { it == OverscrollSides.Left || it == OverscrollSides.Horizontal } - override suspend fun onPreFling(available: Velocity): Velocity { - if (overscrollEffect == null) return super.onPreFling(available) - val toFling = Offset(available.x, available.y).toFloat() - return if (isStuck(toFling)) { - val performFling: suspend (Velocity) -> Velocity = { remaining -> - remaining + val isOverscrollRight = isMovingBackwards(available.x) && canScrollForward.not() + && overscrollEffectSides.any { it == OverscrollSides.Right || it == OverscrollSides.Horizontal } + + if (isOverscrollTop || isOverscrollBottom || isOverscrollLeft || isOverscrollRight) { + return overscrollEffect.applyToScroll(available, source, noScroll) + } } - overscrollEffect.applyToFling(available, performFling) - available - performFling(available) - } else { - Velocity.Zero + + return super.onPreScroll(available, source) } - } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - overscrollEffect?.applyToFling(available) { remaining -> - remaining + override suspend fun onPreFling(available: Velocity): Velocity { + if (overscrollEffect !== null) { + overscrollEffect.applyToFling(available, consumeVelocity) + return available + } + return super.onPreFling(available) } - return available - } - fun performDrag(delta: Float): Float { - val potentiallyConsumed = state.scrollOffset + delta + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + if (overscrollEffect != null) { + overscrollEffect.applyToFling(available, consumeVelocity) + return available + } + return super.onPostFling(consumed, available) + } - val clamped = when { - delta > 0 -> potentiallyConsumed.coerceAtMost(0.toDouble()) - delta < 0 -> potentiallyConsumed.coerceAtLeast(state.maxScrollOffset) - else -> potentiallyConsumed + val consumeVelocity: (Velocity) -> Velocity = { velocity -> + // we are forwarding the full velocity to the overscroll effect, always + velocity } - val deltaToConsume = clamped - state.scrollOffset - if (abs(deltaToConsume) > 0) { - scope.launch { - state.scrollTo(deltaToConsume) - } + + val noScroll: (Offset) -> Offset = { + // we are only listening to scrolling + // we are consuming nothing + Offset.Zero } - return deltaToConsume.toFloat() - } - fun isStuck(delta: Float): Boolean { - val canScrollBackwards = state.scrollOffset > 0.toDouble() - val canScrollForward = state.scrollOffset < state.maxScrollOffset + val canScrollBackwards: Boolean + get() = state.scrollOffset > 0 - return (delta > 0 && !canScrollBackwards - || delta < 0 && !canScrollForward) - } + val canScrollForward: Boolean + get() = state.scrollOffset < state.maxScrollOffset - fun Offset.toFloat(): Float = y + fun isMovingForward(delta: Float): Boolean = delta < 0 + + fun isMovingBackwards(delta: Float): Boolean = delta > 0 + } + }) + .let { if (overscrollEffect != null) it.overscroll(overscrollEffect) else it } + ) { - fun Float.toOffset(): Offset = Offset(0f, this) - } - }) - .let { if (overscrollEffect != null) it.overscroll(overscrollEffect) else it } - ) { - NoOverscroll { val boxScope = this val scrollAreaScope = remember { ScrollAreaScope(boxScope, state, scrollEvents) @@ -807,7 +834,9 @@ internal abstract class LazyLineContentScrollAreaState : ScrollAreaState { } -internal class LazyListScrollAreaState(private val scrollState: LazyListState) : LazyLineContentScrollAreaState() { +internal class LazyListScrollAreaState( + private val scrollState: LazyListState +) : LazyLineContentScrollAreaState() { override val interactionSource: InteractionSource get() = scrollState.interactionSource diff --git a/demo-scrollarea/build.gradle.kts b/demo-scrollarea/build.gradle.kts index c4cc700..670d020 100644 --- a/demo-scrollarea/build.gradle.kts +++ b/demo-scrollarea/build.gradle.kts @@ -75,8 +75,6 @@ kotlin { dependencies { implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.activity:activity:1.9.0") - implementation("io.coil-kt:coil-compose:2.7.0") - } } }