안드로이드에서 시간을 설정하기 위해 보통 TimePicker가 사용된다.

spinner와 clock 두가지 모드가 있는데 아래와 같이 생겼다.

좌 - clock, 우 - spinner

Timepicker의 분단위 시간은 1분 단위로 설정되어 있는데, 나는 0, 5, 10 이런식으로 5분 간격으로 표시하고 싶었다..

기본 TimePicker엔 interval을 따로 설정할 수 있는 속성이 없다. 그래서 시간 간격을 바꾸고 싶다면 코드로 설정 해줘야한다.

 

시간 간격 설정하기

Spinner 타입 TimePicker는 NumberPicker 2개가 나란히 붙어있는 형태다.

리플렉션을 활용해 TimePicker에서 분단위 시간을 표시하는 NumberPicker 객체를 가져오고, NumberPicker의 void setDisplayedValues(String[] displayedValues) 메서드를 이용해 보여줄 문자열을 직접 설정했다.

 

TimePicker의 확장 함수로 만들었다.

const val DEFAULT_INTERVAL = 5
const val MINUTES_MIN = 0
const val MINUTES_MAX = 60

@SuppressLint("PrivateApi")
fun TimePicker.setTimeInterval(
    @IntRange(from = 0, to = 30)
    timeInterval: Int = DEFAULT_INTERVAL
) {
    try {
        val classForId = Class.forName("com.android.internal.R\$id")
        val fieldId = classForId.getField("minute").getInt(null)
        
        (this.findViewById(fieldId) as NumberPicker).apply {
            minValue = MINUTES_MIN
            maxValue = MINUTES_MAX / timeInterval - 1
            displayedValues = getDisplayedValue(timeInterval)
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

private fun getDisplayedValue(
    @IntRange(from = 0, to = 30)
    timeInterval: Int = DEFAULT_INTERVAL
): Array<String> {
    val minutesArray = ArrayList<String>()
    for (i in 0 until MINUTES_MAX step timeInterval) {
        minutesArray.add(i.toString())
    }

    return minutesArray.toArray(arrayOf(""))
}

 

minute 값 받아오기

참고로 interval이 1 이상의 숫자로 설정된 상태에서 getMinute()를 사용하면 실제 값이 아니라 displayedValue의 인덱스 값이 넘어오게 된다. spinner에서 0, 5, 10, 15... 이런식으로 값이 설정되어 있을 때, 15가 선택된 상태에서 getMinute()를 호출하면 15가 아닌 3이 돌아온다. 제대로 된 값을 받고 싶다면 getMinute()의 반환값과 interval을 곱해야 한다.

fun TimePicker.getDisplayedMinute(
    @IntRange(from = 0, to = 30)
    timeInterval: Int = DEFAULT_INTERVAL
): Int = minute * timeInterval

 

작성한 코드는 timePicker 객체에 아래와 같이 적용 가능하다.

binding.timePicker.setTimeInterval() // 시간 간격을 5분 단위로 설정
val currentMinute = binding.timePicker.getDisplayedMinutes()

 

 

커스텀 뷰로 만들기

interval을 매번 인자로 넘기지 않기 위해 커스텀 뷰로도 만들 수 있다. (기본 코드는 동일하다)

class RangeTimePicker @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : TimePicker(context, attrs) {
    private val defaultInterval = 5
    val minInterval = 1
    val maxInterval = 30

    var timeInterval = defaultInterval
        set(value) {
            if (field !in minInterval..maxInterval) {
                Log.w("RangeTimePicker", "timeInterval must be between $minInterval..$maxInterval")
            }

            field = MathUtils.clamp(minInterval, maxInterval, value)
            setInterval(field)
            invalidate()
        }

    init {
        setInterval()
    }

    @SuppressLint("PrivateApi")
    fun setInterval(
        @IntRange(from = 1, to = 30)
        timeInterval: Int = defaultInterval
    ) {
        try {
            val classForId = Class.forName("com.android.internal.R\$id")
            val fieldId = classForId.getField("minute").getInt(null)
            (this.findViewById(fieldId) as NumberPicker).apply {
                minValue = DateTimeUtil.MINUTES_MIN
                maxValue = DateTimeUtil.MINUTES_MAX / timeInterval - 1
                displayedValues = getDisplayedValue()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    fun getDisplayedMinutes(): Int = minute * timeInterval

    private fun getDisplayedValue(
        interval: Int = timeInterval
    ): Array<String> {
        val minutesArray = ArrayList<String>()
        for (i in 0 until DateTimeUtil.MINUTES_MAX step interval) {
            minutesArray.add(String.format(DateTimeUtil.MINUTE_FORMAT, i))
        }

        return minutesArray.toArray(arrayOf(""))
    }
}

 

참고 : 보통 커스텀 뷰를 만들 때 생성자로 Context, AttributeSet, defStyleAttr 이렇게 세개를 정의한다. 여기서 defStyleAttr를 정의하지 않은건 TimePicker의 기본 attr인 R.attr.timePickerStyle이 com.android.internal.R; 패키지에 있어서 접근이 불가능하기 때문이다. 다른 아이디 값을 넣게될 경우 timePicker 모양이 깨져서 인자로 context, attributeSet 두개만 넘겼다.

 

시간 간격이 잘 들어가나 테스트해보자.

class RangeTimePickerTest {
    private lateinit var rangeTimePicker: RangeTimePicker

    @Before
    fun setUp() {
        val app: Context = ApplicationProvider.getApplicationContext()
        rangeTimePicker = RangeTimePicker(app)
    }

    @Test
    fun testSetTimeInterval() {
        rangeTimePicker.timeInterval = 60
        assertEquals(rangeTimePicker.timeInterval, rangeTimePicker.maxInterval)
    }

    @Test
    fun testGetDisplayedMinutes() {
        val method = rangeTimePicker::class.java.getDeclaredMethod("getDisplayedValue", Int::class.java)
        method.isAccessible = true
        val result: Array<String> = method.invoke(rangeTimePicker, 10) as Array<String>
        assertArrayEquals(result, arrayOf("00", "10", "20", "30", "40", "50"))
    }
}

 

실행했을 때 5분 간격이 적용된 모습

 

아래의 글을 참고함