[Android/Kotlin] TimePicker 시간 간격(interval) 설정하기

코코아의 개발일지·2024년 9월 10일
0

Android-Kotlin

목록 보기
32/36
post-thumbnail

✍🏻 요구 사항 분석

아래처럼 5분 간격의 타임 피커를 만들 것을 요구받았다.

다만, 구현 시간 상 디자인은 네이티브 기본 디자인으로 구현해도 된다고 했다.
그래서 TimePicker를 커스텀해서 5분 간격을 가지게끔 만들고자 했다.
(예전에는 뭣도 모르고 NumberPicker를 일일이 수정해서 사용했지만, TimePicker를 바로 활용하면 편하다.


💻 코드 작성

1️⃣ 바텀시트/피커 스타일 설정 (styles.xml)

  • 바텀시트 스타일
<style name="BottomSheetDialogStyle"
        parent="Theme.Design.Light.BottomSheetDialog">
        <item name="bottomSheetStyle">@style/BottomSheetModel</item>
</style>

<style name="BottomSheetModel"
        parent="Widget.Design.BottomSheet.Modal">
        <item name="android:background">@drawable/bottom_dialog_radius</item>
</style>

바텀시트 상단 모서리를 둥글게 만들어줘야 해서 미리 스타일 지정을 한다.

  • 피커 스타일
<style name="MyBase.TimePicker" parent="Theme.AppCompat.Light.Dialog">
        <item name="android:background">@color/white</item>
        <item name="android:headerBackground">@color/white</item>
        <item name="android:textColor">@color/title_black</item>
        <item name="android:colorAccent">@color/title_black</item>
        <item name="android:textColorPrimary">@color/title_black</item>
        <!-- NumberPicker divider color -->
        <item name="colorControlNormal">@color/main</item>
</style>

피커도 Material 피커를 사용하는 거긴 하지만, 우리 앱과 최대한 어우러질 수 있게끔 메인 색상을 지정하고, 조금 꾸며주자!

Android Studio에서 확인해 보면 이런 모습니다.

2️⃣ 레이아웃 코드 작성 (bottom_sheet_time_picker.xml)

타임 피커를 띄울 때 바텀 시트로 띄우기 때문에 바텀 시트의 레이아웃을 먼저 만들어 준다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/picker_close_iv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:layout_marginEnd="22dp"
            android:padding="5dp"
            android:src="@drawable/ic_close"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <TimePicker
            android:id="@+id/picker_tp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:timePickerMode="spinner"
            android:theme="@style/MyBase.TimePicker"
            app:layout_constraintTop_toBottomOf="@id/picker_close_iv"/>

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/picker_save_btn"
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:text="@string/save"
            android:layout_marginHorizontal="20dp"
            android:layout_marginBottom="15dp"
            app:layout_constraintTop_toBottomOf="@id/picker_tp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintVertical_bias="0"
            style="@style/large_fill_default"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

TimePicker에 android:timePickerMode="spinner"를 설정하면 돌릴 수 있는 피커 모양이 나온다.

디자인 탭에서 확인해 보면 오른쪽 모습과 같다.
xml에서는 따로 시간 간격을 설정하는 옵션이 없어서, 미리보기에는 분이 1분 간격으로 표시된다.
이걸 이제 5분 간격을 가지게끔 바꿔보자.

3️⃣ TimePickerBottomSheet.kt

class TimePickerBottomSheet(private val initHour: Int, private val initMinute: Int) : BottomSheetDialogFragment() {
    private lateinit var binding: BottomSheetTimePickerBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = BottomSheetTimePickerBinding.inflate(inflater, container, false)

        return binding.root
    }

    private fun initPicker() {
        // 시간 간격을 5분 단위로 설정
        binding.pickerTp.setTimeInterval(MINUTES_INTERVAL)

        // TimePicker에 초기 시간을 설정
        val adjustedMinute = initMinute / MINUTES_INTERVAL
        binding.pickerTp.apply {
            hour = initHour
            minute = adjustedMinute
        }
    }

    private fun TimePicker.setTimeInterval(
        @IntRange(from = 1, to = 30)
        timeInterval: Int = MINUTES_INTERVAL
    ) {
        try {
            // 분 단위 스피너 찾기
            val minutePicker = findViewById<NumberPicker>(
                resources.getIdentifier("minute", "id", "android")
            )

            // 5분 간격의 배열을 생성해 분 단위 스피너에 적용하기
            val minuteValues = Array(MINUTES_MAX / timeInterval) { String.format(MINUTE_FORMAT, (it * timeInterval)) }
            minutePicker.minValue = MINUTES_MIN
            minutePicker.maxValue = MINUTES_MAX / timeInterval - 1
            minutePicker.displayedValues = minuteValues
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    companion object {
        const val MINUTES_INTERVAL = 5 // 5분 간격 설정
        const val MINUTES_MIN = 0
        const val MINUTES_MAX = 60
        const val MINUTE_FORMAT = "%02d"
    }
}

MINUTES_INTERVAL의 숫자를 조정하면 원하는 간격을 설정할 수 있다.
피커 안에 들어가는, 분을 나타내는 배열인 minuteValues를 만들 때는 (디자인에 맞춰) 한 자리 숫자이더라도 '00', '05' 식으로 표시해 주어야 한다. 따라서 정수가 문자열에 %02d 포맷으로 들어가도록 해 주었다.

* 아래 코드로도 동일한 작동이 가능하다.

val minuteValues = Array(MINUTES_MAX / timeInterval) { (it * timeInterval).toString().padStart(2, '0') }

4️⃣ 피커 띄우기

Activity/Fragment에서 클릭 이벤트로 피커를 show 해주면 된다.

private fun initClickListeners() {
        // 시작 시간
        binding.routeCreateStartTimeTv.setOnClickListener {
            showTimePickerBottomSheet(true, viewModel.startTimePair.value)
        }

        // 종료 시간
        binding.routeCreateEndTimeTv.setOnClickListener {
            showTimePickerBottomSheet(false, viewModel.endTimePair.value)
        }
    }
    
private fun showTimePickerBottomSheet(isStartTime: Boolean, initTime: Pair<Int, Int>?) {
        val pickerBottomSheet = TimePickerBottomSheet(
            initTime?.first ?: Calendar.getInstance().get(Calendar.HOUR_OF_DAY), // 선택한 시간 정보가 없다면 현재 hour로 피커 초기화
            initTime?.second ?: Calendar.getInstance().get(Calendar.MINUTE) // 선택한 시간 정보가 없다면 현재 minute로 피커 초기화
        )
        pickerBottomSheet.run {
            setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)
        }
        pickerBottomSheet.show(this.supportFragmentManager, pickerBottomSheet.tag)
    }

setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)는 1️⃣에서 만든 바텀시트 다이얼로그 스타일을 넣어주는 코드이다. 이 코드를 통해 상단이 둥근 바텀 시트를 만들어줄 수 있다.

실행 시켜보면 이처럼 5분 간격의 타임 피커가 잘 나타나는 걸 확인할 수 있다!
이제 바텀 시트에서 시간을 선택하고 저장 버튼을 눌렀을 때 텍스트뷰에 해당 시간을 표시해 주기만 하면 된다.


5️⃣ 선택한 시간 텍스트뷰에 나타내기

1) Interface 구현

시간을 선택했을 때 선택된 시간을 넘길 인터페이스를 만들어 준다.

interface TimeChangedListener {
    fun onTimeSelected(isStartTime: Boolean, hour: Int, minute: Int)
}

레이아웃을 보면 아래와 같이 시작 시간과 종료 시간 두 가지 옵션이 필요하다. 옵션 지정을 안 해주면 시작/종료 시간 중 무엇이 수정되었는지 알지 못한다. 때문에 isStartTime을 통해 시작 시간인지, 종료 시간인지를 함께 받을 수 있도록 했다.

2) TimePickerBottomSheet에 listener 달기

class TimePickerBottomSheet(private var listener: TimeChangedListener, private val isStartTime: Boolean, private val initHour: Int, private val initMinute: Int) : BottomSheetDialogFragment() {
    private lateinit var binding: BottomSheetTimePickerBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = BottomSheetTimePickerBinding.inflate(inflater, container, false)

        initPicker()
        initClickListeners()

        return binding.root
    }

    private fun initClickListeners() {
        // x 버튼
        binding.pickerCloseIv.setOnClickListener {
            dismiss() // 종료
        }

        // 저장 버튼
        binding.pickerSaveBtn.setOnClickListener {
            val selectedHour = binding.pickerTp.hour
            val selectedMinute = binding.pickerTp.minute * MINUTES_INTERVAL
            listener.onTimeSelected(isStartTime, selectedHour, selectedMinute) // 선택한 시간 넘기기
            dismiss()
        }
    }
}

저장 버튼을 클릭했을 때 현재 선택된 타임피커의 hour, minute를 가져와서 넘겨준다.
이때 minute는 5분 간격으로 구현한다고 개수를 줄여놨기 때문에 MINUTES_INTERVAL를 곱해주어야 한다.

3) 선택한 시간 받기

class RouteCreateActivity : AppCompatActivity(), TimeChangedListener {
    private lateinit var binding: ActivityRouteCreateBinding

    private val viewModel: RouteCreateViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_route_create)

        binding.apply {
            viewModel = this@RouteCreateActivity.viewModel
            lifecycleOwner = this@RouteCreateActivity
        }

        initClickListeners()
    }

    private fun initClickListeners() {
        // ...

        // 시작 시간
        binding.routeCreateStartTimeTv.setOnClickListener {
            showTimePickerBottomSheet(true, viewModel.startTimePair.value)
        }

        // 종료 시간
        binding.routeCreateEndTimeTv.setOnClickListener {
            showTimePickerBottomSheet(false, viewModel.endTimePair.value)
        }
    }

    private fun showTimePickerBottomSheet(isStartTime: Boolean, initTime: Pair<Int, Int>?) {
        val pickerBottomSheet = TimePickerBottomSheet(
            this,
            isStartTime,
            initTime?.first ?: Calendar.getInstance().get(Calendar.HOUR_OF_DAY), // 선택한 시간 정보가 없다면 현재 hour로 피커 초기화
            initTime?.second ?: Calendar.getInstance().get(Calendar.MINUTE) // 선택한 시간 정보가 없다면 현재 minute로 피커 초기화
        )
        pickerBottomSheet.run {
            setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)
        }
        pickerBottomSheet.show(this.supportFragmentManager, pickerBottomSheet.tag)
    }

    override fun onTimeSelected(isStartTime: Boolean, hour: Int, minute: Int) {
        viewModel.updateTime(isStartTime, Pair(hour, minute))
    }
}

시간이 선택되었을 때의 코드 처리는 뷰모델에게 넘겨준다.

  • 뷰모델
@HiltViewModel
@RequiresApi(Build.VERSION_CODES.O)
class RouteCreateViewModel @Inject constructor(
    private val repository: RouteRepository
) : ViewModel() {
    private val _startTimePair = MutableLiveData<Pair<Int, Int>>()
    val startTimePair: LiveData<Pair<Int, Int>> = _startTimePair

    private val _endTimePair = MutableLiveData<Pair<Int, Int>>()
    val endTimePair: LiveData<Pair<Int, Int>> = _endTimePair

    fun updateTime(isStartTime: Boolean, timePair: Pair<Int, Int>) {
        if (isStartTime) _startTimePair.value = timePair
        else _endTimePair.value = timePair
        updateButtonActivation()
    }
}

4) DateConverter 작성

마지막으로 시간을 텍스트뷰에 실제로 표시하기 위한 작업이다.
TextView에 시간을 나타낼 형식을 지정해 준다.

object DateConverter {
    private const val TIME_PLACEHOLDER = "시간 선택"
    private const val TIME_DELIMINATOR = ":"

    @SuppressLint("DefaultLocale")
    @JvmStatic
    fun getFormattedTime(timePair: Pair<Int, Int>?): String {
        if (timePair == null) return TIME_PLACEHOLDER
        return "${timePair.first}${TIME_DELIMINATOR}${format(MINUTE_FORMAT, timePair.second)}"
    }
}

시간 선택 전에는 '시간 선택'이라는 placeHolder가 표시될 수 있게 한다.

데이터바인딩을 사용하기 때문에 시간을 표시해 줄 레이아웃에서 아래 코드를 작성해주면 된다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <import type="com.daval.routebox.presentation.utils.DateConverter"/>
        <variable
            name="viewModel"
            type="com.daval.routebox.presentation.ui.route.write.RouteCreateViewModel" />
    </data>
</layout>

DateConverter를 import 해주고

<androidx.appcompat.widget.AppCompatTextView
                    android:id="@+id/route_create_end_time_tv"
                    android:text="@{DateConverter.getFormattedTime(viewModel.startTimePair)}"
                    tools:text="19:05"
                    style="@style/common_time_selected_tv" />

사용할 TextView의 text에 앞서 작성한 @{DateConverter.getFormattedTime(viewModel.startTimePair)}를 사용해주면!
드디어 모든 작업이 끝났다.



📱 완성 화면

피커가 5분 간격으로 나오고, 저장 버튼을 눌렀을 때 TextView에 잘 표시되는 것을 확인할 수 있다.

📚 참고 자료

profile
안드로이드 개발자를 꿈꾸는 학생입니다

0개의 댓글