wear: fluid magnetic slide-to-cancel - cancel button leans toward the approaching finger and blushes green-to-red continuously with proximity (finger-driven), soft springy scale bloom on activation; rotation wobble removed

This commit is contained in:
callebtc 2026-07-29 02:28:07 +02:00
parent 18831c20fa
commit 9998e7ae3f
2 changed files with 56 additions and 10 deletions

View File

@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@ -135,23 +136,37 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier:
fun VoiceRecordOverlay(
voice: VoiceNoteController,
hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
) {
val palette = LocalBitchatPalette.current
// Snappy, slightly overshooting snap for the cancel morph.
// The cancel morph, choreographed for feel:
// - color flows green→red CONTINUOUSLY as the finger approaches (finger-driven, so it
// is perfectly fluid), completing to full red on activation
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
// smooth spring so it lags and settles naturally
// - scale blooms with a soft bounce on activation — no rotation, no wobble
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.3f else 1f,
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessHigh
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "cancelSnap"
)
val cancelColor by androidx.compose.animation.animateColorAsState(
targetValue = if (hoveringCancel) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "cancelColor"
val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "magnetPull"
)
val cancelColor = androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f
)
AnimatedVisibility(
visible = voice.recording,
@ -177,14 +192,19 @@ fun VoiceRecordOverlay(
)
}
.size(52.dp)
.graphicsLayer { scaleX = cancelScale; scaleY = cancelScale }
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.QUICK_MS),
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon"
) { cancel ->
Icon(

View File

@ -154,6 +154,24 @@ private fun ChatBody(
val hoveringCancel = fingerActive &&
cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true
// Magnetic attraction: as the finger approaches the target (but is not on it yet), the
// button leans toward the finger and blushes red in proportion to the closeness;
// only actually entering the activation zone snaps it into full cancel mode.
val cancelCenter = cancelBounds?.center
val proximity: Float
val magnetPull: Offset
if (fingerActive && cancelCenter != null) {
val toFinger = fingerPos - cancelCenter
val dist = toFinger.getDistance()
proximity = ((MAGNET_OUTER_PX - dist) / (MAGNET_OUTER_PX - MAGNET_INNER_PX))
.coerceIn(0f, 1f)
magnetPull = if (dist > 1f) toFinger * (proximity * MAGNET_PULL_PX / dist)
else Offset.Zero
} else {
proximity = 0f
magnetPull = Offset.Zero
}
// Tactile tick each time the finger enters or leaves the cancel target.
var hoverHapticState by remember { mutableStateOf(false) }
LaunchedEffect(hoveringCancel, voice.recording) {
@ -264,6 +282,8 @@ private fun ChatBody(
VoiceRecordOverlay(
voice = voice,
hoveringCancel = hoveringCancel,
proximity = proximity,
magnetPull = magnetPull,
onCancelBounds = { cancelBounds = it }
)
}
@ -272,3 +292,9 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
// Magnetic zone geometry (px at watch density): the button starts reacting at
// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.
private const val MAGNET_OUTER_PX = 170f
private const val MAGNET_INNER_PX = 104f
private const val MAGNET_PULL_PX = 22f