- Displays a duration in a circular format.
- The duration can be in seconds, minutes or hours.
- The progress indicators can be animated or not.
- The view is divided into three parts: hours, minutes and seconds.
- For each part, a progress indicator is displayed to show the progress of that part of the duration.
- A text view is displayed at the center of the view to show the duration in a readable format.
Attribute / Property | Type | Description | Default Value | in XML |
---|---|---|---|---|
indicatorSize | dimension | The size of the progress indicators. | undefined | yes |
indicatorsGapSize | dimension | The gap size between the progress indicators. | 5dp | yes |
indicatorsColor | color | The color of the progress indicators. | colorPrimary | yes |
indicatorsTrackColor | color | The color of the track of the progress indicators. | colorPrimary | yes |
indicatorsTrackGapSize | dimension | The gap size between the track of the progress indicators. | 10dp | yes |
indicatorsTrackThickness | dimension | The thickness of the track of the progress indicators. | 15dp | yes |
indicatorsTrackCornerRadius | dimension | The corner radius of the track of the progress indicators. | 10dp | yes |
staggeredInfiniteAnimationDelay | integer | The delay between the animations of the progress indicators when the duration is infinite | 50ms | yes |
animated | boolean | Whether the progress indicators are animated or not. | true | yes |
hoursIndicatorMax | integer | The maximum value of the hours indicator. | 24 | yes |
hoursIndicatorProgress | integer | The progress of the hours indicator. | 0 | yes |
hoursIndicatorColor | color | The color of the hours indicator. | colorPrimary | yes |
hoursIndicatorTrackColor | color | The color of the track of the hours indicator. | colorPrimary | yes |
hoursIndicatorTrackGapSize | dimension | The gap size between the track of the hours indicator. | undefined | yes |
hoursIndicatorTrackThickness | dimension | The thickness of the track of the hours indicator. | undefined | yes |
hoursIndicatorTrackCornerRadius | dimension | The corner radius of the track of the hours indicator. | undefined | yes |
minutesIndicatorProgress | integer | The progress of the minutes indicator. | 0 | yes |
minutesIndicatorColor | color | The color of the minutes indicator. | colorPrimary | yes |
minutesIndicatorTrackColor | color | The color of the track of the minutes indicator. | colorPrimary | yes |
minutesIndicatorTrackGapSize | dimension | The gap size between the track of the minutes indicator. | undefined | yes |
minutesIndicatorTrackThickness | dimension | The thickness of the track of the minutes indicator. | undefined | yes |
minutesIndicatorTrackCornerRadius | dimension | The corner radius of the track of the minutes indicator. | undefined | yes |
secondsIndicatorProgress | integer | The progress of the seconds indicator. | 0 | yes |
secondsIndicatorColor | color | The color of the seconds indicator. | colorPrimary | yes |
secondsIndicatorTrackColor | color | The color of the track of the seconds indicator. | colorPrimary | yes |
secondsIndicatorTrackGapSize | dimension | The gap size between the track of the seconds indicator. | undefined | yes |
secondsIndicatorTrackThickness | dimension | The thickness of the track of the seconds indicator. | undefined | yes |
secondsIndicatorTrackCornerRadius | dimension | The corner radius of the track of the seconds indicator. | undefined | yes |
text | string | The text to be displayed at the center of the view. | undefined | yes |
progress | Duration | The progress of the duration. | hoursProgress + minutesProgress + secondsProgress | no |
textColor | color | The color of the text. | colorPrimary | yes |
textStyle | flags | The style of the text. | normal | yes |
textAlign | enum | The alignment of the text. | center | yes |
textPadding | dimension | The padding of the text. | 0dp | yes |
textFontFamily | reference | The font family of the text. | undefined | yes |
showSubText | boolean | Whether to show the sub text or not. This shows the sub-second value of the duration | false | yes |
subTextPadding | dimension | The padding of the sub text. | 5dp | yes |
Add the following dependency to your module build.gradle
file:
dependencies {
implementation 'com.github.abdalmoniem:CircularDurationView:1.1.1'
}
dependencies {
implementation("com.github.abdalmoniem:CircularDurationView:1.1.1")
}
Add the following dependency to your module libs.versions.toml
file:
[versions]
circulardurationview = "1.1.1"
[libraries]
circulardurationview = { module = "com.github.abdalmoniem:CircularDurationView", version.ref = "circulardurationview" }
Add the following dependency to your module build.gradle.kts
file:
dependencies {
implementation(libs.circulardurationview)
}
<com.hifnawy.circulardurationview.CircularDurationView
android:id="@+id/progressIndicator"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:padding="20dp"
app:hoursIndicatorColor="?colorPrimary"
app:hoursIndicatorMax="24"
app:hoursIndicatorProgress="7"
app:hoursIndicatorTrackColor="?colorPrimaryContainer"
app:indicatorSize="350dp"
app:indicatorsGapSize="5dp"
app:indicatorsTrackCornerRadius="30dp"
app:indicatorsTrackGapSize="10dp"
app:indicatorsTrackThickness="10dp"
app:layout_constraintBottom_toTopOf="@+id/settingsCardTitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_weight="2"
app:minutesIndicatorColor="?colorSecondary"
app:minutesIndicatorProgress="30"
app:minutesIndicatorTrackColor="?colorSecondaryContainer"
app:secondsIndicatorColor="?colorTertiary"
app:secondsIndicatorProgress="40"
app:secondsIndicatorTrackColor="?colorTertiaryContainer"
app:textAlign="center"
app:textColor="?colorPrimary"
app:textFontFamily="@font/manrope"
app:textPadding="5dp"
app:textStyle="normal" />
val progressIndicator = findViewById<CircularDurationView>(R.id.progressIndicator)
progressIndicator.hoursIndicatorProgress = 7
progressIndicator.minutesIndicatorProgress = 30
progressIndicator.secondsIndicatorProgress = 40
progressIndicator.text = "00:00:00"
progressIndicator.progress = 100.minutes
...
When I was developing the library, there was a need to calculate the intersection of the
bounding box
of the subtext and the seconds indicator (which is a circle).
Here is what I did:
Breaking it down step by step:
private fun getTextBounds(textStr: String, textX: Float, textY: Float, textPaint: Paint): RectF = Rect().let { textBounds ->
textPaint.getTextBounds(textStr, 0, textStr.length, textBounds)
val left = textX - textBounds.width() * 0.5f
val top = textY + textBounds.top
val right = textX + textBounds.width() * 0.5f
val bottom = textY + textBounds.bottom
RectF(left, top, right, bottom)
}
get, since the seconds indicator is a circle and it's radius is already calculated based on the hours and minutes indicators.
val secondsIndicatorRadius = mSecondsIndicator.indicatorSize * 0.5f - mSecondsIndicatorTrackThickness
indicator, I can calculate the intersection of the two bounding boxes. By looping through all
the points of the circle and checking if they are inside the text bounds, I can get the
intersection of the bounding box
of the subtext and the bounding circle
of the seconds
indicator.
private fun isTextBoundsOutsideRadius(textBounds: RectF, circleX: Float, circleY: Float, radius: Float): Boolean {
// Loop through all possible angles and check if any cartesian point of the circle is inside the text bounds
val stepSize = 0.5f
generateSequence(0f) {
if (it + stepSize <= 360f) it + stepSize else null
}.forEach { angle ->
val radians = Math.toRadians(angle.toDouble())
val x = circleX + radius * cos(radians).toFloat()
val y = circleY + radius * sin(radians).toFloat()
if (x <= textBounds.right && x >= textBounds.left && y >= textBounds.top && y <= textBounds.bottom) return true
}
return false
}
Notice that the step size is
0.5f
, which means that I will loop through all the angles from0
to360
degrees, with a step size of0.5f
degrees. This will give me720
angles to compare with the text bounds. By comparing the cartesian coordinates of the circle with the text bounds, I can determine if the circle is inside the text bounds or outside the text bounds. Playing with the step size will determine how accurate the intersection is but at the cost of performance.
Notice the yellow
and magenta
points, these are the cartesian coordinates of the circle that
that lie between the top
and bottom
of the text bounds and the left
and right
of the
text bounds respectively. the red
points are the cartesian coordinates of the circle that lie
both between the top
and bottom
of the text bounds and the left
and right
of the text
bounds.
mSubTextPaint.textSize = mTextPaint.textSize
val nanos = mProgress.toComponents { _, _, _, nanoseconds -> nanoseconds / 1_000_000 }
val subText = String.format(Locale.ENGLISH, ".%03d", nanos % 1_000)
val subTextWidth = mSubTextPaint.measureText(subText)
val subTextHeight = mSubTextPaint.textHeight
var subTextX = textX + (textWidth * 0.5f) - (subTextWidth * 0.5f) - (mSecondsIndicatorTrackThickness * 0.5f) /* - mTextPadding */
val subTextY = textY - subTextHeight + mSubTextPadding
var subTextBounds = getTextBounds(subText, subTextX, subTextY, mSubTextPaint)
val secondsIndicatorRadius = mSecondsIndicator.indicatorSize * 0.5f - mSecondsIndicatorTrackThickness
while (isTextBoundsOutsideRadius(subTextBounds, textX, textY + textHeight * 0.5f, secondsIndicatorRadius)) {
mSubTextPaint.textSize -= 0.1f
subTextX -= 0.1f
subTextBounds = getTextBounds(subText, subTextX, subTextY, mSubTextPaint)
}
Since the subtext's size is the same as the main text to start, I need to make sure that the subtext is inside the radius of the seconds indicator. If the subtext is outside the radius, I decrease the size of the subtext and move it to the left until it is inside the radius.