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..33ace56 --- /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 261 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/Image.png b/Image.png deleted file mode 100644 index 3eb60fc..0000000 Binary files a/Image.png and /dev/null differ 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..26c9aa1 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,895 @@ # 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, so the two +render identically. -Download the Demo app on Google Play Store: +![Three segmented progress bars: a pill run with a drop shadow, a twenty segment +bar, and a green bar with a slim off track](docs/images/hero.png) -Get it on Google Play +Every image in this README is rendered by the library itself, from +[`DocsScreenshotTest`](app/src/test/java/com/rachitgoyal/segmentedprogressbar/demo/DocsScreenshotTest.kt), +so none of them can drift away from what the code actually draws. -

Installation

+--- -

Gradle

+## Contents -Add JitPack in your root build.gradle at the end of repositories: +- [Install](#install) +- [How to use](#how-to-use) + - [XML](#xml) + - [Kotlin](#kotlin) + - [Java](#java) + - [Jetpack Compose](#jetpack-compose) + - [Letting users tap segments](#letting-users-tap-segments) +- [Configuration in detail](#configuration-in-detail) + - [Segments and selection](#segments-and-selection) + - [Colours](#colours) + - [Space between segments](#space-between-segments) + - [Rounded edges](#rounded-edges) + - [Segment heights](#segment-heights) + - [Drop shadow](#drop-shadow) + - [Size and maximums](#size-and-maximums) + - [Animation](#animation) + - [Right to left](#right-to-left) +- [XML attributes](#xml-attributes) +- [API reference](#api-reference) +- [How it renders](#how-it-renders) +- [Accessibility](#accessibility) +- [Requirements](#requirements) +- [Upgrading from 0.0.1](#upgrading-from-001) +- [Building from source](#building-from-source) +- [Contributing](#contributing) +- [License](#license) -``` -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. The Compose artifact depends on the View one for the shared +geometry and option types, so taking both never gives you two copies of anything. + +> [!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). -Add a SegmentedProgressBar to your XML layout +--- + +## How to use + +The same bar, four ways. Each of these produces the image below: + +![Ten segments with the second, third, sixth, seventh and tenth turned +on](docs/images/basic.png) + +### 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="26dp" + app:cornerRadius="4dp" + app:dividerColor="@android:color/transparent" + app:dividerWidth="3dp" + app:divisions="10" + app:progressBarBackgroundColor="#E4E7EB" + app:progressBarColor="#2F6FED" /> +``` + +Nothing else is required. Add `app:spb_tapToToggle="true"` and the bar is +interactive with no code at all. + +### Kotlin + +```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() +``` + +Building the whole thing in code, with no layout file: + +```kotlin +val density = resources.displayMetrics.density + +val bar = SegmentedProgressBar(context).apply { + divisions = 10 + enabledDivisions = listOf(1, 2, 5, 6, 9) + progressBarColor = Color.parseColor("#2F6FED") + progressBarBackgroundColor = Color.parseColor("#E4E7EB") + dividerColor = Color.TRANSPARENT + dividerWidth = 3 * density + cornerRadius = 4 * density +} +container.addView(bar, ViewGroup.LayoutParams.MATCH_PARENT, (26 * density).toInt()) +``` + +### Java + +The library is Kotlin, with a Java-first surface: `@JvmOverloads` constructors, +getters and setters for every property, and a functional interface for the click +listener, so none of it needs Kotlin syntax to use. ```java +SegmentedProgressBar bar = findViewById(R.id.progress); + +bar.setDivisions(10); +bar.setEnabledDivisions(Arrays.asList(1, 2, 5, 6, 9)); +bar.toggleDivision(4); + +bar.setProgressBarColor(Color.parseColor("#2F6FED")); +bar.setProgressBarBackgroundColor(Color.parseColor("#E4E7EB")); +bar.setDividerWidth(3 * getResources().getDisplayMetrics().density); +bar.setCornerMode(CornerMode.EACH_RUN); + +bar.setTapToToggleEnabled(true); +bar.setOnDivisionClickListener((view, index) -> + label.setText(view.getCompletedSegmentCount() + " of " + view.getDivisions())); + +bar.reset(); +``` + +### Jetpack Compose + +A real Composable, not an `AndroidView` wrapper: it draws with Compose's own +canvas, so it composes with modifiers, previews, themes and animation the way you +would expect. + +```kotlin +var on by remember { mutableStateOf(setOf(1, 2, 5, 6, 9)) } + +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + modifier = Modifier.fillMaxWidth().height(26.dp), + onColor = Color(0xFF2F6FED), + offColor = Color(0xFFE4E7EB), + gap = 3.dp, + cornerRadius = 4.dp, + onSegmentClick = { index -> on = if (index in on) on - index else on + index }, +) +``` + +Everything the View supports is a parameter here, using the same option enums +(`CornerMode`, `SegmentAnimation`, `EntryAnimation`, `RecurringAnimation`, +`ShadowTarget`) imported from the base artifact. The names differ in a few places, +because Compose conventions differ: + +| | View | Compose | +|---|---|---| +| Segment set | `enabledDivisions: List`, sorted and de-duplicated for you | `enabledSegments: Set`, already unordered | +| Space between segments | `dividerWidth` and `dividerColor`, defaulting to a 1px white line | `gap` and `gapColor`, defaulting to a real 2dp gap | +| Heights | `activeHeightRatio`, `inactiveHeightRatio` | `activeHeightFraction`, `inactiveHeightFraction` | +| Durations | `animationDurationMs: Long` | `animationDurationMillis: Int` | +| Sizing | `layout_height`, `spb_maxWidth` | `Modifier` | +| Drop shadow | `shadowRadius` and friends, which force a software layer and confine the blur to the view | a `SegmentShadow`, hardware accelerated, free to overflow the composable | +| Interaction | `spb_tapToToggle`, or a listener, or both | an `onSegmentClick` handler, since the lit set lives outside the bar | + +### 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. + +--- + +## Configuration in detail + +Every option, with what it looks like. The demo app's **Playground** tab drives all +of them from live controls if you would rather dial in a look by hand. + +### Segments and selection + +`divisions` is how many equal segments there are; `enabledDivisions` is which of +them are on. Any subset, in any order. + +| Four segments | Twenty-four segments | +|---|---| +| ![Four segments, the first and third on](docs/images/divisions-few.png) | ![Twenty-four narrow segments in a repeating pattern](docs/images/divisions-many.png) | + +| Everything on | Nothing on | +|---|---| +| ![All ten segments on](docs/images/state-all.png) | ![All ten segments off](docs/images/state-none.png) | + +```xml +app:divisions="10" +``` + +```kotlin +bar.divisions = 10 +bar.enabledDivisions = listOf(1, 2, 5, 6, 9) +bar.completedSegmentCount // 5 +``` + +```kotlin +SegmentedProgressBar(divisions = 10, enabledSegments = setOf(1, 2, 5, 6, 9)) +``` + +Out-of-range indices are kept rather than discarded, so the order in which you set +`divisions` and `enabledDivisions` does not matter: + +```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 +``` + +### Colours + +Two colours: on and off. There is nothing special about the defaults. + +![A green bar with a pale green off track](docs/images/colours.png) + +```xml +app:progressBarColor="#12A150" +app:progressBarBackgroundColor="#D7F0E0" +``` + +```kotlin +bar.progressBarColor = Color.parseColor("#12A150") +bar.progressBarBackgroundColor = Color.parseColor("#D7F0E0") +``` + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + onColor = Color(0xFF12A150), + offColor = Color(0xFFD7F0E0), +) +``` + +> [!NOTE] +> `progressBarBackgroundColor` is the colour of the **off segments**, not the +> view's background. `setBackgroundColor` is deprecated here for exactly that +> reason: in 0.0.1 it painted the track, which shadowed `View.setBackgroundColor` +> with a different meaning. + +### Space between segments + +One number, `dividerWidth`, and what it looks like depends on `dividerColor`. + +| A real gap, the page showing through | A painted divider line | +|---|---| +| ![Ten segments separated by wide gaps](docs/images/gap-wide.png) | ![A continuous bar cut by dark vertical lines](docs/images/divider-line.png) | + +| No gap at all | +|---| +| ![Ten segments running flush into each other](docs/images/gap-none.png) | + +```xml +app:dividerWidth="8dp" +app:dividerColor="@android:color/transparent" +app:isDividerEnabled="true" +``` + +```kotlin +bar.dividerWidth = 8 * density +bar.dividerColor = Color.TRANSPARENT // a real gap +bar.dividerColor = Color.WHITE // a painted divider line +bar.isDividerEnabled = false // segments run flush into each other +``` + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + gap = 8.dp, + gapColor = Color.Transparent, // or a colour, to paint the gap +) +``` + +The bar is drawn cell by cell rather than as one continuous strip, which is what +makes a transparent divider a genuine gap rather than a window onto the track. An +over-wide value is clamped to one segment, so segments can never collapse. + +### Rounded edges + +`cornerRadius` sets the radius, and `cornerMode` decides which edges it applies to. +Set the radius to half the height for a pill. + +| Mode | Effect | | +|---|---|---| +| `BAR_ENDS` | The default. Only the outer ends of the whole bar; interior edges stay square. | ![One continuous bar with rounded outer ends](docs/images/corners-bar-ends.png) | +| `EACH_SEGMENT` | All four corners of every segment, on or off, so each reads as its own pill. | ![Ten separately rounded segments](docs/images/corners-each-segment.png) | +| `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. | ![Runs of on segments forming single pills](docs/images/corners-each-run.png) | + +```xml +app:cornerRadius="13dp" +app:spb_cornerMode="eachRun" +``` + +```kotlin +bar.cornerRadius = 13 * density +bar.cornerMode = CornerMode.EACH_RUN +``` + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + cornerRadius = 13.dp, + cornerMode = CornerMode.EACH_RUN, +) +``` + +`EACH_RUN` plus a transparent gap is the combination worth trying: a sparse +selection renders as a series of pills whose shapes are defined by the gaps. The +radius is always clamped to half the smaller dimension, so an over-large value +gives a pill rather than an artefact. + +### Segment heights + +On and off segments have independent heights, as a fraction of the bar. Both +default to the full height, so this costs nothing until you ask for it, and both +bands stay centred on the same axis. + +![Full-height pills over a slim off track](docs/images/heights.png) + +```xml +app:spb_activeHeightRatio="1.0" +app:spb_inactiveHeightRatio="0.45" +``` + +```kotlin +bar.activeHeightRatio = 1f +bar.inactiveHeightRatio = 0.45f +``` + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + activeHeightFraction = 1f, + inactiveHeightFraction = 0.45f, +) +``` + +Either can be the smaller of the two, so on segments can read as raised or as an +inset fill inside a channel. Gaps always span the taller band, so neighbouring +segments stay visually separated. - 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. +> [!TIP] +> Pair a large difference with `CornerMode.EACH_RUN`, as above. On its own, a very +> small ratio turns the on segments into disconnected blocks floating over a thin +> rail, which reads as a rendering bug rather than a design. + +### Drop shadow + +| Cast by every segment | Cast by the on segments only | +|---|---| +| ![The whole bar casting one soft shadow](docs/images/shadow.png) | ![Only the lit runs casting a shadow](docs/images/shadow-on-segments.png) | + +```xml +app:spb_shadowRadius="5dp" +app:spb_shadowDy="3dp" +app:spb_shadowColor="#40000000" +app:spb_shadowTarget="all" +android:padding="12dp" +``` + +```kotlin +bar.shadowRadius = 5 * density +bar.shadowDy = 3 * density +bar.shadowColor = 0x40000000 +bar.shadowTarget = ShadowTarget.ALL // or ON_SEGMENTS, or OFF_SEGMENTS +``` + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + shadow = SegmentShadow(radius = 5.dp, dy = 3.dp, target = ShadowTarget.ALL), +) ``` -Now within the code, you can anytime change the value of the enabled/disabled divisions by simply making a call to `spb.setEnabledDivisions(enabledList)`. +**A shadow never changes the bar.** It is drawn outside the bar, and enabling one +or changing its blur or offset cannot move or resize anything. The trade-off is +that it needs somewhere to go: **give the view padding**, as in the XML above, or +the blur has nowhere to land. `android:clipChildren="false"` on the parent does not +help, because the 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` chooses which segments contribute. Because no shadow is ever drawn +inside the bar, `ON_SEGMENTS` shows up along the outside of each lit run, as in the +second image, rather than as a shadow cast onto the track. + +One 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. + +### Size and maximums + +Standard layout params work as on any view, plus a maximum, which Android does not +otherwise give a plain `View`: + +```xml +android:layout_width="match_parent" +android:layout_height="26dp" +app:spb_maxWidth="420dp" +``` + +```kotlin +bar.maxWidth = (420 * density).toInt() // do not stretch across a tablet +bar.maxHeight = SegmentedProgressBar.NO_MAX_SIZE +``` + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + modifier = Modifier.widthIn(max = 420.dp).height(26.dp), +) +``` + +A minimum wins over a smaller maximum, matching how the framework treats minimums +as the harder constraint. `wrap_content` falls back to an intrinsic 144dp x 8dp +rather than collapsing to nothing. + +### Animation + +Three independent axes, all off by default so upgrading from 0.0.1 changes +nothing. Static images cannot show these; the demo app's Playground tab can. + +**When a segment is toggled**, once the bar is already on screen: + +```xml +app:spb_segmentAnimation="fade" +app:spb_animationDuration="320" +``` + +```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**: + +```xml +app:spb_entryAnimation="stagger" +app:spb_entryStaggerDelay="60" +``` + +```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**: + +```xml +app:spb_recurringAnimation="shimmer" +app:spb_recurringDuration="1600" +app:spb_shimmerColor="#73FFFFFF" +``` + +```kotlin +bar.recurringAnimation = RecurringAnimation.SHIMMER // or PULSE +bar.recurringDurationMs = 1600 +bar.shimmerColor = 0x73FFFFFF +``` + +In Compose, all three at once: + +```kotlin +SegmentedProgressBar( + divisions = 10, + enabledSegments = on, + segmentAnimation = SegmentAnimation.FADE, + entryAnimation = EntryAnimation.STAGGER, + recurringAnimation = RecurringAnimation.SHIMMER, + animationDurationMillis = 320, + entryStaggerDelayMillis = 60, + recurringDurationMillis = 1600, +) +``` + +`SHIMMER` sweeps a highlight across the on segments; `PULSE` breathes them in and +out together. The loop stops on its own while the view is detached or hidden and +resumes when it comes back, so it costs nothing off-screen. + +Three deliberate behaviours: + +- **The initial state never animates unless you ask.** With `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. + +### Right to left + +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. + +![The same selection mirrored, starting from the right](docs/images/rtl.png) + +```xml +android:layoutDirection="rtl" +``` + +Compose follows `LocalLayoutDirection`, so it mirrors with the rest of your UI and +needs nothing set on the bar itself. + +> [!IMPORTANT] +> Android gates RTL resolution for *every* view on the application declaring +> `android:supportsRtl="true"` in its manifest. Without it the platform never +> resolves a right-to-left layout direction and this view will draw +> left-to-right no matter what you set. + +--- -

