Created
May 5, 2024 07:00
-
-
Save Sal7one/444ad688bf8f3338fca4d66da904aa2d to your computer and use it in GitHub Desktop.
Animated heart jetpack compose, with gaps
This file contains hidden or 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
// infinite with gap of 1 | |
@Composable | |
fun AnimatedHeartShapeRaw( | |
brush: Brush = Brush.verticalGradient(colors = listOf(Color.Magenta, Color.Magenta)), // Default gradient from Magenta to Blue | |
) { | |
val animationPercentage = remember { Animatable(0f) } // Animation state from 0 to 1 | |
LaunchedEffect(Unit) { | |
animationPercentage.animateTo( | |
targetValue = 1f, | |
animationSpec = infiniteRepeatable( | |
animation = tween(durationMillis = 3000, easing = LinearEasing), | |
repeatMode = RepeatMode.Restart // The animation will go from 0 to 1 and then from 1 to 0 | |
) | |
) | |
} | |
Canvas(modifier = Modifier.fillMaxSize()) { | |
val width = size.width | |
val height = size.height | |
val path = getHeartPath(width, height) | |
val pathMeasure = PathMeasure(path.asAndroidPath(), false) | |
val totalLength = pathMeasure.length | |
val gapSize = totalLength * 0.05f // 5% of total path length for the gap | |
// Adjusting visible length of the path | |
val visibleLength = totalLength - gapSize | |
// Creating a PathEffect to animate a gap moving through the heart | |
val pathEffect = PathEffect.dashPathEffect( | |
floatArrayOf( | |
visibleLength, | |
gapSize | |
), // The first float is the visible segment, the second is the invisible segment (the gap) | |
phase = totalLength - totalLength * animationPercentage.value // The phase moves back and forth with the animation | |
) | |
drawPath( | |
path = path, | |
brush = brush, // Using a single color for the entire path | |
style = Stroke(width = 5f, pathEffect = pathEffect) | |
) | |
} | |
} | |
@Composable | |
fun AnimatedHeartShapeWithGaps( | |
numGaps: Int = 3, // Number of gaps | |
gapSizeFraction: Float = 0.02f, // Fraction of total path length to be used as the gap size | |
brush: Brush = Brush.verticalGradient(colors = listOf(Color.Magenta, Color.Magenta)), // Default gradient from Magenta to Blue | |
strokeWidth: Float = 5f, | |
animationDurationMillis: Int = 2000 | |
) { | |
val animationPercentage = remember(numGaps){ Animatable(0f) } | |
LaunchedEffect(numGaps) { | |
animationPercentage.animateTo( | |
targetValue = 1f, | |
animationSpec = infiniteRepeatable( | |
animation = tween(durationMillis = animationDurationMillis, | |
easing = LinearEasing | |
), | |
repeatMode = RepeatMode.Restart | |
) | |
) | |
} | |
Canvas(modifier = Modifier.fillMaxSize()) { | |
val width = size.width | |
val height = size.height | |
val path = getHeartPath(width, height) | |
val pathMeasure = PathMeasure(path.asAndroidPath(), false) | |
val totalLength = pathMeasure.length | |
// Calculate the gap size and segment length | |
val gapSize = totalLength * gapSizeFraction | |
val segmentLength = | |
(totalLength - numGaps * gapSize) / (numGaps) // Calculating the visible segments | |
val pathEffect = PathEffect.dashPathEffect( | |
floatArrayOf(segmentLength, gapSize), // Visible segment and gap size | |
phase = -(totalLength - segmentLength - gapSize * numGaps) * animationPercentage.value // Adjusting phase for reverse movement | |
) | |
drawPath( | |
path = path, | |
brush = brush, // Use the gradient brush for drawing | |
style = Stroke(width = strokeWidth, pathEffect = pathEffect) | |
) | |
} | |
} | |
@Composable | |
fun HeartShapeAnimated( | |
brush: Brush = Brush.verticalGradient(colors = listOf(Color.Magenta, Color.Magenta)), // Default gradient from Magenta to Blue | |
animationDurationMillis: Int = 2000 | |
) { | |
val animationPercentage = remember { Animatable(0f) } // Animation state from 0 to 1 | |
// Start the animation when the composable enters the composition | |
LaunchedEffect(Unit) { | |
animationPercentage.animateTo( | |
targetValue = 1f, | |
animationSpec = tween(animationDurationMillis) | |
) | |
} | |
Canvas(modifier = Modifier.fillMaxSize()) { | |
val width = size.width | |
val height = size.height | |
val path = getHeartPath(width, height) | |
val pathLength = PathMeasure(path.asAndroidPath(), false).length | |
val pathEffect = PathEffect.dashPathEffect( | |
floatArrayOf(pathLength, pathLength), | |
phase = pathLength * (1 - animationPercentage.value) | |
) | |
drawPath( | |
path = path, | |
brush = brush, | |
style = Stroke( | |
width = 5f, | |
pathEffect = pathEffect | |
) // Ensure the stroke width matches your design | |
) | |
} | |
} | |
fun getHeartPath(width: Float, height: Float): Path { | |
return Path().apply { | |
reset() | |
// Starting slightly more to the right | |
moveTo(0.02f * width, 0.3418f * height) | |
// Adjusting control points to ensure symmetry | |
cubicTo( | |
0.02f * width, | |
0.56075f * height, | |
0.19432f * width, | |
0.77611f * height, | |
0.46971f * width, | |
0.96114f * height | |
) | |
cubicTo( | |
0.47996f * width, | |
0.96783f * height, | |
0.49461f * width, | |
0.97502f * height, | |
0.50486f * width, | |
0.97502f * height | |
) | |
cubicTo( | |
0.51512f * width, | |
0.97502f * height, | |
0.52977f * width, | |
0.96783f * height, | |
0.54051f * width, | |
0.96114f * height | |
) | |
cubicTo( | |
0.81541f * width, | |
0.77611f * height, | |
0.98973f * width, | |
0.56075f * height, | |
0.98973f * width, | |
0.3418f * height | |
) | |
cubicTo( | |
0.98973f * width, | |
0.15985f * height, | |
0.87108f * width, | |
0.03135f * height, | |
0.71287f * width, | |
0.03135f * height | |
) | |
cubicTo( | |
0.62254f * width, | |
0.03135f * height, | |
0.5493f * width, | |
0.07658f * height, | |
0.50486f * width, | |
0.14597f * height | |
) | |
cubicTo( | |
0.46141f * width, | |
0.0771f * height, | |
0.38719f * width, | |
0.03135f * height, | |
0.29686f * width, | |
0.03135f * height | |
) | |
cubicTo( | |
0.13865f * width, | |
0.03135f * height, | |
0.02f * width, | |
0.15985f * height, | |
0.02f * width, | |
0.3418f * height | |
) | |
close() | |
} | |
} | |
// Screen code and color pallete | |
class MainActivity : ComponentActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
enableEdgeToEdge() | |
setContent { | |
ComposeChallengeTheme { | |
var numOfGaps by remember { | |
mutableIntStateOf(3) | |
} | |
var brush by remember { | |
mutableStateOf(Brush.linearGradient(listOf(Color.Magenta, Color.Magenta))) | |
} | |
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> | |
Column( | |
modifier = Modifier | |
.padding(innerPadding) | |
.fillMaxSize(), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center | |
) { | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.SpaceAround | |
) { | |
Column( | |
verticalArrangement = Arrangement.spacedBy(8.dp) | |
) { | |
Text(text = "Once", textAlign = TextAlign.Center) | |
Box( | |
modifier = Modifier.size(110.dp) | |
) { | |
HeartShapeAnimated(brush = brush) | |
} | |
} | |
Column( | |
verticalArrangement = Arrangement.spacedBy(8.dp) | |
) { | |
Text(text = "Animated", textAlign = TextAlign.Center) | |
Box( | |
modifier = Modifier.size(110.dp) | |
) { | |
AnimatedHeartShapeRaw(brush = brush) | |
} | |
} | |
Column( | |
verticalArrangement = Arrangement.spacedBy(8.dp) | |
) { | |
Text(text = "With Gaps", textAlign = TextAlign.Center) | |
Box( | |
modifier = Modifier.size(110.dp) | |
) { | |
AnimatedHeartShapeWithGaps( | |
numGaps = numOfGaps, | |
brush = brush | |
) | |
} | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.spacedBy(8.dp) | |
) { | |
Button(onClick = { numOfGaps++ }) { | |
Text(text = "+") | |
} | |
Text( | |
text = "$numOfGaps", style = TextStyle( | |
fontStyle = FontStyle.Italic, | |
fontWeight = FontWeight.Bold | |
) | |
) | |
Button(onClick = { | |
if (numOfGaps != 2) | |
numOfGaps-- | |
}) { | |
Text(text = "-") | |
} | |
} | |
} | |
} | |
ColorPalette { selectedBrush -> | |
brush = selectedBrush | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun ColorPalette( | |
onColorSelected: (Brush) -> Unit | |
) { | |
val colors = listOf( | |
Color.Red, | |
Color.Green, | |
Color.Blue, | |
Color.Yellow, | |
Color.Magenta, | |
Color.Cyan | |
) | |
val gradients = listOf( | |
Brush.horizontalGradient(listOf(Color.Red, Color.Yellow)), | |
Brush.verticalGradient( listOf(Color.Green, Color.Blue)), | |
Brush.radialGradient( listOf(Color.Magenta, Color.Cyan)) | |
) | |
Row( | |
horizontalArrangement = Arrangement.SpaceAround, | |
modifier = Modifier | |
.fillMaxWidth() | |
.horizontalScroll(rememberScrollState()) | |
) { | |
// Displaying solid colors | |
colors.forEach { color -> | |
Box( | |
modifier = Modifier | |
.size(50.dp) | |
.background( | |
Brush.linearGradient( | |
listOf( | |
color, | |
color | |
) | |
) | |
) // Ensuring two colors are present even if they are the same. | |
.clickable { onColorSelected(Brush.linearGradient(listOf(color, color))) } | |
) | |
} | |
// Displaying gradient brushes | |
gradients.forEach { gradient -> | |
Box( | |
modifier = Modifier | |
.size(50.dp) | |
.background(gradient) | |
.clickable { onColorSelected(gradient) } | |
) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screenshot

// inspired by
https://twitter.com/amos_gyamfi/status/1786636566967795974