개인 프로젝트에서 날짜를 입력하는 화면이 있다.

조건
- 시작일은 필수로 입력해야 하며, 종료일은 선택할 수 있다.
- EditText가 형식에 맞게 채워지면 초록색 테두리로 변한다.
- 화면에 주어진 EditText가 모두 YYYY-MM-DD 형식으로 채워져야 등록버튼이 활성화된다.
- 날짜 범위가 올바르지 않다면 Toast Massage 알림
상태 정의
class DateViewModel: ViewModel() {
private val _startDate = MutableStateFlow("")
val startDate = _startDate.asStateFlow()
private val _endDate = MutableStateFlow("")
val endDate = _endDate.asStateFlow()
private val _endDateState = MutableStateFlow(false)
val endDateState = _endDateState.asStateFlow()
private val _registerButtonEnabled = MutableStateFlow(false)
val registerButtonEnabled = _registerButtonEnabled.asStateFlow()
}
EditText Date 형식으로 자동 변환
현재 Date의 형식은 YYYY-MM-DD인데, EditText에 숫자만 입력해도 자동으로 2025-01-01 형식으로 바뀌도록 구현
object EditTextBindingAdapter {
private const val DATE_MASK = "####-##-##"
private const val MASK = '#'
private const val MAX_LENGTH = DATE_MASK.length
private const val DATE_FORMAT = "\\D"
@JvmStatic
@BindingAdapter("app:date_format")
fun onQueryTextChangeDate(view: EditText, onChanged: (String) -> Unit) {
view.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun afterTextChanged(s: Editable?) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
regexDate()
}
private fun regexDate() {
val cleanString = view.text.toString().replace(DATE_FORMAT.toRegex(), "")
val maskBuffer = StringBuilder()
var maskIndex = 0
var cleanIndex = 0
while (maskIndex < MAX_LENGTH && cleanIndex < cleanString.length) {
if (DATE_MASK[maskIndex] == MASK) {
maskBuffer.append(cleanString[cleanIndex])
cleanIndex++
} else {
maskBuffer.append(DATE_MASK[maskIndex])
}
maskIndex++
}
onChanged(maskBuffer.toString())
view.setSelection(view.text.length)
}
})
}
}
테두리 변환
<EditText
...
app:is_vaild="@{viewModel.startDate.length() == 10}"
...
/>
@JvmStatic
@BindingAdapter("app:is_vaild")
fun isVaildEditText(view: EditText, isVaild: Boolean){
view.background = AppCompatResources.getDrawable(
view.context,
if(isVaild) R.drawable.edittext_vaild_border
else R.drawable.edittext_invaild_border
)
}
날짜 범위 체크
날짜 범위는 등록 버튼 클릭 시 확인하도록 구현했고, 범위가 벗어나면 Toast Massage를 출력하도록 구현하였다.
Room DB에 날짜를 LocalDate 타입으로 저장하기에 범위 체크 또한 LocalDate를 사용하였다.
viewModelScope.launch {
runCatching {
ScheduleEntity(
title = title.value,
start_date = LocalDate.parse(startDate.value),
end_date = if (endDateState.value) LocalDate.parse(endDate.value)
else LocalDate.parse(startDate.value),
color = getRandomColor()
)
}.onSuccess {
repository.insert(it)
_finish.update { true }
}.onFailure {
_toastMessage.emit("날짜 범위가 올바르지 않습니다.")
}
}
LocalDate.parse에 날짜 String 형식의 값을 넣어 LocalDate로 변환하는데, 만일 범위가 올바르지 않은 경우 다음과 같은 Error가 발생한다.
java.time.DateTimeException
이를 이용하여 runCatching을 사용해 성공 시 저장을, 실패 시 Toast Message를 구현하였다.
등록 버튼 활성화
등록 버튼 활성화 조건은 다음과 같다.
- endDate가 false, startDate의 길이가 10인 경우 활성화 (YYYY-MM-DD)
- endDate가 true, startDate와 endDate 길이가 모두 10인 경우 활성화
이를 Flow를 이용해 구현하고자 했고, 어렵지 않을 것 같았지만 수 많은 시행착오가 있었다..
startDate
.onEach {
if (it.length < 10) _registerButtonEnabled.value = false
}
.filter { it.length == 10 }
.flatMapLatest { endDateState }
.onEach {
if (!it) _registerButtonEnabled.value = true
}
.filter { it }
.flatMapLatest { endDate }
.onEach {
_registerButtonEnabled.value = it.length == 10
}
.launchIn(viewModelScope)
위와 같은 코드를 작성하고 생각한 흐름은 다음과 같다.
- startDate가 모두 입력된 경우에 filter로 flatMapLatest로 넘어감
- endDateState가 false인 경우 버튼 활성화
- true라면 endDate를 확인하여 길이가 10인 경우 버튼 활성화
그러나 Flow를 좀 다뤄봤던 사람들은 바로 잘못된 점을 알았을 것이다.
flatMap의 특징
flatMap~의 중간 연산자는 새로운 Flow의 흐름을 기존의 Flow와 병합하는 기능을 한다.
startDate
.onEach {
if (it.length < 10) _registerButtonEnabled.value = false
}
.filter { it.length == 10 }
.flatMapLatest { endDateState }
.onEach {
if (!it) _registerButtonEnabled.value = true
}
해당 코드에서의 Flow는 하나가 아닌 startDate와 endDateState 총 2개의 Flow가 생성된다.
처음에는 순차적으로 startDate → onEach → filter → flatMapLatest → onEach의 순서로 진행되는게 맞지만, flatMapLatest가 실행되어 하나의 Flow가 추가되는 순간 filter는 startDate의 값이 바뀔 때만 동작하게 된다.

