From 5c77dde843ccb2fd6a0b28a64a22b8f1f7dd6f1d Mon Sep 17 00:00:00 2001 From: Rachit Goyal Date: Fri, 21 Aug 2026 17:01:52 -0400 Subject: [PATCH 1/3] Modernise the library and bring it back to life for 2.0.0 The first substantive change in eight years. The toolchain, the code, the tests and the docs all move at once, because the old build could not run on a current JDK and nothing could be verified until it did. Toolchain and build: * Gradle 9.7.1, AGP 9.3.1, Kotlin DSL, a version catalog, compileSdk 37, minSdk 26, Java 17. AGP 9 supplies Kotlin itself, so the Kotlin plugin is gone from every module. * Support Library to AndroidX. Publishing moves from the old maven-push.gradle to maven-publish, and a second artifact ships the Jetpack Compose bindings. * CI on every push, running the same tests and lint the release does. The view is rewritten in Kotlin, keeping the 0.0.1 Java API compiling, and a long list of rendering bugs is fixed: dividers vanishing on an empty bar, segments bleeding under the divider to their right, reset() resetting the wrong thing, setters that never repainted, and corner rounding that painted square patches over a round rect. See CHANGELOG.md for the full list. New: any subset of segments can be lit, which is the point of the library; height bands, corner modes, a drop shadow, entry and toggle and recurring animations, tap to toggle, RTL, padding, instance state, accessibility and a Compose renderer that shares the View's geometry so the two stay identical. The drop shadow is worth calling out. It follows the bar's outline: one shape, blurred once, drawn before any fill and clipped to the outside of the bar. Blurring cell by cell instead made lit segments twice as dark as unlit ones, grew a dark tick above every gap, and outlined each cell where the shadow sat under an anti-aliased edge. Pixel tests cover all of it, in both renderers, since neither shows up in a list of draw calls. 259 tests, no lint findings. Security: gradle.properties committed a Nexus password and a GPG signing passphrase in plain text. They are out of the working tree, but they remain in this repository's public history and must be treated as compromised. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 55 + .gitignore | 97 +- .idea/gradle.xml | 19 - .idea/misc.xml | 33 - .idea/modules.xml | 10 - .idea/runConfigurations.xml | 12 - .idea/vcs.xml | 6 - CHANGELOG.md | 230 ++ CONTRIBUTING.md | 107 + LICENSE | 202 ++ README.md | 667 +++++- app/build.gradle | 25 - app/build.gradle.kts | 87 + app/src/main/AndroidManifest.xml | 10 +- .../segmentedprogressbar/MainActivity.java | 24 - .../segmentedprogressbar/demo/ColorPicker.kt | 323 +++ .../segmentedprogressbar/demo/MainActivity.kt | 234 ++ .../demo/PlaygroundScreen.kt | 742 ++++++ .../ic_launcher_foreground.xml | 0 app/src/main/res/layout/activity_main.xml | 414 +++- .../ic_launcher.xml | 1 + .../ic_launcher_round.xml | 1 + app/src/main/res/values/colors.xml | 6 - app/src/main/res/values/strings.xml | 57 + app/src/main/res/values/styles.xml | 32 +- .../demo/InflationTest.kt | 142 ++ .../demo/MainActivityTest.kt | 248 ++ app/src/test/resources/robolectric.properties | 4 + build.gradle | 30 - build.gradle.kts | 7 + docs/MIGRATION.md | 206 ++ gradle.properties | 50 +- gradle/gradle-daemon-jvm.properties | 13 + gradle/libs.versions.toml | 68 + gradle/wrapper/gradle-wrapper.jar | Bin 53636 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 7 +- gradlew | 306 ++- gradlew.bat | 110 +- jitpack.yml | 17 + segmented-compose/build.gradle.kts | 139 ++ segmented-compose/consumer-rules.pro | 2 + .../src/main/AndroidManifest.xml | 2 + .../segmented/compose/SegmentedProgressBar.kt | 782 +++++++ .../SegmentedProgressBarComposeShadowTest.kt | 227 ++ .../SegmentedProgressBarComposeTest.kt | 336 +++ .../src/test/resources/robolectric.properties | 12 + segmented/build.gradle | 27 - segmented/build.gradle.kts | 131 ++ segmented/consumer-rules.pro | 12 + segmented/gradle.properties | 3 - segmented/maven-push.gradle | 112 - segmented/src/main/AndroidManifest.xml | 8 +- .../java/com/rachitgoyal/segmented/Options.kt | 94 + .../rachitgoyal/segmented/SegmentGeometry.kt | 117 + .../segmented/SegmentedProgressBar.java | 239 -- .../segmented/SegmentedProgressBar.kt | 2007 +++++++++++++++++ segmented/src/main/res/values/attrs.xml | 107 +- segmented/src/main/res/values/colors.xml | 6 - segmented/src/main/res/values/strings.xml | 15 +- .../segmented/JavaApiCompatibilityTest.java | 204 ++ .../rachitgoyal/segmented/RecordingCanvas.kt | 105 + .../segmented/SegmentGeometryTest.kt | 341 +++ .../SegmentedProgressBarDrawingTest.kt | 557 +++++ .../SegmentedProgressBarShadowTest.kt | 308 +++ .../segmented/SegmentedProgressBarTest.kt | 1127 +++++++++ .../SegmentedProgressBarVariantsTest.kt | 1122 +++++++++ .../src/test/resources/robolectric.properties | 12 + settings.gradle | 1 - settings.gradle.kts | 30 + 69 files changed, 11856 insertions(+), 931 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations.xml delete mode 100644 .idea/vcs.xml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 app/src/main/java/com/rachitgoyal/segmentedprogressbar/MainActivity.java create mode 100644 app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/ColorPicker.kt create mode 100644 app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/MainActivity.kt create mode 100644 app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/PlaygroundScreen.kt rename app/src/main/res/{drawable-v24 => drawable}/ic_launcher_foreground.xml (100%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher.xml (79%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher_round.xml (79%) delete mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/test/java/com/rachitgoyal/segmentedprogressbar/demo/InflationTest.kt create mode 100644 app/src/test/java/com/rachitgoyal/segmentedprogressbar/demo/MainActivityTest.kt create mode 100644 app/src/test/resources/robolectric.properties delete mode 100644 build.gradle create mode 100644 build.gradle.kts create mode 100644 docs/MIGRATION.md create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 jitpack.yml create mode 100644 segmented-compose/build.gradle.kts create mode 100644 segmented-compose/consumer-rules.pro create mode 100644 segmented-compose/src/main/AndroidManifest.xml create mode 100644 segmented-compose/src/main/java/com/rachitgoyal/segmented/compose/SegmentedProgressBar.kt create mode 100644 segmented-compose/src/test/java/com/rachitgoyal/segmented/compose/SegmentedProgressBarComposeShadowTest.kt create mode 100644 segmented-compose/src/test/java/com/rachitgoyal/segmented/compose/SegmentedProgressBarComposeTest.kt create mode 100644 segmented-compose/src/test/resources/robolectric.properties delete mode 100644 segmented/build.gradle create mode 100644 segmented/build.gradle.kts create mode 100644 segmented/consumer-rules.pro delete mode 100644 segmented/gradle.properties delete mode 100644 segmented/maven-push.gradle create mode 100644 segmented/src/main/java/com/rachitgoyal/segmented/Options.kt create mode 100644 segmented/src/main/java/com/rachitgoyal/segmented/SegmentGeometry.kt delete mode 100644 segmented/src/main/java/com/rachitgoyal/segmented/SegmentedProgressBar.java create mode 100644 segmented/src/main/java/com/rachitgoyal/segmented/SegmentedProgressBar.kt delete mode 100644 segmented/src/main/res/values/colors.xml create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/JavaApiCompatibilityTest.java create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/RecordingCanvas.kt create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/SegmentGeometryTest.kt create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarDrawingTest.kt create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarShadowTest.kt create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarTest.kt create mode 100644 segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarVariantsTest.kt create mode 100644 segmented/src/test/resources/robolectric.properties delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d36a3b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build, lint and test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Unit tests + run: ./gradlew test + + - name: Lint + run: ./gradlew lint + + - name: Assemble libraries + run: ./gradlew :segmented:assembleRelease :segmented-compose:assembleRelease + + - name: Assemble demo app + run: ./gradlew :app:assembleRelease + + - name: Verify the published artifacts resolve + run: >- + ./gradlew :segmented:publishReleasePublicationToMavenLocal + :segmented-compose:publishReleasePublicationToMavenLocal + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-and-lint-reports + path: | + **/build/reports/tests/ + **/build/reports/lint-results-*.html + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index fe2e20b..7450e39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,86 +1,57 @@ -# [Android] ======================== -# Built application files +# Build output +build/ +bin/ +gen/ +out/ *.apk +*.aab *.ap_ - -# Files for the Dalvik VM +*.aar *.dex - -# Java class files *.class -# Generated files -bin/ -gen/ - -# Gradle files +# Gradle .gradle/ -build/ +gradle-app.setting +# `*.jar` is NOT ignored wholesale: doing so silently excludes the Gradle wrapper +# jar, which every clone needs in order to build. -# Local configuration file (sdk path, etc) -local.properties +# Kotlin +.kotlin/ -# Proguard folder generated by Eclipse -proguard/ - -# Log Files -*.log +# NDK +.cxx/ +.externalNativeBuild/ +# Machine-specific and secret +local.properties +signing.properties +keystore.properties +*.jks +*.keystore +!debug.keystore -## Directory-based project format: +# IDE .idea/ - -## File-based project format: +*.iml *.ipr *.iws - -## Plugin-specific files: - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin .idea_modules/ +captures/ -# JIRA plugin -atlassian-ide-plugin.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml +# OS +.DS_Store +Thumbs.db +# Logs and crash dumps +*.log +hs_err_pid* +replay_pid* -# [Maven] ======================== +# Maven target/ pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next release.properties - - -# [Gradle-Android] ======================== - -# Ignore Gradle GUI config -gradle-app.setting - -# Gradle Signing -signing.properties -trestle.keystore - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.ear - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -# Misc -/.idea/workspace.xml -.DS_Store -/captures -**/*.iml -*.class \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 337dd90..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 75dac50..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f2ed8e9..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38fef1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,230 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-08-21 + +The first release in eight years. The library is brought up to a current +toolchain, the rendering bugs are fixed, and it gains a test suite and real +documentation. The Java API from 0.0.1 still compiles, see +[docs/MIGRATION.md](docs/MIGRATION.md). + +### Security + +- **Removed publishing credentials from `gradle.properties`.** The file + committed a Nexus password and a GPG signing passphrase in plain text. + They are gone from the working tree, but they remain in the git history of a + public repository and **must be treated as compromised and rotated.** + +### Fixed + +- **Dividers no longer disappear on an empty bar.** The divider-drawing loop was + nested inside the loop over enabled segments, so a bar with nothing lit drew no + dividers at all. +- **Dividers are drawn once each instead of once per lit segment.** The same + nesting bug caused redundant overdraw that scaled with progress. +- **Segments no longer bleed under the divider to their right.** The old geometry + added the full divider width to a segment's left edge without subtracting it + from the right. Dividers are now centred on cell boundaries and segments are + inset by half a divider on interior edges, so the two tile the bar exactly. +- **`reset()` now resets.** It used to clear the internal divider positions, + wiping the dividers, while leaving the progress untouched. +- **Every setter now repaints.** Only `setDivisions` and `setEnabledDivisions` + called `invalidate()`, so programmatic colour, divider-width, divider-enabled + and corner-radius changes did not appear until something else redrew the view. +- **`setDivisions()` before layout no longer places every divider at zero.** + Divider positions were computed eagerly from a width that was still `0`. +- **Negative indices in `enabledDivisions` no longer crash.** They caused an + `IndexOutOfBoundsException` inside `onDraw`; they are now dropped on assignment. +- **The caller's list is copied on assignment.** `setEnabledDivisions` stored the + caller's `List` by reference, so later mutations changed the view's state + behind its back and without a repaint. +- **Removed the `ViewTreeObserver.OnGlobalLayoutListener` sizing hack.** It only + detached itself once the view reached a non-zero width, so a view that never + did leaked the listener, and it cached geometry that later resizes never + recomputed. Geometry is now derived from the view's current size at draw time, + so there is no cached state to go stale. +- **Corner rounding is now correct.** The old code drew a round rect and then + painted square patches over parts of it, which produced visible artefacts. + Only the bar's outer corners are rounded now, via per-corner paths, and the + radius is clamped to half the smaller dimension. +- **An oversized `dividerWidth` can no longer collapse segments to a negative + width.** It is clamped to one segment's width. +- **Removed a dead `percentCompleted` field** that was assigned but never read. +- **Fade transitions now report their opacity through the paint colour** rather + than `Paint.setAlpha`. The two are equivalent for a solid paint on a device, + but only the former is visible through `Paint.getColor`, which is what made the + behaviour testable, and the tests immediately caught that a cleared segment + was being dropped from the draw loop before it could animate out. +- **The drop shadow follows the bar's outline instead of its individual layers.** + Hanging a `Paint` shadow layer on both fill paints and letting it fall where it + may produced four separate visible artefacts, all of which are now covered by + pixel-level tests in both renderers: + - A lit segment came out twice as dark as an unlit one, because the track cell + and the segment drawn over it each cast their own shadow onto the same spot. + - Every gap grew a dark tick above and below the bar, where the blurs of its two + neighbours met and added up. + - Each cell was outlined in shadow, because the shadow shape sat at full alpha + directly beneath an anti-aliased fill edge; anything translucent, including a + segment mid-fade, looked dirty for the same reason. + - A shadow landed on top of whichever neighbouring cell had already been + painted, drawing a dark line down one side of every cell. + + The shadow is now built as a single shape, blurred once, drawn before any fill + and clipped to the outside of the bar's outline, gaps included. One consequence + worth knowing: since it can no longer be drawn inside the bar, `ON_SEGMENTS` + shows along the outside of a lit run rather than as a shadow cast onto the track. +- **(Demo app) The first section is no longer hidden behind the app bar.** Since + targetSdk 35 Android enforces edge-to-edge, and a theme `ActionBar` lays the + content out from the top of the window. The demo now uses a `Toolbar` in its + own layout with explicit window-inset padding. + +### Added + +- `progressBarBackgroundColor`: the method the 0.0.1 README documented as + `setProgressBarBackgroundColor` but which never existed. +- `enableDivision(index)`, `disableDivision(index)`, `toggleDivision(index)`, + `isDivisionEnabled(index)`: operate on a single segment without rebuilding the + whole list. +- `setOnDivisionClickListener`: makes segments individually tappable, reporting + which one the user hit. Implemented via `onTouchEvent` plus a `performClick` + override so it stays reachable by accessibility services, rather than pushing + an `OnTouchListener` onto callers. +- `isTapToToggleEnabled` and `spb_tapToToggle`: makes the bar interactive with no + code at all, which is all an XML-only consumer needs. Off by default, and it + toggles before notifying any listener so the listener sees the state the user is + looking at. Touch as a whole is still gated by the platform's `isClickable`, so + `android:clickable="false"` switches everything here off. +- `divisionAt(x)` and `NO_DIVISION`: the raw mapping from a touch position to a + segment index, accounting for padding and layout direction so callers never + have to duplicate (and mirror wrong) the geometry. +- `completedSegmentCount`: how many segments are lit and in range. +- **Padding support.** The bar draws inside the view's padding. +- **Right-to-left support.** Under `layoutDirection=rtl`, segment `0` is drawn at + the right-hand end. Requires `android:supportsRtl="true"` in the consuming app, + which is how Android gates RTL for all views. +- **Sensible `wrap_content` measurement.** The view had no `onMeasure`; it now + reports an intrinsic `144dp × 8dp`. +- **Instance state saving.** Progress and division count survive configuration + changes when the view has an `android:id`. +- **Accessibility.** The bar reports itself as a `ProgressBar` and supplies a + localised, correctly pluralised content description such as + "6 of 10 segments complete" when the caller has not set one. +- **A test suite of 259 tests** covering geometry, attribute parsing, validation, + drawing output, corner modes, height bands, gaps, size constraints, drop + shadow, toggle/entry/recurring animation, touch handling, RTL, padding, instance + state, accessibility, Java interoperability, the Compose bindings and the demo + app. Most assert on recorded draw calls; the drop-shadow tests assert on real + pixels, in both renderers, because how shadows accumulate is invisible in a list + of draw calls. +- **Height bands.** `activeHeightRatio` and `inactiveHeightRatio` give the lit + segments and the unlit track independent heights, both centred on the same + axis, for the common "slim rail with chunky completed segments" look. Dividers + span the taller of the two so neighbours stay separated. +- **Corner modes.** `cornerMode` selects which edges `cornerRadius` applies to. A + cell covered by a segment takes that segment's rounding, so an `EACH_RUN` run + does not show the square corner of the rail beneath its rounded end. The modes + are: + `BAR_ENDS` (the previous and default behaviour), `EACH_SEGMENT` (every cell + becomes its own pill, track included) or `EACH_RUN` (each contiguous run of lit + segments becomes one pill, so the gaps define the shapes). +- **Drop shadow.** `shadowRadius`, `shadowDx`, `shadowDy`, `shadowColor` and + `shadowTarget`, the last choosing whether the on segments, the off segments or + all of them cast it. The shadow is drawn *outside* the bar and **never changes + the bar's size or position**, so the View needs padding for the blur to land in; + Compose does not clip to bounds, so it overflows there. Enabling one + switches the View to a software layer, because Android ignores `Paint` shadow + layers for shapes on a hardware-accelerated canvas; it is off by default for + that reason. Separately, the view now supplies a correctly rounded + `ViewOutlineProvider`, so `android:elevation` casts a bar-shaped shadow instead + of a rectangular one. +- **Segment animation.** `segmentAnimation` (`NONE`, `FADE`, `GROW`) with + `animationDurationMs`. Off by default so upgrading changes nothing. Animates in + both directions, mirrors `GROW` under RTL, never animates the initial state, + and resumes interrupted transitions from where they had got to rather than + snapping. +- **Entry animation.** `entryAnimation` (`NONE`, `FADE`, `GROW`, `STAGGER`) with + `entryStaggerDelayMs`, for how the bar's initial state arrives. A separate + opt-in from `segmentAnimation`, so a bar can animate itself in without + animating every later change, and it runs once rather than on every re-measure. +- **Recurring animation.** `recurringAnimation` (`NONE`, `SHIMMER`, `PULSE`) with + `recurringDurationMs` and `shimmerColor`. The loop stops while the view is + detached or hidden and resumes when it comes back, so it costs nothing + off-screen. +- **Size constraints.** `maxWidth` and `maxHeight`, which Android does not + otherwise offer for a plain `View`. A minimum still wins over a smaller + maximum, matching how the framework treats minimums. +- **Gaps.** The track is now drawn cell by cell rather than as one continuous + bar, so a transparent `dividerColor` produces a genuine gap with the page + showing through, instead of a window onto the track. An opaque colour still + paints a divider line exactly as before. +- **A Jetpack Compose artifact**, `segmentedprogressbar-compose`. A real + Composable drawing through Compose's own `Canvas` rather than an `AndroidView` + wrapper, sharing this library's `SegmentGeometry` and option enums so the two + renderers stay identical. Published separately so View-only consumers never + inherit the Compose runtime. +- **Consumer ProGuard rules** bundled in the AAR, so R8 works with no + configuration on the consumer's side. +- **GitHub Actions CI** running tests, lint, both assemble tasks and a publish + smoke test. +- **`LICENSE`, `CHANGELOG.md`, `CONTRIBUTING.md`, `docs/MIGRATION.md`** and KDoc + on every public member. +- **A demo app with two tabs:** a Gallery of worked examples, and a Playground + that pins a live bar above a scrolling set of controls for every option, with an + HSV colour picker (and hex entry) behind the last swatch in each colour row. + +### Changed + +- **Rewritten in Kotlin.** The published API is unchanged for Java callers; see + the Java compatibility test for the proof. The class is explicitly `open`, + because Kotlin classes are final by default and 0.0.1's Java class was + subclassable. +- **Invalid configuration throws instead of being logged and ignored.** + `divisions < 1` and a negative `dividerWidth` or `cornerRadius` now throw + `IllegalArgumentException`. Invalid *progress data* is still tolerated. +- **Out-of-range `enabledDivisions` are retained rather than discarded,** so the + order in which `divisions` and `enabledDivisions` are set no longer matters. +- `enabledDivisions` now returns a sorted, de-duplicated copy. +- **minSdk 16 → 26**, **compileSdk 27 → 37**, target Java 7 → 17. +- **Migrated from the Android Support Library to AndroidX.** The library's only + AndroidX dependency is now `androidx.annotation`. +- **`CornerMode` and `SegmentAnimation` are top-level types** in + `com.rachitgoyal.segmented` rather than nested in the View, so the Compose + artifact can use the same enums. `SegmentGeometry` is public for the same + reason. +- **Library namespace is now `com.rachitgoyal.segmented`** (it previously + collided with the demo app's package). The `SegmentedProgressBar` class package + is unchanged, so imports do not move. +- **Toolchain:** Gradle 4.4 → 9.7.1, AGP 3.1.2 → 9.3.1, Groovy build scripts → + Kotlin DSL with a version catalog. +- **Publishing:** replaced the abandoned `android-maven-gradle-plugin` and the + dead Bintray plugin with Gradle's `maven-publish`. The published POM now + carries correct licence, SCM and developer metadata, and a sources jar ships + alongside the AAR. +- **Repositories:** `jcenter()` (shut down in 2021) → `mavenCentral()`. + +### Removed + +- `segmented/maven-push.gradle` and the Sonatype/Bintray publishing path. +- The library's `app_name` string and its `white`, `grey_light` and + `progress_bar` colour resources. A library has no business shipping either, and + they could collide with a consumer's resources. Defaults are compile-time + constants now, with identical values. +- `.idea/` project files from version control (they were already gitignored). + +### Known issues + +- Individual segments are not exposed as separate accessibility nodes, so + `setOnDivisionClickListener` cannot fire for a service that activates the view + without pointer coordinates. Pair the bar with a non-positional control. +- Robolectric is pinned to SDK 35 rather than 37: its API 36+ sandboxes require + Java 21 and this project builds on the Java 17 toolchain. + +## [0.0.1] - 2018-05-06 + +- Initial release. + +[2.0.0]: https://github.com/rayzone107/SegmentedProgressBar/releases/tag/2.0.0 +[0.0.1]: https://github.com/rayzone107/SegmentedProgressBar/releases/tag/0.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a752ae7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing + +Thanks for taking an interest. Issues and pull requests are both welcome. + +## Getting set up + +You need **JDK 17** and the Android SDK with **API 37** installed. Nothing else, +the Gradle wrapper handles the rest. + +```bash +git clone https://github.com/rayzone107/SegmentedProgressBar.git +cd SegmentedProgressBar +./gradlew test +``` + +If Gradle cannot find your SDK, create a `local.properties` at the repo root: + +```properties +sdk.dir=/Users/you/Library/Android/sdk +``` + +`local.properties` is gitignored and must never be committed. + +## Repository layout + +| Path | What it is | +|---|---| +| `segmented/` | The published View library. Ships as `segmentedprogressbar`. | +| `segmented-compose/` | The Jetpack Compose bindings. Ships as `segmentedprogressbar-compose`. Depends on `:segmented` for the shared geometry and option enums rather than duplicating them. | +| `app/` | Demo app, a Gallery tab in XML and a Playground tab in Compose, and the library's consumer-side tests. | +| `gradle/libs.versions.toml` | Every dependency version. Change versions here, not in module files. | + +## Before opening a pull request + +```bash +./gradlew test lint +``` + +Both must pass. Specifically: + +- **`lint` must report zero findings.** The library module runs with + `warningsAsErrors = true`, so a new warning fails the build. Fix it rather than + suppressing it; if a suppression is genuinely right, say why in the PR. +- **New behaviour needs a test.** See below for where it belongs. + +## Where tests go + +The suite is split by what it can prove, and it is worth putting a new test in +the right place: + +| File | Scope | +|---|---| +| `SegmentGeometryTest.kt` | Pure layout maths. No Android, no Robolectric, so it can be exhaustive and stays fast. Prefer this whenever the logic can be expressed as arithmetic. | +| `SegmentedProgressBarTest.kt` | The view's public contract: attribute parsing, validation, progress bookkeeping, measurement, instance state, accessibility. | +| `SegmentedProgressBarDrawingTest.kt` | What actually reaches the canvas, recorded through a `RecordingCanvas`. Every rendering bug fixed in 2.0.0 has a regression test here. | +| `JavaApiCompatibilityTest.java` | The Java-visible API surface. Written in Java on purpose, a Kotlin test would silently follow a rename. | +| `app/src/test/.../InflationTest.kt` | Inflation from a real XML layout, which the library module cannot do since it has no layouts. | +| `app/src/test/.../MainActivityTest.kt` | The demo app driven like a user, including a configuration change. | +| `segmented-compose/src/test/.../SegmentedProgressBarComposeTest.kt` | The Composable: composition, parameter validation, tap mapping and semantics. The layout maths is *not* retested here, it is the same shared code. | + +Two conventions in the existing tests that are worth keeping: + +- **Drawing tests hard-code their expected coordinates** rather than deriving + them from `SegmentGeometry`, so a bug in the geometry cannot make a drawing + test pass. +- **Tests that depend on framework state assert that state as a precondition.** + The RTL helper is the example: layout direction silently resolves to LTR unless + the application declares `supportsRtl`, which would leave the assertions + quietly checking the wrong thing, so the helper `check()`s it explicitly. + +## Style + +- Kotlin official style (`kotlin.code.style=official`), four-space indents, a + 120-column soft limit. +- The library module runs in **explicit API mode**: every public declaration needs + an explicit visibility modifier and return type. This is deliberate, it makes + growing the published API surface a conscious act. +- Public members need KDoc. Say what it does *and* what it does when given + something unreasonable. +- `onDraw` is allocation-free. Keep it that way: the geometry helpers return + scalars, and the `Path`, `RectF` and radii array are reused across frames. + +## Backwards compatibility + +This library has users on 0.0.1 who found it years after the last commit. Treat +the public API as something people depend on: + +- Don't rename or re-type anything in the 0.0.1 surface. Deprecate and delegate + instead, `setBackgroundColor` is the worked example. +- Don't rename the XML attributes. They are unprefixed, which is not ideal, but + renaming them would break every existing layout. +- Changing a default value changes how existing bars render. Don't, unless it is + fixing an outright bug, and note it in `CHANGELOG.md` if so. + +## Releasing + +1. Update `VERSION_NAME` in `gradle.properties`. +2. Add a `CHANGELOG.md` entry. +3. Tag and push: `git tag 2.1.0 && git push origin 2.1.0`. Both artifacts are + built from the one tag. +4. JitPack builds the tag on first request, per `jitpack.yml`. Check the build + log at `https://jitpack.io/#rayzone107/SegmentedProgressBar`: it prints the + exact coordinate consumers should use. + +Publishing credentials and signing keys belong in `~/.gradle/gradle.properties` +or environment variables. Never in the repo, this project has been burned by +that before. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..686d485 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + 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. diff --git a/README.md b/README.md index 8c9d465..cffc942 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,649 @@ # Segmented Progress Bar -Segmented Progress Bar allows you to create progress bars with Segments. And you can update the segments in any sequence. +[![CI](https://github.com/rayzone107/SegmentedProgressBar/actions/workflows/ci.yml/badge.svg)](https://github.com/rayzone107/SegmentedProgressBar/actions/workflows/ci.yml) +[![JitPack](https://jitpack.io/v/rayzone107/SegmentedProgressBar.svg)](https://jitpack.io/#rayzone107/SegmentedProgressBar) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -This comes in very handy when you wish to inform the user about completed/pending segments of the app. +An Android progress bar split into a fixed number of equal segments, where **each +segment turns on and off independently**. -![alt text](/Image.png) +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?". -

