Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created November 18, 2025 16:09
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/2c23d8456349db7d2166db8cb7fd8927 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/2c23d8456349db7d2166db8cb7fd8927 to your computer and use it in GitHub Desktop.
/*
* Copyright 2025 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PathMeasure
import androidx.annotation.DrawableRes
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import org.xmlpull.v1.XmlPullParser
import kotlin.math.min
import android.graphics.Path as AndroidPath
private const val MIN_PATH_LENGTH = 0.0001f
private const val AUTO_THIN_FACTOR = 0.45f
private const val MIN_STROKE_WIDTH_PX = 0.75f
private const val COMPLETION_EPSILON = 1e-3f
/**
* Styled representation of an individual SVG path.
*
* @property rawPath Raw path data in SVG pathData format.
* @property fillColor Optional fill color.
* @property strokeColor Optional stroke color.
* @property strokeWidthPxAt1x Optional base stroke width in pixels for a 1x viewport.
* @property fillAlpha Alpha multiplier applied to the fill color.
* @property strokeAlpha Alpha multiplier applied to the stroke color.
* @property cap Stroke cap style.
* @property join Stroke join style.
* @property fillTypeEvenOdd Whether the fill type is even-odd (otherwise non-zero).
*/
data class StyledPath(
val rawPath: String,
val fillColor: Color? = null,
val strokeColor: Color? = null,
val strokeWidthPxAt1x: Float? = null,
val fillAlpha: Float = 1f,
val strokeAlpha: Float = 1f,
val cap: StrokeCap = StrokeCap.Round,
val join: StrokeJoin = StrokeJoin.Round,
val fillTypeEvenOdd: Boolean = false
)
/**
* Specification of an SVG path set, including viewport and paths.
*
* @property viewportWidth SVG viewport width.
* @property viewportHeight SVG viewport height.
* @property paths List of styled paths contained in the SVG.
*/
data class SvgPathSpec(
val viewportWidth: Float,
val viewportHeight: Float,
val paths: List<StyledPath>
)
/**
* Convenience constructor for a single-path [SvgPathSpec].
*
* @param viewportWidth SVG viewport width.
* @param viewportHeight SVG viewport height.
* @param d Raw path data in SVG pathData format.
*/
fun SvgPathSpec(
viewportWidth: Float,
viewportHeight: Float,
d: String
): SvgPathSpec = SvgPathSpec(
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
paths = listOf(StyledPath(rawPath = d))
)
private data class BuiltPath(
val styled: StyledPath,
val androidPath: AndroidPath,
val length: Float
)
private fun buildPaths(spec: SvgPathSpec): List<BuiltPath> {
return spec.paths.map { styledPath ->
val composePath = PathParser()
.parsePathString(styledPath.rawPath)
.toPath()
.apply {
fillType = if (styledPath.fillTypeEvenOdd) {
PathFillType.EvenOdd
} else {
PathFillType.NonZero
}
}
val androidPath = composePath.asAndroidPath()
val pathMeasure = PathMeasure(androidPath, false)
var totalLength = 0f
do {
totalLength += pathMeasure.length
} while (pathMeasure.nextContour())
BuiltPath(
styled = styledPath,
androidPath = androidPath,
length = if (totalLength <= 0f) MIN_PATH_LENGTH else totalLength
)
}
}
/**
* Draws a styled SVG path specification with a progressive trace animation.
*
* The [progress] in [0f, 1f] controls how much of the combined path length is
* revealed. Both fills and strokes are supported, with optional auto-thinning
* of stroke widths as the SVG is scaled.
*
* @param spec SVG path specification.
* @param progress Normalized progress in [0f, 1f] for the trace animation.
* @param modifier Modifier applied to the [Canvas].
* @param defaultStrokeColor Fallback stroke color when none is defined on the path.
* @param defaultStrokeWidth Fallback stroke width when none is defined on the path.
* @param autoThin Whether to automatically clamp stroke thickness relative to scale.
*/
@Composable
fun PathTraceStyled(
spec: SvgPathSpec,
progress: Float,
modifier: Modifier = Modifier,
defaultStrokeColor: Color = Color(0x171717),
defaultStrokeWidth: Dp = 2.dp,
autoThin: Boolean = true
) {
val builtPaths = remember(spec) {
buildPaths(spec)
}
val totalLength = remember(builtPaths) {
builtPaths.sumOf { it.length.toDouble() }.toFloat()
}
Canvas(modifier = modifier) {
if (totalLength <= 0f) return@Canvas
val clampedProgress = progress.coerceIn(0f, 1f)
val targetLength = clampedProgress * totalLength
val scaleX = size.width / spec.viewportWidth
val scaleY = size.height / spec.viewportHeight
val scale = min(scaleX, scaleY)
val translateX = (size.width - spec.viewportWidth * scale) / 2f
val translateY = (size.height - spec.viewportHeight * scale) / 2f
val baseMatrix = Matrix().apply {
setScale(scale, scale)
postTranslate(translateX, translateY)
}
fun createStrokePaint(styledPath: StyledPath, finalStrokePx: Float): Paint {
return Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = finalStrokePx
color = (styledPath.strokeColor ?: defaultStrokeColor).toArgb()
alpha = ((styledPath.strokeColor?.alpha ?: 1f) * 255)
.toInt()
.coerceIn(0, 255)
strokeCap = when (styledPath.cap) {
StrokeCap.Butt -> Paint.Cap.BUTT
StrokeCap.Round -> Paint.Cap.ROUND
StrokeCap.Square -> Paint.Cap.SQUARE
else -> Paint.Cap.ROUND
}
strokeJoin = when (styledPath.join) {
StrokeJoin.Round -> Paint.Join.ROUND
StrokeJoin.Miter -> Paint.Join.MITER
StrokeJoin.Bevel -> Paint.Join.BEVEL
else -> Paint.Join.ROUND
}
strokeMiter = 1f
}
}
fun createFillPaint(styledPath: StyledPath): Paint? {
val color = styledPath.fillColor ?: return null
return Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
this.color = color.toArgb()
alpha = (styledPath.fillAlpha * 255)
.toInt()
.coerceIn(0, 255)
}
}
drawIntoCanvas { canvas ->
var accumulatedLength = 0f
builtPaths.forEach { builtPath ->
val isPathCompleted =
targetLength >= accumulatedLength + builtPath.length - COMPLETION_EPSILON
if (isPathCompleted) {
val fillPath = AndroidPath(builtPath.androidPath)
fillPath.transform(baseMatrix)
createFillPaint(builtPath.styled)?.let { paint ->
canvas.nativeCanvas.drawPath(fillPath, paint)
}
}
accumulatedLength += builtPath.length
}
var remainingLength = targetLength
builtPaths.forEach { builtPath ->
if (remainingLength <= 0f) return@forEach
val pathDrawLength = remainingLength.coerceAtMost(builtPath.length)
if (pathDrawLength > 0f) {
val tracedPath = AndroidPath()
val pathMeasure = PathMeasure(builtPath.androidPath, false)
var lengthLeftOnPath = pathDrawLength
do {
val contourLength = pathMeasure.length
if (lengthLeftOnPath <= 0f) break
val segmentLength = lengthLeftOnPath.coerceAtMost(contourLength)
if (segmentLength > 0f) {
pathMeasure.getSegment(
0f,
segmentLength,
tracedPath,
true
)
}
lengthLeftOnPath -= segmentLength
} while (pathMeasure.nextContour())
tracedPath.transform(baseMatrix)
val desiredStrokePx =
builtPath.styled.strokeWidthPxAt1x ?: defaultStrokeWidth.toPx()
val maxStrokePx = AUTO_THIN_FACTOR * scale
val finalStrokePx = if (autoThin) {
min(desiredStrokePx, maxStrokePx).coerceAtLeast(MIN_STROKE_WIDTH_PX)
} else {
desiredStrokePx
}
val strokePaint = createStrokePaint(builtPath.styled, finalStrokePx)
canvas.nativeCanvas.drawPath(tracedPath, strokePaint)
}
remainingLength -= pathDrawLength
}
}
}
}
/**
* Parses an Android vector drawable resource into an [SvgPathSpec].
*
* Only `<path>` elements with `android:pathData` are considered. The
* vector's viewport width/height and basic styling attributes are mapped
* to [StyledPath] instances.
*
* @param context Context used to resolve the drawable and resources.
* @param drawableResId Resource ID of the vector drawable.
*
* @return Parsed [SvgPathSpec] for the provided drawable.
*
* @throws IllegalArgumentException If no path elements are found.
*/
@SuppressLint("ResourceType")
fun loadPathSpecFromVectorDrawable(
context: Context,
@DrawableRes drawableResId: Int
): SvgPathSpec {
val parser = context.resources.getXml(drawableResId)
val androidNs = "http://schemas.android.com/apk/res/android"
fun parseColorAttr(name: String): Color? {
val resId = parser.getAttributeResourceValue(androidNs, name, 0)
if (resId != 0) {
return Color(context.getColor(resId))
}
val raw = parser.getAttributeValue(androidNs, name) ?: return null
return try {
Color(raw.toColorInt())
} catch (_: Throwable) {
null
}
}
fun parseFloatAttr(name: String, fallback: Float? = null): Float? {
val raw = parser.getAttributeValue(androidNs, name) ?: return fallback
return raw.toFloatOrNull() ?: fallback
}
var viewportWidth = 24f
var viewportHeight = 24f
val styledPaths = mutableListOf<StyledPath>()
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"vector" -> {
viewportWidth = parser
.getAttributeValue(androidNs, "viewportWidth")
?.toFloatOrNull()
?: viewportWidth
viewportHeight = parser
.getAttributeValue(androidNs, "viewportHeight")
?.toFloatOrNull()
?: viewportHeight
}
"path" -> {
val pathData = parser.getAttributeValue(androidNs, "pathData")
if (!pathData.isNullOrBlank()) {
val fillColor = parseColorAttr("fillColor")
val strokeColor = parseColorAttr("strokeColor")
val strokeWidth = parseFloatAttr("strokeWidth")
val fillAlpha = parseFloatAttr("fillAlpha", 1f) ?: 1f
val strokeAlpha = parseFloatAttr("strokeAlpha", 1f) ?: 1f
val cap = when (parser.getAttributeValue(androidNs, "strokeLineCap")) {
"butt" -> StrokeCap.Butt
"square" -> StrokeCap.Square
else -> StrokeCap.Round
}
val join = when (parser.getAttributeValue(androidNs, "strokeLineJoin")) {
"miter" -> StrokeJoin.Miter
"bevel" -> StrokeJoin.Bevel
else -> StrokeJoin.Round
}
val isFillTypeEvenOdd =
parser.getAttributeValue(androidNs, "fillType") == "evenOdd"
styledPaths += StyledPath(
rawPath = pathData,
fillColor = fillColor?.copy(alpha = fillAlpha),
strokeColor = strokeColor?.copy(alpha = strokeAlpha),
strokeWidthPxAt1x = strokeWidth,
fillAlpha = fillAlpha,
strokeAlpha = strokeAlpha,
cap = cap,
join = join,
fillTypeEvenOdd = isFillTypeEvenOdd
)
}
}
}
}
eventType = parser.next()
}
require(styledPaths.isNotEmpty()) {
"No <path android:pathData=\"...\"> found in vector drawable #$drawableResId"
}
return SvgPathSpec(
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
paths = styledPaths
)
}
/**
* Traces the paths of a vector drawable using [PathTraceStyled].
*
* A "cycle" consists of:
* - Animating [progress] from 0f to 1f over [speedMs] using [easing].
* - Holding at 100% progress for [pauseMs] (if > 0).
* - Invoking [onCycle] to signal that the cycle (including the hold) has completed.
*
* Depending on [stopSignal] and [stopAtEndOfCurrentCycle], the composable either
* loops cycles or stops after the current one:
*
* - If [stopSignal] is false, cycles repeat indefinitely with a pause between them.
* - If [stopSignal] becomes true and [stopAtEndOfCurrentCycle] is true, the current
* cycle (including the pause) completes, [onCycle] is invoked, and then it stops.
* - If [stopSignal] becomes true and [stopAtEndOfCurrentCycle] is false, the current
* cycle still completes (including the pause) and then it stops.
*
* The latest value of [stopSignal] is always used when deciding whether to continue.
*
* @param drawableId Vector drawable resource to trace.
* @param modifier Modifier applied to the drawing area.
* @param speedMs Duration of one trace cycle in milliseconds.
* @param pauseMs Duration to hold at 100% progress at the end of each cycle.
* @param easing Easing used for the trace animation.
* @param stopSignal Flag indicating that the animation should stop after the current cycle.
* @param stopAtEndOfCurrentCycle If true, guarantees that a stop happens only after
* the current cycle completes; otherwise, it still completes the current cycle but
* will not start a new one.
* @param onCycle Optional callback invoked after each completed cycle.
*/
@Composable
fun PathTraceFromSvg(
@DrawableRes drawableId: Int,
modifier: Modifier = Modifier,
speedMs: Int = 3800,
pauseMs: Int = 1000,
easing: Easing = LinearEasing,
stopSignal: Boolean = false,
stopAtEndOfCurrentCycle: Boolean = true,
onCycle: (() -> Unit)? = null
) {
val context = LocalContext.current
val spec = remember(drawableId) {
loadPathSpecFromVectorDrawable(context, drawableId)
}
val progress = remember { Animatable(0f) }
val stopRef = rememberUpdatedState(stopSignal)
LaunchedEffect(drawableId, speedMs, pauseMs, easing) {
while (true) {
progress.snapTo(0f)
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = speedMs,
easing = easing
)
)
val requestedStop = stopRef.value
val stopAtEnd = requestedStop && stopAtEndOfCurrentCycle
val shouldLoop = !requestedStop
if (pauseMs > 0 && (shouldLoop || requestedStop)) {
kotlinx.coroutines.delay(pauseMs.toLong())
}
onCycle?.invoke()
if (stopAtEnd || (requestedStop && !stopAtEndOfCurrentCycle)) {
progress.snapTo(1f)
break
}
}
}
PathTraceStyled(
spec = spec,
progress = progress.value,
modifier = modifier
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment