An Android progress bar split into a fixed number of equal segments, where each segment turns on and off independently.
Progress here is not a single scalar. Any subset of segments can be on at once, in any order, which makes the view useful for step indicators, checklist completion, streak calendars, onboarding flows and anything else that answers "which of these are done?".
Available as a View and as a Jetpack Compose Composable, from separate artifacts that share the same layout maths and the same option types, so the two render identically.
Every image in this README is rendered by the library itself, from
DocsScreenshotTest,
so none of them can drift away from what the code actually draws.
- Install
- How to use
- Configuration in detail
- XML attributes
- API reference
- How it renders
- Accessibility
- Requirements
- Upgrading from 0.0.1
- Building from source
- Contributing
- License
On Maven Central, so there is no repository to add. Gradle Kotlin DSL:
dependencies {
// The View. No Compose dependency.
implementation("io.github.rayzone107:segmentedprogressbar:2.1.0")
// Optional: the Jetpack Compose bindings.
implementation("io.github.rayzone107:segmentedprogressbar-compose:2.1.0")
}They are separate artifacts on purpose, so a View-only project never inherits the Compose runtime. The Compose artifact depends on the View one for the shared geometry and option types, so taking both never gives you two copies of anything.
Using something other than the Kotlin DSL? Expand whichever applies:
Gradle (Groovy DSL)
dependencies {
implementation 'io.github.rayzone107:segmentedprogressbar:2.1.0'
implementation 'io.github.rayzone107:segmentedprogressbar-compose:2.1.0'
}Version catalog
In gradle/libs.versions.toml:
[versions]
segmentedprogressbar = "2.1.0"
[libraries]
segmentedprogressbar = { module = "io.github.rayzone107:segmentedprogressbar", version.ref = "segmentedprogressbar" }
segmentedprogressbar-compose = { module = "io.github.rayzone107:segmentedprogressbar-compose", version.ref = "segmentedprogressbar" }dependencies {
implementation(libs.segmentedprogressbar)
implementation(libs.segmentedprogressbar.compose)
}Maven (pom.xml)
The aar type matters; without it Maven looks for a jar that does not exist.
<dependency>
<groupId>io.github.rayzone107</groupId>
<artifactId>segmentedprogressbar</artifactId>
<version>2.1.0</version>
<type>aar</type>
</dependency>Worth saying plainly: the Android build tooling is Gradle-only these days, so this is here for completeness rather than as a route anyone should choose.
JitPack
Versions 2.0.0 and 2.1.0 are on JitPack too, which is where this library was
published before Maven Central. It needs its repository added, in
settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}dependencies {
implementation("com.github.rayzone107.SegmentedProgressBar:segmentedprogressbar:2.1.0")
implementation("com.github.rayzone107.SegmentedProgressBar:segmentedprogressbar-compose:2.1.0")
}Mind the group there: com.github.rayzone107.SegmentedProgressBar, with a
dot before the repository name, not com.github.rayzone107:. That is how
JitPack addresses a repository publishing more than one artifact. The shorter
com.github.rayzone107:segmentedprogressbar does resolve, by coincidence,
because JitPack reads the artifact id as a repository name and this repository
happens to be called that; there is no repository named
segmentedprogressbar-compose, so the Compose artifact resolves only under the
dotted group.
JitPack also builds each tag on first request, so the first resolve after a release takes a few minutes. Maven Central needs none of these caveats, which is why it is the recommendation above.
The same bar, four ways. Each of these produces the image below:
<com.rachitgoyal.segmented.SegmentedProgressBar
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="26dp"
app:cornerRadius="4dp"
app:dividerColor="@android:color/transparent"
app:dividerWidth="3dp"
app:divisions="10"
app:progressBarBackgroundColor="#E4E7EB"
app:progressBarColor="#2F6FED" />Nothing else is required. Add app:spb_tapToToggle="true" and the bar is
interactive with no code at all.
val bar = findViewById<SegmentedProgressBar>(R.id.progress)
bar.divisions = 10
// One assignment sets exactly which segments are on.
bar.enabledDivisions = listOf(1, 2, 5, 6, 9)
// Or work one segment at a time; order does not matter.
bar.enableDivision(4)
bar.disableDivision(9)
bar.toggleDivision(0)
bar.isDivisionEnabled(5) // true
// Back to empty. Colours, divisions and gaps are preserved.
bar.reset()Building the whole thing in code, with no layout file:
val density = resources.displayMetrics.density
val bar = SegmentedProgressBar(context).apply {
divisions = 10
enabledDivisions = listOf(1, 2, 5, 6, 9)
progressBarColor = Color.parseColor("#2F6FED")
progressBarBackgroundColor = Color.parseColor("#E4E7EB")
dividerColor = Color.TRANSPARENT
dividerWidth = 3 * density
cornerRadius = 4 * density
}
container.addView(bar, ViewGroup.LayoutParams.MATCH_PARENT, (26 * density).toInt())The library is Kotlin, with a Java-first surface: @JvmOverloads constructors,
getters and setters for every property, and a functional interface for the click
listener, so none of it needs Kotlin syntax to use.
SegmentedProgressBar bar = findViewById(R.id.progress);
bar.setDivisions(10);
bar.setEnabledDivisions(Arrays.asList(1, 2, 5, 6, 9));
bar.toggleDivision(4);
bar.setProgressBarColor(Color.parseColor("#2F6FED"));
bar.setProgressBarBackgroundColor(Color.parseColor("#E4E7EB"));
bar.setDividerWidth(3 * getResources().getDisplayMetrics().density);
bar.setCornerMode(CornerMode.EACH_RUN);
bar.setTapToToggleEnabled(true);
bar.setOnDivisionClickListener((view, index) ->
label.setText(view.getCompletedSegmentCount() + " of " + view.getDivisions()));
bar.reset();A real Composable, not an AndroidView wrapper: it draws with Compose's own
canvas, so it composes with modifiers, previews, themes and animation the way you
would expect.
var on by remember { mutableStateOf(setOf(1, 2, 5, 6, 9)) }
SegmentedProgressBar(
divisions = 10,
enabledSegments = on,
modifier = Modifier.fillMaxWidth().height(26.dp),
onColor = Color(0xFF2F6FED),
offColor = Color(0xFFE4E7EB),
gap = 3.dp,
cornerRadius = 4.dp,
onSegmentClick = { index -> on = if (index in on) on - index else on + index },
)Everything the View supports is a parameter here, using the same option enums
(CornerMode, SegmentAnimation, EntryAnimation, RecurringAnimation,
ShadowTarget) imported from the base artifact. The names differ in a few places,
because Compose conventions differ:
| View | Compose | |
|---|---|---|
| Segment set | enabledDivisions: List<Int>, sorted and de-duplicated for you |
enabledSegments: Set<Int>, already unordered |
| Space between segments | dividerWidth and dividerColor, defaulting to a 1px white line |
gap and gapColor, defaulting to a real 2dp gap |
| Heights | activeHeightRatio, inactiveHeightRatio |
activeHeightFraction, inactiveHeightFraction |
| Durations | animationDurationMs: Long |
animationDurationMillis: Int |
| Sizing | layout_height, spb_maxWidth |
Modifier |
| Drop shadow | shadowRadius and friends, which force a software layer and confine the blur to the view |
a SegmentShadow, hardware accelerated, free to overflow the composable |
| Interaction | spb_tapToToggle, or a listener, or both |
an onSegmentClick handler, since the lit set lives outside the bar |
| Partial fills | setDivisionProgress(index, fraction) |
segmentProgress: Map<Int, Float> |
| Per-segment colours | setDivisionColor(index, color) |
segmentColors: Map<Int, Color> |
| Per-segment accessibility | isPerDivisionAccessibilityEnabled / spb_perDivisionAccessibility |
perSegmentAccessibility |
One attribute makes the bar interactive, with no code at all:
app:spb_tapToToggle="true"or, from code:
bar.isTapToToggleEnabled = trueIf a tap has to do something else as well, register a listener. It reports which segment was hit, and runs after the bar has toggled it, so it always sees the state the user is looking at:
bar.setOnDivisionClickListener { view, index ->
label.text = "${view.completedSegmentCount} of ${view.divisions}"
}A listener on its own reports taps without changing anything, which is what you want if you hold the lit set yourself:
bar.setOnDivisionClickListener { view, index -> viewModel.onSegmentTapped(index) }Leave spb_tapToToggle off in that case, or the bar and your own code will both
toggle and cancel each other out.
To switch touch off entirely, use the platform's own gate, isClickable = false
or android:clickable="false"; nothing here is dispatched while a view is not
clickable. In Compose there is no separate flag: a bar with an onSegmentClick is
interactive and one without it is not.
The view does the coordinate work itself, padding and layout direction included,
and routes taps through View.performClick, so it stays accessible. If you need
the raw mapping, divisionAt(x) is public too.
Note
An accessibility service or keyboard activates a view without pointer coordinates, so there is no segment to report, and neither the toggle nor the listener fires in that case. Pair the bar with a non-positional control; the demo app uses a "Clear all" button.
Every option, with what it looks like. The demo app's Playground tab drives all of them from live controls if you would rather dial in a look by hand.
divisions is how many equal segments there are; enabledDivisions is which of
them are on. Any subset, in any order.
| Four segments | Twenty-four segments |
|---|---|
![]() |
![]() |
| Everything on | Nothing on |
|---|---|
![]() |
![]() |
app:divisions="10"bar.divisions = 10
bar.enabledDivisions = listOf(1, 2, 5, 6, 9)
bar.completedSegmentCount // 5SegmentedProgressBar(divisions = 10, enabledSegments = setOf(1, 2, 5, 6, 9))Out-of-range indices are kept rather than discarded, so the order in which you set
divisions and enabledDivisions does not matter:
bar.enabledDivisions = listOf(0, 3, 7)
bar.completedSegmentCount // 1, only index 0 is in range
bar.divisions = 10
bar.completedSegmentCount // 3, the rest are revealedEvery division has a fill fraction: 1 is exactly enableDivision, 0 is
exactly disableDivision, and anything between draws the fill over the leading
part of that cell, mirrored under RTL. This is the stories and chapters pattern:
finished segments full, the current one advancing.
bar.enabledDivisions = listOf(0, 1) // chapters already read
bar.setDivisionProgress(2, 0.4f) // 40% through chapter 3
bar.getDivisionProgress(2) // 0.4fSegmentedProgressBar(
divisions = 5,
enabledSegments = setOf(0, 1),
segmentProgress = mapOf(2 to 0.4f),
)The rules, all deliberate:
- A partial division is not enabled:
isDivisionEnabledreportsfalseandcompletedSegmentCountdoes not count it; it joins the lit set only when its fraction reaches1. - Any number of divisions can carry a partial at once; each is just a fraction by index.
- Every corner mode shapes the fill correctly. Under
CornerMode.EACH_RUNthe fill continues the run beside it: the joint is square on both sides and the fill's moving edge carries the run's rounded end, so a stories bar reads as one pill growing through its cells. The other modes clip the fill to the cell's own shape.
- Changes between partial values apply with no transition, because the callers that drive them (playback positions, download progress) update continuously and a built-in animation would fight them.
- Reaching
1hands over tosegmentAnimationas usual, and a GROW transition continues from the partial fill rather than restarting at zero. - Values are clamped to
0..1; a NaN throws, since that can only be a bug.
Two colours: on and off. There is nothing special about the defaults.
app:progressBarColor="#12A150"
app:progressBarBackgroundColor="#D7F0E0"bar.progressBarColor = Color.parseColor("#12A150")
bar.progressBarBackgroundColor = Color.parseColor("#D7F0E0")SegmentedProgressBar(
divisions = 10,
enabledSegments = on,
onColor = Color(0xFF12A150),
offColor = Color(0xFFD7F0E0),
)Note
progressBarBackgroundColor is the colour of the off segments, not the
view's background. setBackgroundColor is deprecated here for exactly that
reason: in 0.0.1 it painted the track, which shadowed View.setBackgroundColor
with a different meaning.
One division can have its own on-colour. The rule is one sentence: a colour
set for a division wins over progressBarColor for that division; every
division without one keeps using progressBarColor. Changing the global
colour never clears the overrides, and clearDivisionColor or
clearDivisionColors is the way back to the single-colour path, which remains
the primary API.
The classic use is a heatmap or streak calendar: every division on, each carrying an intensity.
bar.enabledDivisions = (0 until bar.divisions).toList()
intensities.forEachIndexed { index, level ->
bar.setDivisionColor(index, shadeFor(level))
}
bar.getDivisionColor(3) // the effective colour: override if set, else global
bar.hasDivisionColor(3) // whether an override is set
bar.clearDivisionColors() // back to one colourSegmentedProgressBar(
divisions = 14,
enabledSegments = (0 until 14).toSet(),
segmentColors = intensities.mapIndexed { i, level -> i to shadeFor(level) }.toMap(),
)An override colours everything on-coloured in its division: the full fill, a
partial fill, and the base a shimmer or pulse tints. Off segments always use
progressBarBackgroundColor.
One number, dividerWidth, and what it looks like depends on dividerColor.
| A real gap, the page showing through | A painted divider line |
|---|---|
![]() |
![]() |
| No gap at all |
|---|
![]() |
app:dividerWidth="8dp"
app:dividerColor="@android:color/transparent"
app:isDividerEnabled="true"bar.dividerWidth = 8 * density
bar.dividerColor = Color.TRANSPARENT // a real gap
bar.dividerColor = Color.WHITE // a painted divider line
bar.isDividerEnabled = false // segments run flush into each otherSegmentedProgressBar(
divisions = 10,
enabledSegments = on,
gap = 8.dp,
gapColor = Color.Transparent, // or a colour, to paint the gap
)The bar is drawn cell by cell rather than as one continuous strip, which is what makes a transparent divider a genuine gap rather than a window onto the track. An over-wide value is clamped to one segment, so segments can never collapse.
cornerRadius sets the radius, and cornerMode decides which edges it applies to.
Set the radius to half the height for a pill.
app:cornerRadius="13dp"
app:spb_cornerMode="eachRun"bar.cornerRadius = 13 * density
bar.cornerMode = CornerMode.EACH_RUNSegmentedProgressBar(
divisions = 10,
enabledSegments = on,
cornerRadius = 13.dp,
cornerMode = CornerMode.EACH_RUN,
)EACH_RUN plus a transparent gap is the combination worth trying: a sparse
selection renders as a series of pills whose shapes are defined by the gaps. The
radius is always clamped to half the smaller dimension, so an over-large value
gives a pill rather than an artefact.
On and off segments have independent heights, as a fraction of the bar. Both default to the full height, so this costs nothing until you ask for it, and both bands stay centred on the same axis.
app:spb_activeHeightRatio="1.0"
app:spb_inactiveHeightRatio="0.45"bar.activeHeightRatio = 1f
bar.inactiveHeightRatio = 0.45fSegmentedProgressBar(
divisions = 10,
enabledSegments = on,
activeHeightFraction = 1f,
inactiveHeightFraction = 0.45f,
)Either can be the smaller of the two, so on segments can read as raised or as an inset fill inside a channel. Gaps always span the taller band, so neighbouring segments stay visually separated.
Tip
Pair a large difference with CornerMode.EACH_RUN, as above. On its own, a very
small ratio turns the on segments into disconnected blocks floating over a thin
rail, which reads as a rendering bug rather than a design.
| Cast by every segment | Cast by the on segments only |
|---|---|
![]() |
![]() |
app:spb_shadowRadius="5dp"
app:spb_shadowDy="3dp"
app:spb_shadowColor="#40000000"
app:spb_shadowTarget="all"
android:padding="12dp"bar.shadowRadius = 5 * density
bar.shadowDy = 3 * density
bar.shadowColor = 0x40000000
bar.shadowTarget = ShadowTarget.ALL // or ON_SEGMENTS, or OFF_SEGMENTSSegmentedProgressBar(
divisions = 10,
enabledSegments = on,
shadow = SegmentShadow(radius = 5.dp, dy = 3.dp, target = ShadowTarget.ALL),
)A shadow never changes the bar. It is drawn outside the bar, and enabling one
or changing its blur or offset cannot move or resize anything. The trade-off is
that it needs somewhere to go: give the view padding, as in the XML above, or
the blur has nowhere to land. android:clipChildren="false" on the parent does not
help, because the bitmap the View renders the shadow into is itself the size of
the view. Compose has no such bitmap and does not clip to a composable's bounds,
so there the shadow simply overflows.
The bar casts one shadow, shaped like its outline. Concretely:
- Each segment contributes at most one shadow, so a lit segment is never darker than the unlit one beside it.
- Nothing is drawn inside the bar, so a shadow cannot outline a segment or show through a translucent one.
- A gap gets shadow only when it is a real opening. Between separate pieces, the
neighbours' blur spills in exactly as it does beside the bar's outer ends, so
each piece casts like its own object. A painted divider seals its gaps, the bar
being one slab there, and so does the slit inside a
CornerMode.EACH_RUNrun, where blur falling in would draw the very divider line that mode exists to remove.
shadowTarget chooses which segments contribute. Because no shadow is ever drawn
inside the bar, ON_SEGMENTS shows up along the outside of each lit run, as in the
second image, rather than as a shadow cast onto the track.
One note for the View: Android ignores Paint shadow layers on a
hardware-accelerated canvas, so the View renders the shadow once into a cached
bitmap the size of the view and blits it each frame, rebuilding it only when the
bar's silhouette changes. The view itself stays fully hardware accelerated, even
while animating.
For a shadow under the bar as a whole with no padding needed, prefer
android:elevation. The view supplies a correctly rounded outline, so the
elevation shadow follows the bar's shape rather than its bounding box.
Standard layout params work as on any view, plus a maximum, which Android does not
otherwise give a plain View:
android:layout_width="match_parent"
android:layout_height="26dp"
app:spb_maxWidth="420dp"bar.maxWidth = (420 * density).toInt() // do not stretch across a tablet
bar.maxHeight = SegmentedProgressBar.NO_MAX_SIZESegmentedProgressBar(
divisions = 10,
enabledSegments = on,
modifier = Modifier.widthIn(max = 420.dp).height(26.dp),
)A minimum wins over a smaller maximum, matching how the framework treats minimums
as the harder constraint. wrap_content falls back to an intrinsic 144dp x 8dp
rather than collapsing to nothing.
Three independent axes, all off by default so upgrading from 0.0.1 changes nothing. Static images cannot show these; the demo app's Playground tab can.
When a segment is toggled, once the bar is already on screen:
app:spb_segmentAnimation="fade"
app:spb_animationDuration="320"bar.segmentAnimation = SegmentAnimation.FADE // or GROW
bar.animationDurationMs = 320FADE cross-fades. GROW extends from the leading edge, mirrored under RTL so it
always grows in the reading direction. Both animate in both directions: turning
a segment off animates it out.
animationDurationMs covers this transition and the entry animation below; the
recurring loop has its own recurringDurationMs. A duration of 0 means the same
thing as NONE: the change happens on the next frame, with nothing in between. A
new duration applies to the next transition, so changing it while the bar is idle
looks like nothing happened.
When the bar first appears:
app:spb_entryAnimation="stagger"
app:spb_entryStaggerDelay="60"bar.entryAnimation = EntryAnimation.STAGGER // or FADE, GROW
bar.entryStaggerDelayMs = 60STAGGER reveals segments one after another, which reads as the bar filling itself
in. Entry is a separate opt-in from segmentAnimation, so a bar can animate itself
in without animating every later change. Exactly one of the two is ever in effect
at a time.
Always, while on screen:
app:spb_recurringAnimation="shimmer"
app:spb_recurringDuration="1600"
app:spb_shimmerColor="#73FFFFFF"bar.recurringAnimation = RecurringAnimation.SHIMMER // or PULSE
bar.recurringDurationMs = 1600
bar.shimmerColor = 0x73FFFFFFIn Compose, all three at once:
SegmentedProgressBar(
divisions = 10,
enabledSegments = on,
segmentAnimation = SegmentAnimation.FADE,
entryAnimation = EntryAnimation.STAGGER,
recurringAnimation = RecurringAnimation.SHIMMER,
animationDurationMillis = 320,
entryStaggerDelayMillis = 60,
recurringDurationMillis = 1600,
)SHIMMER sweeps a highlight across the on segments; PULSE breathes them in and
out together. The loop stops on its own while the view is detached or hidden and
resumes when it comes back, so it costs nothing off-screen.
Three deliberate behaviours:
- The initial state never animates unless you ask. With
entryAnimationatNONE, a screen does not visibly assemble itself when it first appears. - The entry animation runs once, not again on every re-measure or scroll.
- Interrupted transitions resume from where they got to. Toggling a segment twice in quick succession reverses smoothly instead of snapping.
The view honours layoutDirection: under RTL, segment 0 is drawn at the
right-hand end of the bar and taps map to the segment actually under the
finger.
android:layoutDirection="rtl"Compose follows LocalLayoutDirection, so it mirrors with the rest of your UI and
needs nothing set on the bar itself.
Important
Android gates RTL resolution for every view on the application declaring
android:supportsRtl="true" in its manifest. Without it the platform never
resolves a right-to-left layout direction and this view will draw
left-to-right no matter what you set.
The original seven:
| Attribute | Format | Default | Description |
|---|---|---|---|
divisions |
integer | 1 |
Number of equal segments. Must be at least 1. |
progressBarColor |
color | #5097E2 |
Colour of segments that are on. |
progressBarBackgroundColor |
color | #C1C1C1 |
Colour of segments that are off. |
dividerColor |
color | #FFFFFF |
Colour painted in the gap. Transparent leaves a real gap. |
dividerWidth |
dimension | 1px |
Space between segments. Must not be negative. |
isDividerEnabled |
boolean | true |
Whether that space is applied at all. |
cornerRadius |
dimension | 2px |
Corner radius, applied per spb_cornerMode. |
Everything added since 2.0.0 is prefixed spb_:
| Attribute | Format | Default | Description |
|---|---|---|---|
spb_cornerMode |
enum | barEnds |
barEnds, eachSegment or eachRun. |
spb_activeHeightRatio |
float | 1.0 |
Height of on segments as a fraction of the bar. |
spb_inactiveHeightRatio |
float | 1.0 |
Height of off segments as a fraction of the bar. |
spb_shadowRadius |
dimension | 0 |
Drop-shadow blur. 0 disables it. |
spb_shadowDx / spb_shadowDy |
dimension | 0 |
Drop-shadow offset. |
spb_shadowColor |
color | #40000000 |
Drop-shadow colour. |
spb_shadowTarget |
enum | all |
onSegments, offSegments or all. |
spb_segmentAnimation |
enum | none |
Transition when a segment is toggled: none, fade, grow. |
spb_animationDuration |
integer | 200 |
Duration of that transition, in milliseconds. |
spb_entryAnimation |
enum | none |
How the initial state arrives: none, fade, grow, stagger. |
spb_entryStaggerDelay |
integer | 60 |
Per-segment delay for a staggered entry. |
spb_recurringAnimation |
enum | none |
Continuous animation while visible: none, shimmer, pulse. |
spb_recurringDuration |
integer | 1600 |
Period of one recurring cycle. |
spb_shimmerColor |
color | #73FFFFFF |
Colour blended in at the peak of a shimmer. |
spb_maxWidth / spb_maxHeight |
dimension | none | Upper bound on the measured size. |
spb_tapToToggle |
boolean | false |
Whether tapping a segment toggles it. |
spb_perDivisionAccessibility |
boolean | false |
Whether each division is its own accessibility node. |
Note
The original names are unprefixed for backwards compatibility with 0.0.1
layouts. Names added since are prefixed because shadowColor, shadowRadius
and cornerMode are common enough that an unprefixed attribute of the same name
but a different format in another library would break the resource merge for
consumers, which is worse than an inconsistent-looking attribute set.
| Property | Type | Notes |
|---|---|---|
divisions |
Int |
Throws IllegalArgumentException below 1. |
enabledDivisions |
List<Int> |
Sorted, de-duplicated copy. Assigning copies the list and drops negatives. |
progressBarColor |
Int |
@ColorInt. Segments that are on. |
progressBarBackgroundColor |
Int |
@ColorInt. Segments that are off, not the view background. |
dividerColor |
Int |
@ColorInt. Transparent leaves a real gap. |
dividerWidth |
Float |
Pixels. Throws on negative or non-finite values. |
isDividerEnabled |
Boolean |
|
cornerRadius |
Float |
Pixels. Throws on negative or non-finite values. |
cornerMode |
CornerMode |
BAR_ENDS, EACH_SEGMENT or EACH_RUN. |
activeHeightRatio |
Float |
0..1. Height of on segments; throws outside that range. |
inactiveHeightRatio |
Float |
0..1. Height of off segments; throws outside that range. |
shadowRadius |
Float |
Pixels. 0 disables the shadow. Throws on negative or non-finite. |
shadowDx / shadowDy |
Float |
Pixels. Shadow offset. |
shadowColor |
Int |
@ColorInt. |
shadowTarget |
ShadowTarget |
ON_SEGMENTS, OFF_SEGMENTS or ALL. |
segmentAnimation |
SegmentAnimation |
NONE, FADE or GROW. Applies to toggles. |
animationDurationMs |
Long |
Milliseconds. 0 disables animation. Throws on negative. |
entryAnimation |
EntryAnimation |
NONE, FADE, GROW or STAGGER. Runs once, on first layout. |
entryStaggerDelayMs |
Long |
Per-segment delay for STAGGER. Throws on negative. |
recurringAnimation |
RecurringAnimation |
NONE, SHIMMER or PULSE. Pauses while detached or hidden. |
recurringDurationMs |
Long |
Period of one cycle. Throws if not positive. |
shimmerColor |
Int |
@ColorInt. |
maxWidth / maxHeight |
Int |
Pixels, or NO_MAX_SIZE. A minimum still wins over a smaller maximum. |
isTapToToggleEnabled |
Boolean |
Whether a tap toggles the segment it hit. Sets isClickable and isFocusable. |
isPerDivisionAccessibilityEnabled |
Boolean |
Whether each division is its own accessibility node. Off by default. |
completedSegmentCount |
Int |
Read-only: how many segments are on and in range. |
| Function | Description |
|---|---|
enableDivision(index) |
Turns one segment on, leaving the others alone. Negative indices ignored. |
disableDivision(index) |
Turns one segment off, leaving the others alone. |
toggleDivision(index) |
Flips one segment and returns its new state. |
isDivisionEnabled(index) |
Whether that segment is on. |
setDivisionProgress(index, fraction) |
Fills the leading fraction of that segment; 1 enables it, 0 clears it. |
getDivisionProgress(index) |
1 for an enabled segment, the partial fill otherwise, 0 when off. |
setDivisionColor(index, color) |
Gives one segment its own on-colour, superseding progressBarColor there. |
clearDivisionColor(index) / clearDivisionColors() |
Back to the single global colour. |
getDivisionColor(index) / hasDivisionColor(index) |
The effective on-colour, and whether it is an override. |
divisionAt(x) |
The segment index at horizontal position x (view coordinates, for example MotionEvent.getX), or NO_DIVISION outside the bar. Handles padding and RTL. |
setOnDivisionClickListener(l) |
Reports which segment was tapped, after isTapToToggleEnabled has had its say. Sets isClickable and isFocusable; pass null to clear. |
reset() |
Turns every segment off. Preserves divisions, colours, gaps and corners. |
The two kinds of bad input are treated differently, on purpose:
- Invalid configuration, such as
divisions < 1or a negativedividerWidth,cornerRadiusorshadowRadius, throwsIllegalArgumentException. These can only be programming errors, and a bar that silently renders wrong is harder to debug than a stack trace. This applies to XML too, so a bad layout fails at inflation. - Invalid progress data, meaning indices in
enabledDivisionsoutside the current division count, is tolerated and simply not drawn, because that list usually comes from live application state.
Which segments are on, and the division count, are saved and restored
automatically across configuration changes, provided the view has an
android:id.
A bar of width W with n divisions is split into n equal cells. Each of the
n - 1 interior boundaries carries a gap of dividerWidth, centred on the
boundary, and segments are inset by half a gap on every side that touches an
interior boundary. Segments and gaps therefore tile the bar exactly, with no
overlap:
divisions = 3, dividerWidth = d
0 W/3 2W/3 W
|-------------|------------|-------------|
[ segment 0 ]d[ segment 1 ]d[ segment 2 ]
^ ^
gaps, centred on the boundary
A few consequences worth knowing:
- The bar is drawn inside the view's padding, so padding works as expected.
dividerWidthis clamped to one segment's width, so an over-large value can never collapse segments to a negative size.- With
layout_height="wrap_content"the view measures to an intrinsic 144dp x 8dp rather than collapsing to nothing. onDrawallocates nothing.
The Compose renderer calls the same geometry functions in the same order, from the
same SegmentGeometry in the base artifact, which is what keeps the two artifacts
pixel-identical and covered by one set of geometry tests.
By default the bar reports itself to accessibility services as a ProgressBar
and, when you have not set a contentDescription of your own, supplies a
generated one such as "6 of 10 segments complete", localisable and correctly
pluralised. Setting your own contentDescription always wins. That single
summary node is the right experience for a passive progress indicator.
For a bar the user is expected to operate, opt in to per-division nodes:
app:spb_perDivisionAccessibility="true"bar.isPerDivisionAccessibilityEnabled = trueSegmentedProgressBar(..., perSegmentAccessibility = true)Each division becomes its own focusable, checkable node ("Segment 3 of 10", checked or not checked), which a screen reader steps through and, when the bar is interactive, toggles in place. Activation behaves exactly like a tap: the division toggles if tap-to-toggle is on, and any listener is notified after. On an interactive View it also enables keyboard use, arrow keys moving between divisions and Enter activating one, which is the path taps could never serve since an accessibility activation carries no coordinates. On a non-interactive bar the nodes are read-only state.
| minSdk | 26 (Android 8.0) |
| compileSdk | 37 (Android 17) |
| Java/Kotlin target | 17 |
| Language | Kotlin, fully usable from Java |
| View dependencies | androidx.annotation, androidx.customview (for the accessibility virtual tree) and kotlin-stdlib, nothing else |
| Compose dependencies | the above plus Compose foundation and UI |
The View artifact ships no colour resources that could collide with yours, declares nothing in its manifest, and bundles consumer ProGuard rules so it works under R8 with no configuration on your side.
Most code needs no changes. See docs/MIGRATION.md for the full list; the short version:
- The coordinate changed, to
io.github.rayzone107:segmentedprogressbaron Maven Central, so no custom repository is needed at all. Worth knowing either way: the 0.0.1 README told people to depend oncom.github.rayzone107:durationview, a different library of mine entirely, and the tags that did build,1.00andtag_v1, no longer resolve at all, because JitPack cannot build 2018 Gradle any more. setBackgroundColor(int)still works but is deprecated, because it shadowsView.setBackgroundColorwith a different meaning. UsesetProgressBarBackgroundColor(int)instead, the method the old README documented but which never existed.- Invalid configuration now throws instead of being logged and ignored.
reset()now actually resets. In 0.0.1 it cleared the gaps and left the selection alone.- Several rendering bugs are fixed, so bars look slightly different. In particular gaps now appear on an empty bar, and segments no longer bleed under the gap to their right.
- minSdk moved from 16 to 26 and the library is now AndroidX-only.
git clone https://github.com/rayzone107/SegmentedProgressBar.git
cd SegmentedProgressBar
./gradlew test # 336 unit tests across three modules
./gradlew lint # must report zero findings
./gradlew :app:installDebug # the demo appThe demo app has two tabs. Playground pins a live bar above a scrolling set of controls for every option, including a colour picker, so you can dial in a look and read the resulting values off the screen. Gallery is a set of worked examples: a tappable bar, a declarative selection, a weekly habit tracker, gaps without divider lines, RTL, and each styling option on its own.
Regenerating this README's images after a rendering change:
./gradlew :app:testDebugUnitTest --tests '*DocsScreenshotTest*' -PdocsPublishing a release, in full, is docs/PUBLISHING.md. The short version, once a maintainer's machine is set up:
git tag 2.1.0 && git push origin 2.1.0 # JitPack
./gradlew publishAndReleaseToMavenCentral --no-configuration-cache # Maven CentralJitPack builds the tag on first request using jitpack.yml, and
publishes both artifacts from the one tag. Maven Central is published under
io.github.rayzone107, signed, from the same build.
Issues and pull requests are welcome. See CONTRIBUTING.md.
Copyright 2016-2026 Rachit Goyal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.



