Contribution

+## XML attributes -Any kind of contribution will be appreciated; feel free to create a pull request or file issues on the issue tracker. +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. + +### 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`. + +--- + +## 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. + +The Compose renderer calls the same geometry functions in the same order, from the +same `SegmentGeometry` in the base artifact, which is what keeps the two artifacts +pixel-identical and covered by one set of geometry tests. + +--- + +## 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: + +- **The coordinate is `com.github.rayzone107:segmentedprogressbar`.** The 0.0.1 + README told people to depend on `com.github.rayzone107:durationview`, which is a + different library of mine entirely. +- **`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 # 261 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. + +Regenerating this README's images after a rendering change: + +```bash +./gradlew :app:testDebugUnitTest --tests '*DocsScreenshotTest*' -Pdocs +``` + +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..e14c065 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,103 @@ +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) +} + +// Regenerating the README's images is opt-in: +// +// ./gradlew :app:testDebugUnitTest --tests '*DocsScreenshotTest*' -Pdocs +// +// Without -Pdocs the generator still runs as an ordinary test, asserting that +// every documented configuration renders, but writes nothing. Keeping the write +// behind a flag means a normal test run never touches tracked files. +tasks.withType().configureEach { + if (project.hasProperty("docs")) { + systemProperty( + "spb.docs.dir", + rootProject.layout.projectDirectory.dir("docs/images").asFile.absolutePath, + ) + } +} 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 @@ -