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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@ -135,23 +136,37 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier:
fun VoiceRecordOverlay( fun VoiceRecordOverlay(
voice: VoiceNoteController, voice: VoiceNoteController,
hoveringCancel: Boolean, hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
) { ) {
val palette = LocalBitchatPalette.current 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( 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( animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessHigh stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
), ),
label = "cancelSnap" label = "cancelSnap"
) )
val cancelColor by androidx.compose.animation.animateColorAsState( val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = if (hoveringCancel) MaterialTheme.colorScheme.error targetValue = magnetPull,
else MaterialTheme.colorScheme.primary, animationSpec = androidx.compose.animation.core.spring(
animationSpec = tween(BitchatMotion.QUICK_MS), dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
label = "cancelColor" 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( AnimatedVisibility(
visible = voice.recording, visible = voice.recording,
@ -177,14 +192,19 @@ fun VoiceRecordOverlay(
) )
} }
.size(52.dp) .size(52.dp)
.graphicsLayer { scaleX = cancelScale; scaleY = cancelScale } .graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape) .clip(CircleShape)
.background(cancelColor), .background(cancelColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
androidx.compose.animation.Crossfade( androidx.compose.animation.Crossfade(
targetState = hoveringCancel, targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.QUICK_MS), animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon" label = "cancelIcon"
) { cancel -> ) { cancel ->
Icon( Icon(

View File

@ -154,6 +154,24 @@ private fun ChatBody(
val hoveringCancel = fingerActive && val hoveringCancel = fingerActive &&
cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true 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. // Tactile tick each time the finger enters or leaves the cancel target.
var hoverHapticState by remember { mutableStateOf(false) } var hoverHapticState by remember { mutableStateOf(false) }
LaunchedEffect(hoveringCancel, voice.recording) { LaunchedEffect(hoveringCancel, voice.recording) {
@ -264,6 +282,8 @@ private fun ChatBody(
VoiceRecordOverlay( VoiceRecordOverlay(
voice = voice, voice = voice,
hoveringCancel = hoveringCancel, hoveringCancel = hoveringCancel,
proximity = proximity,
magnetPull = magnetPull,
onCancelBounds = { cancelBounds = it } 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 // 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. // engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f 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