mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
18831c20fa
commit
9998e7ae3f
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user