feat(bolt): white-core glow pulse for in-flight zap
Ports iOS commit #6 from feat/one-tap-zap. Replaces the multi-layer Canvas bolt animation that was smearing the silhouette at scale peaks. New approach: always-white silhouette + three stacked zap-color shadows underneath, driven by a single sin-eased oscillator. Math (period 0.9s): sine ∈ [-1, 1] phase ∈ [0, 1] = (sine + 1) / 2 iconScale = 1.0 + 0.10 * sine (0.90 → 1.10) verticalOffset = -0.5 * sine (±0.5dp centered on baseline) Shadow layers (Canvas strokes — drawn outer → inner so the white core sits on top): outer — radius 8 + 6*phase dp, α = 0.30 + 0.50*phase medium — radius 4 + 3*phase dp, α = 0.55 + 0.45*phase inner — radius 1.5dp constant, α = 0.95 core — solid white silhouette, untinted Vertical motion held to ±0.5dp so the icon doesn't lift off the action-bar baseline and misalign with neighbouring glyphs. The white IS the luminous core; the warm halos do the heat work. LinearEasing on the sineAngle (not FastOutSlowInEasing) — the sine function itself supplies the easing curve. Wrapping with another easing would double-stack and visibly stutter.
This commit is contained in:
@@ -57,8 +57,10 @@ import com.wisp.app.ui.util.AmountFormatter
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import coil3.compose.AsyncImage
|
||||
import com.wisp.app.nostr.Nip30
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.math.sin
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -282,50 +284,65 @@ fun ActionBar(
|
||||
|
||||
@Composable
|
||||
internal fun LightningAnimation(modifier: Modifier = Modifier) {
|
||||
val transition = rememberInfiniteTransition(label = "lightning")
|
||||
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0.5f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse"
|
||||
)
|
||||
|
||||
val scale by transition.animateFloat(
|
||||
initialValue = 0.92f,
|
||||
targetValue = 1.08f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "scale"
|
||||
)
|
||||
|
||||
// White-core glow pulse — matches iOS commit #6 of feat/one-tap-zap.
|
||||
// Single sin-eased oscillator on a 0.9s period drives:
|
||||
// sine ∈ [-1, 1]
|
||||
// phase ∈ [ 0, 1] = (sine + 1) / 2
|
||||
// iconScale = 1.0 + 0.10 * sine (0.90 → 1.10)
|
||||
// verticalOffset = -0.5 * sine (±0.5dp, centered on baseline)
|
||||
// The silhouette stays solid white — the three stacked stroked
|
||||
// shadows behind it do the warm-glow work (inner constant, medium
|
||||
// and outer breathing on the phase). Vertical motion is held to
|
||||
// ±0.5dp so the icon doesn't lift off the action-bar baseline and
|
||||
// misalign with neighbouring glyphs.
|
||||
val zapColor = WispThemeColors.zapColor
|
||||
val density = LocalDensity.current
|
||||
val transition = rememberInfiniteTransition(label = "bolt-pulse")
|
||||
|
||||
val sineAngle by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2.0 * Math.PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900, easing = LinearEasing)
|
||||
),
|
||||
label = "bolt-sine"
|
||||
)
|
||||
|
||||
val s = sin(sineAngle.toDouble()).toFloat()
|
||||
val phase = (s + 1f) / 2f
|
||||
val iconScale = 1.0f + 0.10f * s
|
||||
|
||||
val verticalOffsetPx = with(density) { (-0.5f * s).dp.toPx() }
|
||||
val innerStrokePx = with(density) { 1.5.dp.toPx() }
|
||||
val medStrokePx = with(density) { (4f + 3f * phase).dp.toPx() }
|
||||
val outerStrokePx = with(density) { (8f + 6f * phase).dp.toPx() }
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val boltPath = icBoltPath(w, h, scale)
|
||||
translate(top = verticalOffsetPx) {
|
||||
val boltPath = icBoltPath(size.width, size.height, iconScale)
|
||||
|
||||
// Soft outer glow
|
||||
drawPath(
|
||||
path = boltPath,
|
||||
color = zapColor.copy(alpha = pulse * 0.3f),
|
||||
style = Stroke(width = w * 0.14f, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
)
|
||||
|
||||
// Bolt fill
|
||||
drawPath(path = boltPath, color = zapColor)
|
||||
|
||||
// White-hot core
|
||||
drawPath(
|
||||
path = boltPath,
|
||||
color = Color.White.copy(alpha = pulse * 0.4f)
|
||||
)
|
||||
// Outer halo — widest, lowest opacity, breathing with phase.
|
||||
drawPath(
|
||||
path = boltPath,
|
||||
color = zapColor.copy(alpha = 0.3f + 0.5f * phase),
|
||||
style = Stroke(width = outerStrokePx, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
)
|
||||
// Medium glow.
|
||||
drawPath(
|
||||
path = boltPath,
|
||||
color = zapColor.copy(alpha = 0.55f + 0.45f * phase),
|
||||
style = Stroke(width = medStrokePx, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
)
|
||||
// Inner — tight, constant 95%.
|
||||
drawPath(
|
||||
path = boltPath,
|
||||
color = zapColor.copy(alpha = 0.95f),
|
||||
style = Stroke(width = innerStrokePx, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
)
|
||||
// White-core silhouette on top. Don't tint — the white IS
|
||||
// the luminous core; the warm halos do the heat work.
|
||||
drawPath(path = boltPath, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user