Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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:

  1. State-based: animateXAsState() - Animates between state values
  2. Visibility: AnimatedVisibility - Enter/exit animations
  3. Content change: AnimatedContent - Transitions between content
  4. Infinite: rememberInfiniteTransition - Looping animations
  5. 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

ConceptPurposeKey Points
CanvasCustom drawingMust specify size
animateXAsStateState-based animationSimple value transitions
AnimatedVisibilityEnter/exitCombine enter/exit specs
AnimatedContentContent transitionsUse for state changes
graphicsLayerGPU transformsNo recomposition
pointerInputGesture detectionTap, drag, zoom
CompositionLocalTheme extensionsCustom colors/values
Dynamic colorsMaterial YouAndroid 12+ only

Next in series: Part 8 - System Integrations covers Health Connect, WorkManager, and notifications.

← Previous

Android System Integrations: Health Connect, WorkManager, and More

Next →

Mastering Kotlin Flow and Reactive Patterns