Skip to content

Customization

ChromaDial’s power comes from its fully customizable thumb and track composables. Both receive a DialState object that provides all the information needed to create rich, interactive designs.

The thumb is the draggable handle that users interact with. It’s positioned and rotated automatically by the Dial.

Dial(
degree = degree,
onDegreeChanged = { degree = it },
thumb = { state ->
Box(
Modifier
.size(32.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(Color.Blue, Color.Cyan)
),
shape = CircleShape
)
.border(2.dp, Color.White, CircleShape)
)
},
)

For designs where the track shows all the visual feedback:

thumb = { Box(Modifier.fillMaxSize()) }

The track is the background that shows the dial’s path and can display progress, tick marks, or any custom graphics.

track = { state ->
Box(
Modifier
.fillMaxSize()
.drawBehind {
// Background arc
drawArc(
color = Color.Gray.copy(alpha = 0.3f),
startAngle = state.degreeRange.start - 90f,
sweepAngle = state.degreeRange.endInclusive - state.degreeRange.start,
topLeft = Offset(16.dp.toPx(), 16.dp.toPx()),
size = Size(
state.radius * 2 - 32.dp.toPx(),
state.radius * 2 - 32.dp.toPx()
),
useCenter = false,
style = Stroke(width = 32.dp.toPx(), cap = StrokeCap.Round)
)
// Progress arc
drawArc(
color = Color.Blue,
startAngle = state.degreeRange.start - 90f,
sweepAngle = state.degree - state.degreeRange.start,
topLeft = Offset(16.dp.toPx(), 16.dp.toPx()),
size = Size(
state.radius * 2 - 32.dp.toPx(),
state.radius * 2 - 32.dp.toPx()
),
useCenter = false,
style = Stroke(width = 32.dp.toPx(), cap = StrokeCap.Round)
)
}
)
}

Use the drawEveryStep utility to draw tick marks at step positions:

track = { state ->
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawEveryStep(
dialState = state,
steps = 12,
padding = 16.dp,
) { position, degrees, inActiveRange ->
rotate(degrees, pivot = position) {
drawLine(
color = if (inActiveRange) Color.Blue else Color.Gray,
start = position,
end = position + Offset(0f, 15f),
strokeWidth = 2.dp.toPx()
)
}
}
}
)
}
track = { state ->
Box(Modifier.fillMaxSize()) {
Text(
text = "${(state.value * 100).toInt()}%",
modifier = Modifier.align(Alignment.Center),
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
}

Both thumb and track receive a DialState object with these useful properties:

PropertyTypeDescription
degreeFloatCurrent rotation in degrees
valueFloatNormalized 0-1 value based on position within range
degreeRangeClosedFloatingPointRange<Float>Allowed rotation range
radiusFloatCalculated radius in pixels
thumbSizeFloatMeasured thumb size in pixels
overshotAngleFloatAmount dragged beyond limits (for rubber-band effects)

The value property normalizes the current degree to a 0-1 range:

track = { state ->
// state.value is 0.0 at degreeRange.start
// state.value is 1.0 at degreeRange.endInclusive
val percentage = (state.value * 100).toInt()
}

Create rubber-band effects when users drag beyond the limits:

thumb = { state ->
val scale = 1f - (state.overshotAngle.absoluteValue / 180f).coerceIn(0f, 0.3f)
Box(
Modifier
.size(32.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.background(Color.Blue, CircleShape)
)
}

For placing composables at step positions around the dial, use StepContent:

track = { state ->
StepContent(
modifier = Modifier.fillMaxSize(),
degreeRange = state.degreeRange,
steps = 10,
) { index, position, degree, inActiveRange ->
Text(
text = "$index",
color = if (inActiveRange) Color.Blue else Color.Gray,
fontSize = 12.sp
)
}
}

Combine with Compose animations for smooth interactions:

var degree by remember { mutableFloatStateOf(0f) }
val animatedDegree by animateFloatAsState(
targetValue = degree,
animationSpec = spring(
stiffness = Spring.StiffnessHigh,
dampingRatio = Spring.DampingRatioLowBouncy,
)
)
Dial(
degree = animatedDegree,
onDegreeChanged = { degree = it },
steps = 10, // Combine with steps for bouncy snapping
)
@Composable
fun GradientArcDial() {
var degree by remember { mutableFloatStateOf(270f) }
Dial(
degree = degree,
onDegreeChanged = { degree = it },
modifier = Modifier.size(200.dp, 100.dp),
startDegrees = 270f,
sweepDegrees = 180f,
thumb = { state ->
Box(
Modifier
.size(24.dp)
.background(
brush = Brush.radialGradient(
colors = listOf(Color.White, Color.Blue)
),
shape = CircleShape
)
.border(2.dp, Color.White, CircleShape)
)
},
track = { state ->
Box(
Modifier
.fillMaxSize()
.drawBehind {
val strokeWidth = 16.dp.toPx()
val arcSize = Size(
state.radius * 2 - strokeWidth,
state.radius * 2 - strokeWidth
)
// Background
drawArc(
color = Color.Gray.copy(alpha = 0.2f),
startAngle = 180f,
sweepAngle = 180f,
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
size = arcSize,
useCenter = false,
style = Stroke(strokeWidth, cap = StrokeCap.Round)
)
// Progress
val progress = state.value * 180f
drawArc(
brush = Brush.sweepGradient(
colors = listOf(Color.Cyan, Color.Blue, Color.Magenta)
),
startAngle = 180f,
sweepAngle = progress,
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
size = arcSize,
useCenter = false,
style = Stroke(strokeWidth, cap = StrokeCap.Round)
)
}
) {
Text(
text = "${(state.value * 100).toInt()}%",
modifier = Modifier.align(Alignment.BottomCenter),
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
)
}