Loading packages/SystemUI/compose/core/src/com/android/compose/gesture/NestedDraggable.kt +4 −2 Original line number Diff line number Diff line Loading @@ -464,13 +464,15 @@ private class NestedDraggableNode( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ): Velocity { // Make sure we only use the velocity in this draggable orientation. val orientationVelocity = velocity.toFloat().toVelocity() return if (overscrollEffect != null) { overscrollEffect.applyToFling(velocity) { performFling(it) } overscrollEffect.applyToFling(orientationVelocity) { performFling(it) } // Effects always consume the whole velocity. velocity } else { performFling(velocity) performFling(orientationVelocity) } } Loading packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/ContentOverscrollEffect.kt +52 −3 Original line number Diff line number Diff line Loading @@ -23,7 +23,9 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import com.android.compose.ui.util.HorizontalSpaceVectorConverter import com.android.compose.ui.util.SpaceVectorConverter import com.android.compose.ui.util.VerticalSpaceVectorConverter import kotlin.math.abs import kotlin.math.sign import kotlinx.coroutines.CoroutineScope Loading @@ -40,13 +42,12 @@ interface ContentOverscrollEffect : OverscrollEffect { } open class BaseContentOverscrollEffect( orientation: Orientation, private val animationScope: CoroutineScope, private val animationSpec: AnimationSpec<Float>, ) : ContentOverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) { ) : ContentOverscrollEffect { /** The [Animatable] that holds the current overscroll value. */ private val animatable = Animatable(initialValue = 0f, visibilityThreshold = 0.5f) private var lastConverter: SpaceVectorConverter? = null override val overscrollDistance: Float get() = animatable.value Loading @@ -58,6 +59,15 @@ open class BaseContentOverscrollEffect( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val converter = converterOrNull(delta.x, delta.y) ?: return performScroll(delta) return converter.applyToScroll(delta, source, performScroll) } private fun SpaceVectorConverter.applyToScroll( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val deltaForAxis = delta.toFloat() Loading Loading @@ -105,6 +115,14 @@ open class BaseContentOverscrollEffect( override suspend fun applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { val converter = converterOrNull(velocity.x, velocity.y) ?: return converter.applyToFling(velocity, performFling) } private suspend fun SpaceVectorConverter.applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { // We launch a coroutine to ensure the fling animation starts after any pending [snapTo] // animations have finished. Loading @@ -117,4 +135,35 @@ open class BaseContentOverscrollEffect( } } } protected fun requireConverter(): SpaceVectorConverter { return checkNotNull(lastConverter) { "lastConverter is null, make sure to call requireConverter() only when " + "overscrollDistance != 0f" } } private fun converterOrNull(x: Float, y: Float): SpaceVectorConverter? { val converter: SpaceVectorConverter = when { x != 0f && y != 0f -> error( "BaseContentOverscrollEffect only supports single orientation scrolls " + "and velocities" ) x == 0f && y == 0f -> lastConverter ?: return null x != 0f -> HorizontalSpaceVectorConverter else -> VerticalSpaceVectorConverter } if (lastConverter != null) { check(lastConverter == converter) { "BaseContentOverscrollEffect should always be used in the same orientation" } } else { lastConverter = converter } return converter } } packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/OffsetOverscrollEffect.kt +42 −31 Original line number Diff line number Diff line Loading @@ -19,7 +19,7 @@ package com.android.compose.gesture.effect import androidx.annotation.VisibleForTesting import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.OverscrollFactory import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable Loading @@ -37,24 +37,42 @@ import androidx.compose.ui.unit.dp import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope /** An [OverscrollEffect] that offsets the content by the overscroll value. */ class OffsetOverscrollEffect( orientation: Orientation, animationScope: CoroutineScope, animationSpec: AnimationSpec<Float>, ) : BaseContentOverscrollEffect(orientation, animationScope, animationSpec) { private var _node: DelegatableNode = newNode() override val node: DelegatableNode get() = _node @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun rememberOffsetOverscrollEffect( animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.defaultSpatialSpec() ): OffsetOverscrollEffect { val animationScope = rememberCoroutineScope() return remember(animationScope, animationSpec) { OffsetOverscrollEffect(animationScope, animationSpec) } } @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun rememberOffsetOverscrollEffectFactory( animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.defaultSpatialSpec() ): OverscrollFactory { val animationScope = rememberCoroutineScope() return remember(animationScope, animationSpec) { OffsetOverscrollEffectFactory(animationScope, animationSpec) } } fun newNode(): DelegatableNode { return object : Modifier.Node(), LayoutModifierNode { override fun onDetach() { super.onDetach() // TODO(b/379086317) Remove this workaround: avoid to reuse the same node. _node = newNode() data class OffsetOverscrollEffectFactory( private val animationScope: CoroutineScope, private val animationSpec: AnimationSpec<Float>, ) : OverscrollFactory { override fun createOverscrollEffect(): OverscrollEffect { return OffsetOverscrollEffect(animationScope, animationSpec) } } /** An [OverscrollEffect] that offsets the content by the overscroll value. */ class OffsetOverscrollEffect(animationScope: CoroutineScope, animationSpec: AnimationSpec<Float>) : BaseContentOverscrollEffect(animationScope, animationSpec) { override val node: DelegatableNode = object : Modifier.Node(), LayoutModifierNode { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, Loading @@ -62,7 +80,12 @@ class OffsetOverscrollEffect( val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) { val offsetPx = computeOffset(density = this@measure, overscrollDistance) placeable.placeRelativeWithLayer(position = offsetPx.toIntOffset()) if (offsetPx != 0) { placeable.placeRelativeWithLayer( with(requireConverter()) { offsetPx.toIntOffset() } ) } else { placeable.placeRelative(0, 0) } } } Loading @@ -80,18 +103,6 @@ class OffsetOverscrollEffect( } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun rememberOffsetOverscrollEffect( orientation: Orientation, animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.defaultSpatialSpec(), ): OffsetOverscrollEffect { val animationScope = rememberCoroutineScope() return remember(orientation, animationScope, animationSpec) { OffsetOverscrollEffect(orientation, animationScope, animationSpec) } } /** This converter lets you change a linear progress into a function of your choice. */ fun interface ProgressConverter { fun convert(progress: Float): Float Loading packages/SystemUI/compose/core/src/com/android/compose/ui/util/SpaceVectorConverter.kt +4 −4 Original line number Diff line number Diff line Loading @@ -37,11 +37,11 @@ interface SpaceVectorConverter { fun SpaceVectorConverter(orientation: Orientation) = when (orientation) { Orientation.Horizontal -> HorizontalConverter Orientation.Vertical -> VerticalConverter Orientation.Horizontal -> HorizontalSpaceVectorConverter Orientation.Vertical -> VerticalSpaceVectorConverter } private data object HorizontalConverter : SpaceVectorConverter { data object HorizontalSpaceVectorConverter : SpaceVectorConverter { override fun Offset.toFloat() = x override fun Velocity.toFloat() = x Loading @@ -55,7 +55,7 @@ private data object HorizontalConverter : SpaceVectorConverter { override fun Int.toIntOffset() = IntOffset(this, 0) } private data object VerticalConverter : SpaceVectorConverter { data object VerticalSpaceVectorConverter : SpaceVectorConverter { override fun Offset.toFloat() = y override fun Velocity.toFloat() = y Loading packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/effect/OffsetOverscrollEffectTest.kt +3 −10 Original line number Diff line number Diff line Loading @@ -55,10 +55,7 @@ class OffsetOverscrollEffectTest { } } private fun setupOverscrollableBox( scrollableOrientation: Orientation, overscrollEffectOrientation: Orientation = scrollableOrientation, ): LayoutInfo { private fun setupOverscrollableBox(scrollableOrientation: Orientation): LayoutInfo { val layoutSize: Dp = 200.dp var touchSlop: Float by Delegates.notNull() // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is Loading @@ -67,7 +64,7 @@ class OffsetOverscrollEffectTest { rule.setContent { density = LocalDensity.current touchSlop = LocalViewConfiguration.current.touchSlop val overscrollEffect = rememberOffsetOverscrollEffect(overscrollEffectOrientation) val overscrollEffect = rememberOffsetOverscrollEffect() Box( Modifier.overscroll(overscrollEffect) Loading Loading @@ -102,11 +99,7 @@ class OffsetOverscrollEffectTest { @Test fun applyNoOffset_duringHorizontalOverscroll() { val info = setupOverscrollableBox( scrollableOrientation = Orientation.Vertical, overscrollEffectOrientation = Orientation.Horizontal, ) val info = setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) Loading Loading
packages/SystemUI/compose/core/src/com/android/compose/gesture/NestedDraggable.kt +4 −2 Original line number Diff line number Diff line Loading @@ -464,13 +464,15 @@ private class NestedDraggableNode( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ): Velocity { // Make sure we only use the velocity in this draggable orientation. val orientationVelocity = velocity.toFloat().toVelocity() return if (overscrollEffect != null) { overscrollEffect.applyToFling(velocity) { performFling(it) } overscrollEffect.applyToFling(orientationVelocity) { performFling(it) } // Effects always consume the whole velocity. velocity } else { performFling(velocity) performFling(orientationVelocity) } } Loading
packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/ContentOverscrollEffect.kt +52 −3 Original line number Diff line number Diff line Loading @@ -23,7 +23,9 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import com.android.compose.ui.util.HorizontalSpaceVectorConverter import com.android.compose.ui.util.SpaceVectorConverter import com.android.compose.ui.util.VerticalSpaceVectorConverter import kotlin.math.abs import kotlin.math.sign import kotlinx.coroutines.CoroutineScope Loading @@ -40,13 +42,12 @@ interface ContentOverscrollEffect : OverscrollEffect { } open class BaseContentOverscrollEffect( orientation: Orientation, private val animationScope: CoroutineScope, private val animationSpec: AnimationSpec<Float>, ) : ContentOverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) { ) : ContentOverscrollEffect { /** The [Animatable] that holds the current overscroll value. */ private val animatable = Animatable(initialValue = 0f, visibilityThreshold = 0.5f) private var lastConverter: SpaceVectorConverter? = null override val overscrollDistance: Float get() = animatable.value Loading @@ -58,6 +59,15 @@ open class BaseContentOverscrollEffect( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val converter = converterOrNull(delta.x, delta.y) ?: return performScroll(delta) return converter.applyToScroll(delta, source, performScroll) } private fun SpaceVectorConverter.applyToScroll( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val deltaForAxis = delta.toFloat() Loading Loading @@ -105,6 +115,14 @@ open class BaseContentOverscrollEffect( override suspend fun applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { val converter = converterOrNull(velocity.x, velocity.y) ?: return converter.applyToFling(velocity, performFling) } private suspend fun SpaceVectorConverter.applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { // We launch a coroutine to ensure the fling animation starts after any pending [snapTo] // animations have finished. Loading @@ -117,4 +135,35 @@ open class BaseContentOverscrollEffect( } } } protected fun requireConverter(): SpaceVectorConverter { return checkNotNull(lastConverter) { "lastConverter is null, make sure to call requireConverter() only when " + "overscrollDistance != 0f" } } private fun converterOrNull(x: Float, y: Float): SpaceVectorConverter? { val converter: SpaceVectorConverter = when { x != 0f && y != 0f -> error( "BaseContentOverscrollEffect only supports single orientation scrolls " + "and velocities" ) x == 0f && y == 0f -> lastConverter ?: return null x != 0f -> HorizontalSpaceVectorConverter else -> VerticalSpaceVectorConverter } if (lastConverter != null) { check(lastConverter == converter) { "BaseContentOverscrollEffect should always be used in the same orientation" } } else { lastConverter = converter } return converter } }
packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/OffsetOverscrollEffect.kt +42 −31 Original line number Diff line number Diff line Loading @@ -19,7 +19,7 @@ package com.android.compose.gesture.effect import androidx.annotation.VisibleForTesting import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.OverscrollFactory import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable Loading @@ -37,24 +37,42 @@ import androidx.compose.ui.unit.dp import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope /** An [OverscrollEffect] that offsets the content by the overscroll value. */ class OffsetOverscrollEffect( orientation: Orientation, animationScope: CoroutineScope, animationSpec: AnimationSpec<Float>, ) : BaseContentOverscrollEffect(orientation, animationScope, animationSpec) { private var _node: DelegatableNode = newNode() override val node: DelegatableNode get() = _node @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun rememberOffsetOverscrollEffect( animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.defaultSpatialSpec() ): OffsetOverscrollEffect { val animationScope = rememberCoroutineScope() return remember(animationScope, animationSpec) { OffsetOverscrollEffect(animationScope, animationSpec) } } @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun rememberOffsetOverscrollEffectFactory( animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.defaultSpatialSpec() ): OverscrollFactory { val animationScope = rememberCoroutineScope() return remember(animationScope, animationSpec) { OffsetOverscrollEffectFactory(animationScope, animationSpec) } } fun newNode(): DelegatableNode { return object : Modifier.Node(), LayoutModifierNode { override fun onDetach() { super.onDetach() // TODO(b/379086317) Remove this workaround: avoid to reuse the same node. _node = newNode() data class OffsetOverscrollEffectFactory( private val animationScope: CoroutineScope, private val animationSpec: AnimationSpec<Float>, ) : OverscrollFactory { override fun createOverscrollEffect(): OverscrollEffect { return OffsetOverscrollEffect(animationScope, animationSpec) } } /** An [OverscrollEffect] that offsets the content by the overscroll value. */ class OffsetOverscrollEffect(animationScope: CoroutineScope, animationSpec: AnimationSpec<Float>) : BaseContentOverscrollEffect(animationScope, animationSpec) { override val node: DelegatableNode = object : Modifier.Node(), LayoutModifierNode { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, Loading @@ -62,7 +80,12 @@ class OffsetOverscrollEffect( val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) { val offsetPx = computeOffset(density = this@measure, overscrollDistance) placeable.placeRelativeWithLayer(position = offsetPx.toIntOffset()) if (offsetPx != 0) { placeable.placeRelativeWithLayer( with(requireConverter()) { offsetPx.toIntOffset() } ) } else { placeable.placeRelative(0, 0) } } } Loading @@ -80,18 +103,6 @@ class OffsetOverscrollEffect( } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun rememberOffsetOverscrollEffect( orientation: Orientation, animationSpec: AnimationSpec<Float> = MaterialTheme.motionScheme.defaultSpatialSpec(), ): OffsetOverscrollEffect { val animationScope = rememberCoroutineScope() return remember(orientation, animationScope, animationSpec) { OffsetOverscrollEffect(orientation, animationScope, animationSpec) } } /** This converter lets you change a linear progress into a function of your choice. */ fun interface ProgressConverter { fun convert(progress: Float): Float Loading
packages/SystemUI/compose/core/src/com/android/compose/ui/util/SpaceVectorConverter.kt +4 −4 Original line number Diff line number Diff line Loading @@ -37,11 +37,11 @@ interface SpaceVectorConverter { fun SpaceVectorConverter(orientation: Orientation) = when (orientation) { Orientation.Horizontal -> HorizontalConverter Orientation.Vertical -> VerticalConverter Orientation.Horizontal -> HorizontalSpaceVectorConverter Orientation.Vertical -> VerticalSpaceVectorConverter } private data object HorizontalConverter : SpaceVectorConverter { data object HorizontalSpaceVectorConverter : SpaceVectorConverter { override fun Offset.toFloat() = x override fun Velocity.toFloat() = x Loading @@ -55,7 +55,7 @@ private data object HorizontalConverter : SpaceVectorConverter { override fun Int.toIntOffset() = IntOffset(this, 0) } private data object VerticalConverter : SpaceVectorConverter { data object VerticalSpaceVectorConverter : SpaceVectorConverter { override fun Offset.toFloat() = y override fun Velocity.toFloat() = y Loading
packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/effect/OffsetOverscrollEffectTest.kt +3 −10 Original line number Diff line number Diff line Loading @@ -55,10 +55,7 @@ class OffsetOverscrollEffectTest { } } private fun setupOverscrollableBox( scrollableOrientation: Orientation, overscrollEffectOrientation: Orientation = scrollableOrientation, ): LayoutInfo { private fun setupOverscrollableBox(scrollableOrientation: Orientation): LayoutInfo { val layoutSize: Dp = 200.dp var touchSlop: Float by Delegates.notNull() // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is Loading @@ -67,7 +64,7 @@ class OffsetOverscrollEffectTest { rule.setContent { density = LocalDensity.current touchSlop = LocalViewConfiguration.current.touchSlop val overscrollEffect = rememberOffsetOverscrollEffect(overscrollEffectOrientation) val overscrollEffect = rememberOffsetOverscrollEffect() Box( Modifier.overscroll(overscrollEffect) Loading Loading @@ -102,11 +99,7 @@ class OffsetOverscrollEffectTest { @Test fun applyNoOffset_duringHorizontalOverscroll() { val info = setupOverscrollableBox( scrollableOrientation = Orientation.Vertical, overscrollEffectOrientation = Orientation.Horizontal, ) val info = setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) Loading