What You Will Build

A WhatsApp-inspired chat list where tapping a contact's avatar triggers a hero animation that scales the profile picture to full screen with a dark overlay. This is the profile preview pattern used in messaging apps where users can quickly view a contact's photo without navigating away.

Why This Pattern Matters

Hero animations create visual continuity between list and detail views. The combination of AnimatedVisibility with scaleIn/scaleOut and fadeIn/fadeOut transitions is a fundamental animation pattern that applies to image galleries, product previews, and any tap-to-expand interaction.

Key Concepts

  • AnimatedVisibility with combined transitions for scale + fade effects.
  • mutableStateOf with nullable type for tracking selected items.
  • Overlay pattern with semi-transparent background covering the entire screen.

The WhatsApp Hero Screen

@Composable
fun WhatsAppHeroScreen() {
    var selectedProfile by remember { mutableStateOf<Int?>(null) }
    val contacts = listOf("Alice", "Bob", "Charlie", "Diana", "Eve")
    val colors = listOf(
        Color(0xFF25D366), Color(0xFF128C7E),
        Color(0xFF075E54), Color(0xFF34B7F1), Color(0xFFDCF8C6)
    )

    Box(Modifier.fillMaxSize()) {
        // Chat list
        Column(Modifier.fillMaxSize().padding(16.dp)) {
            Text("Chats", style = MaterialTheme.typography.headlineMedium)
            Spacer(Modifier.height(16.dp))
            contacts.forEachIndexed { i, name ->
                Row(
                    Modifier.fillMaxWidth()
                        .clickable { selectedProfile = i }
                        .padding(vertical = 12.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Box(
                        Modifier.size(48.dp).clip(CircleShape)
                            .background(colors[i]),
                        contentAlignment = Alignment.Center
                    ) {
                        Icon(Icons.Filled.Person, null,
                            tint = Color.White)
                    }
                    Spacer(Modifier.width(16.dp))
                    Column {
                        Text(name, style = MaterialTheme.typography.titleMedium)
                        Text("Last message...",
                            color = Color.Gray,
                            style = MaterialTheme.typography.bodySmall)
                    }
                }
                if (i < contacts.lastIndex) HorizontalDivider()
            }
        }

        // Hero overlay
        AnimatedVisibility(
            selectedProfile != null,
            enter = scaleIn(tween(300)) + fadeIn(),
            exit = scaleOut(tween(300)) + fadeOut()
        ) {
            selectedProfile?.let { idx ->
                Box(
                    Modifier.fillMaxSize()
                        .background(Color.Black.copy(alpha = 0.9f)),
                    contentAlignment = Alignment.Center
                ) {
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
                        Box(
                            Modifier.size(200.dp).clip(CircleShape)
                                .background(colors[idx]),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(Icons.Filled.Person, null,
                                tint = Color.White,
                                modifier = Modifier.size(80.dp))
                        }
                        Spacer(Modifier.height(24.dp))
                        Text(contacts[idx],
                            color = Color.White,
                            style = MaterialTheme.typography.headlineMedium)
                    }
                    IconButton(
                        onClick = { selectedProfile = null },
                        modifier = Modifier.align(Alignment.TopEnd)
                            .padding(16.dp)
                    ) {
                        Icon(Icons.Filled.Close, null,
                            tint = Color.White)
                    }
                }
            }
        }
    }
}

Tips and Pitfalls

  • Nullable state pattern: Using mutableStateOf<Int?>(null) elegantly represents "nothing selected" without extra boolean flags.
  • Combined transitions: scaleIn() + fadeIn() runs both animations simultaneously for a polished entrance.
  • Overlay z-order: Place AnimatedVisibility after the list content in the Box so it renders on top.
  • Back button handling: Add BackHandler(selectedProfile != null) { selectedProfile = null } so the hardware back button dismisses the overlay.

Minimum SDK: API 21+ with Compose BOM

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.