You can find the final code for this blog post on GitHub.


Our Goal Link to heading

Let’s create some fun animations with the Airbnb logo using Jetpack Compose on Android. After we’re done, we’ll have a reusable Canvas based vector animation. Here’s our final product:

Drawing on the Canvas creates an extremely performant, resizeable Compose animation with no loading time, that can be included in an app for a few KB. The above animation is only 18 KB when compiled with no optimizations.

Breaking Down Our Approach Link to heading

  1. Parse the Logo Vector Points - Extract the logo’s vector coordinates from a svg, and draw it using a Jetpack Compose Canvas.
  2. Animate the Logo - Use points we extract from the svg to create a custom model that can be animated.
  3. Split the Logo Animation - Split the Animation into a Left and Right animation
  4. Fade the Tip of the Stroke - The tip of the stroke is a sharp line. By adding a gradient to the tip of the stroke, we can soften it and make it feel a bit better.

Parse the Logo Vector Points Link to heading

I found an Airbnb SVG Logo online that I’ll use:

airbnb logo

After looking at the SVG, it’s just a single path command, which makes sense. It only uses 3 path commands:

  • L - Line To - Draws a simple straight line connecting two points.
  • C - Curve To - Curves to a specified point using an endpoint and two different control points to change the direction of the curve.
  • S - Smooth Curve To - Similar to Curve To, but it only has one control point.

Let’s create a simple model for this:

/**
 * Represents a command in an SVG path.
 */
sealed interface SvgPathCommand {
    /**
     * The end point of each SVG command
     */
    val p: Point

    /**
     * Represents a "Line To" command in an SVG path.
     * Draws a line from the current point to the given point.
     *
     * @property p The target point.
     */
    data class LineTo(override val p: Point) : SvgPathCommand

    /**
     * Represents a "Curve To" command in an SVG path.
     * Draws a cubic Bézier curve from the current point to the given point using two control points.
     *
     * @property p1 The first control point.
     * @property p2 The second control point.
     * @property p The target point.
     */
    data class CurveTo(
        val p1: Point,
        val p2: Point,
        override val p: Point,
    ) : SvgPathCommand

    /**
     * Represents a "Smooth Curve To" command in an SVG path.
     * Draws a cubic Bézier curve from the current point to the given point using one control point.
     *
     * @property p2 The control point.
     * @property p The target point.
     */
    data class SmoothCurveTo(val p2: Point, override val p: Point) : SvgPathCommand
}

We can then create a Simple SVG Parser which parses the SVG to our model, and then prints it out as code.

We’re left with something like this:

