[Circuit] Overlay

이지훈·2024년 12월 31일
0

Circuit

목록 보기
8/9
post-thumbnail

서론

이번 글에서는 Circuit 에서 Dialog, BottomSheet 와 같은 UI 컴포넌드들을 다룰 때, 사용할 수 있는 Overlay 에 대해 알아보도록 하겠다.

또한, 기존에 주로 사용해왔던 Material3 UI 컴포넌트인 Material3 Dialog, ModalBottomSheet 와 어떤 차이점이 있는지 분석해보도록 하겠다.

본론

Material3 ModalBottomSheet 와의 차이점부터, BottomSheetOverlay 의 사용 방법, 그리고 Navigation 의 Result API 와의 비교까지 순서대로 살펴보도록 하겠다.

Material3 ModalBottomSheet 와의 차이점

결론부터 말하면, Circuit 의 Overlay 컴포넌트들은 Material3 의 Dialog 와 ModalBottomSheet 를 기반으로 구현되어 있으면서도, 한 가지 중요한 차별점을 제공한다.

바로 Result API를 통한 결과값 전달 구조이다.

Circuit 개발팀은 Result API 를 통한 결과 처리에 진심인 듯하다.

Overlay 가 닫힐 때 호출부에 결과값을 전달할 수 있는 Result API 를 도입함으로써, 별도의 콜백 처리없이, Type Safe 한 상태 관리를 가능하게 한다.

DialogOverlay 와 BottomSheetOverlay 의 내부 구현을 살펴보면, 각각 Material3 Dialog 와 Material3 ModalBottomSheet 를 직접 사용하는 것을 확인할 수 있다.

DialogOverlay.kt

import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.slack.circuit.overlay.Overlay
import com.slack.circuit.overlay.OverlayNavigator

/** An overlay that shows a [Dialog]. */
public class BasicDialogOverlay<Model : Any, Result : Any>(
  private val model: Model,
  private val onDismissRequest: () -> Result,
  private val properties: DialogProperties = DialogProperties(),
  private val content: @Composable (Model, OverlayNavigator<Result>) -> Unit,
) : Overlay<Result> {
  @Composable
  override fun Content(navigator: OverlayNavigator<Result>) {
    // Dialog 등장!
    Dialog(
      content = {
        Surface(
          shape = AlertDialogDefaults.shape,
          color = AlertDialogDefaults.containerColor,
          tonalElevation = AlertDialogDefaults.TonalElevation,
        ) {
          content(model, navigator::finish)
        }
      },
      properties = properties,
      onDismissRequest = {
        // This is apparently as close as we can get to an "onDismiss" callback, which
        // unfortunately has no animation
        navigator.finish(onDismissRequest())
      },
    )
  }
}

BottomSheetOverlay.kt

...
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetDefaults
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import com.slack.circuit.overlay.Overlay
import com.slack.circuit.overlay.OverlayNavigator
...

@OptIn(ExperimentalMaterial3Api::class)
public class BottomSheetOverlay<Model : Any, Result : Any>
private constructor(
  private val model: Model,
  private val dismissOnTapOutside: Boolean,
  private val onDismiss: (() -> Result)?,
  private val sheetShape: Shape?,
  private val sheetContainerColor: Color?,
  private val tonalElevation: Dp?,
  private val dragHandle: (@Composable () -> Unit)?,
  private val skipPartiallyExpandedState: Boolean,
  private val properties: ModalBottomSheetProperties,
  private val content: @Composable (Model, OverlayNavigator<Result>) -> Unit,
) : Overlay<Result> {

  public constructor(
 	...
  )

  @Composable
  override fun Content(navigator: OverlayNavigator<Result>) {
    var hasShown by remember { mutableStateOf(false) }
    val sheetState =
      rememberModalBottomSheetState(
        skipPartiallyExpanded = skipPartiallyExpandedState,
        confirmValueChange = { newValue ->
          if (hasShown && newValue == SheetValue.Hidden) {
            dismissOnTapOutside
          } else {
            true
          }
        },
      )

    var pendingResult by remember { mutableStateOf<Result?>(null) }
    // ModalBottomSheet 등장!
    ModalBottomSheet(
      content = {
        val coroutineScope = rememberStableCoroutineScope()
        BackHandler(enabled = sheetState.isVisible) {
          coroutineScope
            .launch { sheetState.hide() }
            .invokeOnCompletion {
              if (!sheetState.isVisible) {
                navigator.finish(onDismiss!!.invoke())
              }
            }
        }
        // Delay setting the result until we've finished dismissing
        content(model) { result ->
          // This is the OverlayNavigator.finish() callback
          coroutineScope.launch {
            pendingResult = result
            sheetState.hide()
          }
        }
      },
      sheetState = sheetState,
      shape = sheetShape ?: RoundedCornerShape(32.dp),
      containerColor = sheetContainerColor ?: BottomSheetDefaults.ContainerColor,
      tonalElevation = tonalElevation ?: 0.dp,
      dragHandle = dragHandle ?: { BottomSheetDefaults.DragHandle() },
      // Go edge-to-edge
      contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
      onDismissRequest = {
        // Only possible if dismissOnTapOutside is false
        check(dismissOnTapOutside)
        navigator.finish(onDismiss!!.invoke())
      },
      properties = properties,
    )

    LaunchedEffect(model, onDismiss) {
      snapshotFlow { sheetState.currentValue }
        .collect { newValue ->
          if (hasShown && newValue == SheetValue.Hidden) {
            // This is apparently as close as we can get to an "onDismiss" callback, which
            // unfortunately has no animation
            val result = pendingResult ?: onDismiss?.invoke() ?: error("no result!")
            navigator.finish(result)
          }
        }
    }
    LaunchedEffect(model, onDismiss) {
      hasShown = true
      sheetState.show()
    }
  }
}
...