Sample App

+Available as a **View** and as a **Jetpack Compose** Composable, from separate +artifacts that share the same layout maths and the same option types. -Download the Demo app on Google Play Store: +![Segmented Progress Bar](/Image.png) -Get it on Google Play +--- -

Installation

+## Contents -

Gradle

+- [Install](#install) +- [Quick start](#quick-start) +- [Jetpack Compose](#jetpack-compose) +- [XML attributes](#xml-attributes) +- [API reference](#api-reference) +- [Styling](#styling) +- [How it renders](#how-it-renders) +- [Right-to-left support](#right-to-left-support) +- [Accessibility](#accessibility) +- [Requirements](#requirements) +- [Upgrading from 0.0.1](#upgrading-from-001) +- [Building from source](#building-from-source) +- [Contributing](#contributing) +- [License](#license) -Add JitPack in your root build.gradle at the end of repositories: +--- -``` -allprojects { - repositories { - ... - maven { url 'https://jitpack.io' } +## Install + +Add JitPack to your repositories. In a modern project that means +`settings.gradle.kts`: + +```kotlin +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } } } ``` -Add this line as a dependency in your app's build.gradle file -``` +Then take whichever artifacts you need: + +```kotlin dependencies { - implementation 'com.github.rayzone107:durationview:0.0.1' + // The View. No Compose dependency. + implementation("com.github.rayzone107:segmentedprogressbar:2.0.0") + + // Optional: the Jetpack Compose bindings. + implementation("com.github.rayzone107:segmentedprogressbar-compose:2.0.0") } ``` -

How to use

+They are separate artifacts on purpose, so a View-only project never inherits the +Compose runtime. Both share the same option enums and the same geometry, so they +render identically. -Add a SegmentedProgressBar to your XML layout +> [!NOTE] +> JitPack builds each tag on first request, so the very first resolve after a +> release can take a minute. The exact coordinate for a tag is printed on that +> tag's [JitPack build page](https://jitpack.io/#rayzone107/SegmentedProgressBar). + +--- + +## Quick start + +### From XML -Below are the default values for all the properties ```xml -``` - -These values can also be set programatically. + android:id="@+id/progress" + android:layout_width="match_parent" + android:layout_height="16dp" + app:cornerRadius="8dp" + app:dividerColor="@android:color/white" + app:dividerWidth="2dp" + app:divisions="10" + app:progressBarBackgroundColor="#E4E7EB" + app:progressBarColor="#2F6FED" /> +``` + +### From code + +```kotlin +val bar = findViewById(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() +``` + +
+Java ```java +SegmentedProgressBar bar = findViewById(R.id.progress); - SegmentedProgressBar spb = findViewById(R.id.segmented_progress_bar); - - spb.setDivisions(10); - spb.setProgressBarBackgroundColor(Color.GREY); - spb.setProgressBarColor(Color.BLUE); - spb.setDividerColor(Color.WHITE); - spb.setDividerWidth(10); \\ in px (best to set this using xml, or convert from dp to px first) - - spb.setEnabledDivisions(Arrays.asList(1, 3, 4, 6, 8, 9)); \\ pass a list of integers, with the values that are to be enabled. +bar.setDivisions(10); +bar.setEnabledDivisions(Arrays.asList(1, 2, 5, 6, 9)); +bar.toggleDivision(4); +bar.setProgressBarColor(Color.BLUE); +bar.setDividerWidth(2 * getResources().getDisplayMetrics().density); +bar.reset(); ``` +
+ +### Letting users tap segments + +One attribute makes the bar interactive, with no code at all: + +```xml +app:spb_tapToToggle="true" +``` + +or, from code: + +```kotlin +bar.isTapToToggleEnabled = true +``` + +If 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: + +```kotlin +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: + +```kotlin +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. + +--- + +## Jetpack Compose + +`segmentedprogressbar-compose` is a real Composable. It draws with Compose's own +`Canvas` rather than wrapping the View in an `AndroidView`, so it composes with +modifiers, previews, themes and animation the way you would expect. + +```kotlin +var on by remember { mutableStateOf(setOf(1, 2, 5, 6, 9)) } + +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + modifier = Modifier.fillMaxWidth().height(24.dp), + onColor = Color(0xFF2F6FED), + offColor = Color(0xFFE4E7EB), + gap = 3.dp, + cornerRadius = 8.dp, + cornerMode = CornerMode.EACH_RUN, + 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. Differences worth knowing: + +| | View | Compose | +|---|---|---| +| Segment set | `List`, sorted and de-duplicated for you | `Set`, already unordered | +| Space between segments | `dividerWidth` and `dividerColor`, defaulting to a 1px white line | `gap` and `gapColor`, defaulting to a real 2dp gap | +| Sizing | `layout_height`, `spb_maxWidth` | `Modifier` | +| Drop shadow | `Paint` shadow layer, which forces a software layer and confines the blur to the view | stacked outset passes, 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 | + +The demo app's **Playground** tab drives every option from live controls, which is +the quickest way to see what each one does. + +--- + +## XML attributes + +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 in 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. | + +> [!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. + +--- + +## API reference + +### Properties + +| Property | Type | Notes | +|---|---|---| +| `divisions` | `Int` | Throws `IllegalArgumentException` below `1`. | +| `enabledDivisions` | `List` | 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`. | +| `completedSegmentCount` | `Int` | Read-only: how many segments are on *and* in range. | + +### Functions + +| 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. | +| `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. | + +### Validation policy + +The two kinds of bad input are treated differently, on purpose: + +- **Invalid configuration**, such as `divisions < 1` or a negative `dividerWidth`, + `cornerRadius` or `shadowRadius`, throws `IllegalArgumentException`. 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 `enabledDivisions` outside the + current division count, is tolerated and simply not drawn, because that list + usually comes from live application state. + +Out-of-range indices are also *retained* rather than discarded, so the order in +which you set `divisions` and `enabledDivisions` does not matter: + +```kotlin +bar.enabledDivisions = listOf(0, 3, 7) +bar.completedSegmentCount // 1, only index 0 is in range +bar.divisions = 10 +bar.completedSegmentCount // 3, the rest are revealed +``` + +### Instance state + +Which segments are on, and the division count, are saved and restored +automatically across configuration changes, provided the view has an +`android:id`. + +--- + +## Styling + +### Colours + +`progressBarColor` and `progressBarBackgroundColor` are the on and off colours. +Any colour works; there is nothing special about the defaults. + +### Gaps + +The space between segments is one number, `dividerWidth`, and what it looks like +depends on `dividerColor`: + +```kotlin +bar.dividerWidth = 4 * density + +bar.dividerColor = Color.TRANSPARENT // a real gap: the page shows through +bar.dividerColor = Color.WHITE // a painted divider line +bar.dividerWidth = 0f // segments run flush into each other +``` + +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. + +### Rounded edges + +`cornerRadius` sets the radius; set it to half the height for a pill. +`cornerMode` decides which edges it applies to: + +| Mode | Effect | +|---|---| +| `BAR_ENDS` (default) | Only the outer ends of the whole bar. Interior edges stay square. | +| `EACH_SEGMENT` | All four corners of every segment, on or off, so each reads as its own pill. | +| `EACH_RUN` | The outer ends of each *contiguous run* of on segments. Edges touching another on segment stay square, so a run reads as one pill. | + +`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. -Now within the code, you can anytime change the value of the enabled/disabled divisions by simply making a call to `spb.setEnabledDivisions(enabledList)`. +The radius is always clamped to half the smaller dimension, so an over-large value +gives a pill rather than an artefact. -

Contribution

+### Height -Any kind of contribution will be appreciated; feel free to create a pull request or file issues on the issue tracker. +Both heights default to the full bar, so this costs nothing until you ask for it. +Set them independently to make on segments read as raised, or as an inset fill +inside a channel. Both bands stay centred on the same axis: + +```kotlin +bar.inactiveHeightRatio = 0.7f // off segments slightly inset +bar.activeHeightRatio = 1f // on segments at full height +``` + +Either can be the smaller of the two. Gaps always span the taller band, so +neighbouring segments stay visually separated. + +> [!TIP] +> Keep the difference modest. A very small ratio, say `0.3`, turns the on segments +> into disconnected blocks floating over a thin rail, which reads as a rendering +> bug rather than a design. Pair it with `CornerMode.EACH_RUN` to keep runs +> looking connected. + +### Size + +Standard layout params work as on any view, plus a maximum, which Android does not +otherwise give a plain `View`: + +```kotlin +bar.maxWidth = (420 * density).toInt() // do not stretch across a tablet +bar.maxHeight = SegmentedProgressBar.NO_MAX_SIZE +``` + +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. + +### Drop shadow + +```kotlin +bar.shadowRadius = 5 * density +bar.shadowDy = 3 * density +bar.shadowColor = 0x73000000 +bar.shadowTarget = ShadowTarget.ALL // or ON_SEGMENTS, or OFF_SEGMENTS +``` + +**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**, or the blur has nowhere +to land. `android:clipChildren="false"` on the parent does not help, because the +software layer the shadow needs is itself the size of the view. Compose has no +such layer 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. +- Nothing is drawn *between* segments either. A narrow gap would otherwise fill in + with blur from both sides and become the very divider line that leaving it + transparent asked to be rid of. + +`shadowTarget` then chooses which segments contribute: `ALL` gives the whole bar +one shadow, `ON_SEGMENTS` shadows the lit runs only, and `OFF_SEGMENTS` the rest. +Because no shadow is ever drawn inside the bar, `ON_SEGMENTS` shows up along the +outside of each lit run rather than as a shadow cast onto the track. + +One caveat for the View: **it forces a software layer.** Android ignores `Paint` +shadow layers for shapes on a hardware-accelerated canvas, so enabling one +switches the view to `LAYER_TYPE_SOFTWARE`. That is an off-screen bitmap the size +of the view, cheap for a bar, but the reason this is off by default. + +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. + +### Animation + +Three independent axes, all off by default so upgrading from 0.0.1 changes +nothing. + +**When a segment is toggled**, once the bar is already on screen: + +```kotlin +bar.segmentAnimation = SegmentAnimation.FADE // or GROW +bar.animationDurationMs = 320 +``` + +`FADE` 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**: + +```kotlin +bar.entryAnimation = EntryAnimation.STAGGER // or FADE, GROW +bar.entryStaggerDelayMs = 60 +``` + +`STAGGER` 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**: + +```kotlin +bar.recurringAnimation = RecurringAnimation.SHIMMER // or PULSE +bar.recurringDurationMs = 1600 +bar.shimmerColor = 0x73FFFFFF +``` + +`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 `entryAnimation` at + `NONE`, 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. + +--- + +## How it renders + +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. +- `dividerWidth` is 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. +- `onDraw` allocates nothing. + +--- + +## Right-to-left support + +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. + +> [!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. + +--- + +## Accessibility + +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. + +--- + +## Requirements + +| | | +|---|---| +| **minSdk** | 26 (Android 8.0) | +| **compileSdk** | 37 (Android 17) | +| **Java/Kotlin target** | 17 | +| **Language** | Kotlin, fully usable from Java | +| **View dependencies** | `androidx.annotation` 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. + +--- + +## Upgrading from 0.0.1 + +Most code needs no changes. See [docs/MIGRATION.md](docs/MIGRATION.md) for the +full list; the short version: + +- **`setBackgroundColor(int)` still works** but is deprecated, because it shadows + `View.setBackgroundColor` with a different meaning. Use + `setProgressBarBackgroundColor(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. + +--- + +## Building from source + +```bash +git clone https://github.com/rayzone107/SegmentedProgressBar.git +cd SegmentedProgressBar + +./gradlew test # 228 unit tests across three modules +./gradlew lint # must report zero findings +./gradlew :app:installDebug # the demo app +``` + +The 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. + +Publishing a release: + +```bash +git tag 2.0.0 && git push origin 2.0.0 +``` + +JitPack builds the tag on first request using [`jitpack.yml`](jitpack.yml), and +publishes both artifacts from the one tag. + +--- + +## Contributing + +Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). + +--- + +## License + +``` +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. +``` diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 85ececa..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,25 +0,0 @@ -apply plugin: 'com.android.application' - -android { - compileSdkVersion 27 - defaultConfig { - applicationId "com.rachitgoyal.segmentedprogressbar" - minSdkVersion 16 - targetSdkVersion 27 - versionCode 1 - versionName "1.0" - multiDexEnabled true - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'com.android.support:appcompat-v7:27.1.1' - implementation project(path: ':segmented') -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..67f2a16 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + // AGP 9 provides Kotlin compilation itself; applying org.jetbrains.kotlin.android + // on top of it is now an error. See https://kotl.in/gradle/agp-built-in-kotlin + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.rachitgoyal.segmentedprogressbar.demo" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.rachitgoyal.segmentedprogressbar.demo" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 2 + versionName = "2.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + // The demo app is not distributed; sign release builds with the debug + // key so `assembleRelease` verifies shrinking end to end in CI. + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + viewBinding = true + // The Gallery tab is XML; the Playground tab is Compose. Having both in + // one app is the point, it demonstrates that the two artifacts are + // interchangeable. + compose = true + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + lint { + abortOnError = true + } +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":segmented")) + implementation(project(":segmented-compose")) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.constraintlayout) + implementation(libs.material) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + // These tests are the library's consumer-side coverage: they prove the AAR + // inflates from a real layout and behaves when driven by an app. + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.truth) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f1646a3..1caa05d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + + - + @@ -18,4 +20,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/rachitgoyal/segmentedprogressbar/MainActivity.java b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/MainActivity.java deleted file mode 100644 index 0271aec..0000000 --- a/app/src/main/java/com/rachitgoyal/segmentedprogressbar/MainActivity.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.rachitgoyal.segmentedprogressbar; - -import android.os.Bundle; -import android.support.v7.app.AppCompatActivity; - -import com.rachitgoyal.segmented.SegmentedProgressBar; - -import java.util.Arrays; - -public class MainActivity extends AppCompatActivity { - - SegmentedProgressBar mSegmentedProgressBar; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - mSegmentedProgressBar = findViewById(R.id.segmented_pb_1); - - Integer[] enabled = new Integer[]{1, 4, 5, 8, 9}; - - mSegmentedProgressBar.setEnabledDivisions(Arrays.asList(enabled)); - } -} diff --git a/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/ColorPicker.kt b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/ColorPicker.kt new file mode 100644 index 0000000..85dc244 --- /dev/null +++ b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/ColorPicker.kt @@ -0,0 +1,323 @@ +package com.rachitgoyal.segmentedprogressbar.demo + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** + * A colour picker in a dialog. + * + * A dialog rather than an inline panel for two reasons: it keeps the controls + * list short, and it takes the picker out of the scrolling column, where a drag + * across the saturation panel was being claimed by the scroll container instead + * of the picker. + */ +@Composable +fun ColorPickerDialog( + initialColor: Color, + title: String, + onDismiss: () -> Unit, + onConfirm: (Color) -> Unit, +) { + var working by remember { mutableStateOf(initialColor) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + ColorPickerBody(color = working, onColorChange = { working = it }) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(working) }) { Text("Use colour") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** The saturation and value panel, the hue strip, and a hex readout. */ +@Composable +private fun ColorPickerBody(color: Color, onColorChange: (Color) -> Unit) { + // A fully desaturated or black colour carries no hue of its own, so the last + // real hue is remembered; otherwise the panel snaps back to red as soon as a + // drag reaches an edge. + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(color.toArgb(), hsv) + val lastHue = remember { floatArrayOf(hsv[0]) } + if (hsv[1] > 0.01f && hsv[2] > 0.01f) lastHue[0] = hsv[0] + val hue = lastHue[0] + + Column { + SaturationValuePanel( + hue = hue, + saturation = hsv[1], + value = hsv[2], + onChange = { s, v -> + onColorChange(Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, s, v)))) + }, + ) + + Spacer(Modifier.height(14.dp)) + + HueStrip( + hue = hue, + onChange = { newHue -> + onColorChange( + Color( + android.graphics.Color.HSVToColor( + floatArrayOf( + newHue, + hsv[1].coerceAtLeast(0.05f), + hsv[2].coerceAtLeast(0.05f), + ), + ), + ), + ) + }, + ) + + Spacer(Modifier.height(14.dp)) + + HexField(color = color, onColorChange = onColorChange) + } +} + +/** + * A hex entry field that stays in step with the panel above it. + * + * The text is local state rather than derived from [color], so a half-typed value + * survives keystrokes. It is pushed back into sync only when the colour changes + * from somewhere else, which is what stops the field fighting the panel while you + * drag. + */ +@Composable +private fun HexField(color: Color, onColorChange: (Color) -> Unit) { + var text by remember { mutableStateOf(color.toHexString()) } + + LaunchedEffect(color) { + if (parseHexColor(text) != color) text = color.toHexString() + } + + val parsed = parseHexColor(text) + + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(10.dp)) + .background(color) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(10.dp), + ), + ) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = text, + onValueChange = { raw -> + text = raw + parseHexColor(raw)?.let(onColorChange) + }, + label = { Text("Hex") }, + singleLine = true, + isError = parsed == null, + supportingText = if (parsed == null) { + { Text("Use #RRGGBB") } + } else { + null + }, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Done, + ), + modifier = Modifier.weight(1f), + ) + } +} + +/** + * Parses `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB`, or returns `null`. + * + * Hand-parsed rather than handed to `Color.parseColor`, which throws on bad input + * and would need a try/catch on every keystroke. + */ +private fun parseHexColor(raw: String): Color? { + val hex = raw.trim().removePrefix("#") + if (hex.any { it.digitToIntOrNull(16) == null }) return null + val expanded = when (hex.length) { + 3 -> hex.map { "$it$it" }.joinToString("") + 6 -> hex + else -> return null + } + val value = expanded.toLongOrNull(16) ?: return null + return Color(0xFF000000.toInt() or value.toInt()) +} + +/** + * Reports the position of every touch and drag, consuming the events. + * + * Two things matter here, and getting either wrong breaks dragging while leaving + * tapping perfectly functional: + * + * 1. Written with [awaitEachGesture] and an explicit `consume()` rather than + * `detectDragGestures`, so an enclosing scrollable never gets the chance to + * claim the gesture. `detectDragGestures` only takes over once the drag passes + * touch slop, and a scrolling parent wins during that window. + * 2. Keyed on `Unit`. Keying it on the current hue looked reasonable but was + * fatal: an HSV round-trip nudges the hue by a fraction of a degree, the key + * changes, and `pointerInput` restarts, cancelling the drag that caused it. A + * single tap survived that, so the bug only showed up when dragging. + * + * Callers must therefore read their own live state through `rememberUpdatedState` + * rather than relying on the lambda being recreated. + */ +private fun Modifier.trackTouch(onFraction: (Float, Float) -> Unit): Modifier = pointerInput(Unit) { + // Normalised here, where the pointer scope knows the size, so callers only + // deal in 0..1 fractions. + fun report(position: Offset) { + onFraction( + (position.x / size.width).coerceIn(0f, 1f), + (position.y / size.height).coerceIn(0f, 1f), + ) + } + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + report(down.position) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + change.consume() + report(change.position) + } + } +} + +/** The two-dimensional saturation (x) and value (y) area. */ +@Composable +private fun SaturationValuePanel( + hue: Float, + saturation: Float, + value: Float, + onChange: (Float, Float) -> Unit, +) { + val pureHue = remember(hue) { + Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f))) + } + // Read through an updated state, because the gesture handler below is keyed + // on Unit and so captures whatever lambda existed when it started. + val latestOnChange by rememberUpdatedState(onChange) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clip(RoundedCornerShape(14.dp)) + .trackTouch { xFraction, yFraction -> + latestOnChange(xFraction, 1f - yFraction) + }, + ) { + // White to the pure hue horizontally, then transparent to black + // vertically, which together give the standard saturation/value square. + drawRect(Brush.horizontalGradient(listOf(Color.White, pureHue))) + drawRect(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + + val cx = saturation * size.width + val cy = (1f - value) * size.height + drawCircle(Color.White, 10.dp.toPx(), Offset(cx, cy), style = Stroke(2.dp.toPx())) + drawCircle( + Color.Black.copy(alpha = 0.35f), + 12.dp.toPx(), + Offset(cx, cy), + style = Stroke(1.dp.toPx()), + ) + } +} + +/** The hue selector strip. */ +@Composable +private fun HueStrip(hue: Float, onChange: (Float) -> Unit) { + val hueColors = remember { + (0..360 step 30).map { + Color(android.graphics.Color.HSVToColor(floatArrayOf(it.toFloat(), 1f, 1f))) + } + } + val latestOnChange by rememberUpdatedState(onChange) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .clip(RoundedCornerShape(14.dp)) + .trackTouch { xFraction, _ -> latestOnChange(xFraction * 360f) }, + ) { + drawRect(Brush.horizontalGradient(hueColors)) + val cx = (hue / 360f) * size.width + drawCircle( + Color.White, + 10.dp.toPx(), + Offset(cx, size.height / 2f), + style = Stroke(2.dp.toPx()), + ) + } +} + +/** `#RRGGBB`, which is what a developer wants to copy out of a picker. */ +fun Color.toHexString(): String { + val argb = toArgb() + return "#%02X%02X%02X".format( + (argb shr 16) and 0xFF, + (argb shr 8) and 0xFF, + argb and 0xFF, + ) +} + +/** Rounded percentage, for compact slider labels. */ +fun Float.asPercent(): String = "${(this * 100).roundToInt()}%" + +/** Spacing used by the swatch rows. */ +val PickerRowSpacing: Arrangement.Horizontal = Arrangement.spacedBy(10.dp) diff --git a/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/MainActivity.kt b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/MainActivity.kt new file mode 100644 index 0000000..9b42301 --- /dev/null +++ b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/MainActivity.kt @@ -0,0 +1,234 @@ +package com.rachitgoyal.segmentedprogressbar.demo + +import android.os.Bundle +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import com.google.android.material.tabs.TabLayout +import com.rachitgoyal.segmented.SegmentedProgressBar +import com.rachitgoyal.segmentedprogressbar.demo.databinding.ActivityMainBinding + +/** + * Demo screen for [SegmentedProgressBar]. + * + * The ordering of the sections is deliberate. This library's reason to exist is + * that **any arbitrary subset of segments can be lit at once**: something + * [android.widget.ProgressBar] cannot express, so the screen leads with + * independent per-segment toggling and sparse sets, and treats the contiguous + * "first N segments" case as the special case it actually is. + */ +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + applyWindowInsets() + setUpTabs() + + setUpToggleBar() + setUpSparseBars() + setUpHabitTracker() + setUpStylingVariants() + setUpAnimatedBars() + } + + /** + * Insets the app bar and the scrolling content by the system bars. + * + * Since targetSdk 35 Android enforces edge-to-edge, so the window extends + * behind the status and navigation bars and nothing is inset automatically. + * The top inset goes on the toolbar (which grows past its `minHeight` + * accordingly) and the bottom inset on the scroll view, which keeps + * `clipToPadding=false` so content still scrolls *through* the gesture area + * rather than stopping short of it. + */ + private fun applyWindowInsets() { + WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = false + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + binding.toolbar.updatePadding(top = bars.top, left = bars.left, right = bars.right) + binding.scroll.updatePadding(left = bars.left, right = bars.right, bottom = bars.bottom) + WindowInsetsCompat.CONSUMED + } + } + + /** + * Wires the two tab pages. + * + * Both pages stay inflated and are shown or hidden, rather than living in a + * ViewPager2: a pager recycles its pages, and the Gallery's views are the + * library's consumer-side test surface, so they need to exist for the whole + * lifetime of the activity. + */ + private fun setUpTabs() { + binding.playgroundPage.setContent { PlaygroundScreen() } + + binding.tabs.apply { + addTab(newTab().setText(R.string.tab_playground)) + addTab(newTab().setText(R.string.tab_gallery)) + addOnTabSelectedListener( + object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) = showPage(tab.position) + override fun onTabUnselected(tab: TabLayout.Tab) = Unit + override fun onTabReselected(tab: TabLayout.Tab) = Unit + }, + ) + } + showPage(0) + } + + private fun showPage(position: Int) { + binding.playgroundPage.isVisible = position == 0 + binding.scroll.isVisible = position == 1 + } + + // region tappable bar + + /** + * Turns the bar into a row of independently tappable segments. + * + * All the coordinate work lives in the library: + * [SegmentedProgressBar.setOnDivisionClickListener] reports which segment was + * hit, so this stays correct under padding and RTL and the demo duplicates no + * geometry. The bar reports the tap; deciding that a tap means "toggle" is + * the app's call. + */ + private fun setUpToggleBar() { + val bar = binding.toggleBar + bar.enabledDivisions = SPARSE_SELECTION + + bar.setOnDivisionClickListener { view, index -> + view.toggleDivision(index) + renderToggleLabel() + } + + // A non-positional control, so the bar is still usable by anyone who + // cannot aim at an individual segment. + binding.toggleReset.setOnClickListener { + bar.reset() + renderToggleLabel() + } + + renderToggleLabel() + } + + private fun renderToggleLabel() { + binding.toggleLabel.text = getString( + R.string.toggle_label, + binding.toggleBar.enabledDivisions.toString(), + ) + } + + // endregion + + // region declarative selections + + private fun setUpSparseBars() { + binding.sparseBar.enabledDivisions = SPARSE_SELECTION + + // With dividers off, adjacent segments that are on merge into one run. + binding.pillBar.enabledDivisions = listOf(0, 1, 2, 5, 8, 9, 10) + + // Identical API under RTL. The pattern mirrors; the code does not change. + binding.rtlBar.enabledDivisions = SPARSE_SELECTION + + binding.paddedBar.enabledDivisions = listOf(0, 2, 5) + binding.wrapBar.enabledDivisions = listOf(0, 3) + } + + // endregion + + // region habit tracker + + /** + * A concrete use case for gaps: days of the week, where the days you missed + * are exactly the point. + */ + private fun setUpHabitTracker() { + binding.habitBar.enabledDivisions = DAYS_COMPLETED + + val dayLabels = listOf( + R.string.day_monday, + R.string.day_tuesday, + R.string.day_wednesday, + R.string.day_thursday, + R.string.day_friday, + R.string.day_saturday, + R.string.day_sunday, + ) + dayLabels.forEachIndexed { index, label -> + binding.habitDays.addView( + TextView(this).apply { + text = getString(label) + gravity = Gravity.CENTER + textSize = 12f + alpha = if (index in DAYS_COMPLETED) 1f else 0.35f + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }, + ) + } + } + + // endregion + + // region styling variants + + /** + * The purely visual variants. Every one is configured in XML; this only + * supplies the data, and deliberately supplies *sparse* data so the styling + * is shown doing the thing the library is for. + */ + private fun setUpStylingVariants() { + // Height difference: a slim track with full-height lit segments. + binding.heightBar.enabledDivisions = listOf(0, 1, 2, 5, 7) + + // Every cell rounded, so unlit cells read as pills too. + binding.eachSegmentBar.enabledDivisions = listOf(0, 2, 3, 6) + + // Runs of adjacent segments merge into single pills. + binding.eachRunBar.enabledDivisions = listOf(0, 1, 2, 4, 7, 8, 11) + + binding.shadowBar.enabledDivisions = listOf(0, 1, 3, 5) + } + + // endregion + + // region animation + + /** + * The two animated bars are tappable, because an animation you cannot + * trigger is not much of a demo. + * + * No click listener here on purpose: both bars carry + * `app:spb_tapToToggle="true"` in the layout, which is all a bar needs to + * become interactive when nothing else has to happen on a tap. Compare + * [setUpToggleBar], which does have something else to do. + */ + private fun setUpAnimatedBars() { + listOf(binding.fadeBar, binding.growBar).forEach { bar -> + bar.enabledDivisions = listOf(1, 2, 5) + } + } + + // endregion + + private companion object { + /** An arbitrary selection, which is all this library ever asks for. */ + val SPARSE_SELECTION = listOf(1, 2, 5, 6, 9) + + /** Mon, Wed, Thu, Sun, a week with two gaps in it. */ + val DAYS_COMPLETED = listOf(0, 2, 3, 6) + } +} diff --git a/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/PlaygroundScreen.kt b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/PlaygroundScreen.kt new file mode 100644 index 0000000..94a7814 --- /dev/null +++ b/app/src/main/java/com/rachitgoyal/segmentedprogressbar/demo/PlaygroundScreen.kt @@ -0,0 +1,742 @@ +package com.rachitgoyal.segmentedprogressbar.demo + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.rachitgoyal.segmented.CornerMode +import com.rachitgoyal.segmented.EntryAnimation +import com.rachitgoyal.segmented.RecurringAnimation +import com.rachitgoyal.segmented.SegmentAnimation +import com.rachitgoyal.segmented.ShadowTarget +import com.rachitgoyal.segmented.compose.SegmentShadow +import com.rachitgoyal.segmented.compose.SegmentedProgressBar +import kotlin.math.roundToInt + +/** + * Every option the library exposes, wired to one live bar. + * + * The preview is pinned above the controls and only the controls scroll, so the + * bar you are configuring never leaves the screen. + */ +@Composable +fun PlaygroundScreen(modifier: Modifier = Modifier) { + // The Compose content carries its own theme: a bare MaterialTheme defaults to + // the light scheme regardless of the system setting, which looked wrong in + // dark mode. + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), + ) { + PlaygroundContent(state = rememberPlaygroundState(), modifier = modifier) + } +} + +@Composable +private fun PlaygroundContent(state: PlaygroundState, modifier: Modifier) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Column(Modifier.fillMaxSize()) { + PinnedPreview(state) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SegmentsCard(state) + ColorsCard(state) + ShapeCard(state) + SizeCard(state) + ShadowCard(state) + AnimationCard(state) + Spacer(Modifier.height(8.dp)) + } + } + } +} + +// region state + +/** Everything the playground can change, in one place. */ +private class PlaygroundState { + var divisions by mutableIntStateOf(10) + var lit by mutableStateOf(setOf(1, 2, 5, 6, 9)) + + var onColor by mutableStateOf(Color(0xFF2F6FED)) + var offColor by mutableStateOf(Color(0xFFE4E7EB)) + + var barHeight by mutableStateOf(28.dp) + var cappedWidth by mutableStateOf(false) + + var gap by mutableStateOf(3.dp) + var paintedDivider by mutableStateOf(false) + + var cornerRadius by mutableStateOf(8.dp) + var cornerMode by mutableStateOf(CornerMode.EACH_RUN) + + var activeHeight by mutableFloatStateOf(1f) + var inactiveHeight by mutableFloatStateOf(1f) + + var shadowOn by mutableStateOf(false) + var shadowRadius by mutableStateOf(6.dp) + var shadowDy by mutableStateOf(3.dp) + var shadowTarget by mutableStateOf(ShadowTarget.ALL) + + var tapToToggle by mutableStateOf(true) + + var segmentAnimation by mutableStateOf(SegmentAnimation.FADE) + var entryAnimation by mutableStateOf(EntryAnimation.STAGGER) + var recurring by mutableStateOf(RecurringAnimation.NONE) + var durationMs by mutableIntStateOf(240) + var recurringMs by mutableIntStateOf(1600) + + /** Whether anything on screen is driven by [durationMs]. */ + val usesDuration: Boolean + get() = segmentAnimation != SegmentAnimation.NONE || + entryAnimation != EntryAnimation.NONE + + /** Bumped to remount the bar, which replays the entry animation. */ + var replayKey by mutableIntStateOf(0) + + val shadow: SegmentShadow? + get() = if (shadowOn) { + SegmentShadow(radius = shadowRadius, dy = shadowDy, target = shadowTarget) + } else { + null + } + + fun toggle(index: Int) { + lit = if (index in lit) lit - index else lit + index + } + + fun clampLitToDivisions() { + lit = lit.filter { it < divisions }.toSet() + } +} + +@Composable +private fun rememberPlaygroundState() = remember { PlaygroundState() } + +private val OnPresets = listOf( + Color(0xFF2F6FED), + Color(0xFF12A150), + Color(0xFFF31260), + Color(0xFF9353D3), + Color(0xFFF5A524), + Color(0xFF06B7DB), +) + +private val OffPresets = listOf( + Color(0xFFE4E7EB), + Color(0xFFF1F3F5), + Color(0xFFD0D7DE), + Color(0xFF2A3138), + Color(0xFFFFE1EC), + Color(0xFFE3F0FF), +) + +// endregion + +// region pinned preview + +/** + * The bar under configuration, fixed above the scrolling controls. + * + * One bar, not two. The Compose and View implementations render identically, so + * showing both was duplication rather than information. + */ +@Composable +private fun PinnedPreview(state: PlaygroundState) { + // shadowElevation, not just tonalElevation: the point is for the scrolling + // controls to visibly pass underneath the panel. + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + shadowElevation = 8.dp, + ) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp), + contentAlignment = Alignment.Center, + ) { + key(state.replayKey) { + SegmentedProgressBar( + divisions = state.divisions, + enabledSegments = state.lit, + modifier = Modifier + .then( + if (state.cappedWidth) { + Modifier.width(240.dp) + } else { + Modifier.fillMaxWidth() + }, + ) + .height(state.barHeight), + onColor = state.onColor, + offColor = state.offColor, + gap = state.gap, + gapColor = if (state.paintedDivider) Color.White else Color.Transparent, + cornerRadius = state.cornerRadius, + cornerMode = state.cornerMode, + activeHeightFraction = state.activeHeight, + inactiveHeightFraction = state.inactiveHeight, + shadow = state.shadow, + segmentAnimation = state.segmentAnimation, + entryAnimation = state.entryAnimation, + recurringAnimation = state.recurring, + animationDurationMillis = state.durationMs, + recurringDurationMillis = state.recurringMs, + // Compose holds the lit set outside the bar, so an + // interactive bar is one with a click handler and a + // read-only one is one without. + onSegmentClick = if (state.tapToToggle) state::toggle else null, + ) + } + } + + Spacer(Modifier.height(10.dp)) + + Text( + text = "enabledDivisions = ${state.lit.sorted()}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(10.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = { state.lit = (0 until state.divisions).toSet() }) { + Text("All") + } + FilledTonalButton(onClick = { state.lit = emptySet() }) { Text("None") } + FilledTonalButton( + onClick = { + state.lit = (0 until state.divisions).filter { (it * 7) % 3 != 0 }.toSet() + }, + ) { Text("Shuffle") } + OutlinedButton(onClick = { state.replayKey++ }) { Text("Replay") } + } + } + } +} + +// endregion + +// region control cards + +@Composable +private fun SegmentsCard(state: PlaygroundState) { + PlaygroundCard( + title = "Segments", + subtitle = if (state.tapToToggle) { + "Tap the bar above to turn segments on and off" + } else { + "Touch is off, so the bar above ignores taps" + }, + ) { + SliderRow( + label = "Total segments", + value = state.divisions.toFloat(), + range = 1f..24f, + steps = 22, + display = "${state.divisions}", + ) { + state.divisions = it.roundToInt() + state.clampLitToDivisions() + } + + ToggleRow(label = "Tapping a segment toggles it", checked = state.tapToToggle) { + state.tapToToggle = it + } + } +} + +@Composable +private fun ColorsCard(state: PlaygroundState) { + // Two labelled rows rather than one row behind an On/Off selector: a bare + // "On"/"Off" pair reads as a feature toggle, not as "which colour am I + // editing", which is the question the control actually answers. + PlaygroundCard(title = "Colours", subtitle = "Any colour, not just the presets") { + SwatchPicker( + label = "Segments that are ON", + presets = OnPresets, + selected = state.onColor, + dialogTitle = "Colour for segments that are on", + onChange = { state.onColor = it }, + ) + + Spacer(Modifier.height(18.dp)) + + SwatchPicker( + label = "Segments that are OFF", + presets = OffPresets, + selected = state.offColor, + dialogTitle = "Colour for segments that are off", + onChange = { state.offColor = it }, + ) + } +} + +/** + * A row of preset swatches with a custom-colour swatch at the end. + * + * The custom swatch shows the colour in use when it is not one of the presets, + * so the row always reflects the current value. + */ +@Composable +private fun SwatchPicker( + label: String, + presets: List, + selected: Color, + dialogTitle: String, + onChange: (Color) -> Unit, +) { + var pickerOpen by remember { mutableStateOf(false) } + val isCustom = selected !in presets + + Text(label, style = labelStyle()) + Spacer(Modifier.height(8.dp)) + + Row(horizontalArrangement = PickerRowSpacing) { + presets.forEach { color -> + Swatch( + color = color, + selected = color == selected && !isCustom, + onClick = { onChange(color) }, + ) + } + CustomSwatch( + color = if (isCustom) selected else null, + selected = isCustom, + onClick = { pickerOpen = true }, + ) + } + + if (pickerOpen) { + ColorPickerDialog( + initialColor = selected, + title = dialogTitle, + onDismiss = { pickerOpen = false }, + onConfirm = { + onChange(it) + pickerOpen = false + }, + ) + } +} + +@Composable +private fun Swatch(color: Color, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(color) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + shape = RoundedCornerShape(10.dp), + ) + .clickable(onClick = onClick), + ) +} + +/** The last swatch in a row: opens the colour picker. */ +@Composable +private fun CustomSwatch(color: Color?, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background( + color ?: Color.Transparent, + ) + .then( + if (color == null) { + // A hue sweep, so it is obvious this one opens a picker. + Modifier.background( + Brush.sweepGradient( + listOf( + Color(0xFFF31260), + Color(0xFFF5A524), + Color(0xFF12A150), + Color(0xFF06B7DB), + Color(0xFF2F6FED), + Color(0xFF9353D3), + Color(0xFFF31260), + ), + ), + ) + } else { + Modifier + }, + ) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + shape = RoundedCornerShape(10.dp), + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + ) + } +} + +@Composable +private fun ShapeCard(state: PlaygroundState) { + PlaygroundCard(title = "Shape") { + SliderRow( + label = "Gap", + value = state.gap.value, + range = 0f..16f, + steps = 15, + display = "${state.gap.value.roundToInt()}dp", + ) { state.gap = it.roundToInt().dp } + + ToggleRow( + label = "Paint the gap as a divider line", + checked = state.paintedDivider, + ) { state.paintedDivider = it } + + SliderRow( + label = "Corner radius", + value = state.cornerRadius.value, + range = 0f..24f, + steps = 23, + display = "${state.cornerRadius.value.roundToInt()}dp", + ) { state.cornerRadius = it.roundToInt().dp } + + ChoiceRow( + label = "Corner mode", + options = CornerMode.entries, + selected = state.cornerMode, + name = { it.label() }, + ) { state.cornerMode = it } + } +} + +@Composable +private fun SizeCard(state: PlaygroundState) { + PlaygroundCard(title = "Size") { + SliderRow( + label = "Bar height", + value = state.barHeight.value, + range = 6f..64f, + steps = 57, + display = "${state.barHeight.value.roundToInt()}dp", + ) { state.barHeight = it.roundToInt().dp } + + ToggleRow(label = "Cap width at 240dp", checked = state.cappedWidth) { + state.cappedWidth = it + } + + SliderRow( + label = "On-segment height", + value = state.activeHeight, + range = 0.2f..1f, + steps = 0, + display = state.activeHeight.asPercent(), + ) { state.activeHeight = it } + + SliderRow( + label = "Off-segment height", + value = state.inactiveHeight, + range = 0.2f..1f, + steps = 0, + display = state.inactiveHeight.asPercent(), + ) { state.inactiveHeight = it } + } +} + +@Composable +private fun ShadowCard(state: PlaygroundState) { + PlaygroundCard( + title = "Drop shadow", + subtitle = "Drawn outside the bar, so it never changes the bar's size", + ) { + ToggleRow(label = "Enabled", checked = state.shadowOn) { state.shadowOn = it } + if (state.shadowOn) { + ChoiceRow( + label = "Cast by", + options = ShadowTarget.entries, + selected = state.shadowTarget, + name = { it.label() }, + ) { state.shadowTarget = it } + + SliderRow( + label = "Blur", + value = state.shadowRadius.value, + range = 0f..16f, + steps = 15, + display = "${state.shadowRadius.value.roundToInt()}dp", + ) { state.shadowRadius = it.roundToInt().dp } + + SliderRow( + label = "Y offset", + value = state.shadowDy.value, + range = 0f..12f, + steps = 11, + display = "${state.shadowDy.value.roundToInt()}dp", + ) { state.shadowDy = it.roundToInt().dp } + } + } +} + +@Composable +private fun AnimationCard(state: PlaygroundState) { + PlaygroundCard( + title = "Animation", + subtitle = "Tap a segment to see the first; press Replay to see the second", + ) { + ChoiceRow( + label = "When a segment is tapped", + options = SegmentAnimation.entries, + selected = state.segmentAnimation, + name = { it.name.lowercase() }, + ) { state.segmentAnimation = it } + + ChoiceRow( + label = "When the bar first appears", + options = EntryAnimation.entries, + selected = state.entryAnimation, + name = { it.name.lowercase() }, + ) { state.entryAnimation = it } + + ChoiceRow( + label = "Always, while on screen", + options = RecurringAnimation.entries, + selected = state.recurring, + name = { it.name.lowercase() }, + ) { state.recurring = it } + + // Both sliders appear only while something is using them, which is what + // makes it obvious which duration belongs to which animation. Shown + // unconditionally, the transition slider looked broken whenever both + // transitions were set to none. + if (state.usesDuration) { + SliderRow( + label = "Tap and first-appear duration", + value = state.durationMs.toFloat(), + range = 0f..1000f, + steps = 19, + display = if (state.durationMs == 0) "instant" else "${state.durationMs}ms", + ) { state.durationMs = it.roundToInt() } + } + + if (state.recurring != RecurringAnimation.NONE) { + SliderRow( + label = "One cycle of the recurring animation", + value = state.recurringMs.toFloat(), + range = 400f..3000f, + steps = 12, + display = "${state.recurringMs}ms", + ) { state.recurringMs = it.roundToInt() } + } + } +} + +private fun ShadowTarget.label() = when (this) { + ShadowTarget.ON_SEGMENTS -> "on segments" + ShadowTarget.OFF_SEGMENTS -> "off segments" + ShadowTarget.ALL -> "all segments" +} + +private fun CornerMode.label() = when (this) { + CornerMode.BAR_ENDS -> "bar ends" + CornerMode.EACH_SEGMENT -> "each segment" + CornerMode.EACH_RUN -> "each run" +} + +// endregion + +// region building blocks + +@Composable +private fun labelStyle() = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, +) + +@Composable +private fun PlaygroundCard( + title: String, + subtitle: String? = null, + content: @Composable () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(18.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + ) + if (subtitle != null) { + Spacer(Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(14.dp)) + content() + } + } +} + +@Composable +private fun SliderRow( + label: String, + value: Float, + range: ClosedFloatingPointRange, + steps: Int, + display: String, + onChange: (Float) -> Unit, +) { + Column(Modifier.padding(bottom = 6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = labelStyle(), modifier = Modifier.weight(1f)) + Text( + text = display, + style = MaterialTheme.typography.labelLarge.copy(fontFamily = FontFamily.Monospace), + ) + } + Slider( + value = value.coerceIn(range), + onValueChange = onChange, + valueRange = range, + steps = steps, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = labelStyle(), modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} + +@Composable +private fun ChoiceRow( + label: String, + options: List, + selected: T, + name: (T) -> String, + onChange: (T) -> Unit, +) { + Column(Modifier.padding(vertical = 6.dp)) { + Text(label, style = labelStyle()) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + Chip(label = name(option), selected = option == selected) { onChange(option) } + } + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit) { + val background by animateColorAsState( + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + label = "chip-bg", + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +// endregion diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to app/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index e116315..c70796c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,57 +1,379 @@ -