androidengineers.Book a session

Jetpack Compose System Design

Exercise: Complex Animated Screen

exercise60 minHard

Build a profile screen with coordinated animations: a collapsing header with avatar scale/fade, content fade-in on scroll, and a tab bar that slides in when the header collapses.

Goal

A scrollable screen where:

  1. Hero header with avatar + name collapses as user scrolls up
  2. Avatar scales from 80dp to 40dp and moves to the toolbar
  3. Name fades out in the hero; appears in the toolbar title
  4. Content list items fade and slide in staggered

Step 1: Scroll State and Derived Values

@Composable
fun ProfileScreen(profile: Profile) {
    val scrollState = rememberScrollState()
    val density = LocalDensity.current

    // Header is 240dp tall — compute collapse progress
    val headerHeightPx = with(density) { 240.dp.toPx() }
    val collapseProgress by remember {
        derivedStateOf { (scrollState.value / headerHeightPx).coerceIn(0f, 1f) }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // Main content
        Column(modifier = Modifier.verticalScroll(scrollState)) {
            ProfileHeader(collapseProgress = collapseProgress, profile = profile)
            ProfileContent(profile = profile)
        }

        // Fixed toolbar that appears as header collapses
        CollapsedToolbar(
            collapseProgress = collapseProgress,
            title = profile.name
        )
    }
}

Step 2: Animated Header

@Composable
fun ProfileHeader(collapseProgress: Float, profile: Profile) {
    // Header shrinks from 240dp to 56dp (toolbar height)
    val headerHeight = lerp(240.dp, 56.dp, collapseProgress)

    // Avatar shrinks from 80dp to 40dp
    val avatarSize = lerp(80.dp, 40.dp, collapseProgress)

    // Name fades out as header collapses
    val nameAlpha = 1f - collapseProgress

    // Avatar moves right from center to toolbar position
    val avatarOffsetX = lerp(0.dp, (-100).dp, collapseProgress)

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(headerHeight)
            .background(MaterialTheme.colorScheme.primaryContainer)
    ) {
        // Avatar
        AsyncImage(
            model = profile.avatarUrl,
            contentDescription = null,
            modifier = Modifier
                .size(avatarSize)
                .align(Alignment.Center)
                .offset(x = avatarOffsetX)
                .clip(CircleShape)
        )

        // Name — fades out as we collapse
        Text(
            text = profile.name,
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 16.dp)
                .alpha(nameAlpha)  // direct alpha from scroll position
        )
    }
}

fun lerp(start: Dp, end: Dp, fraction: Float): Dp =
    start + (end - start) * fraction

Step 3: Collapsed Toolbar

@Composable
fun CollapsedToolbar(collapseProgress: Float, title: String) {
    // Toolbar appears as header collapses
    val toolbarAlpha = collapseProgress

    TopAppBar(
        title = {
            Text(
                text = title,
                modifier = Modifier.alpha(toolbarAlpha)
            )
        },
        modifier = Modifier.alpha(toolbarAlpha),
        colors = TopAppBarDefaults.topAppBarColors(
            containerColor = MaterialTheme.colorScheme.primaryContainer
                .copy(alpha = toolbarAlpha)
        ),
        navigationIcon = {
            IconButton(onClick = { /* back */ }) {
                Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
            }
        }
    )
}

Step 4: Staggered Content Animation

@Composable
fun ProfileContent(profile: Profile) {
    Column(modifier = Modifier.padding(16.dp)) {
        profile.posts.forEachIndexed { index, post ->
            // Each item appears with a staggered delay
            var visible by remember { mutableStateOf(false) }

            LaunchedEffect(Unit) {
                delay(index * 80L)  // 80ms stagger between items
                visible = true
            }

            AnimatedVisibility(
                visible = visible,
                enter = fadeIn(tween(300)) + slideInVertically(
                    initialOffsetY = { it / 3 },
                    animationSpec = tween(300)
                )
            ) {
                PostCard(post = post, modifier = Modifier.padding(bottom = 8.dp))
            }
        }
    }
}

Step 5: Tab Bar with Animated Indicator

@Composable
fun AnimatedTabRow(
    selectedTab: Int,
    tabs: List<String>,
    onTabSelected: (Int) -> Unit
) {
    val indicator = @Composable { tabPositions: List<TabPosition> ->
        // Animate indicator position with spring
        val currentTabWidth by animateDpAsState(
            targetValue = tabPositions[selectedTab].width,
            animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
            label = "tab_width"
        )
        val indicatorOffset by animateDpAsState(
            targetValue = tabPositions[selectedTab].left,
            animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
            label = "tab_offset"
        )

        Box(
            modifier = Modifier
                .wrapContentSize(Alignment.BottomStart)
                .offset(x = indicatorOffset)
                .width(currentTabWidth)
                .height(3.dp)
                .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
        )
    }

    TabRow(
        selectedTabIndex = selectedTab,
        indicator = indicator
    ) {
        tabs.forEachIndexed { index, tab ->
            Tab(
                selected = selectedTab == index,
                onClick = { onTabSelected(index) },
                text = { Text(tab) }
            )
        }
    }
}

Verification Checklist

[ ] Scroll down: header collapses, avatar shrinks and shifts right
[ ] Toolbar title fades in as header collapses
[ ] Content items appear with staggered fade/slide on initial load
[ ] Tab indicator slides smoothly between tabs (spring animation)
[ ] No recomposition storms: use Compose layout inspector to verify
[ ] Slow animation (developer options: 5x animator speed) — animations look intentional
[ ] Fling gesture — scroll continues smoothly after lifting finger

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Complex Animated Screen | Android System Design | Android Engineers