프로그래밍/Android

[안드로이드] Dialog Queue 구현하기

Lou Park 2023. 10. 7. 17:51

요구사항

  • 앱 내 액션에 필요한 다이얼로그가 떠야해요.
  • 하지만 유저가 클릭하지 않아도 서버가 푸시하면 앱의 어디서든, 언제든지 다이얼로그가 뜰 수 있어요.
  • 튜토리얼을 할때는 튜토리얼용 다이얼로그를 제외한 모든 다이얼로그가 뜨지 않아야해요.
  • 그리고 이 모든 다이얼로그들이 서로 꼬이지 않아야 해요.

다이얼로그를 한 두개 띄울때는 아무런 문제가 없었다. 하지만 요구사항에 따라 다이얼로그 추가되고, 이내 범벅이 되면서 다이얼로그 위에 다이얼로그가 떠버리거나 순서가 꼬여버려 좋지 않은 UX를 제공하게되는 결과를 야기했다.

 

아이디어

이것은 고등학교 급식 문제와 같다.

https://www.sisain.co.kr/news/articleView.html?idxno=22952

12시 종이 땡 치면 전교생이 우르르 급식을 먹으러오는 상황이다. 하지만 배식 라인(View)은 단 하나뿐!

3학년은 점심시간 중 자습시간이 있어 빨리 밥을 먹어야하는데, 급식의 체계가 없으니 밥을 못먹는 3학년이나 밥먹다 자습에 늦는 3학년도 생기는 거다.

 

이를 해결하기 위해서 PriorityQueue가 필요하다고 생각했다.

급식 줄을 쫙 서서 한명씩 보내는데, 3학년은 우선으로 먹게해주는거다.

 

기본적인 라인은 이건데, 다시 돌아와서 내가 풀어야할 건 안드로이드 앱에서 이것을 다이얼로그들에 적용시기는 것이다. 생명주기도 고려해야하고, 심지어 앱이 아직 전부 JetpackCompose로 넘어간것도 아니라서 XML/Compose가 혼용되고 있다. 해결의 단순화를 위해 일단은, DialogFragment만 생각하기로 했다.

 

사용법

팀장님이 뭔가를 만들때, “내가 어떻게 쓸거냐”를 먼저 생각해보라고 하셨다.

그래서 일단 쓰고싶은 희망사항으로…이렇게 구성해봤다.

coroutineScope.launch {
    val ageResult = DialogHandler.showDialogForResult(PickAge) // 나이선택
    ageResult.onSuccess { age ->
        DialogHandler.showDialog(ShowAge, ageResult.age) // 선택한 나이가 보여짐
    }
}

coroutineScope.launch {
    delay(300)
    DialogHandler.showDialog(GotMail) // 메일이 갑자기 날아옴
}

다이얼로그를 보여주는 것은 다이얼로그의 유형과, 다이얼로그를 그리는데 필요한 데이터를 전달해주는 것 만으로 끝나면 좋을 거 같았다.

내가 바라는 Flow는 다음과 같다.

  • 나이 선택 다이얼로그가 뜸
  • 유저가 나이를 선택하는 동안, 다이얼로그로 보여줘야하는 메일이 옴.
  • 유저는 나이를 선택하고 다이얼로그가 닫힘
  • 메일 다이얼로그를 봄
  • 유저가 이전에 선택했던 나이를 확인하는 다이얼로그가 뜸

 

메일 다이얼로그 이전에 UX를 개선하기 위해 나이 확인 다이얼로그를 다른 어떤 다이얼로그보다 먼저 보여줘야한다면, 이렇게 고치면된다.

DialogHandler.showDialog(ShowAge, ageResult.age, priority = Priority.HIGH)

 

그러면 이렇게 동작한다.

  • 나이 선택 다이얼로그가 뜸
  • 유저가 나이를 선택하는 동안, 다이얼로그로 보여줘야하는 메일이 옴.
  • 유저는 나이를 선택하고 다이얼로그가 닫힘
  • 유저가 이전에 선택했던 나이를 확인하는 다이얼로그가 뜸
  • 메일 다이얼로그를 봄

 

구현하기

바라는대로, 실제로 구현이 되었다. 뿅!하고 생기기보다는 삽질을 좀 한것같다…

전체 구조를 보면 다음과 같다.

 

DialogQueue

DialogQueue는 보여줄 다이얼로그를 밀어넣는(add) Public Interface뿐이다.

Queue의 아이템으로는 DialogQueueElement가 있는데 이는 다이얼로그의 우선순위와 구분자(tag)와 함께, 주입받은 Context를 가지고 다이얼로그를 만들어내는 builder를 가지고 있다. 이렇게 구성한 이유는, 다이얼로그를 밀어넣었을때와 다이얼로그를 실제로 보여줄 수 있을때 Context의 차이가 있을 수 있기 때문이다. 다이얼로그를 보여주려고 할때쯤 유저가 Activity를 옮겨버리는 경우가 그 예시다.

 

DialogFragment가 아닌 QueueDialogFragment가 필요한지는 후술하겠다.

 

