Chapter 10: Advanced UI & Animations
Introduction: Beyond the Basics
So far, we've learned how to build functional UIs. You can display lists (LazyColumn), show text (Text), and react to clicks (Button). This is the "science" of app development. Now, we learn the "art."
This chapter is about **polish**. It's about what makes an app feel "Googley" or "Apple-like." It's about the small, delightful interactions that make a user *want* to use your app. A functional app solves a problem, but a *beautiful* app creates a connection with the user.
We will cover four main areas:
- Material Design 3: The "Why" and "How" of modern Android design principles (Color, Typography, Theming).
- Custom UI Components: How to build complex, reusable UI components from scratch (both in legacy XML and modern Compose).
- Animations: How to make your UI move and react, from simple fades to complex physics-based motion.
- Advanced Gestures: How to handle complex touch interactions like dragging, swiping, and pinch-to-zoom.
Part 1: Material Design 3 (M3) - The Design System
You should not be inventing your design from scratch. Google has invested thousands of hours into creating a comprehensive, beautiful, and accessible design system called **Material Design**. The current version is **Material 3 (M3)**, also known as "Material You."
M3 is a set of guidelines *and* a library of pre-built components (like Button, Card, TopAppBar) that automatically implement these guidelines. By using the androidx.compose.material3 library, you get a beautiful, professional-looking app for free.
Core Concept: Color
M3's color system is the most important part. It's based on "color slots" rather than hard-coded colors. You don't say "this button is blue." You say "this button uses the `Primary` color." This allows your app to easily support **Light Mode** and **Dark Mode**.
The Main Color Slots
primary: The main "accent" color for key components (like a "Login" button).onPrimary: The color of text/icons that appear *on top of* theprimarycolor (usually White or Black).primaryContainer: A lighter, more subtle version of your primary color (e.g., for a selected item).onPrimaryContainer: The color of text/icons on top ofprimaryContainer.secondary/tertiary: Other accent colors for less important parts of the UI.surface: The main background color for "surfaces" like Cards, Bottom Sheets, and Menus.onSurface: The color of text/icons on top ofsurface(your main text color).background: The main background color of the entire app.onBackground: The color of text/icons on top ofbackground.
How to Implement M3 Theming
When you create a new "Empty Compose Activity," Android Studio automatically creates a theme for you in ui/theme/Theme.kt and ui/theme/Color.kt.
import androidx.compose.ui.graphics.Color
// 1. Define your brand's base colors
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Purple40 = Color(0xFF6650a4)
// ...
ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
// 2. Create your app's Dark Color Scheme
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F)
// ... set all other colors
)
// 3. Create your app's Light Color Scheme
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE)
// ...
)
// 4. Create your main Theme composable
@Composable
fun MyApplicationTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography, // (From Typography.kt)
content = content
)
}
Step 5: Use the Theme
In your MainActivity.kt, you wrap your *entire app* in this theme. Now, all M3 composables (like Button) will automatically pick the right colors for light/dark mode.
// In MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Wrap your whole app in your theme
MyApplicationTheme {
// Now all Composables inside here use your theme
MyAppNavigation()
}
}
}
// In any Composable
@Composable
fun MyScreen() {
Button(onClick = {}) { // This button is auto-themed
Text("Click Me")
}
// You can also access colors manually
Text(
text = "Hello",
color = MaterialTheme.colorScheme.primary
)
}
Dynamic Color (Material You)
On Android 12+, M3 can automatically generate your *entire* color scheme from the user's **wallpaper**. This is called "Material You" or "Dynamic Color."
To enable this, you just need to update your `Theme.kt`:
// In Theme.kt
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.ui.platform.LocalContext
@Composable
fun MyApplicationTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colorScheme = when {
// 1. Check if dynamic color is available (Android 12+)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
// 2. Fallback to your hard-coded theme
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
// ... MaterialTheme { ... } ...
}
With this code, your app's colors will automatically match the user's wallpaper, making it feel deeply integrated with the OS.
Read More about Material Design 3 →Part 2: Creating Custom UI Components
Material Design gives you 90% of what you need. But sometimes you need a component that is unique to your brand (e.g., a "star rating" bar, a custom graph, or a special button). In this case, you must build it yourself.
The Modern Way: Custom Composables
In Jetpack Compose, this is the default way of working. You just combine smaller composables into a new, reusable one. Let's build a custom `CircularProgressBar` composable.
components/CircularProgressBar.ktimport androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun CircularProgressBar(
percentage: Float,
radius: Dp = 50.dp,
color: Color = MaterialTheme.colorScheme.primary,
strokeWidth: Dp = 8.dp
) {
// We use a Box to stack the text on top of the canvas
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(radius * 2f)
) {
// 1. The Canvas is where we draw custom shapes
Canvas(modifier = Modifier.size(radius * 2f)) {
// 2. Draw the background track (the grey circle)
drawArc(
color = Color.LightGray,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round)
)
// 3. Draw the foreground progress arc
drawArc(
color = color,
startAngle = -90f,
sweepAngle = 360f * percentage,
useCenter = false,
style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round)
)
}
// 4. The Text in the center
Text(
text = "${(percentage * 100).toInt()}%",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
The Legacy Way: Custom XML Views
In the old XML system, this was *much* harder. You had to create a new class that inherits from View and manually override two complex methods: onMeasure() (to tell Android how big your view is) and onDraw() (to draw on a Canvas object).
class CustomCircleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 20f
color = Color.RED
}
// 1. Tell Android how big the view should be
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// We'll just make it a fixed 200x200 pixels
val size = 200
setMeasuredDimension(size, size)
}
// 2. Draw the view on the canvas
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Draw a red circle in the center
val radius = (width / 2f) - 10f // 10f is half stroke width
canvas.drawCircle(width / 2f, height / 2f, radius, paint)
}
}
This shows how much simpler Compose has made custom UI development.
Part 3: Animations in Jetpack Compose
In Compose, animations are first-class citizens. The library is built from the ground up to be animatable. There are several levels of animation APIs, from very simple to very complex.
High-Level APIs (Easy)
These APIs are "fire and forget." You just wrap your component and it animates automatically.
1. `AnimatedVisibility`
This is the most common. It animates the "enter" (appearing) and "exit" (disappearing) of a Composable. Instead of just popping in, it can fade, slide, or shrink.
import androidx.compose.animation.*
@Composable
fun MyAnimatedComponent(isVisible: Boolean) {
AnimatedVisibility(
visible = isVisible,
enter = slideInHorizontally() + fadeIn(),
exit = slideOutHorizontally() + fadeOut()
) {
// This is the content that will be animated
Text("Hello, I slide and fade!")
}
}
2. `AnimatedContent`
This Composable animates when its *content* changes. It's perfect for a counter that fades from "1" to "2", or an icon that changes.
import androidx.compose.animation.AnimatedContent
@Composable
fun MyAnimatedCounter(count: Int) {
AnimatedContent(targetState = count, label = "counter") { targetCount ->
// This composable will cross-fade whenever targetCount changes
Text(text = "Count: $targetCount", fontSize = 30.sp)
}
}
Low-Level APIs (Powerful)
These are used to animate any *single value* (a Color, a Dp, a Float).
1. `animate*AsState`
This is the workhorse of Compose animation. It takes a "target" value and smoothly animates from its current value to the target value. It's a "set it and forget it" animation for a single value.
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
@Composable
fun ColorChangingBox(isToggled: Boolean) {
// 1. Select the target color based on the state
val targetColor = if (isToggled) Color.Green else Color.Gray
// 2. Animate the color
val backgroundColor by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(durationMillis = 500), // 500ms animation
label = "color"
)
Box(modifier = Modifier.size(100.dp).background(backgroundColor))
}
2. `updateTransition`
Used when you have *multiple* animations that all depend on a *single* state. For example, when a button is "selected," it should get bigger *and* change color. `updateTransition` manages both animations together.
enum class BoxState { Collapsed, Expanded }
@Composable
fun MultiAnimationBox(boxState: BoxState) {
// 1. Create a transition based on the target state
val transition = updateTransition(targetState = boxState, label = "box")
// 2. Animate 'color' based on the transition
val color by transition.animateColor(label = "color") { state ->
if (state == BoxState.Collapsed) Color.Gray else Color.Green
}
// 3. Animate 'size' based on the *same* transition
val size by transition.animateDp(label = "size") { state ->
if (state == BoxState.Collapsed) 100.dp else 200.dp
}
Box(modifier = Modifier.size(size).background(color))
}
Part 4: Lottie (Vector Animations)
What if your animation is extremely complex, like a character waving or a success checkmark drawing itself? You don't code this by hand. You use **Lottie**.
Lottie is a library from Airbnb that parses animations exported from **Adobe After Effects** (as a .json file) and renders them natively in your app. It's the #1 way to add complex, beautiful animations.
Step 1: Add Lottie Dependency
dependencies {
// ...
implementation("com.airbnb.android:lottie-compose:6.1.0")
}
Step 2: Get a Lottie JSON file
Go to LottieFiles.com, find a free animation, and download its .json file. Place this file in your res/raw folder (you may need to create this folder).
Step 3: Use the `LottieAnimation` Composable
import com.airbnb.lottie.compose.*
@Composable
fun MyLottieAnimation() {
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.success_animation)
)
val progress by animateLottieCompositionAsState(
composition,
iterations = LottieConstants.IterateForever // Loop forever
)
LottieAnimation(
composition = composition,
progress = { progress }
)
}
Part 5: Advanced Gestures
Handling a click is easy. What about dragging, swiping, or pinch-to-zoom? For this, you use the pointerInput modifier.
Example: Draggable Box
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.offset
@Composable
fun DraggableBox() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.size(100.dp)
.pointerInput(Unit) { // This is the gesture listener
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
Read the Official Guide to Compose Animation →