Building Custom Android UI and Animations
October 13, 2025 · 9 min read
Android, Kotlin, Jetpack Compose, Animations, Material Design, Interview Prep
Part 7 of the Android Deep Dive series
Building custom composables and implementing cohesive theming are essential for production apps. Interviewers often ask candidates to implement custom UI components to assess their understanding of the Compose rendering pipeline and animation APIs.
2025-2026 Trend
Material You (dynamic colors) is now expected on Android 12+. Interviewers ask about supporting both dynamic colors and fallback brand colors for older devices.
Custom Drawing with Canvas
@Composable
fun NutritionRing(
progress: Float,
color: Color,
modifier: Modifier = Modifier,
strokeWidth: Dp = 12.dp,
backgroundColor: Color = Color.Gray.copy(alpha = 0.2f)
) {
Canvas(
modifier = modifier.size(100.dp)
) {
val sweepAngle = progress * 360f
val stroke = strokeWidth.toPx()
// Background arc
drawArc(
color = backgroundColor,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = stroke, cap = StrokeCap.Round)
)
// Progress arc
drawArc(
color = color,
startAngle = -90f,
sweepAngle = sweepAngle,
useCenter = false,
style = Stroke(width = stroke, cap = StrokeCap.Round)
)
}
}
// Multi-segment ring (macros display)
@Composable
fun MacroRing(
carbs: Float,
protein: Float,
fat: Float,
modifier: Modifier = Modifier
) {
val total = carbs + protein + fat
if (total == 0f) return
Canvas(modifier = modifier.size(120.dp)) {
val strokeWidth = 16.dp.toPx()
var startAngle = -90f
val segments = listOf(
carbs / total to Color(0xFF4CAF50), // Green
protein / total to Color(0xFF2196F3), // Blue
fat / total to Color(0xFFFFC107) // Yellow
)
segments.forEach { (fraction, color) ->
val sweepAngle = fraction * 360f
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweepAngle - 2f, // Gap between segments
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
startAngle += sweepAngle
}
}
}
Interview Tip
When drawing with Canvas, always specify size with Modifier.size(). Canvas has no intrinsic size - forgetting this is a common mistake.
Animations
State-Based Animations
@Composable
fun AnimatedProgress(progress: Float) {
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
label = "progress"
)
LinearProgressIndicator(progress = { animatedProgress })
}
// Spring animation
@Composable
fun BouncyButton(onClick: () -> Unit) {
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.9f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "scale"
)
Button(
onClick = onClick,
modifier = Modifier
.scale(scale)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
}
)
}
) {
Text("Tap Me")
}
}
Animated Visibility
@Composable
fun AnimatedCard(visible: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(initialOffsetY = { it / 2 }),
exit = fadeOut(animationSpec = tween(300)) +
slideOutVertically(targetOffsetY = { it / 2 })
) {
content()
}
}
Infinite Animations
@Composable
fun PulsingDot() {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.5f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
Box(
modifier = Modifier
.size(12.dp)
.scale(scale)
.alpha(alpha)
.background(Color.Green, CircleShape)
)
}
AnimatedContent for State Changes
@Composable
fun AnimatedCounter(count: Int) {
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
} else {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
}.using(SizeTransform(clip = false))
},
label = "counter"
) { targetCount ->
Text(
text = "$targetCount",
style = MaterialTheme.typography.headlineLarge
)
}
}
Gesture Handling
// Tap, double tap, long press
@Composable
fun GestureCard(
onClick: () -> Unit,
onLongClick: () -> Unit,
onDoubleClick: () -> Unit
) {
Card(
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() },
onDoubleTap = { onDoubleClick() }
)
}
) {
// Content
}
}
// Drag gesture
@Composable
fun DraggableCard() {
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Card(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
) {
Text("Drag me!")
}
}
// Pinch to zoom
@Composable
fun ZoomableImage(painter: Painter) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Image(
painter = painter,
contentDescription = null,
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 3f)
offset += pan
}
}
)
}
Material 3 Theming
Color Scheme
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF4CAF50),
onPrimary = Color.White,
primaryContainer = Color(0xFFC8E6C9),
onPrimaryContainer = Color(0xFF1B5E20),
secondary = Color(0xFF03A9F4),
onSecondary = Color.White,
secondaryContainer = Color(0xFFB3E5FC),
onSecondaryContainer = Color(0xFF01579B),
tertiary = Color(0xFFFFC107),
onTertiary = Color.Black,
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
error = Color(0xFFB00020),
onError = Color.White
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF81C784),
onPrimary = Color(0xFF1B5E20),
primaryContainer = Color(0xFF2E7D32),
onPrimaryContainer = Color(0xFFC8E6C9),
secondary = Color(0xFF4FC3F7),
onSecondary = Color(0xFF01579B),
background = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5)
)
Theme Composable with Dynamic Colors
@Composable
fun NutritionTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
Custom Theme Extensions
// Custom colors beyond Material
data class ExtendedColors(
val success: Color,
val warning: Color,
val info: Color,
val caloriesColor: Color,
val carbsColor: Color,
val proteinColor: Color,
val fatColor: Color
)
val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(
success = Color(0xFF4CAF50),
warning = Color(0xFFFFC107),
info = Color(0xFF2196F3),
caloriesColor = Color(0xFFE91E63),
carbsColor = Color(0xFF4CAF50),
proteinColor = Color(0xFF2196F3),
fatColor = Color(0xFFFFC107)
)
}
val MaterialTheme.extendedColors: ExtendedColors
@Composable
@ReadOnlyComposable
get() = LocalExtendedColors.current
// Usage
@Composable
fun MacroDisplay(carbs: Float, protein: Float, fat: Float) {
Row {
MacroItem("Carbs", carbs, MaterialTheme.extendedColors.carbsColor)
MacroItem("Protein", protein, MaterialTheme.extendedColors.proteinColor)
MacroItem("Fat", fat, MaterialTheme.extendedColors.fatColor)
}
}
Common Mistake
Never hardcode colors like Color.Black. Always use MaterialTheme.colorScheme.onSurface to support dark mode properly.
Reusable Components
// Primary button with loading state
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
enabled: Boolean = true
) {
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
enabled = enabled && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
}
Text(text)
}
}
// Validated text field
@Composable
fun ValidatedTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
error: String? = null,
keyboardType: KeyboardType = KeyboardType.Text
) {
Column(modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
isError = error != null,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}
Interview Questions
Q: How do animations work in Compose?
Compose has multiple animation APIs:
- State-based:
animateXAsState()- Animates between state values - Visibility:
AnimatedVisibility- Enter/exit animations - Content change:
AnimatedContent- Transitions between content - Infinite:
rememberInfiniteTransition- Looping animations - Manual:
Animatable- Full control over animation
// State-based
val alpha by animateFloatAsState(if (visible) 1f else 0f, label = "alpha")
// Visibility
AnimatedVisibility(visible) { Content() }
// Content change
AnimatedContent(state, label = "content") { targetState -> Content(targetState) }
// Infinite
val rotation by infiniteTransition.animateFloat(0f, 360f, ...)
// Manual control
val animatable = remember { Animatable(0f) }
LaunchedEffect(key) {
animatable.animateTo(1f, spring())
}
Q: How do you implement custom theming in Material 3?
Extend the theme with CompositionLocal:
// 1. Define custom colors
data class CustomColors(val success: Color, val warning: Color)
val LocalCustomColors = staticCompositionLocalOf { CustomColors(...) }
// 2. Add extension property
val MaterialTheme.customColors: CustomColors
@Composable get() = LocalCustomColors.current
// 3. Provide in theme
@Composable
fun AppTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalCustomColors provides customColors) {
MaterialTheme(..., content = content)
}
}
// 4. Use anywhere
Text(color = MaterialTheme.customColors.success)
Q: What's the difference between Modifier.graphicsLayer and regular modifiers?
graphicsLayer applies GPU-accelerated transformations without recomposition:
// Regular modifier - triggers recomposition
Modifier.alpha(0.5f) // State change = recomposition
// graphicsLayer - no recomposition
Modifier.graphicsLayer(alpha = 0.5f) // Rendered on GPU layer
// Best for animations:
val scale by animateFloatAsState(...)
Box(Modifier.graphicsLayer(scaleX = scale, scaleY = scale))
Use graphicsLayer for: scale, rotation, translation, alpha, shadows, clipping.
Common Mistakes
1. Animating Heavy Recomposition
// BAD - Recomposes entire list on scroll
LazyColumn {
items(items) { item ->
val alpha by animateFloatAsState(...)
Box(Modifier.alpha(alpha)) // Each item recomposes
}
}
// GOOD - Use graphicsLayer
LazyColumn {
items(items) { item ->
val alpha by animateFloatAsState(...)
Box(Modifier.graphicsLayer(alpha = alpha)) // GPU layer
}
}
2. Hardcoding Colors
// BAD - Doesn't support dark mode
Text(color = Color.Black)
// GOOD - Use theme colors
Text(color = MaterialTheme.colorScheme.onSurface)
3. Forgetting Animation Labels
// BAD - Hard to debug
val alpha by animateFloatAsState(0.5f)
// GOOD - Labeled for Layout Inspector
val alpha by animateFloatAsState(0.5f, label = "cardAlpha")
4. Canvas Size Issues
// BAD - Canvas has no intrinsic size
Canvas(Modifier) {
drawCircle(...) // Size is 0!
}
// GOOD - Specify size
Canvas(Modifier.size(100.dp)) {
drawCircle(color = Color.Red, radius = size.minDimension / 2)
}
Summary Table
| Concept | Purpose | Key Points |
|---|---|---|
Canvas | Custom drawing | Must specify size |
animateXAsState | State-based animation | Simple value transitions |
AnimatedVisibility | Enter/exit | Combine enter/exit specs |
AnimatedContent | Content transitions | Use for state changes |
graphicsLayer | GPU transforms | No recomposition |
pointerInput | Gesture detection | Tap, drag, zoom |
CompositionLocal | Theme extensions | Custom colors/values |
| Dynamic colors | Material You | Android 12+ only |
Next in series: Part 8 - System Integrations covers Health Connect, WorkManager, and notifications.