정리해보면, Circuit 의 Overlay 컴포넌트들은 기존이 Compose 라이브러리를 대체하는 것이 아닌, 결과 처리를 위한 추가 기능을 제공하는 래퍼(wrapper) 로 동작한다.

래퍼(Wrapper)란 기존 기능을 감싸서 추가적인 동작이나 기능을 제공하는 코드나 컴포넌트를 의미한다.

Overlay 결과 처리 구현

간단한 토이 앱의 코드를 확인해보면서, BottomSheetOverlay 의 사용 방법과 Result API 를 통한 결과 처리 방법을 알아보도록 하겠다.

토이 앱의 플로우는 다음과 같다.

  1. 홈 화면에서 버튼을 눌러 BottomSheetOverlay 를 화면에 노출한다.
  2. BottomSheet 내의 Textfield 에 텍스트를 입력 후 적용 버튼을 눌러 BottomSheet 를 닫는다.
  3. 홈화면에 BottomSheet 의 Textfield 에 입력했던 텍스트를 출력한다.

MainActivity

...
setContent {
    CircuitNavigationResultTheme {
        val backStack = rememberSaveableBackStack(root = HomeScreen)
        val navigator = rememberCircuitNavigator(backStack)

        CircuitCompositionLocals(circuit) {
            Scaffold(
                topBar = {
                    TopAppBar(
                        title = { Text(text = "MainActivity") },
                    )
                },
            ) { innerPadding ->
                // NavigableCircuitContent 를 ContentWithOverlays 로 감싸준다.
                ContentWithOverlays {
                    NavigableCircuitContent(
                        navigator = navigator,
                        backStack = backStack,
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(innerPadding),
                    )
                }
            }
        }
    }
}

Home

// Screen
@Parcelize
data object HomeScreen : Screen {
    data class State(
        val text: String = "",
        val eventSink: (Event) -> Unit,
    ) : CircuitUiState

    sealed interface Event : CircuitUiEvent {
        ...
        data class UpdateTextByOverlay(val text: String) : Event
    }
}

// UI
@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
@Composable
fun Home(
    state: HomeScreen.State,
    modifier: Modifier = Modifier,
) {
    val scope = rememberCoroutineScope()
    val overlay = LocalOverlayHost.current

    Column(
        modifier = modifier.fillMaxSize(),
    ) {
        if (state.text.isNotEmpty()) {
            Text(text = state.text)
        }
        ...
        Button(
            onClick = {
                scope.launch {
                    // Overlay 를 화면에 노출하고, 사용자 입력 결과를 받음 
                    val result: String = overlay.show(InputBottomSheetOverlay())
					// 결과로 받아온 result(입력받은 text) 를 이벤트로 전달하여, 상태 업데이트                   
                    state.eventSink(HomeScreen.Event.UpdateTextByOverlay(result))
                }
            },
        ) {
            Text(text = "Show input bottomSheet")
        }
    }
}

// Presenter
class HomePresenter @AssistedInject constructor(
    @Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {

    @Composable
    override fun present(): HomeScreen.State {
        var text by remember { mutableStateOf("") }
 		...
        return HomeScreen.State(
            text = text,
        ) { event ->
            when (event) {
                ...
                // 이벤트로 전달받은 text 를 통해, HomeScreen 의 상태를 업데이트
                is HomeScreen.Event.UpdateTextByOverlay -> text = event.text
            }
        }
    }
    ...
}

InputBottomSheetOverlay

// 홈 화면으로 text(String 타입)을 전달하기 때문에, 반환 타입을 Overlay<String> 으로 설정 
// 반환 타입을 명시적으로 정의
fun InputBottomSheetOverlay(): Overlay<String> =
    BottomSheetOverlay(
        model = Unit,
        skipPartiallyExpandedState = true,
        // BottomSheet 를 외부 영역 클릭 또는 시스템 백버튼을 눌러 닫을 경우, Result 로 빈 문자열을 반환
        onDismiss = { "" },
        content = { model, navigator ->
            var text by remember { mutableStateOf("") }
            
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 48.dp),
            ) {
                OutlinedTextField(
                    value = text,
                    onValueChange = {
                        text = it
                    },
                    modifier = Modifier.width(300.dp),
                )
                Button(
                    onClick = {
                        // BottomSheet 를 닫으면서 TextField 에 입력한 text 를 홈 화면에 전달
                        navigator.finish(text)
                    },
                ) {
                    Text(text = "Apply")
                }
            }
        },
    )

