diff --git a/docs/guide/utils.md b/docs/guide/utils.md index 1e4ee720..260b6155 100644 --- a/docs/guide/utils.md +++ b/docs/guide/utils.md @@ -193,6 +193,18 @@ Box( ) ``` +If you want to use both `Modifier.clickable()` and instant feedback, you can pass the `immediate = true` parameter. + +```kotlin +val interactionSource = remember { MutableInteractionSource() } + +Box( + modifier = Modifier + .clickable(interactionSource = interactionSource, indication = null, onClick = {}) + .pressSink(interactionSource, immediate = true) +) +``` + ### Press Feedback Type (`PressFeedbackType`) The `PressFeedbackType` enum defines different types of visual feedback that can be applied when the component is pressed. diff --git a/docs/zh_CN/guide/utils.md b/docs/zh_CN/guide/utils.md index 14d541de..18dc7119 100644 --- a/docs/zh_CN/guide/utils.md +++ b/docs/zh_CN/guide/utils.md @@ -193,6 +193,18 @@ Box( ) ``` +如果既想使用 `Modifier.clickable()` 又想要即时的反馈效果,可以传递 `immediate = true` 参数。 + +```kotlin +val interactionSource = remember { MutableInteractionSource() } + +Box( + modifier = Modifier + .clickable(interactionSource = interactionSource, indication = null, onClick = {}) + .pressSink(interactionSource, immediate = true) +) +``` + ### 按压反馈类型 (PressFeedbackType) `PressFeedbackType` 枚举定义了组件被按下时可以应用的不同类型的视觉反馈。 diff --git a/example/src/commonMain/kotlin/component/OtherComponent.kt b/example/src/commonMain/kotlin/component/OtherComponent.kt index 0002e198..8bb22d7f 100644 --- a/example/src/commonMain/kotlin/component/OtherComponent.kt +++ b/example/src/commonMain/kotlin/component/OtherComponent.kt @@ -32,8 +32,6 @@ import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -481,8 +479,7 @@ fun LazyListScope.otherComponent( ), insideMargin = PaddingValues(16.dp), pressFeedbackType = PressFeedbackType.None, - showIndication = true, - onClick = { } + showIndication = true ) { Text( color = MiuixTheme.colorScheme.onPrimary, @@ -497,7 +494,6 @@ fun LazyListScope.otherComponent( fontWeight = FontWeight.Normal ) } - val hapticFeedback = LocalHapticFeedback.current Row( modifier = Modifier .fillMaxWidth() @@ -510,7 +506,7 @@ fun LazyListScope.otherComponent( insideMargin = PaddingValues(16.dp), pressFeedbackType = PressFeedbackType.Sink, showIndication = true, - onClick = { }, + onClick = { println("Card click") }, content = { Text( color = MiuixTheme.colorScheme.onSurface, @@ -529,7 +525,7 @@ fun LazyListScope.otherComponent( modifier = Modifier.weight(1f), insideMargin = PaddingValues(16.dp), pressFeedbackType = PressFeedbackType.Tilt, - onLongPress = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }, + onLongPress = { println("Card long press") }, content = { Text( color = MiuixTheme.colorScheme.onSurface, diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 369d117f..e17c02e8 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 1.0.4 CFBundleVersion - 515 + 517 LSRequiresIPhoneOS CADisableMinimumFrameDurationOnPhone diff --git a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Card.kt b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Card.kt index 162d9559..62e82025 100644 --- a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Card.kt +++ b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Card.kt @@ -5,14 +5,8 @@ package top.yukonga.miuix.kmp.basic import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.waitForUpOrCancellation -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.indication +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -26,7 +20,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.takeOrElse -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp @@ -104,43 +97,23 @@ fun Card( val pressFeedbackModifier = remember(pressFeedbackType, interactionSource) { when (pressFeedbackType) { PressFeedbackType.None -> Modifier - PressFeedbackType.Sink -> Modifier.pressSink(interactionSource) - PressFeedbackType.Tilt -> Modifier.pressTilt(interactionSource) + PressFeedbackType.Sink -> Modifier.pressSink(interactionSource, immediate = true) + PressFeedbackType.Tilt -> Modifier.pressTilt(interactionSource, immediate = true) } } BasicCard( - modifier = modifier - .pointerInput(onClick, onLongPress) { - detectTapGestures( - onTap = { onClick?.invoke() }, - onLongPress = { onLongPress?.invoke() } - ) - } - .pointerInput(interactionSource) { - awaitEachGesture { - val pressInteraction: PressInteraction.Press - awaitFirstDown().also { - pressInteraction = PressInteraction.Press(it.position) - interactionSource.tryEmit(pressInteraction) - } - if (waitForUpOrCancellation() == null) { - interactionSource.tryEmit(PressInteraction.Cancel(pressInteraction)) - } else { - interactionSource.tryEmit(PressInteraction.Release(pressInteraction)) - } - } - } - .hoverable(interactionSource) - .then(pressFeedbackModifier), + modifier = modifier.then(pressFeedbackModifier), cornerRadius = cornerRadius, colors = colors ) { Column( modifier = Modifier - .indication( + .combinedClickable( interactionSource = interactionSource, - indication = if (showIndication == true) LocalIndication.current else null + indication = if (showIndication == true) LocalIndication.current else null, + onClick = { onClick?.invoke() }, + onLongClick = onLongPress ) .padding(insideMargin), content = content diff --git a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/utils/PressFeedback.kt b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/utils/PressFeedback.kt index 388801f1..000f6aba 100644 --- a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/utils/PressFeedback.kt +++ b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/utils/PressFeedback.kt @@ -3,63 +3,234 @@ package top.yukonga.miuix.kmp.utils -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.spring -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.Interaction import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.launch -/** Default sink amount for the press sink effect. */ -internal const val SinkAmount: Float = 0.94f +private class PressSinkNode( + var interactionSource: MutableInteractionSource, + var sinkAmount: Float, + var animationSpec: AnimationSpec, + var immediate: Boolean = false +) : Modifier.Node(), LayoutModifierNode, PointerInputModifierNode { -/** Default tilt amount for the press tilt effect. */ -internal const val TiltAmount: Float = 8f + private lateinit var pressInteraction: PressInteraction.Press + private var isPressed = false + private val animatedScale = Animatable(1f) -/** Default damping ratio for the press feedback spring animations. */ -internal const val DampingRatio: Float = 0.6f + private fun animateToSink(target: Float) { + coroutineScope.launch { animatedScale.animateTo(target, animationSpec) } + } + + override fun onAttach() { + coroutineScope.launch { + interactionSource.interactions.collect { interaction: Interaction -> + when (interaction) { + is PressInteraction.Press -> { + if (immediate && interaction != pressInteraction) return@collect + animateToSink(sinkAmount) + } + + is PressInteraction.Release -> { + if (immediate && interaction.press != pressInteraction) return@collect + animateToSink(1f) + } + + is PressInteraction.Cancel -> animateToSink(1f) + } + } + } + } -/** Default stiffness for the press feedback spring animations. */ -internal const val Stiffness: Float = 400f + override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) { + if (!immediate || pass != PointerEventPass.Main) return + val currentPressed = pointerEvent.changes.any { it.pressed } + if (currentPressed != isPressed) { + isPressed = currentPressed + if (isPressed) { + pressInteraction = PressInteraction.Press(pointerEvent.changes.first().position) + interactionSource.tryEmit(pressInteraction) + } else interactionSource.tryEmit(PressInteraction.Release(pressInteraction)) + } + } + + override fun onCancelPointerInput() { + if (!immediate) return + interactionSource.tryEmit(PressInteraction.Cancel(pressInteraction)) + } + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + placeable.placeWithLayer(0, 0) { + scaleX = animatedScale.value + scaleY = animatedScale.value + } + } + } +} + +private data class PressSinkElement( + val interactionSource: MutableInteractionSource, + val sinkAmount: Float, + val animationSpec: AnimationSpec, + val immediate: Boolean +) : ModifierNodeElement() { + override fun create() = PressSinkNode(interactionSource, sinkAmount, animationSpec, immediate) + override fun update(node: PressSinkNode) { + node.interactionSource = interactionSource + node.sinkAmount = sinkAmount + node.animationSpec = animationSpec + node.immediate = immediate + } + + override fun InspectorInfo.inspectableProperties() { + name = "PressSinkNode" + properties["sinkAmount"] = sinkAmount + } +} /** * Applies a "sink" visual feedback when the component is pressed, * by scaling the component down smoothly using animation. * - * @param interactionSource An external [MutableInteractionSource] to observe press state. + * The impact of [immediate] on feedback effect: + * `false` - Press Compose default logic and wait for touch confirmation before starting the animation (consistent with Indication). + * `true` - The animation is started directly when touched, even if it is subsequently scrolled or consumed. + * + * @param interactionSource The interaction source to use to detect presses. * @param sinkAmount The target scale when pressed (less than 1f). - * @param dampingRatio The damping ratio for the spring animation. - * @param stiffness The stiffness of the spring animation. + * @param animationSpec The animation spec to use when scaling. + * @param immediate Whether to trigger the press effect and Indication immediately. + * */ fun Modifier.pressSink( interactionSource: MutableInteractionSource, - sinkAmount: Float = SinkAmount, - dampingRatio: Float = DampingRatio, - stiffness: Float = Stiffness -): Modifier = composed { - val isPressed by interactionSource.collectIsPressedAsState() - - val animationSpec = remember(dampingRatio, stiffness) { - spring(dampingRatio = dampingRatio, stiffness = stiffness) + sinkAmount: Float = 0.94f, + animationSpec: AnimationSpec = spring(0.8f, 600f), + immediate: Boolean = false +): Modifier = this then PressSinkElement( + interactionSource, sinkAmount, animationSpec, immediate +) + +private class PressTiltNode( + var interactionSource: MutableInteractionSource, + var tiltAmount: Float, + var animationSpec: AnimationSpec, + var immediate: Boolean +) : Modifier.Node(), LayoutModifierNode, PointerInputModifierNode { + + private lateinit var pressInteraction: PressInteraction.Press + private var isPressed = false + private var transformOrigin: TransformOrigin = TransformOrigin.Center + private var targetX = 0f + private var targetY = 0f + private val animatedTiltX = Animatable(0f) + private val animatedTiltY = Animatable(0f) + + private fun animateToTilt(x: Float, y: Float) { + coroutineScope.launch { animatedTiltX.animateTo(x, animationSpec) } + coroutineScope.launch { animatedTiltY.animateTo(y, animationSpec) } + } + + override fun onAttach() { + coroutineScope.launch { + interactionSource.interactions.collect { interaction: Interaction -> + when (interaction) { + is PressInteraction.Press -> { + if (immediate && interaction != pressInteraction) return@collect + animateToTilt(targetX, targetY) + } + + is PressInteraction.Release -> { + if (immediate && interaction.press != pressInteraction) return@collect + animateToTilt(0f, 0f) + } + + is PressInteraction.Cancel -> animateToTilt(0f, 0f) + } + } + } + } + + override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) { + if (pass != PointerEventPass.Main) return + if (pointerEvent.type == PointerEventType.Press) { + val offset = pointerEvent.changes.first().position + + transformOrigin = TransformOrigin( + pivotFractionX = if (offset.x < bounds.width / 2f) 1f else 0f, + pivotFractionY = if (offset.y < bounds.height / 2f) 1f else 0f + ) + + targetX = if (offset.y < bounds.height / 2f) tiltAmount else -tiltAmount + targetY = if (offset.x < bounds.width / 2f) -tiltAmount else tiltAmount + } + if (!immediate) return + val currentPressed = pointerEvent.changes.any { it.pressed } + if (currentPressed != isPressed) { + isPressed = currentPressed + if (isPressed) { + pressInteraction = PressInteraction.Press(pointerEvent.changes.first().position) + interactionSource.tryEmit(pressInteraction) + } else interactionSource.tryEmit(PressInteraction.Release(pressInteraction)) + } } - val scale by animateFloatAsState( - targetValue = if (isPressed) sinkAmount else 1f, - animationSpec = animationSpec, - label = "pressSinkScale" - ) + override fun onCancelPointerInput() { + if (!immediate) return + interactionSource.tryEmit(PressInteraction.Cancel(pressInteraction)) + } - this.scale(scale) + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + placeable.placeWithLayer(0, 0) { + rotationX = animatedTiltX.value + rotationY = animatedTiltY.value + cameraDistance = 12 * density + this.transformOrigin = this@PressTiltNode.transformOrigin + } + } + } +} + +private data class PressTiltElement( + val interactionSource: MutableInteractionSource, + val tiltAmount: Float, + val animationSpec: AnimationSpec, + val immediate: Boolean +) : ModifierNodeElement() { + override fun create() = PressTiltNode(interactionSource, tiltAmount, animationSpec, immediate) + override fun update(node: PressTiltNode) { + node.interactionSource = interactionSource + node.tiltAmount = tiltAmount + node.animationSpec = animationSpec + node.immediate = immediate + } + + override fun InspectorInfo.inspectableProperties() { + name = "PressTiltNode" + properties["tiltAmount"] = tiltAmount + } } /** @@ -67,62 +238,23 @@ fun Modifier.pressSink( * The tilt direction is determined by touch offset, * giving the effect that one corner "sinks" while the other "static". * - * @param interactionSource An external [MutableInteractionSource] to observe press state. + * The impact of [immediate] on feedback effect: + * `false` - Press Compose default logic and wait for touch confirmation before starting the animation (consistent with Indication). + * `true` - The animation is started directly when touched, even if it is subsequently scrolled or consumed. + * + * @param interactionSource The interaction source to use to detect presses. * @param tiltAmount Maximum rotation (in degrees) to apply along X and Y axes. - * @param dampingRatio The damping ratio for the tilt spring animation. - * @param stiffness The stiffness of the tilt spring animation. + * @param animationSpec The animation spec to use when rotating. + * @param immediate Whether to trigger the press effect and Indication immediately. */ fun Modifier.pressTilt( interactionSource: MutableInteractionSource, - tiltAmount: Float = TiltAmount, - dampingRatio: Float = DampingRatio, - stiffness: Float = Stiffness -): Modifier = composed { - val isPressed by interactionSource.collectIsPressedAsState() - - val animationSpec = remember(dampingRatio, stiffness) { - spring(dampingRatio = dampingRatio, stiffness = stiffness) - } - - var targetX by remember { mutableStateOf(0f) } - var targetY by remember { mutableStateOf(0f) } - - val tiltX by animateFloatAsState( - targetValue = if (isPressed) targetX else 0f, - animationSpec = animationSpec, - label = "pressTiltX" - ) - val tiltY by animateFloatAsState( - targetValue = if (isPressed) targetY else 0f, - animationSpec = animationSpec, - label = "pressTiltY" - ) - var transformOrigin by remember { mutableStateOf(TransformOrigin.Center) } - - this - .graphicsLayer { - rotationX = tiltX - rotationY = tiltY - cameraDistance = 12 * density - this.transformOrigin = transformOrigin - } - .pointerInput(tiltAmount) { - awaitEachGesture { - val down = awaitFirstDown() - val w = size.width - val h = size.height - val offset = down.position - - transformOrigin = TransformOrigin( - pivotFractionX = if (offset.x < w / 2) 1f else 0f, - pivotFractionY = if (offset.y < h / 2) 1f else 0f - ) - - targetX = if (offset.y < h / 2) tiltAmount else -tiltAmount - targetY = if (offset.x < w / 2) -tiltAmount else tiltAmount - } - } -} + tiltAmount: Float = 8f, + animationSpec: AnimationSpec = spring(0.6f, 400f), + immediate: Boolean = false +): Modifier = this then PressTiltElement( + interactionSource, tiltAmount, animationSpec, immediate +) /** * The type of visual feedback to apply when the component is pressed.