Skip to content

Commit

Permalink
Allow disabling overscrollEffect on specific sides of the ScrollArea
Browse files Browse the repository at this point in the history
Addresses: #23
  • Loading branch information
alexstyl committed Oct 6, 2024
1 parent c02b608 commit 44a2aa1
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 73 deletions.
171 changes: 100 additions & 71 deletions core/src/commonMain/kotlin/ScrollArea.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
overscrollSides: List<OverscrollSides> = listOf(OverscrollSides.Vertical, OverscrollSides.Horizontal),
content: @Composable ScrollAreaScope.() -> Unit
) {
val scope = rememberCoroutineScope()

val scrollEvents = remember { MutableSharedFlow<Unit>() }

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()
&& overscrollSides.any { it == OverscrollSides.Top || it == OverscrollSides.Vertical }

val isOverscrollBottom = isMovingForward(available.y) && canScrollForward.not()
&& overscrollSides.any { it == OverscrollSides.Bottom || it == OverscrollSides.Vertical }

val isOverscrollLeft = isMovingBackwards(available.x) && canScrollBackwards.not()
&& overscrollSides.any { it == OverscrollSides.Left || it == OverscrollSides.Horizontal }

val isOverscrollRight = isMovingForward(available.x) && canScrollForward.not()
&& overscrollSides.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()
&& overscrollSides.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()
&& overscrollSides.any { it == OverscrollSides.Bottom || it == OverscrollSides.Vertical }

val isOverscrollLeft = isMovingForward(available.x) && canScrollBackwards.not()
&& overscrollSides.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()
&& overscrollSides.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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions demo-scrollarea/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

}
}
}
Expand Down

0 comments on commit 44a2aa1

Please sign in to comment.