private val AIRBNB_LOGO_PATH_COMMANDS = listOf(
    // Trace of the inside
    SvgPathCommand.MoveTo(1851.6f, 1735.6f, false),
    SvgPathCommand.CurveTo(1836.6f, 1847.2f, 1761.5f, 1943.7f, 1656.4f, 1986.6f, false),
    SvgPathCommand.CurveTo(1604.9f, 2008.0f, 1549.1f, 2014.5f, 1493.3f, 2008.0f, false),
    SvgPathCommand.CurveTo(1439.7001f, 2001.6f, 1386.0f, 1984.4f, 1330.3f, 1952.3f, false),
    SvgPathCommand.CurveTo(1253.1001f, 1909.3f, 1175.8f, 1842.9f, 1085.7001f, 1744.2001f, false),
    SvgPathCommand.CurveTo(1227.3f, 1570.4f, 1313.1001f, 1411.7001f, 1345.3f, 1270.1001f, false),
    SvgPathCommand.CurveTo(1360.3f, 1203.6001f, 1362.5f, 1143.5001f, 1356.0f, 1087.7001f, false),
    SvgPathCommand.CurveTo(1347.4f, 1034.1001f, 1328.1f, 984.7001f, 1298.1f, 941.80005f, false),
    SvgPathCommand.CurveTo(1231.6f, 845.30005f, 1120.0f, 789.50006f, 995.6f, 789.50006f, false),
    SvgPathCommand.CurveTo(871.19995f, 789.50006f, 759.6f, 847.4001f, 693.1f, 941.80005f, false),
    SvgPathCommand.CurveTo(663.1f, 984.7001f, 643.8f, 1034.1001f, 635.19995f, 1087.7001f, false),
    SvgPathCommand.CurveTo(626.6f, 1143.5001f, 628.7999f, 1205.7001f, 645.89996f, 1270.1001f, false),
    SvgPathCommand.CurveTo(678.1f, 1411.7001f, 765.99994f, 1572.6001f, 905.5f, 1746.3f, false),
    SvgPathCommand.CurveTo(817.5f, 1845.0f, 738.2f, 1911.5f, 660.9f, 1954.4f, false),
    SvgPathCommand.CurveTo(605.10004f, 1986.6f, 551.5f, 2003.8f, 497.90002f, 2010.2001f, false),
    SvgPathCommand.CurveTo(442.60004f, 2016.4f, 386.7f, 2009.0001f, 334.90002f, 1988.8f, false),
    SvgPathCommand.CurveTo(229.80002f, 1945.9f, 154.70003f, 1849.3f, 139.70003f, 1737.8f, false),
    SvgPathCommand.CurveTo(133.30003f, 1684.2001f, 137.60002f, 1630.6001f, 159.00003f, 1570.5f, false),
    SvgPathCommand.CurveTo(165.40002f, 1549.0f, 176.20003f, 1527.6f, 186.90002f, 1501.9f, false),
    SvgPathCommand.CurveTo(201.90002f, 1467.6f, 219.10002f, 1431.1f, 236.20003f, 1394.6f, false),
    SvgPathCommand.LineTo(238.40002f, 1390.2999f, false),
    SvgPathCommand.CurveTo(386.40002f, 1070.5999f, 545.2f, 744.49994f, 710.4f, 426.99994f, false),
    SvgPathCommand.LineTo(716.80005f, 414.09995f, false),
    SvgPathCommand.CurveTo(734.00006f, 381.99994f, 751.10004f, 347.59995f, 768.30005f, 315.39996f, false),
    SvgPathCommand.CurveTo(785.50006f, 281.09998f, 804.80005f, 248.89996f, 828.4f, 220.99997f, false),
    SvgPathCommand.CurveTo(873.5f, 169.49997f, 933.5f, 141.59998f, 1000.0f, 141.59998f, false),
    SvgPathCommand.CurveTo(1066.5f, 141.59998f, 1126.6f, 169.49997f, 1171.6f, 220.99997f, false),
    SvgPathCommand.CurveTo(1195.2f, 248.89996f, 1214.5f, 281.09998f, 1231.7f, 315.39996f, false),
    SvgPathCommand.CurveTo(1248.8999f, 347.59998f, 1266.0f, 381.89996f, 1283.2f, 413.99997f, false),
    SvgPathCommand.LineTo(1289.7f, 426.89996f, false),
    SvgPathCommand.CurveTo(1452.7f, 746.5f, 1611.5f, 1072.6f, 1759.5f, 1392.3f, false),
    SvgPathCommand.VerticalLineTo(1394.4f, false),
    SvgPathCommand.CurveTo(1776.7f, 1428.7001f, 1791.7f, 1467.4f, 1808.8f, 1501.7001f, false),
    SvgPathCommand.CurveTo(1819.5f, 1527.5001f, 1830.3f, 1548.9f, 1836.7001f, 1570.3f, false),
    SvgPathCommand.CurveTo(1853.8f, 1626.2001f, 1860.2001f, 1679.8f, 1851.6001f, 1735.6001f, false),
    SvgPathCommand.ClosePath,

    //Trace of the internal loop
    SvgPathCommand.MoveTo(995.6f, 1634.7f, false),
    SvgPathCommand.CurveTo(879.8f, 1488.7999f, 804.69995f, 1351.5f, 778.89996f, 1235.7f, false),
    SvgPathCommand.CurveTo(768.19995f, 1186.2999f, 765.99994f, 1143.3999f, 772.49994f, 1104.7999f, false),
    SvgPathCommand.CurveTo(776.7999f, 1070.4999f, 789.69995f, 1040.3999f, 806.7999f, 1014.69995f, false),
    SvgPathCommand.CurveTo(847.5999f, 956.7999f, 916.19995f, 920.2999f, 995.5999f, 920.2999f, false),
    SvgPathCommand.CurveTo(1074.9999f, 920.2999f, 1145.7999f, 954.69995f, 1184.3999f, 1014.69995f, false),
    SvgPathCommand.CurveTo(1201.5999f, 1040.5f, 1214.3999f, 1070.5f, 1218.7f, 1104.7999f, false),
    SvgPathCommand.CurveTo(1225.1f, 1143.3999f, 1223.0f, 1188.4999f, 1212.2999f, 1235.7f, false),
    SvgPathCommand.CurveTo(1186.6f, 1349.3999f, 1111.4999f, 1486.7f, 995.5999f, 1634.7f, false),
    SvgPathCommand.ClosePath,

    // Trace of the outside
    SvgPathCommand.MoveTo(1963.2f, 1523.2f, false),
    SvgPathCommand.CurveTo(1952.5f, 1497.5f, 1941.7f, 1469.6f, 1931.0f, 1446.0f, false),
    SvgPathCommand.CurveTo(1913.8f, 1407.4f, 1896.7f, 1370.9f, 1881.6f, 1336.6f, false),
    SvgPathCommand.LineTo(1879.5f, 1334.5f, false),
    SvgPathCommand.CurveTo(1731.5f, 1012.7f, 1572.7f, 686.6f, 1405.4f, 364.8f, false),
    SvgPathCommand.LineTo(1399.0f, 351.9f, false),
    SvgPathCommand.CurveTo(1381.8f, 319.69998f, 1364.7f, 285.4f, 1347.5f, 251.09999f, false),
    SvgPathCommand.CurveTo(1326.0f, 212.5f, 1304.6f, 171.69998f, 1270.3f, 133.09999f, false),
    SvgPathCommand.CurveTo(1201.6001f, 47.19999f, 1102.9f, -1.5258789E-5f, 997.80005f, -1.5258789E-5f, false),
    SvgPathCommand.CurveTo(890.50006f, -1.5258789E-5f, 794.00006f, 47.199986f, 723.10004f, 128.69998f, false),
    SvgPathCommand.CurveTo(690.9f, 167.29999f, 667.30005f, 208.09998f, 645.9f, 246.69998f, false),
    SvgPathCommand.CurveTo(628.7f, 280.99997f, 611.60004f, 315.3f, 594.4f, 347.5f, false),
    SvgPathCommand.LineTo(588.0f, 360.3f, false),
    SvgPathCommand.CurveTo(422.8f, 682.1f, 261.9f, 1008.2f, 113.899994f, 1330.0f, false),
    SvgPathCommand.LineTo(111.799995f, 1334.3f, false),
    SvgPathCommand.CurveTo(96.799995f, 1368.6001f, 79.59999f, 1405.1001f, 62.399994f, 1443.7001f, false),
    SvgPathCommand.CurveTo(50.899994f, 1469.1001f, 40.199993f, 1494.9f, 30.199993f, 1520.9f, false),
    SvgPathCommand.CurveTo(2.2999935f, 1600.3f, -6.300007f, 1675.4f, 4.399994f, 1752.6f, false),
    SvgPathCommand.CurveTo(27.999994f, 1913.5f, 135.29999f, 2048.7f, 283.3f, 2108.7f, false),
    SvgPathCommand.CurveTo(339.09998f, 2132.3f, 397.0f, 2143.0f, 457.09998f, 2143.0f, false),
    SvgPathCommand.CurveTo(474.3f, 2143.0f, 495.69998f, 2140.9f, 512.89996f, 2138.7f, false),
    SvgPathCommand.CurveTo(583.69995f, 2130.0999f, 656.6f, 2106.5999f, 727.39996f, 2065.8f, false),
    SvgPathCommand.CurveTo(815.39996f, 2016.5f, 899.0f, 1945.7001f, 993.39996f, 1842.7001f, false),
    SvgPathCommand.CurveTo(1087.7999f, 1945.7001f, 1173.6f, 2016.5001f, 1259.3999f, 2065.8f, false),
    SvgPathCommand.CurveTo(1330.2f, 2106.6f, 1403.0999f, 2130.1f, 1473.8999f, 2138.7f, false),
    SvgPathCommand.CurveTo(1491.0999f, 2140.9f, 1512.4999f, 2143.0f, 1529.7f, 2143.0f, false),
    SvgPathCommand.CurveTo(1589.7999f, 2143.0f, 1649.7999f, 2132.3f, 1703.5f, 2108.7f, false),
    SvgPathCommand.CurveTo(1853.7f, 2048.5999f, 1958.8f, 1911.2999f, 1982.4f, 1752.6f, false),
    SvgPathCommand.CurveTo(1999.6f, 1677.6f, 1991.0f, 1602.6f, 1963.2001f, 1523.2f, false),
    SvgPathCommand.ClosePath
)

We can then create a Composable that can draw the SVG path:

@Composable
fun AirbnbLogo(
    modifier: Modifier = Modifier,
    color: Color = AirbnbLogo.COLOR_RAUSCH,
) {
    Canvas(
        modifier = modifier
            .fillMaxSize()
            .aspectRatio(LOGO_VECTOR_WIDTH / LOGO_VECTOR_HEIGHT)
    ) {
        val xScale = size.width / LOGO_VECTOR_WIDTH
        val yScale = size.height / LOGO_VECTOR_HEIGHT
        val path = svgCommandsToPath(xScale, yScale, AIRBNB_LOGO_PATH_COMMANDS)
        drawPath(path = path, color = color, style = Fill)
    }
}
private fun svgCommandsToPath(
    xScale: Float,
    yScale: Float,
    commands: List<SvgPathCommand>,
): Path {
    val path = Path()

    // Start at the location right before the first command
    commands.last().p.let {
        path.moveTo(it.x * xScale, it.y * yScale)
        currentX = it.x
        currentY = it.y
    }

    commands.forEach { command ->
        when (command) {
            is SvgPathCommand.MoveTo -> {
                path.moveTo(command.p.x * xScale, command.p.y * yScale)
            }
            is SvgPathCommand.Close -> {
                path.close()
            }
            is SvgPathCommand.LineTo -> {
                val scaledX = command.p.x * xScale
                val scaledY = command.p.y * yScale
                path.lineTo(scaledX, scaledY)
            }
            is SvgPathCommand.CurveTo -> {
                val scaledX = command.p.x * xScale
                val scaledY = command.p.y * yScale
                path.cubicTo(
                    command.p1.x * xScale, command.p1.y * yScale,
                    command.p2.x * xScale, command.p2.y * yScale,
                    scaledX, scaledY
                )
            }
            is SvgPathCommand.SmoothCurveTo -> {
                val scaledX = command.p.x * xScale
                val scaledY = command.p.y * yScale
                path.quadraticBezierTo(
                    command.p2.x * xScale,
                    command.p2.y * yScale,
                    scaledX,
                    scaledY
                )
            }
        }
    }

    return path
}

And we’re left with a pretty good result:

Understanding the SVG Link to heading

The SVG is coded with a fill path. This means that it creates a shape by drawing a path, then the Canvas fills the created shape of with a color when drawn. In the case of the Airbnb Logo, 3 shapes are defined in the SVG path. This is more easily represented if I graph the points on the logo.

Above can see the outside path in green, and the inside path in blue. It’s less clear in the image, but there is a 3rd shape defined by the internal twist as well.

Animating a fill like this can be challenging. It’s possible because each point seems to have an equivalent pair, but the data structure and algorithm would be complicated. It’d be much easier if we could change this into a stroke. Instead of two separate paths we’d find points representing the center, and instead of drawing two sets of points, we’d draw one, telling the canvas how much of a line to draw on either side.

We can achieve this by pairing each of the points, and then averaging them together. Lucky for us, this will also work for control points.

We can simplify everything if the model that we animate is able to look at everything as a single kind of draw command. Lucky for us, SmoothCurveTo, and LineTo, can both be represented as a CurveTo. When we convert everything to our new model, everything will have an endpoint, and then two control points.

Animating the Logo Link to heading

Let’s create an interface Point to represent x and y coordinates. This will allow us to create a PairPoint implementation, that will automatically average the two paired off points and represent them as a single x and y coordinate in our model. We could just use Offset and average the points together ourselves. By having a PairPoint we can keep the original SVG data, and if there are any issues, far more easily diagnose what’s going on by looking back at our original source.

interface Point {
    val x: Float
    val y: Float
}

fun Point(x: Float, y: Float) : Point = SinglePoint(x = x, y = y)

data class SinglePoint(
    override val x: Float,
    override val y: Float
) : Point

/** A point that takes 2 separate points and outputs their average */
data class PairPoint(
    val x1: Float,
    val y1: Float,
    val x2: Float,
    val y2: Float,
) : Point {
    override val x: Float = (x1 + x2) / 2f
    override val y: Float = (y1 + y2) / 2f
}

Based on our previous learnings, we can represent all draw commands as CurveTo, which means we just need an end point, and then two control points. This allows us to draw any direction by using a control point from where we are drawing to, along with the opposite control point from the last drawn point. Let’s call this a VectorNode.

internal data class VectorNode(
    /** The first control point for the node */
    val cp1: Point,
    /** The second control point for the node */
    val cp2: Point,
    /** The end point we are drawing to for the node */
    val p: Point,
    /**
     * If true this is a point that will be drawn on again.
     */
    val isIntersection: Boolean = false,
)

We convert everything to a VectorNode with PairPoints, and we’re left with this.

Calculating Distance Link to heading

When animating the logo, there are several approaches that we can take. Given that each point is at varying distances apart, I think the best approach is going to be to track distance traveled from point to point. We can then use Compose to animate our logo based on percent, and as long as we know the total distance of all the points in the animation, we can then calculate how far to draw for each frame.

We could get really fancy calculating the distance of a Cubic Bezier Curve using integrals, but for this blog post, a simple magnitude calculation will suffice:

fun calculateDistance(node1: VectorNode, node2: VectorNode): Float {
    return sqrt((node2.p.x - node1.p.x).pow(2) + (node2.p.y - node1.p.y).pow(2))
}

De Casteljau’s Algorithm Link to heading

What happens when our draw distance is somewhere in between two points in a CurveTo command? We have to calculate a new point, new control points, and draw our line to that. This was one of the more interesting and challenging problems to solve.

I found a solution in De Casteljau’s algorithm, which uses linear interpolation and allows the calculation of partial points in a Cubic Bezier Curve. The actual implementation was fairly simple:

private fun Path.partialCubicBezier(
    start: Offset,
    c1: Offset,
    c2: Offset,
    end: Offset,
    drawPercent: Float,
): Offset {
    // Calculate intermediate points using De Casteljau's algorithm
    val partialControl1 = linearInterpolation(start, c1, drawPercent)
    val p1_1 = linearInterpolation(c1, c2, drawPercent)
    val p2_1 = linearInterpolation(c2, end, drawPercent)

    val partialControl2 = linearInterpolation(partialControl1, p1_1, drawPercent)
    val p1_2 = linearInterpolation(p1_1, p2_1, drawPercent)

    val partialEnd = linearInterpolation(partialControl2, p1_2, drawPercent)

    // Draw the cubic Bézier curve segment
    cubicTo(
        partialControl1.x, partialControl1.y,
        partialControl2.x, partialControl2.y,
        partialEnd.x, partialEnd.y,
    )

    return Offset(partialEnd.x, partialEnd.y)
}

