|
| 1 | +[](https://twitter.com/sfyc23) |
| 2 | +[](https://weibo.com/sfyc23) |
| 3 | +[](https://android-arsenal.com/api?level=21) |
| 4 | +[](https://jitpack.io/#sfyc23/CountTimeProgressView) |
| 5 | +[](LICENSE) |
| 6 | + |
| 7 | +# CountTimeProgressView |
| 8 | + |
| 9 | +> [中文](README.md) | **English** |
| 10 | +
|
| 11 | +**CountTimeProgressView** is a Kotlin-based Android circular countdown progress view. Drop-in, zero runtime dependency, with first-class support for both classic **View (XML)** and **Jetpack Compose**. It ships a full state machine, lifecycle-aware pause/resume, warning threshold, and resume-from-progress out of the box. |
| 12 | + |
| 13 | +Minimum supported version: **Android 5.0 (API 21)**. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Preview |
| 18 | + |
| 19 | +<p align="center"> |
| 20 | + <img src="screenshot/menu.png" width="300" alt="Demo menu" /> |
| 21 | + |
| 22 | + <img src="screenshot/features.png" width="300" alt="Animation control & live state" /> |
| 23 | +</p> |
| 24 | + |
| 25 | +The demo app includes real-world scenarios: **Ad Skip / Verification Code / Progress Resume / Exam Timer**, each with `Java + XML`, `Kotlin + XML`, and `Jetpack Compose` implementations. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Features |
| 30 | + |
| 31 | +- **One-line integration** — classic View / Jetpack Compose, no AppCompat required |
| 32 | +- **Full state machine** — `IDLE` → `RUNNING` → `PAUSED` / `CANCELED` / `FINISHED`, all in a single listener |
| 33 | +- **Multiple text styles** — `jump`, `second`, `clock` (mm:ss), `none`, plus custom `textFormatter` |
| 34 | +- **Warning threshold** — auto-change color and fire a callback in the last N seconds (e.g. red in the final 3s) |
| 35 | +- **Per-second tick** — fires only on second change; perfect for OTP buttons and ad-skip buttons |
| 36 | +- **Resume anywhere** — start from `fromProgress` or `fromRemaining` for list recycling / server-synced countdowns |
| 37 | +- **Lifecycle-aware** — `bindLifecycle(owner)` auto pauses in background and resumes on foreground |
| 38 | +- **Rich visual customization** — gradient progress bar, `StrokeCap`, marker ball, custom interpolator |
| 39 | +- **Click delay** — block clicks for the first N seconds with `disabledText` (ideal for splash ads) |
| 40 | +- **Accessibility-friendly** — automatic `contentDescription`, `wrap_content` defaults to 84dp |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## Install |
| 45 | + |
| 46 | +### Step 1. Add the JitPack repository |
| 47 | + |
| 48 | +In your root `settings.gradle` (or top-level `build.gradle`): |
| 49 | + |
| 50 | +```groovy |
| 51 | +dependencyResolutionManagement { |
| 52 | + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) |
| 53 | + repositories { |
| 54 | + google() |
| 55 | + mavenCentral() |
| 56 | + maven { url 'https://jitpack.io' } |
| 57 | + } |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +### Step 2. Add the dependency |
| 62 | + |
| 63 | +In your app module `build.gradle`: |
| 64 | + |
| 65 | +```groovy |
| 66 | +dependencies { |
| 67 | + implementation 'com.github.sfyc23:CountTimeProgressView:2.1.0' |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +> Releases are published to JitPack automatically on every git tag. See the badge at the top for the latest version. |
| 72 | +
|
| 73 | +--- |
| 74 | + |
| 75 | +## Quick Start |
| 76 | + |
| 77 | +### Option 1. XML layout |
| 78 | + |
| 79 | +```xml |
| 80 | +<com.sfyc.ctpv.CountTimeProgressView |
| 81 | + android:id="@+id/countTimeProgressView" |
| 82 | + android:layout_width="84dp" |
| 83 | + android:layout_height="84dp" |
| 84 | + app:backgroundColorCenter="#FF7F00" |
| 85 | + app:borderWidth="3dp" |
| 86 | + app:borderBottomColor="#D60000" |
| 87 | + app:borderDrawColor="#CDC8EA" |
| 88 | + app:markBallColor="#002FFF" |
| 89 | + app:markBallFlag="true" |
| 90 | + app:markBallWidth="3dp" |
| 91 | + app:titleCenterColor="#000000" |
| 92 | + app:titleCenterText="Click to skip" |
| 93 | + app:titleCenterSize="14sp" |
| 94 | + app:countTime="5000" |
| 95 | + app:startAngle="0" |
| 96 | + app:textStyle="jump" |
| 97 | + app:clockwise="true" |
| 98 | + app:warningTime="3000" |
| 99 | + app:warningColor="#FF3B30" |
| 100 | + app:clickableAfter="2000" |
| 101 | + app:disabledText="Please wait" /> |
| 102 | +``` |
| 103 | + |
| 104 | +### Option 2. Kotlin |
| 105 | + |
| 106 | +```kotlin |
| 107 | +with(countTimeProgressView) { |
| 108 | + countTime = 6000L |
| 109 | + textStyle = CountTimeProgressView.TEXT_STYLE_SECOND |
| 110 | + titleCenterText = "Skip (%s)s" |
| 111 | + |
| 112 | + // v2.1 Turn red in the last 3 seconds |
| 113 | + warningTime = 3000L |
| 114 | + warningColor = Color.parseColor("#FF3B30") |
| 115 | + |
| 116 | + // v2.1 Not clickable for the first 2 seconds |
| 117 | + clickableAfterMillis = 2000L |
| 118 | + disabledText = "Please wait" |
| 119 | + |
| 120 | + setOnCountdownEndListener { |
| 121 | + Toast.makeText(context, "Time's up", Toast.LENGTH_SHORT).show() |
| 122 | + } |
| 123 | + |
| 124 | + setOnClickCallback { overageTime -> |
| 125 | + if (isRunning) cancelCountTimeAnimation() else startCountTimeAnimation() |
| 126 | + } |
| 127 | + |
| 128 | + // Unified state listener |
| 129 | + setOnStateChangedListener { state -> |
| 130 | + Log.d("CTPV", "state=$state") // IDLE / RUNNING / PAUSED / CANCELED / FINISHED |
| 131 | + } |
| 132 | + |
| 133 | + // Fires only on second change — great for OTP / ad-skip buttons |
| 134 | + setOnTickListener { remainingMillis, remainingSeconds -> |
| 135 | + Log.d("CTPV", "Remaining: ${remainingSeconds}s") |
| 136 | + } |
| 137 | + |
| 138 | + setOnWarningListener { remainingMillis -> |
| 139 | + Log.d("CTPV", "About to finish!") |
| 140 | + } |
| 141 | + |
| 142 | + // Fires on every animation frame |
| 143 | + addOnProgressChangedListener { progress, remainingMillis -> |
| 144 | + Log.d("CTPV", "progress=$progress, remaining=$remainingMillis") |
| 145 | + } |
| 146 | + |
| 147 | + // Auto pause / resume with lifecycle |
| 148 | + bindLifecycle(this@SimpleActivity) |
| 149 | + |
| 150 | + startCountTimeAnimation() |
| 151 | + // Or start from a specific progress / remaining time: |
| 152 | + // startCountTimeAnimation(fromProgress = 0.5f) |
| 153 | + // startCountTimeAnimationFromRemaining(3000L) |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +### Option 3. Java |
| 158 | + |
| 159 | +```java |
| 160 | +countTimeProgressView.setCountTime(5000L); |
| 161 | +countTimeProgressView.setTextStyle(CountTimeProgressView.TEXT_STYLE_CLOCK); |
| 162 | +countTimeProgressView.setWarningTime(3000L); |
| 163 | +countTimeProgressView.setWarningColor(Color.parseColor("#FF3B30")); |
| 164 | +countTimeProgressView.setClickableAfterMillis(2000L); |
| 165 | +countTimeProgressView.setDisabledText("Please wait"); |
| 166 | + |
| 167 | +countTimeProgressView.setOnCountdownEndListener(() -> |
| 168 | + Toast.makeText(this, "Time's up", Toast.LENGTH_SHORT).show()); |
| 169 | + |
| 170 | +countTimeProgressView.setOnStateChangedListener(state -> |
| 171 | + Log.d("CTPV", "state=" + state)); |
| 172 | + |
| 173 | +countTimeProgressView.setOnTickListener((remainingMillis, remainingSeconds) -> |
| 174 | + Log.d("CTPV", "Remaining: " + remainingSeconds + "s")); |
| 175 | + |
| 176 | +countTimeProgressView.bindLifecycle(this); |
| 177 | +countTimeProgressView.startCountTimeAnimation(); |
| 178 | +``` |
| 179 | + |
| 180 | +### Option 4. Jetpack Compose |
| 181 | + |
| 182 | +The library ships a lightweight Compose adapter `CountTimeProgressViewCompose` (no Compose runtime dependency required). Use it directly inside `AndroidView`: |
| 183 | + |
| 184 | +```kotlin |
| 185 | +AndroidView( |
| 186 | + modifier = Modifier.size(84.dp), |
| 187 | + factory = { ctx -> |
| 188 | + CountTimeProgressViewCompose.create(ctx) { |
| 189 | + countTime = 5000L |
| 190 | + textStyle = CountTimeProgressView.TEXT_STYLE_SECOND |
| 191 | + warningTime = 3000L |
| 192 | + warningColor = Color.RED |
| 193 | + setOnStateChangedListener { state -> /* ... */ } |
| 194 | + setOnTickListener { _, sec -> /* ... */ } |
| 195 | + startCountTimeAnimation() |
| 196 | + } |
| 197 | + }, |
| 198 | + update = { view -> |
| 199 | + CountTimeProgressViewCompose.update(view) { |
| 200 | + // React to Compose State changes here |
| 201 | + } |
| 202 | + } |
| 203 | +) |
| 204 | +``` |
| 205 | + |
| 206 | +--- |
| 207 | + |
| 208 | +## Typical Use Cases |
| 209 | + |
| 210 | +| Scenario | Recommended Setup | |
| 211 | +| :--- | :--- | |
| 212 | +| **Splash ad skip button** | `textStyle=jump` + `clickableAfterMillis` + `disabledText` to prevent mis-taps | |
| 213 | +| **OTP / Verification code** | `textStyle=second` + `setOnTickListener` (only on second change) | |
| 214 | +| **Server-synced countdown** | `startCountTimeAnimationFromRemaining(remainMs)` | |
| 215 | +| **List item countdown** | `startCountTimeAnimation(fromProgress = 0.7f)` + `bindLifecycle` | |
| 216 | +| **Exam / long timer** | `textStyle=clock` (mm:ss) + `warningTime` to turn red near the end | |
| 217 | +| **Jetpack Compose screens** | `AndroidView` + `CountTimeProgressViewCompose.create` | |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## State Machine |
| 222 | + |
| 223 | +``` |
| 224 | + startCountTimeAnimation() |
| 225 | + IDLE ─────────────────────────────▶ RUNNING |
| 226 | + ▲ │ ▲ │ |
| 227 | + │ resetCountTimeAnimation() │ │ │ pause / resume |
| 228 | + │ │ │ ▼ |
| 229 | + └─────────── CANCELED / FINISHED ─── PAUSED |
| 230 | +``` |
| 231 | + |
| 232 | +- `IDLE` — initial / after reset |
| 233 | +- `RUNNING` — ticking |
| 234 | +- `PAUSED` — manual or lifecycle-driven pause |
| 235 | +- `CANCELED` — stopped via `cancelCountTimeAnimation()` |
| 236 | +- `FINISHED` — naturally ended; `OnCountdownEndListener` fires here |
| 237 | + |
| 238 | +Use `setOnStateChangedListener { state -> ... }` to observe every transition in one place. |
| 239 | + |
| 240 | +--- |
| 241 | + |
| 242 | +## Full XML Attributes |
| 243 | + |
| 244 | +| Attribute | Format | Description | Default | |
| 245 | +| :--- | :--- | :--- | :--- | |
| 246 | +| `backgroundColorCenter` | color | Center background color | `#00BCD4` | |
| 247 | +| `borderWidth` | dimension | Ring stroke width | `3dp` | |
| 248 | +| `borderDrawColor` | color | Progress color | `#4dd0e1` | |
| 249 | +| `borderBottomColor` | color | Track color | `#D32F2F` | |
| 250 | +| `markBallWidth` | dimension | Marker ball width | `6dp` | |
| 251 | +| `markBallColor` | color | Marker ball color | `#536DFE` | |
| 252 | +| `markBallFlag` | boolean | Show marker ball | `true` | |
| 253 | +| `startAngle` | float | Start angle (0 = top) | `0f` | |
| 254 | +| `clockwise` | boolean | Clockwise or counter-clockwise | `true` | |
| 255 | +| `countTime` | integer | Total time in milliseconds | `5000` | |
| 256 | +| `textStyle` | enum | `jump` / `second` / `clock` / `none` | `jump` | |
| 257 | +| `titleCenterText` | string | Center text (for `jump`) | `"jump"` | |
| 258 | +| `titleCenterColor` | color | Center text color | `#FFFFFF` | |
| 259 | +| `titleCenterSize` | dimension | Center text size | `16sp` | |
| 260 | +| `autoStart` | boolean | Auto-start on attach | `false` | |
| 261 | +| `finishedText` | string | Text shown when finished | `-` | |
| 262 | +| `showCenterText` | boolean | Whether to show center text | `true` | |
| 263 | +| `strokeCap` | enum | `butt` / `round` / `square` | `butt` | |
| 264 | +| `gradientStartColor` | color | Gradient start color | `-` | |
| 265 | +| `gradientEndColor` | color | Gradient end color | `-` | |
| 266 | +| `warningTime` | integer | Warning threshold in ms (fires when remaining ≤ value) | `0` | |
| 267 | +| `warningColor` | color | Progress color in warning state | `#FF3B30` | |
| 268 | +| `clickableAfter` | integer | Ms after start before clicks are accepted | `0` | |
| 269 | +| `disabledText` | string | Text shown during non-clickable period | `-` | |
| 270 | + |
| 271 | +> **Unit note**: `borderWidth` and `markBallWidth` setters take a **dp** value and store pixels internally. `titleCenterTextSize` takes an **sp** value. For raw pixel values, use `setBorderWidthPx()`, `setMarkBallWidthPx()`, `setTitleCenterTextSizePx()`. |
| 272 | +
|
| 273 | +--- |
| 274 | + |
| 275 | +## API Cheatsheet |
| 276 | + |
| 277 | +| Method | Description | |
| 278 | +| :--- | :--- | |
| 279 | +| `startCountTimeAnimation()` | Start from the beginning | |
| 280 | +| `startCountTimeAnimation(fromProgress: Float)` | Start from a `0f..1f` progress | |
| 281 | +| `startCountTimeAnimationFromRemaining(millis: Long)` | Start with a given remaining time | |
| 282 | +| `pauseCountTimeAnimation()` / `resumeCountTimeAnimation()` | Pause / resume | |
| 283 | +| `cancelCountTimeAnimation()` | Cancel (enters `CANCELED`) | |
| 284 | +| `resetCountTimeAnimation()` | Reset back to `IDLE` | |
| 285 | +| `bindLifecycle(owner)` | Bind lifecycle, auto pause/resume | |
| 286 | +| `setOnCountdownEndListener { }` | Fired when countdown finishes naturally | |
| 287 | +| `setOnStateChangedListener { state -> }` | Unified state listener | |
| 288 | +| `setOnTickListener { ms, sec -> }` | Fires only on second change | |
| 289 | +| `setOnWarningListener { ms -> }` | Fires when entering warning threshold | |
| 290 | +| `setOnClickCallback { overage -> }` | View clicked, with remaining time | |
| 291 | +| `addOnProgressChangedListener { p, ms -> }` | Per-frame progress callback | |
| 292 | +| `setGradientColors(start, end)` | Set gradient progress color | |
| 293 | +| `textFormatter = { millis -> "..." }` | Custom center text formatter | |
| 294 | + |
| 295 | +--- |
| 296 | + |
| 297 | +## Changelog |
| 298 | + |
| 299 | +### 2.1.0 (2026-04-16) |
| 300 | + |
| 301 | +- Added `CountdownState` state machine (IDLE / RUNNING / PAUSED / CANCELED / FINISHED) with `setOnStateChangedListener` |
| 302 | +- Added per-second tick callback `setOnTickListener` (fires only on second change) |
| 303 | +- Added warning threshold `warningTime` / `warningColor` / `setOnWarningListener` (XML supported) |
| 304 | +- Added resume-from-progress: `startCountTimeAnimation(fromProgress)` and `startCountTimeAnimationFromRemaining(millis)` |
| 305 | +- Added click-delay `clickableAfterMillis` / `disabledText` (XML supported) |
| 306 | +- Fixed `wasRunning` restore: animation auto-resumes from saved progress after rotation |
| 307 | +- Added lifecycle-aware auto pause/resume via `bindLifecycle(owner)` |
| 308 | +- Removed unused `displayText` dead code |
| 309 | + |
| 310 | +### 2.0.0 (2026-04-15) |
| 311 | + |
| 312 | +- Added **Pause / Resume / Reset** animation APIs |
| 313 | +- Added `progress` getter/setter and per-frame progress callback |
| 314 | +- Added **gradient progress bar** (SweepGradient, XML supported) |
| 315 | +- Added **StrokeCap**, **Interpolator**, **autoStart**, **finishedText**, **showCenterText** |
| 316 | +- Added **Jetpack Compose adapter** `CountTimeProgressViewCompose` |
| 317 | +- Added **SavedState** and **accessibility** support |
| 318 | +- Added `wrap_content` support with 84dp default |
| 319 | +- Split callbacks into `OnCountdownEndListener` + `setOnClickCallback()` (lambda-friendly) |
| 320 | +- Migrated to AndroidX; `minSdk` raised to 21 |
| 321 | +- Library module no longer depends on AppCompat |
| 322 | +- See full notes in [CHANGELOG.md](CHANGELOG.md) |
| 323 | + |
| 324 | +### Older Versions |
| 325 | + |
| 326 | +- **1.1.3 (2017-11-11)** — Rewritten in Kotlin |
| 327 | +- **1.1.1 (2017-03-27)** — Fix: stop running on exit |
| 328 | +- **1.1.0 (2017-02-07)** — Clockwise animation support |
| 329 | +- **1.0.0 (2016-12-20)** — First release |
| 330 | + |
| 331 | +--- |
| 332 | + |
| 333 | +## Contributing |
| 334 | + |
| 335 | +Issues and PRs are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting. |
| 336 | + |
| 337 | +## License |
| 338 | + |
| 339 | +Licensed under the [Apache License 2.0](LICENSE). |
0 commit comments