Last active
May 7, 2024 10:35
-
-
Save KlassenKonstantin/df007c4d6c719d32058e82190c2cf533 to your computer and use it in GitHub Desktop.
evervault.com inspired animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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