From 7eabcc8e1bf32f1b0f5723def9576c7123b519bf Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 20 Dec 2024 18:11:28 +0100 Subject: [PATCH] Support defaultOverscrollFactory() on iOS (#1753) Add implementation of OverscrollFactory to support new Overscroll API. Fix overscroll in CoreTextField and BasicTextField. Upstreaming issue added: https://youtrack.jetbrains.com/issue/CMP-7307/Upstreaming.feature.foundation.text.rememberTextFieldOverscrollEffect Fixes https://youtrack.jetbrains.com/issue/CMP-7143/Support-OverscrollFactory-and-LocalOverscrollFactory ## Release Notes ### Fixes - iOS - Enables Cupertino Overscroll by default for scrollable components - Experimental method`optOutOfCupertinoOverscroll()` removed. --- .../compose/foundation/text/BasicTextField.kt | 5 ++ .../compose/foundation/text/CoreTextField.kt | 2 + .../compose/foundation/Overscroll.uikit.kt | 36 +++++++----- .../cupertino/CupertinoOverscrollEffect.kt | 55 +++++++++++++++---- .../gestures/UikitScrollable.uikit.kt | 18 ------ 5 files changed, 72 insertions(+), 44 deletions(-) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt index d584f4eefc430..d35ae669ab684 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.overscroll import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.handwriting.stylusHandwriting @@ -313,6 +314,8 @@ internal fun BasicTextField( DisposableEffect(textFieldSelectionState) { onDispose { textFieldSelectionState.dispose() } } + val overscrollEffect = rememberTextFieldOverscrollEffect() + val handwritingEnabled = !isPassword && keyboardOptions.keyboardType != KeyboardType.Password && @@ -373,6 +376,7 @@ internal fun BasicTextField( reverseScrolling = false ), interactionSource = interactionSource, + overscrollEffect = overscrollEffect ) .pointerHoverIcon(textPointerIcon) @@ -401,6 +405,7 @@ internal fun BasicTextField( ) .textFieldMinSize(textStyle) .clipToBounds() + .overscroll(overscrollEffect) .then( TextFieldCoreModifier( isFocused = isFocused && isWindowFocused, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt index 9eeae2a5f3c60..64815ae8c9f2c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.interaction.Interaction import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.overscroll import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.text.handwriting.stylusHandwriting @@ -701,6 +702,7 @@ internal fun CoreTextField( // TextFields .heightIn(min = state.minHeightForSingleLineField) .heightInLines(textStyle = textStyle, minLines = minLines, maxLines = maxLines) + .overscroll(overscrollEffect) .textFieldScroll( scrollerPosition = scrollerPosition, textFieldValue = value, diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt index ca83f4f5c1e18..ed9dc5a554e13 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt @@ -17,13 +17,13 @@ package androidx.compose.foundation import androidx.compose.foundation.cupertino.CupertinoOverscrollEffect -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.UiKitScrollConfig import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalAccessorScope import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection @Composable internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = @@ -31,18 +31,26 @@ internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun rememberOverscrollEffect(applyClip: Boolean): OverscrollEffect? = - if (UiKitScrollConfig.isRubberBandingOverscrollEnabled) { - val density = LocalDensity.current.density - val layoutDirection = LocalLayoutDirection.current +internal fun rememberOverscrollEffect(applyClip: Boolean): OverscrollEffect { + val density = LocalDensity.current.density + val layoutDirection = LocalLayoutDirection.current - remember(density, layoutDirection) { - CupertinoOverscrollEffect(density, layoutDirection, applyClip) - } - } else { - null + return remember(density, layoutDirection) { + CupertinoOverscrollEffect(density, layoutDirection, applyClip) } +} -internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? = - // TODO https://youtrack.jetbrains.com/issue/CMP-7143/Support-OverscrollFactory-and-LocalOverscrollFactory - null +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? { + val density = LocalDensity.currentValue + val layoutDirection = LocalLayoutDirection.currentValue + return CupertinoOverscrollEffectFactory(density, layoutDirection) +} + +private data class CupertinoOverscrollEffectFactory( + private val density: Density, + private val layoutDirection: LayoutDirection +) : OverscrollFactory { + override fun createOverscrollEffect(): OverscrollEffect { + return CupertinoOverscrollEffect(density.density, layoutDirection, applyClip = false) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt index 4f592236c7838..6e1893ff38ee6 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt @@ -22,7 +22,6 @@ import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.OverscrollEffect -import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,8 +30,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.Velocity @@ -130,14 +136,11 @@ class CupertinoOverscrollEffect( // this effect is considered to be in progress visibleOverscrollOffset.toOffset().getDistance() > 0.5f - override val effectModifier = Modifier - .onPlaced { - scrollSize = it.size.toSize() - } - .clipIfNeeded() - .offset { - visibleOverscrollOffset - } + override val node: DelegatableNode = CupertinoOverscrollNode( + offset = { visibleOverscrollOffset }, + coordinates = { scrollSize = it.size.toSize() }, + applyClip = applyClip + ) private fun Modifier.clipIfNeeded(): Modifier = if (applyClip) { @@ -148,8 +151,8 @@ class CupertinoOverscrollEffect( private fun NestedScrollSource.toCupertinoScrollSource(): CupertinoScrollSource? = when (this) { - NestedScrollSource.Drag -> CupertinoScrollSource.DRAG - NestedScrollSource.Fling -> CupertinoScrollSource.FLING + NestedScrollSource.UserInput -> CupertinoScrollSource.DRAG + NestedScrollSource.SideEffect -> CupertinoScrollSource.FLING else -> null } @@ -440,6 +443,34 @@ class CupertinoOverscrollEffect( } } +private class CupertinoOverscrollNode( + val offset: Density.() -> IntOffset, + val coordinates: (LayoutCoordinates) -> Unit, + val applyClip: Boolean +): LayoutAwareModifierNode, DrawModifierNode, Modifier.Node() { + + override val shouldAutoInvalidate: Boolean = true + + override fun ContentDrawScope.draw() { + if (applyClip) { + clipRect { this@draw.drawContentWithOffset() } + } else { + this@draw.drawContentWithOffset() + } + } + + private fun ContentDrawScope.drawContentWithOffset() { + val offset = offset() + translate(left = offset.x.toFloat(), top = offset.y.toFloat()) { + this@drawContentWithOffset.drawContent() + } + } + + override fun onPlaced(coordinates: LayoutCoordinates) { + coordinates(coordinates) + } +} + private fun Velocity.toOffset(): Offset = Offset(x, y) diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt index 8636d942d9f62..cdcbfdbc02d6e 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt @@ -14,16 +14,11 @@ * limitations under the License. */ -@file:Suppress("DEPRECATION") - package androidx.compose.foundation.gestures -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.Composable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.node.CompositionLocalConsumerModifierNode -import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -31,20 +26,7 @@ import androidx.compose.ui.util.fastFold internal actual fun CompositionLocalConsumerModifierNode.platformScrollConfig(): ScrollConfig = UiKitScrollConfig -/** - * Opt out of the Cupertino overscroll behavior (rubber banding and spring effect). - * - * This method should be called before any @Composable function using this effect is executed - * (so as early as possible, e.g. during app start up). - */ -@ExperimentalFoundationApi -fun optOutOfCupertinoOverscroll() { - UiKitScrollConfig.isRubberBandingOverscrollEnabled = false -} - internal object UiKitScrollConfig : ScrollConfig { - var isRubberBandingOverscrollEnabled: Boolean = true - /* * There are no scroll events produced on iOS, * so in reality this function should not be ever called.