아래처럼 5분 간격의 타임 피커를 만들 것을 요구받았다.
다만, 구현 시간 상 디자인은 네이티브 기본 디자인으로 구현해도 된다고 했다.
그래서 TimePicker를 커스텀해서 5분 간격을 가지게끔 만들고자 했다.
(예전에는 뭣도 모르고 NumberPicker를 일일이 수정해서 사용했지만, TimePicker를 바로 활용하면 편하다.
<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에서 확인해 보면 이런 모습니다.
타임 피커를 띄울 때 바텀 시트로 띄우기 때문에 바텀 시트의 레이아웃을 먼저 만들어 준다.
<?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분 간격을 가지게끔 바꿔보자.
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') }
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분 간격의 타임 피커가 잘 나타나는 걸 확인할 수 있다!
이제 바텀 시트에서 시간을 선택하고 저장
버튼을 눌렀을 때 텍스트뷰에 해당 시간을 표시해 주기만 하면 된다.
시간을 선택했을 때 선택된 시간을 넘길 인터페이스를 만들어 준다.
interface TimeChangedListener {
fun onTimeSelected(isStartTime: Boolean, hour: Int, minute: Int)
}
레이아웃을 보면 아래와 같이 시작 시간과 종료 시간 두 가지 옵션이 필요하다. 옵션 지정을 안 해주면 시작/종료 시간 중 무엇이 수정되었는지 알지 못한다. 때문에 isStartTime
을 통해 시작 시간인지, 종료 시간인지를 함께 받을 수 있도록 했다.
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
를 곱해주어야 한다.
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()
}
}
마지막으로 시간을 텍스트뷰에 실제로 표시하기 위한 작업이다.
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에 잘 표시되는 것을 확인할 수 있다.