Blog Infos
Author
Published
Topics
Author
Published
Topics

Photo by Justyn Warner on UnsplashPhoto by Justyn Warner on Unsplash

Canvas in Compose?
What are we going to build?

Caption of the square segmented progress indicators

This can also be extended with a cool animation, indicating a progress going in this example from 0% to 100%.

SSPI animationSquare segmented progress indicator animation GIF

Where do we start?
Canvas(
modifier = Modifier
.fillMaxSize()
.progressSemantics(progress)
) {
calculatedSegments.forEach { segmentGroups ->
drawPath(
path = Path().apply {
segmentGroups.calculatedSegments.forEach {
it.drawIncomplete(this, progress)
}
},
color = segmentGroups.trackColor,
style = stroke
)
drawPath(
path = Path().apply {
segmentGroups.calculatedSegments.forEach {
it.drawCompleted(this, progress)
}
},
color = segmentGroups.indicatorColor,
style = stroke
)
}
}
view raw sspi-canvas.kt hosted with ❤ by GitHub
private fun calculateSegments(
height: Float,
width: Float,
trackSegments: List<ProgressIndicatorSegment>,
trackColor: Color,
cornerRadiusDp: Dp,
strokeWidth: Dp,
paddingDp: Dp,
localDensity: Density
): List<SegmentGroups> {
val cornerRadius = with(localDensity) { cornerRadiusDp.toPx() }
val stroke = with(localDensity) { Stroke(width = strokeWidth.toPx()) }
val padding = with(localDensity) { paddingDp.toPx() }
val naturalWeight = trackSegments.sumOf { it.weight.toDouble() }.toFloat()
val edgeLength =
(2 * (height - 2 * cornerRadius)) + (2 * (width - 2 * cornerRadius)) + (2 * Math.PI.toFloat() * cornerRadius)
val paddingWeight = padding / edgeLength * naturalWeight
val paddedWeight = (paddingWeight * trackSegments.size) + naturalWeight
val measures = Measures(width, height, cornerRadius, stroke)
var startWeight = 0f
var startPaddingWeight = 0f
return buildList {
trackSegments.forEachIndexed { segmentIndex, trackSegment ->
val localTrackColor = trackSegment.trackColor ?: trackColor
val indicatorColor = trackSegment.indicatorColor
val paddedRange =
((startWeight + startPaddingWeight) / paddedWeight)..((startWeight + startPaddingWeight + trackSegment.weight) / paddedWeight)
val splitSegments = measures.splitSegments(
indicatorColor = indicatorColor,
trackColor = localTrackColor,
range = paddedRange
)
add(
SegmentGroups(
groupNumber = segmentIndex,
indicatorColor = indicatorColor,
trackColor = localTrackColor,
calculatedSegments = splitSegments
)
)
startWeight += trackSegment.weight
startPaddingWeight += paddingWeight
}
}
}
// The provided segments are a list of these
public data class ProgressIndicatorSegment(
val weight: Float,
val indicatorColor: Color,
val trackColor: Color? = null,
val inProgressTrackColor: Color? = null
)

This returns a list of the segment groups that each contain a set of segments. These are calculated using the width, height and corner radii of the canvas. We need this to know where the different sections are going to be defined. This is helpful information when we are going to draw a straight horizontal line, a straight vertical line or an arch.

private val straightWidth: Float = width - (2 * cornerRadius)
private val straightHeight: Float = height - (2 * cornerRadius)
private val cornerArcLength: Float = (0.5 * Math.PI * cornerRadius).toFloat()
private val total = (2 * straightWidth) + (2 * straightHeight) + (4 * cornerArcLength)
internal val topRightPercent = straightWidth / 2 / total
internal val rightTopCornerPercent = topRightPercent + (cornerArcLength / total)
private val rightPercent = rightTopCornerPercent + (straightHeight / total)
private val rightBottomCornerPercent = rightPercent + (cornerArcLength / total)
private val bottomPercent = rightBottomCornerPercent + (straightWidth / total)
internal val leftBottomCornerPercent = bottomPercent + (cornerArcLength / total)
private val leftPercent = leftBottomCornerPercent + (straightHeight / total)
private val leftTopCornerPercent = leftPercent + (cornerArcLength / total)
internal val topLeftPercent = leftTopCornerPercent + (straightWidth / 2 / total)
private val topRightRange = 0f..topRightPercent
private val rightTopCornerRange = topRightPercent..rightTopCornerPercent
private val rightRange = rightTopCornerPercent..rightPercent
private val rightBottomCornerRange = rightPercent..rightBottomCornerPercent
private val bottomRange = rightBottomCornerPercent..bottomPercent
private val leftBottomCornerRange = bottomPercent..leftBottomCornerPercent
private val leftRange = leftBottomCornerPercent..leftPercent
private val leftTopCornerRange = leftPercent..leftTopCornerPercent
private val topLeftRange = leftTopCornerPercent..topLeftPercent

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Advanced Android Canvas APIs

