Sprite Sheet Animation on Android: Compose and AnimationDrawable
TL;DR: Android has two native ways to play a sprite sheet animation. For the View system, use AnimationDrawable: an XML animation-list of frame drawables that you start(). For Jetpack Compose, drive a frame index with state and render the current drawable with Image. A generator like Motif exports the density-aware frames, so you only wire up playback.
A sprite sheet animation is one of the lightest ways to give an Android app personality. There is no runtime rendering engine, no external animation format to decode at scale, and no dependency to maintain: you draw a sequence of images and swap which one is visible. This tutorial covers both the classic View path with AnimationDrawable and the modern Jetpack Compose path, plus densities, performance, and accessibility.
If you are building for both platforms, the SwiftUI sprite sheet animation tutorial is the iOS counterpart to this post, and the concepts map almost one to one. For the broader strategy around when and where to place a character, start with how to add an animated mascot to your app.
Choose your approach: AnimationDrawable vs Compose vs animated WebP
There are three practical ways to loop frames on Android. The right one depends on whether your UI is built with Views or Compose, and how much per-frame control you need.
| Approach | Best for | Frame-rate control | Extra dependency |
|---|---|---|---|
AnimationDrawable |
XML layouts and the View system | Per-frame duration | None |
| Compose frame loop | Jetpack Compose UIs | Exact, via state | None |
| Animated WebP | Simple drop-in with a library | Baked into the file | Coil or Glide |
AnimationDrawable is the simplest option if you already have ImageView-based layouts. A Compose frame loop gives you the most control and fits modern apps. Animated WebP is the least code when you use an image-loading library, but the frame rate and loop count are baked into the file, so you cannot change them at runtime or swap in a still frame as easily.
For a small looping mascot, native frames (the first two rows) are usually the right call because they add no runtime dependency and give you full control over frame rate, looping, and reduced motion. The rest of this tutorial focuses on those two, then covers when WebP is the better trade-off.
Prepare frames: density buckets, naming, and res/drawable
Android scales UI assets by screen density, so each frame should ship at several densities. Android then picks the closest match for the device and avoids expensive runtime scaling. Place each version of a frame in the matching res/drawable bucket:
res/
drawable-mdpi/ frame_000.png (baseline, 1x)
drawable-hdpi/ frame_000.png (1.5x)
drawable-xhdpi/ frame_000.png (2x)
drawable-xxhdpi/ frame_000.png (3x)
drawable-xxxhdpi/ frame_000.png (4x)
Every frame keeps the same file name across buckets. The name is the resource identifier, and the bucket tells Android which density it is. Each frame is a separate PNG with a transparent background so the character sits cleanly on any surface.
A predictable naming convention keeps the playback code trivial. Zero-padded indices sort correctly and are easy to generate:
frame_000 (one PNG per density bucket)
frame_001
frame_002
...
frame_011
Zero-padding matters: frame_2 would sort after frame_10 in a plain string sort, while frame_002 will not. With this layout, the frame names map directly to R.drawable.frame_000 and friends, which you can resolve by name at runtime.
Producing clean, on-model, background-free frames at five densities by hand is the tedious part. Motif exports this exact structure automatically: the individual transparent PNGs, the density buckets from mdpi to xxxhdpi, the animation-list XML described in the next section, and a mascot.json metadata file recording the frame count, suggested frame rate, and canonical frame order. You can read those values at runtime instead of hard-coding them, which keeps playback in sync if you regenerate the loop later.
Its frames are named after the mascot rather than the word "frame" - robot_00.png, robot_01.png and so on, zero-padded for the same sorting reason. The code below uses frame_000 so it reads clearly on its own; substitute your own prefix if you are following along with a Motif export.
AnimationDrawable walkthrough (View system)
If your screen is built with Views and XML, AnimationDrawable is the shortest path. You declare an ordered animation-list with one frame per item and a duration for each, then start it on an ImageView.
First, create res/drawable/mascot_walk.xml. If you exported from Motif this file is already in the bundle, generated at your chosen frame rate - the walkthrough is here so you can read it and change it. The oneshot attribute controls looping: false loops forever, true plays once and stops on the last frame.
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item android:drawable="@drawable/frame_000" android:duration="100" />
<item android:drawable="@drawable/frame_001" android:duration="100" />
<item android:drawable="@drawable/frame_002" android:duration="100" />
<item android:drawable="@drawable/frame_003" android:duration="100" />
<item android:drawable="@drawable/frame_004" android:duration="100" />
<item android:drawable="@drawable/frame_005" android:duration="100" />
<item android:drawable="@drawable/frame_006" android:duration="100" />
<item android:drawable="@drawable/frame_007" android:duration="100" />
<item android:drawable="@drawable/frame_008" android:duration="100" />
<item android:drawable="@drawable/frame_009" android:duration="100" />
<item android:drawable="@drawable/frame_010" android:duration="100" />
<item android:drawable="@drawable/frame_011" android:duration="100" />
</animation-list>
Each duration is in milliseconds. A duration of 100 gives 10 frames per second, which is a good mascot default. To change the frame rate globally, adjust every duration together.
Add an ImageView to your layout and point it at the drawable:
<ImageView
android:id="@+id/mascot"
android:layout_width="160dp"
android:layout_height="160dp"
android:contentDescription="@string/mascot_description"
android:src="@drawable/mascot_walk" />
Then start the animation from your Activity or Fragment. AnimationDrawable cannot start until the view is attached, so kick it off in onResume or post it to the view, and stop it in onPause so it does not run off screen:
import android.graphics.drawable.AnimationDrawable
import android.os.Bundle
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
class MascotActivity : AppCompatActivity() {
private lateinit var mascotAnimation: AnimationDrawable
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_mascot)
val mascot = findViewById<ImageView>(R.id.mascot)
mascotAnimation = mascot.drawable as AnimationDrawable
}
override fun onResume() {
super.onResume()
mascotAnimation.start()
}
override fun onPause() {
super.onPause()
mascotAnimation.stop()
}
}
That is the whole loop. AnimationDrawable handles timing and cycling for you, which is why it stays the simplest option for View-based UIs.
Jetpack Compose walkthrough: a reusable SpriteSheetAnimation composable
In Compose you drive the animation yourself, which gives you exact control over frame rate, looping, and a reduced-motion fallback. The core idea: hold the current frame index in state, advance it on a schedule, and render the matching drawable with Image.
Here is a complete, self-contained composable. It takes an ordered list of drawable resource IDs, advances the index with LaunchedEffect and delay, and shows a single still frame when the user has turned animations off.
import android.provider.Settings
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
/**
* Plays a sprite-sheet animation by cycling drawable frames.
*
* @param frames Ordered drawable resource IDs, in playback order.
* @param fps Target playback speed in frames per second.
* @param loops When false, the animation stops on the last frame.
*/
@Composable
fun SpriteSheetAnimation(
@DrawableRes frames: List<Int>,
modifier: Modifier = Modifier,
fps: Int = 10,
loops: Boolean = true,
contentDescription: String? = null,
) {
if (frames.isEmpty()) return
val context = LocalContext.current
val reduceMotion = remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) == 0f
}
var index by remember { mutableIntStateOf(0) }
if (!reduceMotion && frames.size > 1) {
LaunchedEffect(frames, fps, loops) {
val frameDurationMs = (1000L / fps).coerceAtLeast(1L)
while (true) {
kotlinx.coroutines.delay(frameDurationMs)
index = if (loops) {
(index + 1) % frames.size
} else {
(index + 1).coerceAtMost(frames.size - 1)
}
}
}
}
Image(
painter = painterResource(id = frames[index]),
contentDescription = contentDescription,
modifier = modifier,
)
}
LaunchedEffect is tied to the composition, so the coroutine is cancelled automatically when the composable leaves the screen. That means playback pauses off screen without any extra bookkeeping. Using it is a one-liner:
@Composable
fun OnboardingMascot() {
val frames = listOf(
R.drawable.frame_000, R.drawable.frame_001, R.drawable.frame_002,
R.drawable.frame_003, R.drawable.frame_004, R.drawable.frame_005,
R.drawable.frame_006, R.drawable.frame_007, R.drawable.frame_008,
R.drawable.frame_009, R.drawable.frame_010, R.drawable.frame_011,
)
SpriteSheetAnimation(
frames = frames,
modifier = Modifier.size(160.dp),
fps = 10,
loops = true,
contentDescription = "Waving robot mascot",
)
}
The size modifier gives the mascot a fixed footprint so surrounding content never shifts as frames swap.
If you prefer a declarative timeline over a manual loop, rememberInfiniteTransition can animate the index instead. It ties into Compose's animation clock and reads well when the loop is a straight cycle:
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateInt
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
@Composable
fun InfiniteMascot(@DrawableRes frames: List<Int>, fps: Int = 10) {
val transition = rememberInfiniteTransition(label = "mascot")
val index by transition.animateInt(
initialValue = 0,
targetValue = frames.size - 1,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = frames.size * (1000 / fps),
easing = LinearEasing,
),
),
label = "frameIndex",
)
Image(
painter = painterResource(id = frames[index]),
contentDescription = null,
)
}
Both are valid. The LaunchedEffect version is the better default because it gives you explicit control over looping and a reduced-motion branch in one place.
Frame rate, memory, and battery
A mascot should add character without costing frame drops or battery. The guidance here matches the iOS post so a shared design stays consistent across platforms.
Target 8 to 12 fps. Most app mascots read well at 8 to 24 frames per loop and play back at 8 to 12 frames per second. A lower frame rate, around 8 to 10 fps, feels characterful and hand-crafted rather than mechanical. Going above 12 fps rarely improves a small on-screen mascot and mostly adds decoding and battery cost.
Reuse decoded bitmaps. Decoding a PNG into a Bitmap on every frame is wasteful. painterResource and the AnimationDrawable system both cache decoded drawables, so cycling a fixed set of frames does not re-decode each pass. If you build a custom loader, keep the decoded frames in a list once and index into it rather than decoding per frame, and let the frames be garbage-collected when the mascot leaves the screen.
Pause when off screen. Both native approaches above stop work when the view or composable is gone: AnimationDrawable.stop() in onPause, and LaunchedEffect cancellation when the composable exits. Never leave a loop running behind a screen the user cannot see.
Size assets for their rendered dimensions. Exporting near the display size, rather than scaling a large source down at runtime, keeps memory and decoding cost low. This is why the density buckets matter: each device loads only the density it needs instead of a single oversized image.
Respect reduced motion
Some users feel unwell from looping motion, and Android exposes a system setting for exactly this. When a user drags the animator duration scale to off in Developer options or accessibility settings, Settings.Global.ANIMATOR_DURATION_SCALE reads 0. Treat that as a request to stop non-essential animation.
The SpriteSheetAnimation composable above already reads this value and shows a single still frame when it is 0. For the View system, check the same setting before starting the loop:
val animationScale = Settings.Global.getFloat(
contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
if (animationScale == 0f) {
mascot.setImageResource(R.drawable.frame_000) // representative still frame
} else {
mascotAnimation.start()
}
This is not optional polish. Showing a representative still frame keeps the interface complete for users who need it. Never make animation the only way to understand a status, and pair meaningful states with text such as "Upload complete." For a decorative mascot, set the ImageView contentDescription to null (or importantForAccessibility="no") so screen readers skip it.
Getting the frames without an animator
Every approach above assumes you already have clean, on-model frames at each density. Producing those by hand, with transparent backgrounds and consistent framing across five buckets, is the slow part.
Motif handles it. Describe the character or upload a reference image, pick an action such as waving or celebrating, and it produces the individual transparent PNG frames, the res/drawable density buckets from mdpi to xxxhdpi, and a mascot.json file with the frame count and suggested frame rate. You drop the frames into your project and wire up either AnimationDrawable or the SpriteSheetAnimation composable from this post.
That native-frames path has the smallest footprint of any option: no runtime library, no animation format to decode, just images and a few lines of Kotlin. For a small looping mascot, especially a pixel or flat-illustration style like a Duolingo-style mascot, that simplicity is usually the right call.
Frequently asked questions
How do I animate a sprite sheet in Jetpack Compose?
Load your frames into an ordered list of drawable resources, hold the current frame index in a state variable, and advance it on a schedule with LaunchedEffect and delay, or with rememberInfiniteTransition. Render the current frame with an Image composable. This gives you exact control over frame rate, looping, and reduced motion.
What is AnimationDrawable in Android?
AnimationDrawable is Android's built-in frame animation for the View system. You define an ordered animation-list XML with one drawable per frame and a duration each, set it as an ImageView source, then call start() on it. It cycles the frames for you, which makes it the simplest option for XML layouts.
Should I use GIF, animated WebP, or frames on Android?
For a small looping mascot, individual frames give the most control over frame rate, looping, and reduced motion. Animated WebP is compact and plays through Coil or Glide with less code, but you lose per-frame control. Avoid GIF: it is larger and lower quality than WebP for the same animation.
What frame rate should an Android mascot animation use?
Most app mascots read well at 8 to 12 frames per second with 8 to 24 frames per loop. Lower frame rates feel characterful and keep memory and battery use low, while going above 12 fps rarely improves a small on-screen mascot and only increases decoding and power cost.
How do I handle screen densities for sprite frames?
Provide each frame at multiple densities in the matching res/drawable buckets: drawable-mdpi, hdpi, xhdpi, xxhdpi, and xxxhdpi. Android picks the closest density for the device automatically. Export frames near their rendered size so the system does not scale a large source down at runtime, which wastes memory.
How do I respect reduced motion on Android?
Read Settings.Global.ANIMATOR_DURATION_SCALE; a value of 0 means the user turned animations off. When it is 0, show a single representative still frame instead of the loop. Never rely on motion alone to communicate a status, and always pair a meaningful animation with a text label.
Ship your first loop
Native sprite frames give you an Android mascot with zero runtime dependencies and full control over frame rate, looping, and accessibility. Pick AnimationDrawable for View layouts or the SpriteSheetAnimation composable for Compose, prepare the density-aware frames once, and you have a production-ready animation.
If you would rather skip the frame preparation entirely, generate an animated mascot with Motif and export the frames and density buckets ready to drop into your Android project.