전체 코드는 아래 레포 링크에서 확인할 수 있다.
https://github.com/easyhooon/CircuitNavigationResult

이전 글에서 확인했듯이, Circuit 의 Navigation 에서도 Activity 의 Result API 와 유사한 방식의 결과 처리 방식(PopResult)을 제공한다.

Overlay, Navigation 두 Result API 는 비슷한 목적을 위해 사용되지만, 동작 방식에서 차이점이 존재한다.

아래의 공식 문서의 비교표를 통해 그 차이점을 확인할 수 있다.

Overlay vs PopResult

프로세스 종료 상황에서의 상태 유지

Overlay: 불가능
PopResult: 가능

타입 안전성

Overlay: 완벽한 타입 안전성 보장
PopResult: 부분적 타입 안전성 (특정 화면이 특정 결과 타입을 반환해야 한다는 강제성이 없음)

결과를 기다리는 동안 suspend

Overlay: 가능
PopResult: 불가능

백 스택 참여

Overlay: 불가능
PopResult: 가능

저장 불가능한 입력/출력 지원

Overlay: 가능
PopResult: 불가능

호출자의 UI 와 상호작용

Overlay: 가능
PopResult: 불가능

여러 다른 결과 타입 반환

Overlay: 불가능
PopResult: 가능

백스택 없이 작동

Overlay: 가능
PopResult: 불가능

이러한 차이점들을 고려하여, 각 상황에 맞는 적절한 방식을 선택해야 한다.

결론

Circuit 의 Overlay 에 대해 알아보고, 기존에 사용해왔던 Material3 UI 컴포넌트인 Material3 Dialog, ModalBottomSheet 와의 어떤 차이점이 있는지 확인해볼 수 있었다.
또한 Overlay 에서 지원하는 Result API 를 통해, 결과 처리를 어떻게 구현하면 되는지, 토이 앱의 코드를 확인해보며 이해해볼 수 있었다.

마찬가지로 Circuit 을 적용하는 경우엔 무조건 DialogOverlay, BottomSheetOverlay 를 써야 하는 것은 아니다.
Circuit 의 Overlay Result API 가 프로젝트의 요구사항과 잘 맞을 경우, 도입을 고려해보면 좋을 것 같다.

Circuit 을 도입한 프로젝트에서 기존처럼 UiState 를 통한 visibility flag(Boolean) 의 형식으로 Material3 Dialog, ModalBottomSheet 를 사용해도 문제는 없다.

소감

이번 분석 이전, 기존 샘플 레포지토리들의 코드를 처음 확인했을 때는, 완전히 새로운 Circuit 만의 UI 컴포넌트 시스템인 줄 알았다.

Presenter 나 Circuit Navigation 등 이미 새롭게 공부해야 할 것이 많은 상황에서 또 공부해야 할 것이 추가된 줄 알고 한숨이 나왔는데, 다행히 Material3 UI 컴포넌트들을 래핑하여 사용하는 것이었다.

기존에 Material3 ModalBottomSheet 를 사용했을 때는 BottomSheet 동작 후 결과 처리(서버 데이터 동기화 및 새로고침)를 위해, onResult 와 같은 콜백 함수를 도입하거나, UiState 내에 상태가 변경되었음을 알리는 flag 변수를 도입하여, 결과 처리를 구현하였던 경험이 있다.

하지만 Circuit 의 BottomSheetOverlay 를 사용한다면, 결과 처리를 어떻게 구현할 지, 구현 방식에 대한 고민을 할 필요가 없어질 것으로 보인다.

scope.launch {
    // BottomSheet 호출과 결과 처리가 순차적으로 표현됨
    val result = overlay.show(InputBottomSheetOverlay())
    state.eventSink(HomeScreen.Event.UpdateTextByOverlay(result))
}

일관된 구현 방식을 제시해주기 때문에, 여러 사람들이 함께 개발하는 프로젝트에서 코드 스타일의 일관성을 유지하는데 도움을 줄 것으로 판단된다.

P.S

글을 쓰는데 굉장히 오랜 시간이 걸렸는데, 단도직입적으로 말하자면 레퍼런스가 부족했기 때문이다.

공식문서의 설명에서도 Overlay 에 대한 설명만 있고, 별도의 BottomSheetOverlay 에 대한 설명은 따로 존재하지 않아, Citcuit github 에 정중하게 issue 를 올려보았다.

Zac Sweers 님의 답변은 다음과 같았다.

문서 내용 추가 PR 을 환영한다고 하시는데... 문서의 내용을 추가하는 PR 을 올릴 정도의 지식을 이미 가지고 있다면, 애초에 문서 내용 추가 요청 이슈를 올리지 않았을 것 같은데요... ㅠ
-> CircuitX 문서에Overlay 에 대한 추가적인 설명 및 예제들이 포함되어 있어, 글을 작성할 수 있었다! 머쓱

레퍼런스)
https://slackhq.github.io/circuit/overlays/
https://github.com/slackhq/circuit/issues/1852
https://slackhq.github.io/circuit/circuitx/
https://github.com/slackhq/circuit/tree/main/circuitx/overlays

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글