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:

  1. Material Design 3: The "Why" and "How" of modern Android design principles (Color, Typography, Theming).
  2. Custom UI Components: How to build complex, reusable UI components from scratch (both in legacy XML and modern Compose).
  3. Animations: How to make your UI move and react, from simple fades to complex physics-based motion.
  4. 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* the primary color (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 of primaryContainer.
  • 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 of surface (your main text color).
  • background: The main background color of the entire app.
  • onBackground: The color of text/icons on top of background.

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.

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.kt
import 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).

views/CustomCircleView.kt
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 →