Queue의 구현(DialogQueueImpl)은 다음과 같다.

 

MainActivitySecondaryActivity로 이동한 유저가 SecondaryActivity에서다이얼로그를 보여줘야할때쯤 유저가 뒤로가기를 눌러 MainActivity로 이동했을때, add()에 의해 SecondaryActivity에서 다이얼로그가 이미 떠버렸을 수 있다. 화면을 이동해버린 유저는 이를 꿈에도 모르고, finish()가 되어버렸으니 다이얼로그도 종료된다.

 

이러한 형태의 다이얼로그 유실을 방지하기 위해, 다이얼로그가 떠있는 도중에 onPause 이벤트가 발생하게 되면 현재 다이얼로그(showingElement)를 다시 넣어준다. 기본 다이얼로그 Priority가 LOW이기 때문에, MEDIUM또는 HIGH로 높여서 넣어준다.

 

그러면 다시 MainActivity로 돌아온 유저는 MainActivityonResume 이벤트에 의한 flush()로 이전에 SecondaryActivity에서 못봤던 다이얼로그를 볼 수 있게 된다.

 

DialogHandler

DialogHandler는 Queue에 접근할 수 있는 녀석이고, 하나의 다이얼로그가 보여지고 닫힌 후 결과를 받아와서 돌려주는 역할을 한다. Result<Res>가 바로 그 결과다. 왜 Res가 아닌 Result<Res>로 했냐면, 나이를 묻는 다이얼로그가 있을때 나이를 받아올 수 있지만, “나이는 왜 물어보는거야!”며 다이얼로그를 닫아버릴 수 있다.

 

실제로 나이를 받아와서 어떤 처리를 하려고했다면 못받아 온 것은 오류이기에, Result<Res>로 만들었다. Null은 애매모호하니…

 

각 메소드의 쓰임새는 다음과 같다.

  • showDialog: 그냥 Queue에 던져버리고, 언젠가 보여주면 되는 다이얼로그
  • showDialogForResult: 보여주고 어떤 결과를 받아야하는 다이얼로그

 

실제로 구현 (DialogHandlerImpl) 코드는 다음과 같은데, CompletableDeferred를 이용해 다이얼로그가 결과를 뱉거나 닫히기까지 기다릴 수 있다.

 

CompletableDeferred가 끝날때 invokeOnCompletion이 호출되고, 다이얼로그를 닫고 완료처리하는 method가 실행된다. CompletableDeferred가 끝나야 어쨌든 다음 다이얼로그가 보이기 때문에 다이얼로그가 닫히는 이벤트는 “무조건” 잡아야한다. 그렇지 않으면 영영 다이얼로그는 안보일 것이다.

 

그래서 QueueDialogFragment를 따로 만들었다. 닫힐때 무조건 deferred를 complete 상태로 만들 수 있도록…

abstract class QueueDialogFragment<T>(
    private val deferred: CompletableDeferred<T>,
): DialogFragment() {

    override fun onPause() {
        super.onPause()
        dismiss()
    }

    override fun dismiss() {
        dismissAllowingStateLoss()
    }

    override fun onDismiss(dialog: DialogInterface) {
        super.onDismiss(dialog)
        deferred.completeExceptionally(IllegalStateException("Dialog dismissed"))
    }
}

 

DialogBuilder

DialogHandlershowDialog()를 할때 넘겨주는 Builder는 이렇게 생겼다.

sealed interface DialogBuilder<Req, Res> {
    fun build(
        context: Context,
        req: Req,
        deferred: CompletableDeferred<Res>,
    ): QueueDialogFragment<Res>
}

 

예시로 들었던 나이선택 다이얼로그는 이렇게 할 수 있다.

지금 설명중인 DialogQueue 시스템과는 관련 없지만 현재 프로젝트가 Compose를 혼용하고 있기때문에 다이얼로그 안의 UI는 Compose로 적을 수 있도록 ComposeDialog를 따로 만들었다.

object PickAge : DialogBuilder<Unit, Int> {
    override fun build(
        context: Context,
        req: Unit,
        deferred: CompletableDeferred<Int>,
    ): QueueDialogFragment<Int> {
        return ComposeDialog(
            content = {
                PickAgeDialog(onPick = { age ->
                    deferred.complete(age)
                })
            },
            deferred = deferred,
        )
    }
}

@Composable
fun PickAgeDialog(
    onPick: (Int) -> Unit
) {
    ...
}
class ComposeDialog<T>(
    val content: @Composable () -> Unit,
    deferred: CompletableDeferred<T>,
) : QueueDialogFragment<T>(deferred) {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.dialog_compose, container, false)
        val composeView = view.findViewById<ComposeView>(R.id.compose_view)
        composeView.setContent {
            JetsurveyTheme {
                content()
            }
        }
        return view
    }
}

 

마무리

이 프로젝트의 전체 소스코드는 https://github.com/gold24park/Dialog-Queue 에서 볼 수 있다.

다음 출근때 실제 프로젝트에 적용해봐야지...ㅋㅋㅋ