Skip to content

Instantly share code, notes, and snippets.

@KlassenKonstantin
Last active May 7, 2024 10:35
Show Gist options
  • Save KlassenKonstantin/df007c4d6c719d32058e82190c2cf533 to your computer and use it in GitHub Desktop.
Save KlassenKonstantin/df007c4d6c719d32058e82190c2cf533 to your computer and use it in GitHub Desktop.
evervault.com inspired animation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
@Composable
fun ClippedForeground(
modifier: Modifier,
provideClipAmount: () -> Float,
backgroundContent: @Composable () -> Unit,
foregroundContent: @Composable () -> Unit,
) {
Box(
modifier = modifier
) {
backgroundContent()
Box(modifier = Modifier.clip(provideClipAmount)) {
foregroundContent()
}
}
}
fun Modifier.clip(provideClipAmount: () -> Float) = this.drawWithContent {
// https://stackoverflow.com/questions/73590695/how-to-clip-or-cut-a-composable
with(drawContext.canvas.nativeCanvas) {
saveLayer(null, null)
val topLeft = Offset((size.width - 1) - size.width * provideClipAmount(), 0f)
drawContent()
// Clip content
// Added a pixel to the left and right, otherwise 1px wide lines could still be seen
drawRect(
color = Color.Transparent,
topLeft = topLeft,
size = size.copy(width = size.width + 2, height = size.height),
blendMode = BlendMode.SrcOut
)
restore()
}
}
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseInQuad
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import de.kuno.encryptor.ui.theme.EncryptorTheme
/**
* Used for the star field
*/
private val starsParticleConfig = ParticleConfig(
count = 250,
minRadius = 1.dp,
maxRadius = 3.dp,
maxParallaxFactor = 3.5f,
loopDuration = 20_000,
easing = LinearEasing,
fadeOut = false,
color = Color(0xFF81D4FA),
)
/**
* Used for the dismantle effect of the card
*/
private val dismantleParticleConfig = ParticleConfig(
count = 250,
minRadius = 1.dp,
maxRadius = 5.dp,
loopDuration = 400,
maxParallaxFactor = 1f,
easing = EaseInQuad,
fadeOut = true,
color = Color(0xFF2196F3),
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
setContent {
EncryptorTheme {
Box(
modifier = Modifier
.background(Color.Black)
.fillMaxSize()
.particles(rememberParticleState(starsParticleConfig)),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(185.dp),
contentAlignment = Alignment.CenterStart
) {
var isEncrypting by remember { mutableStateOf(false) }
TravellingCard { isEncrypting = it }
GlowingLine()
DismantleEffect(isEncrypting)
}
}
}
}
}
}
@Composable
fun BoxScope.GlowingLine() {
Canvas(
modifier = Modifier
.align(Alignment.Center)
.fillMaxHeight()
.width(16.dp)
.blur(16.dp, BlurredEdgeTreatment.Unbounded)
) {
drawOval(Color(0xFF2196F3))
}
Canvas(
modifier = Modifier
.align(Alignment.Center)
.fillMaxHeight(0.95f)
.width(4.dp)
) {
drawOval(Color.White, alpha = 0.8f)
}
}
@Composable
fun DismantleEffect(
isEncrypting: Boolean
) {
val alpha by animateFloatAsState(if (isEncrypting) 1f else 0f, spring(stiffness = 100f))
val brush = Brush.horizontalGradient(listOf(Color(0x882196F3), Color.Transparent))
Row(
Modifier.graphicsLayer {
this.alpha = alpha
}
) {
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.background(brush)
.clipToBounds()
.particles(rememberParticleState(dismantleParticleConfig))
.fillMaxHeight()
.width(48.dp)
)
}
}
}
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.random.Random
fun Modifier.particles(state: ParticlesState) = this.drawBehind {
state.particles.forEach { particle ->
val startX = -particle.radius
val endX = size.width + particle.radius
val easedValue = state.easing.transform((state.progress.value + particle.initialCenterX).mod(1f))
drawCircle(
color = particle.color,
radius = particle.radius,
center = Offset(
x = startX + (-startX + endX) * easedValue * particle.parallaxFactor,
y = particle.centerY * size.height
),
alpha = if (state.fadeOut) (particle.alpha * (1f - easedValue * 1.5f)).coerceAtLeast(0f) else particle.alpha,
)
}
}
@Composable
fun rememberParticleState(particleConfig: ParticleConfig): ParticlesState = with(particleConfig) {
val scope = rememberCoroutineScope()
val (minRadiusFloat, maxRadiusFloat) = LocalDensity.current.run { minRadius.toPx() to maxRadius.toPx() }
remember(this) {
ParticlesState(
count = count,
minRadius = minRadiusFloat,
maxRadius = maxRadiusFloat,
maxParallaxFactor = maxParallaxFactor,
loopDuration = loopDuration,
scope = scope,
easing = easing,
fadeOut = fadeOut,
color = color
)
}
}
class ParticlesState internal constructor(
count: Int,
scope: CoroutineScope,
val easing: Easing,
val fadeOut: Boolean,
private val minRadius: Float,
private val maxRadius: Float,
private val maxParallaxFactor: Float,
private val loopDuration: Int,
private val color: Color
) {
val particles = mutableListOf<Particle>()
val progress = Animatable(0f)
init {
repeat(count) {
addParticle()
}
scope.launch {
loop()
}
}
/**
* Loops between 0 and 1. Position of particles is based on [progress] and the parents width
*/
private suspend fun loop() {
progress.animateTo(
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(loopDuration, easing = LinearEasing))
)
}
private fun addParticle() {
val initialCenterX = Random.nextFloat()
val centerY = Random.nextFloat()
val alpha = Random.nextDouble(0.1, 1.0).toFloat()
val radius = Random.nextDouble(
from = minRadius.toDouble(),
until = maxRadius.toDouble()
).toFloat()
val parallaxFactor = if (maxParallaxFactor == 1.0f) {
1.0f
} else {
Random.nextDouble(1.0, maxParallaxFactor.toDouble()).toFloat()
}
val color = lerp(Color.White, color, Random.nextFloat())
particles += Particle(
initialCenterX = initialCenterX,
centerY = centerY,
radius = radius,
parallaxFactor = parallaxFactor,
alpha = alpha,
color = color
)
}
}
@Immutable
data class ParticleConfig(
/**
* The number of created particles
*/
val count: Int,
/**
* Min radius of a particle
*/
val minRadius: Dp,
/**
* Max radius of a particle
*/
val maxRadius: Dp,
/**
* Max parallax factor of a particle, see [Particle.parallaxFactor]. [1f..Float.MAX_VALUE]
*/
val maxParallaxFactor: Float,
/**
* Duration of a loop, in ms
*/
val loopDuration: Int,
/**
* Easing of the particles
*/
val easing: Easing,
/**
* Whether or not the particle should fade out when approaching the end of a loop
*/
val fadeOut: Boolean,
/**
* The final color of a particle is a random "lerp" between white and [color]
*/
val color: Color
)
@Immutable
data class Particle(
/**
* Initial relative horizontal position inside its parent. [0f..1f]
*/
val initialCenterX: Float,
/**
* Relative vertical position inside its parent. [0f..1f]
*/
var centerY: Float,
/**
* Radius in pixels
*/
val radius: Float,
/**
* Is multiplied with the horizontal position at a given time. [1f..Float.MAX_VALUE]
* A particle with a [parallaxFactor] of 2f reaches the end twice as fast
*/
val parallaxFactor: Float,
/**
* Alpha [0f..1f]
*/
val alpha: Float,
/**
* Color
*/
val color: Color
)
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlin.random.Random
const val VALID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ=1234567890"
const val CHAR_COUNT = 600
const val DEFAULT_HIGHLIGHTED_CHARS = 30
private val encryptedString = buildString {
repeat(CHAR_COUNT) {
append(VALID_CHARS.random())
}
}
private val defaultStyle = SpanStyle(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = Color.White.copy(alpha = 0.36f),
fontSize = 11.sp,
letterSpacing = 4.sp,
)
private val defaultStyleHighlighted = defaultStyle.copy(
color = Color.White.copy(alpha = 0.9f)
)
private fun shuffledEncryptedString(
style: SpanStyle = defaultStyle,
styleHighlighted: SpanStyle = defaultStyleHighlighted,
highlightedCount: Int = DEFAULT_HIGHLIGHTED_CHARS
): AnnotatedString {
return buildAnnotatedString {
val shuffled = encryptedString.toList().shuffled().joinToString("")
withStyle(style) {
append(shuffled)
}
repeat(highlightedCount) {
Random.nextInt(shuffled.length).also { index ->
addStyle(styleHighlighted, index, index + 1)
}
}
}
}
@Composable
fun TravellingCard(
onIsEncryptingChanged: (Boolean) -> Unit
) {
var parentWidth by remember { mutableStateOf(0) }
val cardTravelProgress = remember { Animatable(0f) }
var clipAmount by remember { mutableStateOf(-1f) }
val isEncrypting by remember {
derivedStateOf {
clipAmount in 0.0f..1.0f
}
}
LaunchedEffect(isEncrypting) {
onIsEncryptingChanged(isEncrypting)
}
LaunchedEffect(Unit) {
cardTravelProgress.animateTo(
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(7_000, easing = LinearEasing))
)
}
ClippedForeground(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1.5f, true)
.graphicsLayer {
val xMin = -size.width
val xMax = -xMin + parentWidth
translationX = xMin + xMax * cardTravelProgress.value
}
.onGloballyPositioned {
parentWidth = it.parentLayoutCoordinates!!.size.width
val parentHalfWidth = it.parentLayoutCoordinates!!.size.width / 2f
clipAmount = (it.boundsInParent().right - parentHalfWidth) / it.size.width
},
{ clipAmount.coerceIn(0f..1f) },
backgroundContent = {
Encrypted(isEncrypting)
},
foregroundContent = {
Card {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "🤫", fontSize = 32.sp)
}
}
}
)
}
@Composable
fun Encrypted(
isEncrypting: Boolean
) {
var text by remember { mutableStateOf(buildAnnotatedString {}) }
LaunchedEffect(isEncrypting) {
while (isEncrypting) {
text = shuffledEncryptedString()
delay(250)
}
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = text,
lineHeight = 21.sp,
textAlign = TextAlign.Justify,
maxLines = 13
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment