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.- Part 1 - Intro Animation
- Part 2 - Updating the Shadow
- 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 Step | Draw Step | ||
---|---|---|---|
1. Stroke 1 | 2. Stroke 2 | ||
3. Stroke 3 | 4. All Together | ||
5. Bottom Arc | 6. 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. DrawScope
s 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 Official | Final 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.- Part 1 - Intro Animation
- Part 2 - Updating the Shadow
- Part 3 - Starting the Outro Animation (Upcoming)