flatMapLatest로 endDateState의 Flow가 생성된 순간 endDateState의 값이 변경되면 startDate Flow를 건너 뛰고 endDateState Flow의 흐름으로 진행된다.
이 점을 모르고 구현한 위의 코드는 다음과 같은 문제가 생겼다.
- startDate가 모두 입력된 상태
- endDateState가 true로 endDate 입력 EditText 추가
- endDate가 필요 없어져 endDateState를 false로 바꾸고 endDate를 Blank 상태로 변경
이 경우 startDate의 length가 10이고, endDateState가 false라 하더라도 등록 버튼이 활성화되지 않는다.

이러한 특성을 고려하여 다음과 같이 코드를 수정
startDate
.onEach { _registerButtonEnabled.update { false } }
.map { it.length == 10 }
.flatMapLatest { startDateEnabled ->
endDateState
.flatMapLatest { state ->
endDate
.onEach { _registerButtonEnabled.update { false } }
.map { !state || it.length == 10 }
}
.filter { endDateEnabled ->
startDateEnabled && endDateEnabled
}
}
.onEach {
_registerButtonEnabled.update { true }
}
.launchIn(viewModelScope)
- startDate의 startDateEnabled 상태를 Boolean 타입으로 변환하여 넘기고
- endDateState와 endDate를 병합하여 endDateEnabled 상태로 변환하여
- startDateEnabled 의 상태와 endDateState의 상태 모두 true인 경우 버튼을 활성화
테스트 케이스
1. 둘 다 전부 입력 > 활성화
2. 둘 다 전부 입력, endDate 삭제 > 활성화
3. startDate 전부 입력, endDate 일부 입력 상태에서 endDate 삭제 > 활성화
4. Date 일부 입력, endDate 전부 입력 상태에서 endDate 삭제 > 비활성화
5. 둘 다 전부 입력, endDate 삭제, endDate 추가 > 비활성화
다음과 같은 조건을 모두 만족하는 Flow를 구현하였다.
'안드로이드 > View' 카테고리의 다른 글
| [View] Progress Bullet Point (0) | 2025.07.29 |
|---|---|
| [View] 글 머리 (Bullet Point) 만들기 (0) | 2025.07.29 |
| SearchView (0) | 2024.07.14 |
| TimePicker (0) | 2024.07.14 |
| [Layout] CoordinatorLayout (0) | 2024.07.13 |