Dive into the Android Canvas APIs, the underlaying Skia engine and how it powers both native Android and Jetpack Compose UIs.
Watch Video

Advanced Android Canvas APIs

Markus Hintersteiner
Mobile
sentry.io

Advanced Android Canvas APIs

Markus Hinterstein ...
Mobile
sentry.io

Advanced Android Canvas APIs

Markus Hinterste ...
Mobile
sentry.io

Jobs

internal fun splitSegments(
indicatorColor: Color,
trackColor: Color,
range: ClosedFloatingPointRange<Float>
): List<CalculatedSegment> {
return buildList {
range.intersect(topRightRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, topRight, it))
}
}
range.intersect(rightTopCornerRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, rightTopCorner, it))
}
}
range.intersect(rightRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, right, it))
}
}
range.intersect(rightBottomCornerRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, rightBottomCorner, it))
}
}
range.intersect(bottomRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, bottom, it))
}
}
range.intersect(leftBottomCornerRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, leftBottomCorner, it))
}
}
range.intersect(leftRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, left, it))
}
}
range.intersect(leftTopCornerRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, leftTopCorner, it))
}
}
range.intersect(topLeftRange)?.let {
if (!it.isZeroWidth()) {
add(CalculatedSegment(indicatorColor, trackColor, topLeft, it))
}
}
}
}

Here we then check if the range is intersecting with the points that we have calculated above. If it is, we can add a calculated segment to the list. This calculated segment contains a segment drawable characteristic for each segment depending on the type of path we want to draw together with its track colour, indicator colour and the section range.

private val topRight: SegmentDrawable = { range ->
lineRange(
drawRange = range,
segmentRange = topRightRange,
x1 = width / 2,
y1 = 0f,
x2 = width - cornerRadius,
y2 = 0f
)
}
private val rightTopCorner: SegmentDrawable = { range ->
arcRange(
drawRange = range,
segmentRange = rightTopCornerRange,
center = Offset(width - cornerRadius, cornerRadius),
startDegrees = 270f
)
}
private val right: SegmentDrawable = { range ->
lineRange(
drawRange = range,
segmentRange = rightRange,
x1 = width,
y1 = cornerRadius,
x2 = width,
y2 = height - cornerRadius
)
}
private val rightBottomCorner: SegmentDrawable = { range ->
arcRange(
drawRange = range,
segmentRange = rightBottomCornerRange,
center = Offset(width - cornerRadius, height - cornerRadius),
startDegrees = 0f
)
}
val bottom: SegmentDrawable = { range ->
lineRange(
drawRange = range,
segmentRange = bottomRange,
x1 = width - cornerRadius,
y1 = height,
x2 = cornerRadius,
y2 = height
)
}
private val leftBottomCorner: SegmentDrawable = { range ->
arcRange(
drawRange = range,
segmentRange = leftBottomCornerRange,
center = Offset(cornerRadius, height - cornerRadius),
startDegrees = 90f
)
}
private val left: SegmentDrawable = { range ->
lineRange(
drawRange = range,
segmentRange = leftRange,
x1 = 0f,
y1 = height - cornerRadius,
x2 = 0f,
y2 = cornerRadius
)
}
private val leftTopCorner: SegmentDrawable = { range ->
arcRange(
drawRange = range,
segmentRange = leftTopCornerRange,
center = Offset(cornerRadius, cornerRadius),
startDegrees = 180f
)
}
private val topLeft: SegmentDrawable = { range ->
lineRange(
drawRange = range,
segmentRange = topLeftRange,
x1 = cornerRadius,
y1 = 0f,
x2 = width / 2,
y2 = 0f
)
}
view raw sspi-ranges.kt hosted with ❤ by GitHub

SSPI animationSquare segmented progress indicator animation GIF

 

After making the SSPI composable, it leaves us with an API like this