private fun linearInterpolation(a: Offset, b: Offset, t: Float): Offset {
    return Offset(
        x = (1 - t) * a.x + t * b.x,
        y = (1 - t) * a.y + t * b.y
    )
}

Our Animation Algorithm Link to heading

Let’s put it all together, and animate over 1.2 seconds.

private fun drawVectorNodes(
    canvas: Canvas,
    xScale: Float,
    yScale: Float,
    nodes: List<VectorNode>,
    distanceToDraw: Float,
    color: Color,
    strokeWidth: Float,
) {
    val path = Path()

    // The last node we were drawing
    var lastNode: VectorNode = nodes.first()
    // The total distance we've drawn.
    var distanceDrawn = 0f

    // Start at the location right before the first command
    path.moveTo(lastNode.p.x * xScale, lastNode.p.y * yScale)

    // Reset the paint
    val paint = Paint().apply {
        isAntiAlias = true
        style = PaintingStyle.Stroke
        this.strokeWidth = strokeWidth
        this.color = color
    }

    for (i in 1 ..< nodes.size) {
        val node: VectorNode = nodes[i]

        val nodeDistance = calculateDistance(lastNode, node)
        // Percent of line to the node to draw
        val nodeDrawPercent: Float = if (nodeDistance < (distanceToDraw - distanceDrawn)) {
            1f
        } else {
            if (distanceDrawn >= distanceToDraw) {
                // This can happen when the drawPercent is 0
                break
            }
            ((distanceToDraw - distanceDrawn) / nodeDistance).coerceAtMost(1f)
        }

        val c1: Point = lastNode.cp1
        val c2: Point = node.cp2
        val end: Point = node.p

        if (nodeDrawPercent == 1f) {
            val endX = end.x * xScale
            val endY = end.y * yScale
            path.cubicTo(
                c1.x * xScale, c1.y * yScale,
                c2.x * xScale, c2.y * yScale,
                endX, endY,
            )
            Offset(endX, endY)
        } else {
            // Use De Casteljau's Algorithm to animate to a partial point
            path.partialCubicBezier(
                Offset(lastNode.p.x * xScale, lastNode.p.y * yScale),
                Offset(c1.x * xScale, c1.y * yScale),
                Offset(c2.x * xScale, c2.y * yScale),
                Offset(end.x * xScale, end.y * yScale),
                nodeDrawPercent,
            )
        }

        lastNode = node
        distanceDrawn += nodeDistance
    }

    canvas.drawPath(path = path, paint)
}

We can bring it together with a simple composable that animates our logo based on percent:

@Composable
fun AirbnbLogo(
    modifier: Modifier = Modifier,
    color: Color = AirbnbLogo.COLOR_RAUSCH,
    delayMillis: Int = 0,
) {
    var targetDrawPercent: Float by remember { mutableFloatStateOf(0f) }

    val drawPercent: Float by animateFloatAsState(
        targetValue = targetDrawPercent,
        animationSpec = tween(1200, easing = EaseOut, delayMillis = delayMillis),
        label = "Draw Percent",
    )

    LaunchedEffect(Unit) {
        delay(300)
        // Trigger the animation to 1f
        targetDrawPercent = 1f
    }

    Canvas(
        modifier = modifier
            .fillMaxSize()
            .aspectRatio(LOGO_VECTOR_WIDTH / LOGO_VECTOR_HEIGHT)
    ) {
        drawAirbnbLogo(color, drawPercent)
    }
}

