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.