@Composable
public fun SquareSegmentedProgressIndicator(
modifier: Modifier = Modifier,
progress: Float,
strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth,
trackColor: Color = MaterialTheme.colors.onBackground.copy(alpha = 0.1f),
cornerRadiusDp: Dp = 10.dp,
trackSegments: List<ProgressIndicatorSegment>,
paddingDp: Dp = ProgressIndicatorDefaults.StrokeWidth
)
view raw sspi-api.kt hosted with ❤ by GitHub

By using the preview tooling in Android Studio, we can add a preview to check this out.

val previewProgressSections = listOf(
ProgressIndicatorSegment(
weight = 3f,
indicatorColor = Color.Cyan
),
ProgressIndicatorSegment(
weight = 3f,
indicatorColor = Color.Magenta
),
ProgressIndicatorSegment(
weight = 3f,
indicatorColor = Color.Yellow
)
)
@WearSquareDevicePreview
@Composable
fun PreviewHighCornerRadius() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
SquareSegmentedProgressIndicator(
modifier = Modifier
.height(300.dp)
.width(300.dp),
progress = 0.5f,
trackSegments = previewProgressSections,
cornerRadiusDp = 50.dp
)
}
}
enum class PreviewAnimationState(val target: Float) {
Start(0f), End(1f)
}
@WearSquareDevicePreview
@Composable
fun PreviewProgressAnimation() {
var progressState by remember { mutableStateOf(PreviewAnimationState.Start) }
val transition = updateTransition(
targetState = progressState,
label = "Square Progress Indicator"
)
val progress by transition.animateFloat(
label = "Progress",
targetValueByState = { it.target },
transitionSpec = {
tween(durationMillis = 1000, easing = LinearEasing)
}
)
val cornerRadiusDp = 10.dp
Box(modifier = Modifier.size(300.dp)) {
SquareSegmentedProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.height(300.dp)
.width(300.dp)
.clickable {
progressState = if (progressState == PreviewAnimationState.Start) {
PreviewAnimationState.End
} else {
PreviewAnimationState.Start
}
},
progress = progress,
trackSegments = previewProgressSections,
cornerRadiusDp = cornerRadiusDp,
paddingDp = 8.dp
)
Text(
modifier = Modifier.align(Alignment.Center),
text = "${(progress * 100).toInt()}%",
color = Color.White
)
val cornerRadiusPx: Float = with(LocalDensity.current) { cornerRadiusDp.toPx() }
Canvas(modifier = Modifier.fillMaxSize()) {
drawLine(
Color.LightGray,
Offset(size.width / 2, 0f),
Offset(size.width / 2, size.height),
strokeWidth = 0.2f
)
drawLine(
Color.LightGray,
Offset(0f, size.height / 2),
Offset(size.width, size.height / 2),
strokeWidth = 0.2f
)
drawLine(
Color.LightGray,
Offset(cornerRadiusPx, 0f),
Offset(cornerRadiusPx, size.height),
strokeWidth = 0.2f
)
drawLine(
Color.LightGray,
Offset(size.width - cornerRadiusPx, 0f),
Offset(size.width - cornerRadiusPx, size.height),
strokeWidth = 0.2f
)
drawLine(
Color.LightGray,
Offset(0f, cornerRadiusPx),
Offset(size.width, cornerRadiusPx),
strokeWidth = 0.2f
)
drawLine(
Color.LightGray,
Offset(0f, size.height - cornerRadiusPx),
Offset(size.width, size.height - cornerRadiusPx),
strokeWidth = 0.2f
)
}
}
LaunchedEffect(Unit) {
while (true) {
progressState = PreviewAnimationState.End
delay(1000)
progressState = PreviewAnimationState.Start
}
}
}

After drawing our SSPI with the animation, we can add a Canvas on top of the function. Here we can draw horizontal and vertical lines to get reference points through the animation. This will result in something like this

 

Square segmented progress indicator with progress animation on a square Wear device.

 

Square segmented progress indicator with animation in progress

Square segmented progress indicator with progress animation 68% on a square Wear OS device.

 

SSPI animationSquare segmented progress indicator animation GIF

 

Wrap it up 🌯
Helpful resources

This article was originally published on proandroiddev.com on December 12, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
In this part of the article, we’ll delve into creating the groundwork for our…
READ MORE
blog
I need to admit, I totally fall in love with Jetpack Compose. Compose gives…
READ MORE
blog
Often when creating a UI in Jetpack Compose we need to draw lines. Sometimes…
READ MORE
blog
In this blog, I will explain how we can implement this using the Compose…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu