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

This is Part 1 of a multipart blog series on creating the Netflix Logo Animation with Jetpack Compose and a Canvas. Check out other parts of the series below.
  1. Part 1 - Intro Animation
  2. Part 2 - Updating the Shadow
  3. Part 3 - Starting the Outro Animation (Upcoming)

Netflix’s Logo Animation Link to heading

I’ve been looking for a fun project that I can use that would be fun and challenge me on Jetpack Compose, and would involve canvas-based rendering / animation. I think the Netflix Logo animation is a strong contender. Let’s look at the end product that we want:

Breaking Down the Intro Animation Link to heading

Draw StepDraw Step
1. Stroke 12. Stroke 2
3. Stroke 34. All Together
5. Bottom Arc6. Shadow
7. Animation

The Composable Container Link to heading

Our Compose container is going to enforce the aspect ratio required by the Logo. Sizing for the logo Composable is controlled by setting a width or a height on the Modifier passed into NetflixLogo.

Inside the Compose Canvas, we’re quickly transitioned into a DrawScope. This means from this point on, we’re no longer working with Compose. DrawScopes can be setup on any canvas, enhancing portability to an Android View if needed in the future.

We will pass a drawPercent argument to every function. Later on we will animate this drawPercent value to trigger the animation. By animating based off of percent, we can trivially change animation time later as we feel is necessary.

@Composable
fun NetflixLogo(
    animated: Boolean,
    modifier: Modifier = Modifier
) {
    Canvas(
        modifier = modifier
            .aspectRatio(NetflixLogo.ASPECT_RATIO)
    ) {
        drawNetflixN(
            drawPercent = 1f, // TODO replace with animating percent later
            drawShadow = true,
        )
    }
}

1. Stroke 1 Link to heading

The DrawScope allows us the width and height of our drawing area. All code from this point on assumes that the drawing area is in the correct aspect ratio.

DrawScope forces us to specify the top left of the rectangle we’re drawing. Since this stroke is animating bottom to top, we calculate the height of the stroke in the drawHeight, and then subtract that from the height of the drawing area to get our stroke height. When drawPercent is at 1f, we want drawHeight to be the full height of the drawing area, meaning the stroke is fully drawn.

private fun DrawScope.drawNetflixNStroke1(
    strokeWidth: Float,
    drawPercent: Float,
) {
    val drawHeight = drawPercent * size.height
    drawRect(
        color = NetflixLogo.COLOR_RED_DARK,
        topLeft = Offset(
            x = 0f,
            y = size.height - drawHeight,
        ),
        size = Size(width = strokeWidth, height = drawHeight),
    )
}

2. Stroke 2 Link to heading

For the second stroke, we are drawing a diagonal line. A Path is best suited for this. We draw the shape we want with lines, kind of like we would on paper with a pencil.

We start at the top left (0, 0), drawing a line to the right side of the top of the stroke (strokewidth, 0). Then we move to the bottom right of the stroke, which when drawPercent is 1f, will be the bottom right of the drawing area. Because we have to animate it, we need to calculate the intermediary steps as well. We can do this by using the coordinates (drawWidth + strokeWidth, drawHeight). We then go to the bottom left of our stroke (drawWidth, drawHeight), and then we close the path, filling it with the Netflix red color.

private fun DrawScope.drawNetflixNStroke2(
    strokeWidth: Float,
    drawPercent: Float,
) {
    val drawHeight = size.height * drawPercent

    drawPath(
        path = Path().apply {
            val drawWidth = (size.width - strokeWidth) * drawPercent

            moveTo(x = 0f, y = 0f) // Top left
            lineTo(x = strokeWidth, y = 0f) // Top right
            lineTo(x = drawWidth + strokeWidth, y = drawHeight) // Bottom right
            lineTo(x = drawWidth, y = drawHeight) // Bottom left
            close() // Close the path to form a rhombus
        },
        color = NetflixLogo.COLOR_RED,
    )
}

3. Stroke 3 Link to heading

Stroke 3 is almost exactly the same as Stroke 1, but in reverse, which is a bit more natural with the drawRect function, as we’re animating bottom to top.

private fun DrawScope.drawNetflixNStroke3(
    strokeWidth: Float,
    drawPercent: Float,
) {
    val drawHeight = drawPercent * size.height
    drawRect(
        color = NetflixLogo.COLOR_RED_DARK,
        topLeft = Offset(
            x = size.width - strokeWidth,
            y = size.height - drawHeight,
        ),
        size = Size(width = strokeWidth, height = drawHeight),
    )
}

4. Putting them All Together Link to heading

When we draw all of these together, we want to draw them in Z order back to front. There is some overdraw, but that’s reasonable to accept for the sake of maintainability, especially considering all the drawing operations so far are fairly simple, and will scale well, even on old low-end devices.

fun DrawScope.drawNetflixN(drawPercent: Float) {
    val strokeWidth = size.width * NETFLIX_LOGO_STROKE_WIDTH_PERCENT

    drawNetflixNStroke1(strokeWidth = strokeWidth, drawPercent = 1f)
    drawNetflixNStroke3(strokeWidth = strokeWidth, drawPercent = 1f)

    // Stroke 2 goes on top of the other strokes, so it's drawn last
    drawNetflixNStroke2(strokeWidth = strokeWidth, drawPercent = 1f)
}

5. Adding the Bottom Arc Link to heading

This took a bit of math based on measurements I took from an image, along with a bit of trial and error. It’s important that any Y based values we calculate are derived and scaled based on the logo height. This will allow the arc to scale properly whether the logo is drawn large, or small.

private fun DrawScope.bottomArcClipPath(): Path {
    return Path().apply {
        val radius = size.height * 2 // Based on the height so it automatically scales properly
        val radiusOffset = size.height * 0.0184f // Calculated an offset percent based on an image

        addOval(
            Rect(
                center = Offset(
                    x = size.width / 2f,
                    y = size.height + radius - radiusOffset,
                ),
                radius = radius,
            ),
        )
    }
}

Clipping is handled in a DrawScope by simply surrounding the drawing operations we want clipped with the clipPath function.

fun DrawScope.drawNetflixN() {
    val strokeWidth = size.width * NETFLIX_LOGO_STROKE_WIDTH_PERCENT

    // The bottom of the netflix logo has a clipped arc at the bottom
    clipPath(path = bottomArcClipPath(), clipOp = ClipOp.Difference) {
        drawNetflixNStroke1(strokeWidth = strokeWidth, drawPercent = 1f)
        drawNetflixNStroke3(strokeWidth = strokeWidth, drawPercent = 1f)

        // Stroke 2 goes on top of the other strokes, so it's drawn last
        drawNetflixNStroke2(
            strokeWidth = strokeWidth,
            drawPercent = 1f,
            drawShadow = true
        )
    }
}

6. Adding the Shadow Link to heading

We can achieve the shadow effect of the Logo by drawing a Path and filling it with a transparent black. We use a BlendMode to ensure that the shadow is only drawn on the actual Logo that we’ve drawn, otherwise the shadow will draw on other parts of the UI.

The BlendMode is ignored unless we can separate our canvas drawing into its own separate layer. We do this by setting the compositingStrategy to CompositingStrategy.Offscreen on the parent Canvas.

Modifier
    .graphicsLayer {
        compositingStrategy = CompositingStrategy.Offscreen
    }
// Stroke 2 shadow
// - The blend mode only works if the calling DrawScope is being drawn in a separate
//   layer, via the modifier call:
//   graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
drawPath(
    path = Path().apply {
        val shadowStrokeWidth = size.width / 2.1f
        val drawWidth = (size.width - shadowStrokeWidth) * drawPercent

        moveTo(x = 0f, y = 0f) // Top left
        lineTo(x = shadowStrokeWidth, y = 0f) // Top right
        lineTo(x = drawWidth + shadowStrokeWidth, y = drawHeight) // Bottom right
        lineTo(x = drawWidth, y = drawHeight) // Bottom left
        close() // Close the path to form a rhombus
    },
    color = NetflixLogo.COLOR_SHADOW,
    // Use a blend mode to only draw the shadow on the rest of the N. Otherwise it
    // looks odd with the shadow on the background.
    blendMode = BlendMode.DstOut,
)

7. Adding the Animation Link to heading

All the strokes have the same animation time. The N only takes 550 milliseconds to animate in, so that means that each stroke should get about 183 millis of animation time. There is also no acceleration or deceleration on the animation, so we will use linear interpolation.

All the functions that we drew before have a convenient drawPercent: Float parameter on them. We will now use that to animate the logo. The drawNetflixN() function will be given the drawPercent for the whole animation, and it will be responsible for breaking it down into each stroke’s draw percent.

private const val STROKE_1_PERCENT_COMPLETE = 1 / 3f // 1/3 of the intro animation
private const val STROKE_2_PERCENT_COMPLETE = 2 / 3f // 2/3 of the intro animation
private const val STROKE_3_PERCENT_COMPLETE = 1f // 3/3 of the intro animation
fun DrawScope.drawNetflixN(drawPercent: Float, drawShadow: Boolean) {
    val strokeWidth = size.width * NETFLIX_LOGO_STROKE_WIDTH_PERCENT

    val stroke1DrawPercent: Float = (drawPercent / STROKE_1_PERCENT_COMPLETE).coerceIn(0f, 1f)
    val stroke2DrawPercent: Float  = run {
        val amountToDraw = (drawPercent - STROKE_1_PERCENT_COMPLETE)
        val stroke2Size = STROKE_2_PERCENT_COMPLETE - STROKE_1_PERCENT_COMPLETE
        (amountToDraw / stroke2Size).coerceIn(0f, 1f)
    }
    val stroke3DrawPercent: Float  = run {
        val amountToDraw = (drawPercent - STROKE_2_PERCENT_COMPLETE)
        val stroke3Size = STROKE_3_PERCENT_COMPLETE - STROKE_2_PERCENT_COMPLETE
        (amountToDraw / stroke3Size).coerceIn(0f, 1f)
    }

    // The bottom of the netflix logo has a clipped arc at the bottom
    clipPath(path = bottomArcClipPath(), clipOp = ClipOp.Difference) {
        drawNetflixNStroke1(strokeWidth = strokeWidth, drawPercent = stroke1DrawPercent)
        drawNetflixNStroke3(strokeWidth = strokeWidth, drawPercent = stroke3DrawPercent)

        // Stroke 2 goes on top of the other strokes, so it's drawn last
        drawNetflixNStroke2(
            strokeWidth = strokeWidth,
            drawPercent = stroke2DrawPercent,
            drawShadow = drawShadow
        )
    }
}

Final Result Link to heading

We were able to match the Netflix Logo very accurately compared to the high-resolution image I used as a basis. Some versions of the Netflix logos have very hard-lined shadows like we’ve created (easy), and others have a gradient effect, which is a bit more challenging to replicate accurately. Overall, I’m reasonably happy with this result.

Netflix OfficialFinal Result

Next Steps Link to heading

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

This is Part 1 of a multipart blog series on creating the Netflix Logo Animation with Jetpack Compose and a Canvas. Check out other parts of the series below.
  1. Part 1 - Intro Animation
  2. Part 2 - Updating the Shadow
  3. Part 3 - Starting the Outro Animation (Upcoming)