popup & navigation backstack
인트로
영상처럼 compose 공부를 재미나게 하기 위해 여러가지 게임을 만들어보고있다. 그러던 중 땅따먹기 게임이 도전해볼만해서 만들어봤다.
만들면서 마주쳤던 다른 이슈는 fillmaxXX: 왜 꽉 안채우지 너?에 적어놨다.
이번에 하면서 마주쳤던 이슈는 Popup composable이다.
문제
- 게임이 끝나면 파란색이 이겼는지, 빨간색이 이겼는지를 popup으로 보여준다
- Popup composable이 나온뒤에 뒤로가기를 누르면 Popup이 이전 화면에 도달하고 난 뒤에 사라진다.
문제 코드(수정전 코드)
@Composable
fun HopScotchGameScreen() {
if (체크로직) {
Popup(
alignment = Alignment.Center,
properties = PopupProperties(
focusable = false,
)
) {
...
}
}
}
과거의 나에게 묻는 질문
- PopupProperties(focusable = false)로 했는데 이렇게하면 밖에 영역이 터치되어진다. 심지어 default 값도 false다. 왜 그랬니?
앱 전체 구조
- Activity
- Theme
NavHost
- Screen1
- Screen2
- …
- Theme
뒤로가기는 왜 동작했을까?
PopupProperties의 focusable값을 false
로 줘서 뒤로가기 소프트 버튼 클릭이나 제스쳐 액션이 Screen 레벨에서 잡혔다.
즉 NavHost의 BackHandler Composable로 정의된 navController.popBackStack()이 호출되었다.
조금은 더 디테일한 이유
@Immutable
class PopupProperties @ExperimentalComposeUiApi constructor(
val focusable: Boolean = false,
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
val excludeFromSystemGesture: Boolean = true,
val clippingEnabled: Boolean = true,
val usePlatformDefaultWidth: Boolean = false
) {
...
}
focusable = true라는 가정하에
dismissOnBackPress = true
- dispatchKeyEvent() 호출 및 내부에서 Popup composable 생성시 넘겨준 nullable onDismissRequest 람다 호출
dismissOnClickOutside = true
- onTouchEvent() 호출 및 내부에서 Popup composable 생성시 넘겨준 nullable onDismissRequest 람다 호출
하지만 둘 중 하나가 false
면- 다 건너뛰고 각 함수(dispatch.., touch..)의 super.XX() 호출
focusable이 false
면- 두 함수 어느것에도 반응하지 않는다.
- 왜?
- Popup composable 내부는 PopupLayout으로 이루어져있는데 팝업을 생성하는 과정에서
setIsFocusable(Boolean)
함수 호출 - 여기서 window의 flag 값을 업데이트 and 연산자로 업데이트
- Popup composable 내부는 PopupLayout으로 이루어져있는데 팝업을 생성하는 과정에서
솔루션
Reminder: 더 나은 방법이 존재할거같은데 아직 Compose에 익숙하지 않아서 아는 방법이 요거 말고는 없다. 나중에 여러 개념들을 확실하게 알고 좀 더 적응이 된다면 다시 고민해봐야할 포인트로 남겨 둬야겠다.
결국 핵심은 Popup을 먼저 닫는 것
맨 위영상에서도 볼 수 있듯이 Popup 밑에 깔려있는 게임 화면이 사라지고 난 뒤에 사라진다.
왜? 나중에 닫힐까?
그 이유는 Popup Composable의 구현에 있는데, 팝업이 누구에 의해서 닫히는 지를 보면 DisposableEffect에 의해 닫히게 된다.
Side-effects에 관한 설명은 지금 내 지식선에서 설명하기는 확실히 아니기에 아래 링크를 통해 이해하는것이 조금 더 빠를것같다.
간략하게 설명하면 Side-effect는 Composable 범위 밖에서 일어나는 State의 변화를 Side-effect라고 부르는데 기본적으로 Side-effect의 발생을 지양해야하지만, 경우에 따라 이 Side-effect가 필요한데 그때 Side-Effect API를 이용하여 작업을 진행한다.
DisposableEffect
는 side effects composable이 Composition을 떠나거나 key가 바뀐 후 회수 되어야 할 경우에 사용한다. 지금 내 케이스는 Composition을 벗어나는 경우이다.(onForgotten())
Screen에서 나올 때 Composition을 벗어나므로, 벗어나고 난 후에 아래 코드 스니펫의 onDispose { }의 dismiss()가 호출된다.
@Composable
fun Popup(...) {
...
DisposableEffect(popupLayout) {
popupLayout.show()
popupLayout.updateParameters(
onDismissRequest = onDismissRequest,
properties = properties,
testTag = testTag,
layoutDirection = layoutDirection
)
onDispose {
popupLayout.disposeComposition()
// Remove the window
popupLayout.dismiss()
}
}
...
}
자 그럼 다 나왔다. 왜 팝업이 늦게 사라지는 지 알았으니, 이제 Screen이 먼저 사라지기 전에 Popup을 없애면 된다.
@Composable
fun HopScotchGameScreen(
...
onBackPressedWhenPopupIsOpen: () -> Unit = {}
) {
var playerRedHeight by remember {
mutableFloatStateOf(0.5f)
}
var isBackButtonPressed by remember {
mutableStateOf(false)
}
Column {
//...게임 화면...
if (!isBackButtonPressed && isGameOver(playerRedHeight)) {
Popup(
alignment = Alignment.Center,
properties = PopupProperties(focusable = true),
onDismissRequest = {
isBackButtonPressed = true
onBackPressedWhenPopupIsOpen()
}
) {
게임 끝났을시 관련 Composable
}
}
}
}
- focusable을 true로 하여 onDismissRequest가 호출 되게한다.
- 하나의 Boolean State 변수를 더 만들어 backButton이 클릭이 되었는지 업데이트한다.
- PopupProperties의 focusable, dismissOnBackPressed 가 true면 뒤로가기 버튼 클릭 keyevent를 캐치하도록 Popup 내부 구현이 되어있으니, Screen레벨에서 lambda로 navController.popBackStack()을 넘긴다.
@Composable
fun PortfolioApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.Home.route) {
...
composable(Screen.HopScotchGame.route) {
HopScotchGameScreen {
navController.popBackStack()
}
}
...
}
}
간단한 이슈인줄알고 써봐야했다가 식겁했다.