안드로이드 앱에 없어서는 안 되는 기능인 뒤로가기 버튼을 통한 종료 기능, 일반적으로 구현할 때 크게 3가지로 나눌 수 있습니다.
- 즉시 종료
- 종료 팝업
- 종료 토스트
즉시 종료를 시키기도 하지만, 경우에 따라 종료 여부를 팝업으로 띄우거나 토스트/스낵바를 통해 한번 더 눌러 종료한다고 표시하기도 합니다.
이번 글에서는 작년 이 시기 쯤 Rx로 구현한 뒤로가기 기능을 Flow로 구현하기 위해 노력한 기억이 떠올라 시도하였던 경험과 함께 종료기능을 구현하기 위한 아래의 3가지 방법으로 이야기해 보겠습니다.
- 일반적인 방법
- Rx를 사용한 방법
- Flow를 사용한 방법
즉시 종료
우선 뒤로가기 버튼을 눌렀을 때 종료한다면 다음과 같이 구현할 수 있습니다.
Android API 33부터 onBackPressed 방식은 Deprecated 되었기에 onBackPressedDispatcher로 대체할 수 있습니다.
Add support for the predictive back gesture - Android Developer
// Deprecated
override fun onBackPressed() {
finishAffinity()
}
// For API 33 or higher
onBackPressedDispatcher.addCallback {
finishAffinity()
}
두 번 눌러 종료
따로 처리 없이 바로 종료한다면 여기까지만 읽으시면 되고... 사실상 핵심인 두 번 눌러 종료하는 기능을 설명하겠습니다.
일반적인 방법
두 번 눌러 종료하는 경우에는 뒤로가기를 누른 시간이 2000ms 차이가 나지 않는다면 토스트와 함께 현재 시간을 backPressedTime에 저장하고, 다시 눌렀을 때 2000ms 이내라면 앱을 종료하도록 구현할 수 있습니다.
private var backPressedTime: Long = 0
...
// BACK_PRESSED_DURATION = 2_000L
onBackPressedDispatcher.addCallback {
if(System.currentTimeMillis() - backPressedTime >= BACK_PRESSED_DURATION) {
backPressedTime = System.currentTimeMillis()
Toast.makeText(this, "한 번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show()
} else {
finishAffinity()
}
}
ReactiveX
timeInterval 방식
Rx를 알게 된 다음에는 timeInterval을 통해 이벤트 시점이 2000ms 차이 나는지 비교하여 종료하도록 구현하였습니다.
val backPressSubject = PublishSubject.create<Unit>()
...
backPressSubject
.doOnNext {
Toast.makeText(this, "한 번 더 누르면 종료합니다.", Toast.LENGTH_SHORT).show()
}
.observeOn(Schedulers.io())
.timeInterval()
.skip(1)
.filter {
it.time() < BACK_PRESSED_DURATION
}
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe {
finishAffinity()
}
.addTo(compositeDisposable)
buffer 방식
그러나 해당 방식으로는 종료 시점에 토스트가 한 번 더 뜬다는 사실을 깨닫고 BehaviorSubject로 변경하여 다음과 같이 사용하였습니다.
이때 subject가 생성되고 subscribe을 시작하자마자 뒤로가기를 누르는 경우(즉 앱을 실행하자마자 뒤로가기 누른 경우) 차이가 BACK_PRESSED_DURATION 값 이하일 수 있기에 initial value로 현재 시간에서 해당 값을 차감함으로써 사전에 방지하였습니다. 혹은 그냥 0L 넣어주셔도 됩니다.
val backPressSubject = BehaviorSubject.createDefault(System.currentTimeMillis() - BACK_PRESSED_DURATION)
...
backPressSubject
.buffer(2, 1)
.subscribe {
if (it.last() - it.first() < BACK_PRESSED_DURATION) {
finishAffinity()
}
else {
Toast.makeText(this, "한 번 더 누르면 종료합니다.", Toast.LENGTH_SHORT).show()
}
}
.addTo(compositeDisposable)
Flow
그러나 21년을 기점으로 회사에서도 Flow로 넘어가면서 신규 프로젝트에서는 사용할 수가 없었고 변환을 시도하기 위해 크게 아래의 2가지 방식을 시도하였으나 실패하였습니다.
- timeInterval과 같이 방출 시점을 넘겨주기
- Flow에는 위와 같은 오퍼레이터가 존재하지 않음
- buffer를 통해 이벤트를 2개 묶어 방출하기
- Flow의 buffer는 Rx의 buffer와 달리 묶는 개념이 아닌 작업 분리 개념
Flow에는 timeInterval 관련 오퍼레이터가 없었고, buffer의 경우 이벤트를 묶어 방출하는 Rx와 달리, Flow에서는 발행과 소비가 순차적으로 처리되기에 producer/consumer 작업 시간으로 전체 작업 시간 길어지는 경우 사용하여, 각 생산/소비자의 코루틴을 분리하여 지연을 방지하는 기능이었습니다.
시간이 흘러 작년 말, 복학 이후 GDSC의 새로운 프로젝트에서 뒤로가기 기능이 필요하여 방법이 없을까 다시 고민하였고, 좋은 방법일지는 모르겠지만 scan을 활용하여 이전 발행 시간과 현재 시간을 묶어 계산하면 되지 않을까?라는 생각을 하여 여러 가지 시도한 끝에 성공하였습니다.
우선 이벤트를 담당할 SharedFlow를 만들어줍니다.
이때 tryEmit으로 suspend가 아닌 환경에서도 사용할 수 있게 buffer를 설정하였습니다.
val backPressEvent = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
그리고 해당 이벤트를 scan을 통해 연결합니다. 첫 scan 시 list에 현재 시간을 담는데, 이때 마찬가지로 구독이 되자마자 뒤로가기를 누르면 시간 차가 충분하지 않아 종료될 수 있기에 현재 시간에서 BACK_PRESSED_DURATION을 차감하였습니다. 혹은 마찬가지로 0L을 넣으셔도 됩니다.
scan(listOf(System.currentTimeMillis() - BACK_PRESSED_DURATION))
그리고 scan의 람다에서 현재 리스트의 마지막 값에 현재 시간을 추가하도록 하였습니다.
이를 통해 [뒤로가기 누른 이전 시간, 뒤로가기 누른 지금 시간]으로 리스트가 생성됩니다.
{ acc, _ ->
acc.takeLast(1) + System.currentTimeMillis()
}
그리고 drop(1)을 통해 구독 시 생기는 첫 이벤트를 생략해 주고
drop(1)
collectLatest로 리스트의 마지막 값(현재 시간) - 첫 번째 값(이전 시간)으로 계산하여 토스트를 띄우거나 앱을 종료하도록 구현할 수 있게 됩니다.
collectLatest {
if (it.last() - it.first() < BACK_PRESSED_DURATION) {
finishAffinity()
} else {
Toast.makeText(applicationContext, "한 번 더 누르면 종료합니다.", Toast.LENGTH_SHORT).show()
}
}
최종적인 각 기능의 코드는 다음과 같습니다.
// 이벤트를 위한 SharedFlow 생성
val backPressEvent = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// 뒤로가기 시 이벤트 방출
onBackPressedDispatcher.addCallback {
backPressEvent.tryEmit(Unit)
}
// 이벤트 구독
backPressEvent
.scan(listOf(System.currentTimeMillis() - BACK_PRESSED_DURATION)) { acc, _ ->
acc.takeLast(1) + System.currentTimeMillis()
}
.drop(1)
.collectLatest {
if (it.last() - it.first() < BACK_PRESSED_DURATION) {
finishAffinity()
} else {
Toast.makeText(applicationContext, "한 번 더 누르면 종료합니다.", Toast.LENGTH_SHORT).show()
}
}
응용하기 - 스낵바
위와 같이 구현하면 토스트 방식으로 뒤로가기를 안내할 수 있지만 SnackBar로 안내를 원한다면 어떨까요?
이 경우에도 Flow를 통해 쉽게 구현이 가능합니다.
뒤로가기 스낵바
뒤로가기를 누른 경우 스낵바를 표시하기 위해 backPressEvent를 받고 transformLatest를 통해 처음 true를 방출하고 2초뒤 false를 방출하도록 변환합니다.
이때 토스트 혹은 SnackbarHost를 사용한다면 단발성으로 호출하는 형식이겠지만, 저는 자유롭게 커스텀하기 위해 상태에 따라 처리할 수 있도록 SharedFlow가 아닌 StateFlow를 사용하였습니다.
val isShowBackPressSnackbar = backPressEvent
.transformLatest {
emit(true)
delay(BACK_PRESSED_DURATION)
emit(false)
}
.stateIn(scope = viewModelScope, started = SharingStarted.Lazily, initialValue = false)
그다음 UI에서 스낵바를 구현합니다. Compose, XML 각 환경에 맞춰 구현하시면 됩니다.
저는 Compose로 AnimatedVisibility와 Snackbar를 사용하였습니다.
// Compose
...
AnimatedVisibility(
visible = isShowBackPressSnackbar, // viewModel.isShowBackPressSnackbar.collectAsState().value
modifier = modifier,
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
Snackbar(
modifier = Modifier.padding(15.dp),
backgroundColor = MaterialTheme.colors.secondary
) {
Text(text = "뒤로가기를 한번 더 누르면 종료됩니다.", color = Color.White, fontSize = 14.sp)
}
}
앱 종료
앱 종료 이벤트는 scan을 활용하는 것은 동일하지만, 기존과 다르게 종료 이벤트만 방출하면 되기에 filter를 통해 2초 이내 뒤로가기 이벤트가 2번 발생 시 방출하도록 구현합니다.
/** 앱 종료 이벤트 **/
val finishEvent = backPressEvent
.scan(listOf(System.currentTimeMillis() - BACK_PRESSED_DURATION)) { acc, _ ->
acc.takeLast(1) + System.currentTimeMillis()
}
.filter { it.last() - it.first() < BACK_PRESSED_DURATION }
.shareIn(scope = viewModelScope, started = SharingStarted.Lazily)
최종 결과
최종적으로 다음과 같이 처음 뒤로가기를 누르면 스낵바가, 2초 이내 또 누르면 종료되는 기능을 구현하였습니다.
'개발 > Android' 카테고리의 다른 글
안드로이드 Proto 설정 시 오류 (0) | 2023.08.14 |
---|---|
컴포즈 내비게이션 CreationExtras must have a value by `SAVED_STATE_REGISTRY_OWNER_KEY` 오류 (0) | 2022.12.07 |
[Room DB] Room Database 마이그레이션 시 주의점 2가지 (0) | 2022.12.05 |
PopupWindow에서 Jetpack Compose를 사용하는 방법 (1) | 2022.11.04 |
안드로이드 gRPC 개념 및 사용법 (2) | 2022.10.12 |
상상하는 것을 소프트웨어로 구현하는 것을 좋아하는 청년
게시글이 마음에 드시나요? [ 공감❤️ ] 눌러주시면 큰 힘이 됩니다!