Jetpack Compose: Custom Progress Bars

Jetpack Compose is Android’s modern UI toolkit, offering a declarative and efficient way to build user interfaces. One common UI element is the progress bar, used to indicate the status of a loading process or any operation in progress. While Jetpack Compose provides standard progress bars, you can also create custom progress bars to match your app’s unique design and branding.

Why Create Custom Progress Bars in Jetpack Compose?

  • Branding: Align the progress bar’s look and feel with your app’s design.
  • Unique Appearance: Create a distinct visual experience that stands out.
  • Flexibility: Add custom animations and interactive elements.

Implementing Custom Progress Bars

You can implement custom progress bars in Jetpack Compose using various techniques, including:

  • Using shapes and Canvas
  • Animating progress using animateFloatAsState
  • Composing different UI elements to create the desired effect

Example 1: Simple Linear Progress Bar with Custom Colors

Here’s how to create a simple linear progress bar with custom colors:


import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun CustomLinearProgressBar(
    progress: Float, // Progress value between 0 and 1
    color: Color = MaterialTheme.colorScheme.primary,
    backgroundColor: Color = Color.LightGray
) {
    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .height(10.dp)
    ) {
        // Background line
        drawRoundRect(
            color = backgroundColor,
            topLeft = Offset.Zero,
            size = Size(width = size.width, height = size.height),
            cornerRadius = CornerRadius(5.dp.toPx(), 5.dp.toPx())
        )

        // Progress line
        drawRoundRect(
            color = color,
            topLeft = Offset.Zero,
            size = Size(width = size.width * progress, height = size.height),
            cornerRadius = CornerRadius(5.dp.toPx(), 5.dp.toPx())
        )
    }
}

@Preview(showBackground = true)
@Composable
fun CustomLinearProgressBarPreview() {
    var progress by remember { mutableStateOf(0.5f) } // Example progress value

    // Simulate progress animation
    LaunchedEffect(key1 = Unit) {
        progress = 0.8f
    }
    
    CustomLinearProgressBar(progress = progress)
}

In this example:

  • A Canvas is used to draw the progress bar.
  • The progress parameter controls how much of the bar is filled.
  • drawRoundRect is used to create rounded corners.
  • The colors are customizable.

Example 2: Animated Circular Progress Bar

Here’s how to create a circular progress bar with a rotating animation:


import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Composable
fun AnimatedCircularProgressBar(
    progress: Float,   // Progress value between 0 and 1
    strokeWidth: Dp = 8.dp,
    color: Color = MaterialTheme.colorScheme.primary,
    backgroundColor: Color = Color.LightGray
) {
    val animatedProgress = animateFloatAsState(
        targetValue = progress,
        animationSpec = tween(durationMillis = 1000, easing = LinearEasing)
    ).value

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.size(100.dp)
    ) {
        Canvas(
            modifier = Modifier.size(100.dp)
        ) {
            // Background circle
            drawCircle(
                color = backgroundColor,
                radius = size.minDimension / 2,
                center = Offset(x = size.width / 2, y = size.height / 2)
            )

            // Animated progress arc
            drawArc(
                color = color,
                startAngle = -90f, // Start from the top
                sweepAngle = 360 * animatedProgress,
                useCenter = false,
                style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
fun AnimatedCircularProgressBarPreview() {
    var progress by remember { mutableStateOf(0.7f) }

    // Simulate progress animation
    LaunchedEffect(key1 = Unit) {
        progress = 0.9f
    }
    
    AnimatedCircularProgressBar(progress = progress)
}

In this example:

  • animateFloatAsState is used to animate the progress value smoothly.
  • drawArc is used to draw the circular progress arc.
  • Customizable strokeWidth and colors are provided.
  • The stroke style is used to create a hollow circle.

Example 3: Determinate Progress Bar

This example demonstrates a determinate progress bar that visually fills as the progress value changes:


import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@Composable
fun DeterminateCircularProgressBar(
    progress: Float,   // Progress value between 0 and 1
    strokeWidth: Dp = 8.dp,
    color: Color = MaterialTheme.colorScheme.primary,
    backgroundColor: Color = Color.LightGray,
    animationDuration: Int = 1000
) {
    val animatedProgress = animateFloatAsState(
        targetValue = progress,
        animationSpec = tween(durationMillis = animationDuration, easing = LinearEasing),
        label = "Determinate Progress Animation"
    ).value

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.size(100.dp)
    ) {
        Canvas(
            modifier = Modifier.size(100.dp)
        ) {
            // Background circle
            drawCircle(
                color = backgroundColor,
                radius = size.minDimension / 2,
                center = Offset(x = size.width / 2, y = size.height / 2)
            )

            // Animated progress arc
            drawArc(
                color = color,
                startAngle = -90f, // Start from the top
                sweepAngle = 360 * animatedProgress,
                useCenter = false,
                style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
            )
        }

        Text(
            text = "${(animatedProgress * 100).toInt()}%",
            fontSize = 16.sp,
            fontWeight = FontWeight.Bold
        )
    }
}

@Preview(showBackground = true)
@Composable
fun DeterminateCircularProgressBarPreview() {
    var progress by remember { mutableStateOf(0.0f) }

    // Simulate progress animation
    LaunchedEffect(key1 = Unit) {
        progress = 0.75f //Example: Set progress to 75%
    }
    
    DeterminateCircularProgressBar(progress = progress)
}

This code performs the following:

  • Animates a circular progress bar, transitioning to a given progress value over time.
  • The current progress is displayed as a percentage in the center of the circle, providing real-time feedback to the user.

Example 4: Indeterminate Progress Bar

Here is how to implement an indeterminate progress bar which runs a repeating animation for use when progress is unknown:


import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Composable
fun IndeterminateCircularProgressBar(
    strokeWidth: Dp = 8.dp,
    color: Color = MaterialTheme.colorScheme.primary,
    backgroundColor: Color = Color.LightGray,
    animationDuration: Int = 1000
) {
    val animatedFloat = remember { Animatable(0f) }
    
    // Animate the progress bar indefinitely.
    LaunchedEffect(animatedFloat) {
        animatedFloat.animate(
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(durationMillis = animationDuration, easing = LinearEasing),
                repeatMode = RepeatMode.Restart
            )
        )
    }

    val animatedProgress = animatedFloat.value

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.size(100.dp)
    ) {
        Canvas(
            modifier = Modifier.size(100.dp)
        ) {
            // Background circle
            drawCircle(
                color = backgroundColor,
                radius = size.minDimension / 2,
                center = Offset(x = size.width / 2, y = size.height / 2)
            )

            val startAngle = animatedProgress * 360f
            // Animated progress arc
            drawArc(
                color = color,
                startAngle = startAngle,
                sweepAngle = 90f,
                useCenter = false,
                style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
fun IndeterminateCircularProgressBarPreview() {
    IndeterminateCircularProgressBar()
}

This indeterminate progress bar continuously animates the progress by changing the startAngle, creating a dynamic and visually appealing loading indicator.

Conclusion

Creating custom progress bars in Jetpack Compose enables you to enhance your app’s UI by matching progress indicators to your specific branding and design needs. Using techniques such as Canvas drawing and animations, you can produce a variety of progress bars to enrich the user experience. Each of the given examples demonstrates essential aspects of making both determinate and indeterminate progress bars, granting both information and engagement within the user interface. These can also be adjusted to satisfy a range of application scenarios. With these custom progress bars, Jetpack Compose gives you greater control to tailor the feel and look of the app’s progress and status indicators, consequently enhancing the experience for the user.