Compose) Jetpack Compose 앱 종료 모달 띄우기

2ast·2025년 3월 15일

Android 앱 종료 모달

Android 기기는 뒤로가기 버튼이 있기 때문에 앱의 상태와 무관하게 뒤로가기를 호출할 수 있다. 문제는 앱에서 더 이상 뒤로갈 수 없는 상태일 때는 앱이 종료된다는 것이다. 이런 부분은 사용자 입장에서도 의도치 않은 앱 종료가 발생할 수 있고, 제작자 입장에서도 즉시 이탈로 간주할 수 있는 부분이기 때문에 뒤로갈 수 없는 상황에서 물리버튼을 눌렀을 때는 "정말 앱을 종료할까요?"라고 묻는 모달을 띄우는 방식으로 대응했었다. 이번에는 compose에서 앱 종료 확인 모달 띄우는 작업을 해볼 예정이다.

BackHandler의 특징

지난번 Compose에서 WebView 띄우기에서 살펴봤듯 Android의 뒤로가기 이벤트는 BackHandler를 통해 제어할 수 있다. 그래서 WebView Composable에 BackHandler를 두고 canGoBack을 기준으로 액션을 분기해주었다.

@Composable
fun CustomWebView(
    url: String,
    navigationGoBack: () -> Unit
) {
    ...

    val onPressedBack = {
        if (webView?.canGoBack() == true) {
            webView?.goBack()
        } else {
            navigationGoBack()
        }
    }
    
    BackHandler(enabled = true) {
        onPressedBack()
    }
    
    ...
}

하지만 Android 앱 종료 모달의 경우는 WebView Composible과 무관하게 앱 Root에서 항상 리스닝하고 있으면 좋겠다는 판단을 했다. 그렇다면 필연적으로 n개의 BackHandler를 등록하는 것이 가능해야했다.(물론 하나의 BackHandler에서 모두 관리할 수도 있겠지만 내가 추구하는 방향의 코드는 아니다.) 관련 정보를 서치해본 결과 n개의 BackHandler를 등록하는 것은 가능하지만 마지막에 등록된 BackHandler만이 활성화된다는 사실을 알아냈다. 대신 BackHandler가 disabled되면 직전에 등록된 BackHandler가 활성화되는 방식이었다. 이 특징을 이용해서 적절하게 WebView BackHandler의 enabled를 조작해주기로 했다.

BackHandler 리팩토링

WebView BackHandler

먼저 기존에 작성해두었던 WebView의 BackHandler 코드를 조금 변경해줬다.

var webView: WebView? by remember { mutableStateOf(null) }
var canGoBack by remember { mutableStateOf(false) }

BackHandler(enabled = canGoBack) {
    webView?.goBack()
}

기존 navigationGoBack을 호출해주었던 부분을 제거하고, BackHandler의 enabled에 canGoBack을 넣어주었다. 만약 WebView가 뒤로가기 가능한 상태라면 webView.goBack()이 호출되고, canGoBack이 false라면 이 BackHandler는 비활성화되어 Root에 등록한 BackHandler가 동작할 것이다.

이 과정에서 까다로웠던 부분은 canGoBack을 state로 관리하는 부분이었다. react와는 다르게 compose는 state가 명시적으로 변경되어야 recomposition이 발생했을 때 변경된 state를 감지할 수 있다. 그렇기 때문에 BackHandler에서 참조하는 canGoBack을 굳이 state로 만들어 주어야했다. 이후 WebViewClient에 onPageFinished를 override해주어 canGoBack state를 명시적으로 갱신해주었다.

myWebView.webViewClient = object : WebViewClient() {
    override fun onPageFinished(view: WebView?, url: String?) {
        super.onPageFinished(view, url)
        canGoBack = view?.canGoBack() ?: false
    }
}

Root BackHandler

다음으로는 App Root에서 앱 종료 모달을 띄우기 위한 BackHandler를 새롭게 정의해주었다.

@Composable
fun MyApp() {
    Surface(modifier = Modifier.fillMaxSize()) {
        val navController = rememberNavController()

        val canGoBack by remember {
            derivedStateOf { navController.previousBackStackEntry != null }
        }


        BackHandler {
            if (canGoBack) {
                navController.popBackStack()
            } else {
                /*앱 종료 모달 띄우기*/
            }
        }
        ...
    }
}

navController에서 이전 페이지가 없다면 canGoBack이 false라고 간주해서 앱 종료 모달을 띄우는 방식이다. 이 BackHandler는 가장 먼저 등록되고 항상 활성화되어 있어야하기 때문에 BackHandler에 enabled 필드를 생략해주었다.

앱 종료 모달 띄우기

앱 종료 모달은 Dialog라는 Composable을 활용해 구현해보려 한다.

@Composable
fun ModalAlert(
    isVisible: Boolean,
    closeModal: () -> Unit,
) {
    if (isVisible) {
        Dialog(
            onDismissRequest = closeModal,
        ) {
            /*Dialog Content*/
        }
    }
}

기본 형태는 위와 같다. isVisible은 state로 선언해서 isVisible이 true일때만 Dialog를 화면에 그려주는 형태고, Dialog는 기본적으로 현재 스크린 위에 overlay하여 content를 그려준다. onDismissRequest를 필수 parameter로 받으며, properties를 추가로 받아 동작을 커스텀 할 수도 있다.

Dialog(
    onDismissRequest = closeModal,
    properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)

앱 종료하기

모달에서 "종료하기"버튼을 눌렀을 때 앱을 종료해주는 작업만 남았다. 종료하는 방법은 현재 Activity를 가져와, finish를 호출해주면 된다.

val activity = LocalActivity.current
activity?.finish()

이렇게 마무리된 MyApp의 최종 코드는 다음과 같다.

@Composable
fun MyApp() {
    Surface(modifier = Modifier.fillMaxSize()) {
        val navController = rememberNavController()

        val canGoBack by remember {
            derivedStateOf { navController.previousBackStackEntry != null }
        }


        var isVisible by remember { mutableStateOf(false) }
        val activity = LocalActivity.current

        BackHandler {
            if (canGoBack) {
                navController.popBackStack()
            } else {
                isVisible = true
            }
        }

        MainNavigation(navController)
        ModalAlert(
            isVisible = isVisible,
            closeModal = { isVisible = false },
            /* 아래 properties는 프로젝트의 Dialog Content에 따라 다르다*/
            mainButton = ButtonData(
                label = "종료하기",
                onClick = { activity?.finish() },
                type = ButtonType.Caution
            ),
            subButton = ButtonData(
                label = "취소",
                onClick = { isVisible = false },
                type = ButtonType.Tertiary
            )
            ...
        )
    }
}
profile
React-Native 개발블로그

0개의 댓글