Skip to content

Instantly share code, notes, and snippets.

@alexjlockwood
Last active June 18, 2021 18:42
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save alexjlockwood/6dc2bf46c775ad8428335fff5bb55bf9 to your computer and use it in GitHub Desktop.
Save alexjlockwood/6dc2bf46c775ad8428335fff5bb55bf9 to your computer and use it in GitHub Desktop.
Implementation of a 'Playing with Paths' polygon animation
package com.alexjlockwood.playingwithpaths
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animatedFloat
import androidx.compose.animation.core.AnimationConstants
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.onActive
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.Group
import androidx.compose.ui.graphics.vector.Path
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.platform.setContent
import androidx.compose.ui.unit.dp
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PlayingWithPaths(Modifier.fillMaxWidth().fillMaxHeight())
}
}
}
/**Creates a composable 'playing with paths' polygon animation. */
@Composable
private fun PlayingWithPaths(modifier: Modifier = Modifier) {
val animatedProgress = animatedFloat(0f)
onActive {
// Begin the animation as soon as the first composition is applied.
animatedProgress.animateTo(
targetValue = 1f,
anim = repeatable(
iterations = AnimationConstants.Infinite,
animation = tween(durationMillis = 10000, easing = LinearEasing),
)
)
}
val vectorPainter = VectorPainter(
defaultWidth = 48.dp,
defaultHeight = 48.dp,
viewportWidth = ViewportWidth,
viewportHeight = ViewportHeight,
) { _, _ ->
Polygons.forEach {
// Create a colored stroke path for each polygon.
Path(
pathData = it.pathNodes,
stroke = SolidColor(it.color),
strokeLineWidth = 4f,
)
}
// Memoize the path nodes to avoid parsing the SVG path data string on each animation frame.
val dotPathNodes = remember { addPathNodes("m 0 -8 a 8 8 0 1 1 0 16 a 8 8 0 1 1 0 -16") }
Polygons.forEach {
// Draw a black, circular path for each dot and translate it
// to its current animated location along the polygon path.
val dotPoint = it.getPointAlongPath(animatedProgress.value)
Group(
translationX = dotPoint.x,
translationY = dotPoint.y,
) {
Path(
pathData = dotPathNodes,
fill = SolidColor(Color.Black),
)
}
}
}
Image(
painter = vectorPainter,
modifier = modifier,
)
}
// TODO: Should these be capitalized? Who knows... 🤷
internal const val ViewportWidth = 1080f
internal const val ViewportHeight = 1080f
private val Polygons = arrayOf(
Polygon(Color(0xffe84c65), 15, 362f, 2),
Polygon(Color(0xffe84c65), 14, 338f, 3),
Polygon(Color(0xffd554d9), 13, 314f, 4),
Polygon(Color(0xffaf6eee), 12, 292f, 5),
Polygon(Color(0xff4a4ae6), 11, 268f, 6),
Polygon(Color(0xff4294e7), 10, 244f, 7),
Polygon(Color(0xff6beeee), 9, 220f, 8),
Polygon(Color(0xff42e794), 8, 196f, 9),
Polygon(Color(0xff5ae75a), 7, 172f, 10),
Polygon(Color(0xffade76b), 6, 148f, 11),
Polygon(Color(0xffefefbb), 5, 128f, 12),
Polygon(Color(0xffe79442), 4, 106f, 13),
Polygon(Color(0xffe84c65), 3, 90f, 14),
)
package com.alexjlockwood.playingwithpaths
import android.graphics.Path
import android.graphics.PointF
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.PathNode
import androidx.compose.ui.util.lerp
import kotlin.math.cos
import kotlin.math.sin
/**
* A helper class that contains information about each polygon's drawing commands and a
* [getPointAlongPath] method that supports animating motion along the polygon path.
*/
internal class Polygon(val color: Color, sides: Int, radius: Float, laps: Int) {
/** The list of path nodes to use to draw the polygon path. */
val pathNodes: List<PathNode>
/** A precomputed lookup table that will be used to animate motion along the polygon path. */
private val pointsAlongPath: List<PointAlongPath>
init {
val polygonPoints = createPolygonPoints(sides, radius)
pathNodes = createPolygonPathNodes(polygonPoints)
val polygonDotPath = createPolygonDotPath(polygonPoints, laps)
pointsAlongPath = createPointsAlongPath(polygonDotPath)
}
/**
* Returns the [PointF] along the polygon path given a fraction in the interval [0,1].
* This is used to translate the black dot's location along each polygon path throughout
* the animation.
*/
fun getPointAlongPath(fraction: Float): PointF {
if (fraction <= 0f) return pointsAlongPath.first().point
if (fraction >= 1f) return pointsAlongPath.last().point
// Binary search for the correct path point.
var low = 0
var high = pointsAlongPath.size - 1
while (low <= high) {
val mid = (low + high) / 2
val midFraction = pointsAlongPath[mid].fraction
when {
fraction < midFraction -> high = mid - 1
fraction > midFraction -> low = mid + 1
else -> return pointsAlongPath[mid].point
}
}
// Now high is below the fraction and low is above the fraction.
val start = pointsAlongPath[high]
val end = pointsAlongPath[low]
val intervalFraction = (fraction - start.fraction) / (end.fraction - start.fraction)
return lerp(start.point, end.point, intervalFraction)
}
}
/**
* Creates a list of points describing the coordinates of a polygon with the given
* number of [sides] and [radius].
*/
private fun createPolygonPoints(sides: Int, radius: Float): List<PointF> {
val startAngle = (3 * Math.PI / 2).toFloat()
val angleIncrement = (2 * Math.PI / sides).toFloat()
return (0..sides).map {
val theta = startAngle + angleIncrement * it
PointF(
ViewportWidth / 2 + (radius * cos(theta)),
ViewportHeight / 2 + (radius * sin(theta)),
)
}
}
/** Creates a list of [PathNode] drawing commands for the given list of [points]. */
private fun createPolygonPathNodes(points: List<PointF>): List<PathNode> {
return points.mapIndexed { index, it ->
when (index) {
0 -> PathNode.MoveTo(it.x, it.y)
else -> PathNode.LineTo(it.x, it.y)
}
}
}
/**
* Container class that holds the location of a [point] at the given
* [fraction] along a stroked path.
*/
private data class PointAlongPath(val fraction: Float, val point: PointF)
/**
* Creates a [Path] given a polygon's [points] and the number of [laps] its
* corresponding dot should travel during the animation.
*/
private fun createPolygonDotPath(points: List<PointF>, laps: Int): Path {
return Path().apply {
for (i in 0 until laps) {
points.forEachIndexed { index, it ->
when (index) {
0 -> moveTo(it.x, it.y)
else -> lineTo(it.x, it.y)
}
}
}
}
}
/** Creates a lookup table that can be used to animate motion along a path. */
private fun createPointsAlongPath(path: Path): List<PointAlongPath> {
// Note: see j.mp/path-approximate-compat to backport this call for pre-O devices
val approximatedPath = path.approximate(0.5f)
val pointsAlongPath = mutableListOf<PointAlongPath>()
for (i in approximatedPath.indices step 3) {
val fraction = approximatedPath[i]
val point = PointF(approximatedPath[i + 1], approximatedPath[i + 2])
pointsAlongPath.add(PointAlongPath(fraction, point))
}
return pointsAlongPath
}
private fun lerp(start: PointF, end: PointF, fraction: Float): PointF {
return PointF(lerp(start.x, end.x, fraction), lerp(start.y, end.y, fraction))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment