개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - 커스텀 BottomSheetScaffold 개발 유예

interfacer_han 2025. 3. 19. 18:23

#1 개요

#1-1 개발 이유

말로 설명하기 힘들지만, BottomSheetScaffold의 내부 코드를 살짝만 바꾸면 내가 원하는 동작을 구현할 수 있었다. 그렇다고 internal이나 private 접근 지정자가 붙은 내부 코드를 내가 커스텀할 수도 없는 노릇이었다. public한 함수들로 구현도 할 수 있는 모든 걸 해봤다. 결국 깊숙히 파고들어본 결과, 반드시 내부 코드를 바꿔야만 내가 원하는 동작을 구현할 수 있다는 걸 깨달았다.

 

정확히는, NestedScrollConnection의 기제를 알게되고나서 깨달았다. 순정 BottomSheetScaffold에 가해지는 사용자의 터치 입력(Pointer input)은 최상위 부모(인 내부 코드)까지 올라가는데, 이를 막을 방법이 없다는 걸 알게된 것이다. 그래서 몇 주간 순정 BottomSheetScaffold를 모방한 BottomSheetScaffold를 만들려 했다. 커스텀 BottomSheetScaffold를 만들기 위해선 순정 BottomSheetScaffold 내부 코드들을 전부 public하게 만들려고 한 것이다.

 

#1-2 유예 이유

그러나, 순정 BottomSheetScaffold의 코드는 내가 이해하기 벅찼다. 먼저 일단은 (순정 BottomSheetScaffold의) 코드를 읽을 수 있어야 한다고 생각했다. 가령 NestedScroll의 기제에 대한 파악은, BottomSheet의 스크롤 동작을 이해하는데 필수적이다. 또, NestedScroll의 기제를 알려면 더 깊숙히 들어가 Pointer input까지 알고 있어야 한다. 그래서 난 공식 문서의 Pointer input 부분을 아래와 같이 나만의 언어로 정리하며 공부했다.

 

 

[Android] Pointer input - PointerInputChange, PointerEvent

#1 개요 동작 이해하기  |  Jetpack Compose  |  Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. 동작 이해하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저

kenel.tistory.com

스크롤 동작을 이해하고 나니, 훨씬 더 BottomSheetScaffold의 내부 코드를 이해하기 쉬웠다. 그러나, 그 이후에도 넘어야 할 산(드래그 동작 및 아키텍처에 대한 이해)은 많았다. 하나하나 점령하면 그만이었다. 그러나, 내가 이미 소모한 시간 그리고 앞으로 소모해야할 시간이 아른거렸다. 

 

#1-3 실패가 아닌 유예

정말 실패였다면, 굳이 게시글로 남길 이유가 없다. 실패가 아닌 유예다. 살짝만 바꿔서 훨씬 더 좋은 사용자 경험을 얻어낼 수 있는 동작이라고 생각하기 때문에 아예 포기하기는 너무나도 아쉽다. 그래서 내부 코드를 그대로 복사한 코드를 여기에 남겨두고 틈날 때마다 공부해보려고 한다. 

 

또, 본 게시글은 지속적으로 수정할 것이다. 당분간은 주석을 계속 추가해서 코드를 해석하거나, 내가 공부해야할 개념들을 적어나갈듯 하다.

 

#2 코드 - ReplicatedBottomSheetScaffold.kt

#2-1 공식 라이브러리에서 가져온 코드

package com.example.replicatedbottomsheetscaffold

import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffoldState
import androidx.compose.material3.DraggableAnchors // error: Cannot access 'DraggableAnchors': it is internal in 'androidx. compose. material3'
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplicatedBottomSheetScaffold(
    sheetContent: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
    sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
    sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
    sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
    sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
    sheetContentColor: Color = contentColorFor(sheetContainerColor),
    sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
    sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
    sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
    sheetSwipeEnabled: Boolean = true,
    topBar: @Composable (() -> Unit)? = null,
    snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
    containerColor: Color = MaterialTheme.colorScheme.surface,
    contentColor: Color = contentColorFor(containerColor),
    content: @Composable (PaddingValues) -> Unit
) {
    val peekHeightPx = with(LocalDensity.current) {
        sheetPeekHeight.roundToPx()
    }
    ReplicatedBottomSheetScaffoldLayout(
        modifier = modifier,
        topBar = topBar,
        body = content,
        snackbarHost = {
            snackbarHost(scaffoldState.snackbarHostState)
        },
        sheetPeekHeight = sheetPeekHeight,
        sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
        sheetState = scaffoldState.bottomSheetState,
        containerColor = containerColor,
        contentColor = contentColor,
        bottomSheet = { layoutHeight ->
            ReplicatedStandardBottomSheet(
                state = scaffoldState.bottomSheetState,
                peekHeight = sheetPeekHeight,
                sheetMaxWidth = sheetMaxWidth,
                sheetSwipeEnabled = sheetSwipeEnabled,
                calculateAnchors = { sheetSize ->
                    val sheetHeight = sheetSize.height
                    DraggableAnchors {  // error: Cannot access 'DraggableAnchors': it is internal in 'androidx. compose. material3'
                        if (!scaffoldState.bottomSheetState.skipPartiallyExpanded) { // error: Cannot access 'skipPartiallyExpanded': it is internal in 'SheetState'
                            SheetValue.PartiallyExpanded at (layoutHeight - peekHeightPx).toFloat() // error: Cannot access 'DraggableAnchorsConfig': it is internal in 'androidx. compose. material3'
                        }
                        if (sheetHeight != peekHeightPx) {
                            SheetValue.Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat() // error: Cannot access 'DraggableAnchorsConfig': it is internal in 'androidx. compose. material3'
                        }
                        if (!scaffoldState.bottomSheetState.skipHiddenState) { // error: Cannot access 'skipHiddenState': it is internal in 'SheetState'
                            SheetValue.Hidden at layoutHeight.toFloat() // error: Cannot access 'DraggableAnchorsConfig': it is internal in 'androidx. compose. material3'
                        }
                    }
                },
                shape = sheetShape,
                containerColor = sheetContainerColor,
                contentColor = sheetContentColor,
                tonalElevation = sheetTonalElevation,
                shadowElevation = sheetShadowElevation,
                dragHandle = sheetDragHandle,
                content = sheetContent
            )
        }
    )
}

내용 추가 예정.

 

#3 ReplicatedBottomSheetScaffoldLayout.kt

#3-1 공식 라이브러리에서 가져온 코드

package com.example.replicatedbottomsheetscaffold

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplicatedBottomSheetScaffoldLayout(
    modifier: Modifier,
    topBar: @Composable (() -> Unit)?,
    body: @Composable (innerPadding: PaddingValues) -> Unit,
    bottomSheet: @Composable (layoutHeight: Int) -> Unit,
    snackbarHost: @Composable () -> Unit,
    sheetPeekHeight: Dp,
    sheetOffset: () -> Float,
    sheetState: SheetState,
    containerColor: Color,
    contentColor: Color,
) {
    // b/291735717 Remove this once deprecated methods without density are removed
    val density = LocalDensity.current
    SideEffect {
        sheetState.density = density // error: Cannot access 'density': it is internal in 'SheetState'
    }
    SubcomposeLayout { constraints ->
        val layoutWidth = constraints.maxWidth
        val layoutHeight = constraints.maxHeight
        val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)

        val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
            bottomSheet(layoutHeight)
        }[0].measure(looseConstraints)

        val topBarPlaceable = topBar?.let {
            subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
                .measure(looseConstraints)
        }
        val topBarHeight = topBarPlaceable?.height ?: 0

        val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
        val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
            Surface(
                modifier = modifier,
                color = containerColor,
                contentColor = contentColor,
            ) { body(PaddingValues(bottom = sheetPeekHeight)) }
        }[0].measure(bodyConstraints)

        val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
            .measure(looseConstraints)

        layout(layoutWidth, layoutHeight) {
            val sheetOffsetY = sheetOffset().roundToInt()
            val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)

            val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
            val snackbarOffsetY = when (sheetState.currentValue) {
                SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height
                SheetValue.Expanded, SheetValue.Hidden -> layoutHeight - snackbarPlaceable.height
            }

            // Placement order is important for elevation
            bodyPlaceable.placeRelative(0, topBarHeight)
            topBarPlaceable?.placeRelative(0, 0)
            sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
            snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
        }
    }
}

private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar }

내용 추가 예정.

 

#4 ReplicatedStandardBottomSheet.kt

#4-1 공식 라이브러리에서 가져온 코드

package com.example.replicatedbottomsheetscaffold

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection // error: Cannot access 'ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection': it is internal in 'androidx. compose. material3'
import androidx.compose.material3.DraggableAnchors // error: Cannot access 'DraggableAnchors': it is internal in 'androidx. compose. material3'
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.SheetValue.Hidden
import androidx.compose.material3.SheetValue.PartiallyExpanded
import androidx.compose.material3.Strings // error: Cannot access 'Strings': it is internal in 'androidx. compose. material3'
import androidx.compose.material3.Surface
import androidx.compose.material3.anchoredDraggable // error: Cannot access 'anchoredDraggable': it is internal in 'androidx. compose. material3'
import androidx.compose.material3.getString // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplicatedStandardBottomSheet(
    state: SheetState,
    calculateAnchors: (sheetSize: IntSize) -> DraggableAnchors<SheetValue>, // error: Cannot access 'DraggableAnchors': it is internal in 'androidx. compose. material3'
    peekHeight: Dp,
    sheetMaxWidth: Dp,
    sheetSwipeEnabled: Boolean,
    shape: Shape,
    containerColor: Color,
    contentColor: Color,
    tonalElevation: Dp,
    shadowElevation: Dp,
    dragHandle: @Composable (() -> Unit)?,
    content: @Composable ColumnScope.() -> Unit
) {
    val scope = rememberCoroutineScope()

    val orientation = Orientation.Vertical

    Surface(
        modifier = Modifier
            .widthIn(max = sheetMaxWidth)
            .fillMaxWidth()
            .requiredHeightIn(min = peekHeight)
            .nestedScroll(
                remember(state.anchoredDraggableState) { // error: Cannot access 'anchoredDraggableState': it is internal in 'SheetState'
                    ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( // error: Cannot access 'ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection': it is internal in 'androidx. compose. material3
                        sheetState = state,
                        orientation = orientation,
                        onFling = { scope.launch { state.settle(it) } } // error: Cannot access 'settle': it is internal in 'SheetState'
                    )
                }
            )
            .anchoredDraggable( // error: Cannot access 'anchoredDraggable': it is internal in 'androidx. compose. material3'
                state = state.anchoredDraggableState,
                orientation = orientation,
                enabled = sheetSwipeEnabled
            )
            .onSizeChanged { layoutSize ->
                val newAnchors = calculateAnchors(layoutSize)
                val newTarget = when (state.anchoredDraggableState.targetValue) { // Cannot access 'anchoredDraggableState': it is internal in 'SheetState'
                    Hidden, PartiallyExpanded -> PartiallyExpanded
                    Expanded -> {
                        if (newAnchors.hasAnchorFor(Expanded)) Expanded else PartiallyExpanded // Cannot access 'DraggableAnchors': it is internal in 'androidx. compose. material3'
                    }
                }
                state.anchoredDraggableState.updateAnchors(newAnchors, newTarget) // Cannot access 'anchoredDraggableState': it is internal in 'SheetState'
            },
        shape = shape,
        color = containerColor,
        contentColor = contentColor,
        tonalElevation = tonalElevation,
        shadowElevation = shadowElevation,
    ) {
        Column(Modifier.fillMaxWidth()) {
            if (dragHandle != null) {
                val partialExpandActionLabel =
                    getString(Strings.BottomSheetPartialExpandDescription) // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'
                val dismissActionLabel = getString(Strings.BottomSheetDismissDescription) // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'
                val expandActionLabel = getString(Strings.BottomSheetExpandDescription) // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'
                Box(
                    Modifier
                        .align(CenterHorizontally)
                        .semantics(mergeDescendants = true) {
                            with(state) {
                                // Provides semantics to interact with the bottomsheet if there is more
                                // than one anchor to swipe to and swiping is enabled.
                                if (anchoredDraggableState.anchors.size > 1 && sheetSwipeEnabled) { // error: Cannot access 'anchoredDraggableState': it is internal in 'SheetState'
                                    if (currentValue == PartiallyExpanded) {
                                        if (anchoredDraggableState.confirmValueChange(Expanded)) { // error: Cannot access 'anchoredDraggableState': it is internal in 'SheetState'
                                            expand(expandActionLabel) {
                                                scope.launch { expand() }; true
                                            }
                                        }
                                    } else {
                                        if (anchoredDraggableState.confirmValueChange( // error: Cannot access 'anchoredDraggableState': it is internal in 'SheetState'
                                                PartiallyExpanded
                                            )
                                        ) {
                                            collapse(partialExpandActionLabel) {
                                                scope.launch { partialExpand() }; true
                                            }
                                        }
                                    }
                                    if (!state.skipHiddenState) { // error: Cannot access 'skipHiddenState': it is internal in 'SheetState'
                                        dismiss(dismissActionLabel) {
                                            scope.launch { hide() }
                                            true
                                        }
                                    }
                                }
                            }
                        },
                ) {
                    dragHandle()
                }
            }
            content()
        }
    }
}

내용 추가 예정.

 

#4-2 시멘틱 코드

#4-1의 상당 부분이 Semantics(시멘틱) 코드에 난 에러다. 시멘틱 정보에 internal 접근제어자가 붙어있기 때문이리라. 시멘틱은 한 마디로, 스마트폰'을' 위한 UI다. 스마트폰이 화면 속 요소의 설명과 상태 등을 알게하는 것이다. 이게 무슨 의미가 있을까? 분명 일반적인 상황에서는 의미가 없다. 하지만, 사용자에게 시각 장애가 있다면 이런 Sematics가 도움이 된다. 음성 명령 등으로 UI를 조작한다고 생각해보자. 사용자가 "전화 버튼을 눌러줘"라고 말한다. 스마트폰은 "전화 버튼"이 무엇인지 알아야 한다. 시멘틱은 "전화 버튼"과 같은 정보를 프로그래밍 단계에서 미리 입력하는 것이다. 스마트폰이 쓸 수 있게 말이다. 따라서 시멘틱은 스마트폰'을' 위한 UI다.

 

val partialExpandActionLabel = "TODO: internal 시멘틱 라벨 대신, 내가 직접 써넣을 것 1" // 원래 코드: getString(Strings.BottomSheetPartialExpandDescription) // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'
val dismissActionLabel = "TODO: internal 시멘틱 라벨 대신, 내가 직접 써넣을 것 2" // 원래 코드: getString(Strings.BottomSheetDismissDescription) // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'
val expandActionLabel = "TODO: internal 시멘틱 라벨 대신, 내가 직접 써넣을 것 3" // 원래 코드: getString(Strings.BottomSheetExpandDescription) // error: Cannot access 'getString': it is internal in 'androidx. compose. material3'

partialExpandActionLabel, dismissActionLabel, expandActionLabel는 internal 시멘틱 라벨 스트링이 할당된다. 따라서 (빨간 줄을 없애기 위해서) 임시로 아무 라벨을 부여했다.

 

#4-3 AnchoredDraggable

딱 봐도, Drag 동작을 알아야 이해 가능한 코드다. 그러나 난 Darg 코드의 동작 기제를 잘 모른다. 잘 모르는 코드를 겉핥기식으로 이해하는 '척'하는 건 죄악이다. 반드시 그 업보를 치루게 된다. 그래서 아예 Drag 동작에 대해 공부하는 게시글을 만들었다.

 

 

[Android] Pointer input - Drag

#1 개요#1-1 공식 문서 Swipeable에서 AnchoredDraggable로 이전  |  Jetpack Compose  |  Android Developers이 페이지는 Cloud Translation API를 통해 번역되었습니다. Swipeable에서 AnchoredDraggable로 이전 컬렉션을 사용

kenel.tistory.com

내용 추가 예정.

 

#3 후일담

추가 예정