Last active
June 18, 2021 18:42
-
-
Save alexjlockwood/6dc2bf46c775ad8428335fff5bb55bf9 to your computer and use it in GitHub Desktop.
Implementation of a 'Playing with Paths' polygon animation
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
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), | |
) |
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
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