Custom TimePicker in Android

Jamshid Sobirov
6 min readMar 14, 2024

Wassup guys!

Recently, in one of my projects I had to use the Material’s TimePicker in Fragment (not a dialog), but its design didn’t match with one I was given.

Here’s the design I was given:

Here’s the Material’s TimePicker:

As you see the difference is huge. As you see,

I had to remove this keyboard:

I had to apply shapes to these:

The most annoying one is that since the TimePicker is inside the ScrollView, when I want to interact with it, the ScrollView interferes what I am doing:

So I couldn’t succeed!

But I had come up with a different solution, which is to make my own CustomTimePicker view. Of course, ChatGPT was my wonderful helper! I am not going to explain everything here, I will just share the code, and show my final result! I will use Kotlin. Let’s go!

Following is the CustomTimePicker class:

package com.jamesmobiledev.utils.view

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.mytrkr.driver.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

class CustomTimePicker @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

var hour = 12 // Default hour
var minute = 0 // Default minute
var clockMode = ClockMode.HOUR
set(value) {
field = value
invalidate()
}
var timeMode = TimeMode.AM
set(value) {
field = value
invalidate()
}

private var timeChangedListener: TimeChangeListener? = null

private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = context.getColor(R.color.color_calendar_bk)
}
private val numberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
textSize = resources.getDimensionPixelSize(R.dimen.top_spacing).toFloat()
color = context.getColor(R.color.tab_text_color)
}

private val dotAndHandPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = context.getColor(R.color.item_route_orange)
strokeWidth = 5f
}
private val blackDot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.BLACK
}

private val scope = CoroutineScope(Dispatchers.Main + Job())

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerX = width / 2f
val centerY = height / 2f
val radius = minOf(width, height) / 2 * 0.8f

// Draw the white circle for the clock face
canvas.drawCircle(centerX, centerY, radius, paint)

val itemCount = if (clockMode == ClockMode.HOUR) 12 else 60

// Draw all the highlight circles
for (i in 1..itemCount) {
val angle = Math.toRadians((i * 360.0 / itemCount) - 90)
val x = (centerX + cos(angle) * radius * 0.8).toFloat()
val y = (centerY + sin(angle) * radius * 0.8).toFloat()

if (clockMode == ClockMode.HOUR && i == hour || clockMode == ClockMode.MINUTE && (i == minute || minute == 0 && i == 60)) {
canvas.drawCircle(x, y, 55f, dotAndHandPaint) // Highlight selected hour/minute
if (clockMode == ClockMode.MINUTE && i % 5 != 0) canvas.drawCircle(
x, y, 5f, blackDot
)
}
}

// Draw all the text
for (i in 1..itemCount) {
val angle = Math.toRadians((i * 360.0 / itemCount) - 90)
val x = (centerX + cos(angle) * radius * 0.8).toFloat()
val y = (centerY + sin(angle) * radius * 0.8).toFloat()

val displayText = if (clockMode == ClockMode.HOUR) i.toString()
else if (i == 60) "0" // when minute is 60, set 0
else if (i % 5 == 0) i.toString() // display only 5, 10, 15 .. 55
else "" // not display smaller minutes such as 1, 2, 3, 4, 6, 7, 8, 9 etc.

if (displayText.isNotBlank()) {
canvas.drawText(displayText, x, y + numberPaint.textSize / 3, numberPaint)
}
}

// Draw the hand and the central dot as before
val handAngle =
Math.toRadians(((if (clockMode == ClockMode.HOUR) hour % 12 else minute % 60) * 360.0 / itemCount) - 90)
val handEndX = (centerX + cos(handAngle) * (radius * 0.8 - 27)).toFloat()
val handEndY = (centerY + sin(handAngle) * (radius * 0.8 - 27)).toFloat()
canvas.drawLine(centerX, centerY, handEndX, handEndY, dotAndHandPaint)
canvas.drawCircle(centerX, centerY, 10f, dotAndHandPaint)
}


override fun onTouchEvent(event: MotionEvent): Boolean {
val touchX = event.x
val touchY = event.y
val centerX = width / 2f
val centerY = height / 2f

val width = width.toFloat()
val height = height.toFloat()
val radius = width.coerceAtMost(height) / 2 * 0.9f
val radius2 = width.coerceAtMost(height) / 2 * 0.4f

// Calculate the distance from the touch point to the center of the clock
val distance = sqrt(
(touchX - centerX).toDouble().pow(2.0) + (touchY - centerY).toDouble().pow(2.0)
)

// Only react to touch events within the circular clock face
if (distance <= radius) {
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
// When a touch is detected, request the parent ScrollView to not intercept touch events
parent?.requestDisallowInterceptTouchEvent(true)

// Calculate the angle between the touch point and the horizontal line from the center
val angleRad = atan2(
(touchY - centerY).toDouble(), (touchX - centerX).toDouble()
) + Math.PI / 2
var angleDeg = Math.toDegrees(angleRad)

// Adjust the angle degree to be positive
if (angleDeg < 0) {
angleDeg += 360
}

when (clockMode) {
ClockMode.HOUR -> {
// Convert angle to hour (12-hour format)
var selectedHour = Math.round(angleDeg / 30).toInt() % 12
if (selectedHour == 0) selectedHour = 12

setTime(selectedHour, minute) // Update the selected hour
}

ClockMode.MINUTE -> {
// Convert angle to minute (60 minute format)
val selectedMinute = Math.round(angleDeg / 6)
.toInt() % 60 // Converts 0-360 degrees to 0-59 minutes
setTime(hour, selectedMinute) // Update the selected minute
}
}

return true
}

MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// When the touch is lifted or cancelled, allow the parent ScrollView to intercept touch events again
parent?.requestDisallowInterceptTouchEvent(false)


//the followings are for automatically switching from Hour to Minute
scope.launch {
delay(100)
clockMode = ClockMode.MINUTE
setTime(hour, minute)
}
}
}
}
return super.onTouchEvent(event)
}


private fun setTime(hour: Int, minute: Int) {
this.hour = hour
this.minute = minute
invalidate() // Redraw the view with the new time
timeChangedListener?.onTimeChanged(hour, minute)
}


fun setTimeChangedListener(listener: TimeChangeListener) {
this.timeChangedListener = listener
}

interface TimeChangeListener {
fun onTimeChanged(hour: Int, minute: Int)
}

enum class ClockMode {
HOUR, MINUTE
}

enum class TimeMode {
AM, PM
}


}

Here’s how I used it inside xml:

<com.jamesmobiledev.utils.view.CustomTimePicker
android:id="@+id/timePicker"
android:layout_width="320dp"
android:layout_height="320dp"
android:layout_gravity="center"
android:layout_marginTop="16dp" />

Here’s the result:

I added couple of views inside my xml using LinearLayout and MaterialCardView to display the Hour, Minute, AM, PM:

I updated the Hour and Minute using the TimeChangedListener inside the CustomTimePicker class:

fun setTimeChangedListener(listener: TimeChangeListener) {
this.timeChangedListener = listener
}

interface TimeChangeListener {
fun onTimeChanged(hour: Int, minute: Int)
}

In my Fragment:

binding.timePicker.setTimeChangedListener(object : CustomTimePicker.TimeChangeListener {
override fun onTimeChanged(hour: Int, minute: Int) {
binding.tvHour.text = hour.toString()
binding.tvMinute.text = minute.toString().padStart(2, '0')
}
})

Here is the final result:

Voila! 🥳🥳🥳

--

--