Created
November 18, 2025 16:09
-
-
Save Kyriakos-Georgiopoulos/2c23d8456349db7d2166db8cb7fd8927 to your computer and use it in GitHub Desktop.
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
| /* | |
| * 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