private fun DrawScope.drawAirbnbLogo(color: Color, drawPercent: Float) {
    val xScale = size.width / LOGO_VECTOR_WIDTH
    val yScale = size.height / LOGO_VECTOR_HEIGHT

    val scaledStrokeWidth = min(xScale, yScale) * STROKE_WIDTH

    drawIntoCanvas { canvas ->
        drawVectorNodes(
            canvas = canvas,
            xScale = xScale,
            yScale = yScale,
            side = DrawSide.LEFT,
            distanceToDraw = drawPercent * DrawSide.LEFT.distance,
            color = color,
            strokeWidth = scaledStrokeWidth
        )
    }
}

Our result:

Splitting the Logo Animation Link to heading

Instead of animating in a single direction, let’s split the animation so it animates to the left, and the right at the same time, and then joins together at the top.

We can achieve this by breaking up our list of VectorNodes into a left and a right side. Since we’re already drawing to the left, all we need to do is reverse the order of the Right vector nodes. We’ll also need to track each side’s distance separately.

internal enum class DrawSide(
    val distance: Float,
    val nodes: List<VectorNode>,
) {
    LEFT(
        distance = 5817.2754f,
        nodes = AIRBNB_LOGO_LEFT_SIDE,
    ),
    RIGHT(
        distance = 2758.3904f,
        nodes = AIRBNB_LOGO_RIGHT_SIDE.asReversed(),
    ),
}

Now all we need to do is adjust our drawAirbnbLogo method to draw the left side, and then the right:

private fun DrawScope.drawAirbnbLogo(color: Color, drawPercent: Float) {
    val xScale = size.width / LOGO_VECTOR_WIDTH
    val yScale = size.height / LOGO_VECTOR_HEIGHT

    val scaledStrokeWidth = min(xScale, yScale) * STROKE_WIDTH

    drawIntoCanvas { canvas ->
        drawVectorNodes(
            canvas = canvas,
            xScale = xScale,
            yScale = yScale,
            side = DrawSide.LEFT,
            distanceToDraw = drawPercent * DrawSide.LEFT.distance,
            color = color,
            strokeWidth = scaledStrokeWidth
        )

        drawVectorNodes(
            canvas = canvas,
            xScale = xScale,
            yScale = yScale,
            side = DrawSide.RIGHT,
            distanceToDraw = drawPercent * DrawSide.RIGHT.distance,
            color = color,
            strokeWidth = scaledStrokeWidth,
        )
    }
}

Now our logo animates on the left and right, then meets in the center:

Fading the Tip of the Stroke Link to heading

We can improve this further by adding a fade to the tip of our stroke to remove the harsh line. Explaining the full process could be a blog post in and of itself.

In short, while drawing the points of the line, I calculate the point x distance from the tip of the stroke. Then using that point along with the last point that we draw for the path, I draw a new stroke on top of our first one, using a BlendMode and a LinearGradient to create the desired fade effect.

You can find most of the interesting code for this here.

The improvements are subtle, but noticeable:

Creating a Multi Color Logo Animation Link to heading

Now that we’ve got the ability to animate a logo, to create the multi color effect, all we need to do is adjust the color, and then overlay the animations on top of each other with a delay.

@Composable
fun MultiColorAirbnbLogo(
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier
    ) {
        AirbnbLogo(
            modifier = Modifier.fillMaxSize(),
        )
        AirbnbLogo(
            modifier = Modifier.fillMaxSize(),
            color = Color(0xFF6FC8EE),
            delayMillis = 500,
        )
        AirbnbLogo(
            modifier = Modifier.fillMaxSize(),
            color = Color(0xFFF4D45A),
            delayMillis = 800,
        )
        AirbnbLogo(
            modifier = Modifier.fillMaxSize(),
            delayMillis = 1200,
        )
    }
}

I’m sure we could have chosen colors that are more on-brand for Airbnb, but I’m pretty happy with the result:



You can find the final code for this blog post on GitHub.