#1 ๊ฐ์
BottomSheetScaffold()์ ๋ฌธ์ ์ ์ ๊ฐ์ ํ, ์ปค์คํ
์ปดํฌ๋ํธ ContentWithSwipeableBottomSheet()๋ฅผ ๊ตฌํํ๋ค (๊ฒ์๊ธ ๋งจ ์๋์ ์์ค์ฝ๋ ์์).
#2 BottomSheetScaffold()
#2-1 ๊ฐ์
androidx.compose.material | API reference | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
๋ง ๊ทธ๋๋ก Bottom์ชฝ์ Swipe ๊ฐ๋ฅํ Sheet๋ฅผ ๋ณด์ ํ Scaffold๋ค.
#2-2 ํ๊ณ
(1) content ๋์ด์ ๋์ ์กฐ์ ๋ถ๊ฐ๋ฅ
sheet์ ์๋ dragHandle์ swipe ํ ์ ์๋ค. ํ์ง๋ง, sheet์ swipe ๋์์ผ๋ก content์ ๋์ด์ ์ํฅ์ ์ค ์๋ ์๋ค.
(2) nestedScroll ๊ฐ์ ์ ์ฉ
sheet์ ์ ์ฉ๋ nestedScroll ๋๋ฌธ์, sheet๋ฅผ Expanded ์ํ๋ก ๋์ด์ผ ๋น๋ก์ sheetContent๋ฅผ ์คํฌ๋กคํ ์ ์๋ค.
#2-3 ์ ๊ทธ๋ ์ด๋
#2-2์ ํ๊ณ๋๋ฌธ์ ๊ฐ๋ฐ ์ค์ด๋ ์ฑ์ ๋ง๋ค ์ ์์๋ค. ๊ทธ๋์ BottomSheetScaffold()์ ๊ฐ์ ํ ์ปค์คํ
์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค์๋ค. ์ด๋ฆ์ ContentWithSwipeableBottomSheet()๋ค. content๋ฅผ ๊ฐ์ธ๋ wrapper๋ฉด์, bottomSheet๋ฅผ ๋ณด์ ํ์์ ํํํ ์ด๋ฆ์ด๋ค.
#3 ContentWithSwipeableBottomSheet()
#3-1 ์์ฑ๋ ๋ชจ์ต ๋ฏธ๋ฆฌ๋ณด๊ธฐ
ContentWithSwipeableBottomSheet()๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ง๋ ์ํ ์ฑ(#6)์ด๋ค. swipe ๋์์ผ๋ก sheetContent ๋ฐ content ์์ญ์ ํฌ๊ธฐ๊ฐ ๋์ ์ผ๋ก ๋ณํ๋ ๋ชจ์ต ๊ทธ๋ฆฌ๊ณ nestScroll์ด ์์ด sheetContent๋ฅผ ์คํฌ๋กคํ ์ ์์์ ํ์ธํ ์ ์๋ค.
#3-2 API (ํจ์ ์ธ์ ๋ถ๋ถ)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ContentWithSwipeableBottomSheet(
sheetContent: @Composable (() -> Unit),
modifier: Modifier = Modifier,
sheetState: ContentWithSwipeableBottomSheetState = rememberContentWithSwipeableBottomSheetState(),
sheetExpandedAnchorOffset: Dp = 0.dp,
sheetPartiallyExpandedHeight: Dp = 300.dp,
sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = 0.dp,
sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
sheetDragHandle: @Composable (() -> Unit) = { BottomSheetDefaults.DragHandle() },
sheetSwipeEnabled: Boolean = true,
hasContentNavigationBarPadding: Boolean = false,
content: @Composable (() -> Unit),
) {
...
}
์ธ์ ์ด๋ฆ๊ณผ ๊ธฐ๋ฅ์ BottomSheetScaffold()์ ๊ฒ๊ณผ ์ต๋ํ ๋๊ฐ์ด ๋ง์ท๋ค. ์ด์ ๋ 2๊ฐ์ง๋ค. ์ฒซ์งธ๋ก BottomSheetScaffold()์ ๊ธฐ๋ฐํ ์ปค์คํ
์ปดํฌ๋ํธ์ด๊ธฐ ๋๋ฌธ์ด๋ค. ๋์งธ๋ก ๊ธฐ๋ฐ์ด ๋ BottomSheetScaffold()์ ๊ตฌ๊ธ '๊ณต์' ์ปดํฌ๋ํธ์ด๊ธฐ ๋๋ฌธ์ด๋ค. '๊ณต์'์ด ์ ๊ณตํ๋ ์ด๋ฆ์ ์๋๋ก์ด๋ ๊ฐ๋ฐ์ ๋ชจ๋์๊ฒ ์น์ํ ๊ฒ์ด๋ฏ๋ก, ์ด ์น์ํ ์คํค๋ง๋ฅผ ์ฌ์ฉํ์ง ์์ ์ด์ ๊ฐ ์๋ค.
์ธ์ ์ค hasContentNavigationBarPadding๋, content ํ๋กํผํฐ์ ์ ๋ฌํ UI๊ฐ '๋ค๋น๊ฒ์ด์
๋ฐ๋ฅผ ๊ณ ๋ คํ bottomPadding'์ ์ง๋
๋์ง์ ์ฌ๋ถ๋ค. ๋ฌ๊ธ์์ด ์ ์ด๋ฐ ์ธ์๋ฅผ ๋ฃ์๋์ง ๊ถ๊ธํ ๊ฒ์ด๋ค. ์ด๋ bottomSheet์ ์์น ๋ณ๋ ๋๋ฌธ์ด๋ค. #4-3์์ ์์ธํ ์ค๋ช
ํ๋ค.
#3-3 ๊ตฌํ (ํจ์ ๋ชธํต ๋ถ๋ถ)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ContentWithSwipeableBottomSheet(
...
) {
AssertSoftInputModeIsAdjustNothing() // #6-2์์ ์ค๋ช
val density = LocalDensity.current
BoxWithConstraints(
modifier = modifier
) {
val layoutHeight = this.maxHeight
val anchoredDraggableState = sheetState.anchoredDraggableState
LaunchedEffect(density, layoutHeight, sheetExpandedAnchorOffset, sheetPartiallyExpandedHeight) {
anchoredDraggableState.updateAnchors(
DraggableAnchors<SheetValue> {
SheetValue.Expanded at with(density) { sheetExpandedAnchorOffset.toPx() }
SheetValue.PartiallyExpanded at with(density) { (layoutHeight - sheetPartiallyExpandedHeight).toPx() }
SheetValue.Hidden at with(density) { layoutHeight.toPx() }
}
)
}
ContentWithSwipeableBottomSheetLayout(
anchoredDraggableState = anchoredDraggableState,
body = content,
bottomSheet = {
BottomSheetLayout(
anchoredDraggableState = anchoredDraggableState,
sheetSwipeEnabled = sheetSwipeEnabled,
shape = sheetShape,
containerColor = sheetContainerColor,
contentColor = sheetContentColor,
tonalElevation = sheetTonalElevation,
shadowElevation = sheetShadowElevation,
dragHandle = sheetDragHandle,
content = sheetContent
)
},
hasBodyNavigationBarPadding = hasContentNavigationBarPadding
)
}
}
๋ณธ ์ปดํฌ๋ํธ๋ swipe ๋์์ ์ํฅ์ ๋ฐ์ ๋์ด๋ฅผ ์กฐ์ ํ๋ค. ๊ทธ๋์ AnchoredDraggableState๊ฐ ์ค์ํ ์ญํ ์ ์ํํ๋ค. BoxWithConstraints()๋ฅผ ์ด ์ด์ ๋ AnchoredDraggableState ๋๋ฌธ์ด๋ค. AnchoredDraggableState.updateAnchors()๋ฅผ ํตํด anchor๋ฅผ ๊ฒฐ์ ํ๊ธฐ ์ํด์๋ ContentWithSwipeableBottomSheet()๋ฅผ ๋ด๋ ๋ถ๋ชจ์ ๋์ด๊ฐ ํ์ํ๊ธฐ ๋๋ฌธ์ด๋ค.
์ดํ๋ก๋ private fun ContentWithSwipeableBottomSheetLayout()์ผ๋ก ๋์ด๊ฐ๋ค. ContentWithSwipeableBottomSheet()๊ฐ ๋ฐ์ ๋๋ถ๋ถ์ ์ธ์๋ฅผ BottomSheetLayout()์ด ๋ฐ์๋ด๊ณ ์์์ด ๋ณด์ธ๋ค.
#4 ContentWithSwipeableBottomSheetLayout()
#4-1 ์ฝ๋ ์ด์
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContentWithSwipeableBottomSheetLayout(
anchoredDraggableState: AnchoredDraggableState<SheetValue>,
body: @Composable (() -> Unit),
bottomSheet: @Composable (() -> Unit),
// hasBodyNavigationBarPadding: Boolean
) {
if (!anchoredDraggableState.offset.isNaN()) {
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val sheetOffsetY = anchoredDraggableState.offset
val bodyPlaceables = subcompose("body") { body() }.fastMap {
val sheetPartiallyExpandedOffset = anchoredDraggableState.anchors.positionOf(SheetValue.PartiallyExpanded)
val sheetHiddenOffset = anchoredDraggableState.anchors.positionOf(SheetValue.Hidden)
val maxHeightAffectedBySheetOffsetY = sheetOffsetY.coerceIn(
minimumValue = sheetPartiallyExpandedOffset,
maximumValue = sheetHiddenOffset
)
val imePlaceables = subcompose("ime") { Spacer(modifier = Modifier.imePadding()) }.fastMap { it.measure(looseConstraints) }
val imeOffsetY = layoutHeight - (imePlaceables.fastMaxBy { it.height }?.height ?: 0)
val maxHeightAffectedByImeOffsetY = imeOffsetY.coerceAtMost(
maximumValue = sheetHiddenOffset.fastRoundToInt()
)
it.measure(
looseConstraints.copy(
maxHeight = minOf(
maxHeightAffectedBySheetOffsetY.fastRoundToInt(),
maxHeightAffectedByImeOffsetY
)
)
)
}
val bottomSheetPlaceables = subcompose("bottomSheet") { bottomSheet() }.fastMap {
it.measure(
looseConstraints.copy(
maxHeight = (layoutHeight - sheetOffsetY).fastRoundToInt()
)
)
}
layout(layoutWidth, layoutHeight) {
bodyPlaceables.fastForEach { it.place(0, 0) }
bottomSheetPlaceables.fastForEach { it.place(0, sheetOffsetY.fastRoundToInt()) }
}
}
}
}
hasBodyNavigationBarPadding์ ๊ตฌํํ์ง ์์, ์ฝ๋ ์ด์์ด๋ค.
#4-2 content์ maxHeight ๊ฒฐ์ 1
์๋๋ #4-1์ ์๋ ์ฝ๋์ bodyPlaceables ๊ตฌํ ๋ถ๋ถ์ด ์ ๋ณด์ด๊ฒ ๊ฐ์กฐํ๊ณ , ํด๋น ์ฝ๋๋ฅผ ์ค๋ช
ํ ๊ฒ์ด๋ค.
...
@Composable
private fun ContentWithSwipeableBottomSheetLayout(
anchoredDraggableState: AnchoredDraggableState<SheetValue>,
body: @Composable (() -> Unit),
bottomSheet: @Composable (() -> Unit),
// hasBodyNavigationBarPadding: Boolean
) {
if (...) {
SubcomposeLayout { ... ->
...
val bodyPlaceables = subcompose("body") { body() }.fastMap {
val sheetPartiallyExpandedOffset = anchoredDraggableState.anchors.positionOf(SheetValue.PartiallyExpanded)
val sheetHiddenOffset = anchoredDraggableState.anchors.positionOf(SheetValue.Hidden)
val maxHeightAffectedBySheetOffsetY = sheetOffsetY.coerceIn(
minimumValue = sheetPartiallyExpandedOffset,
maximumValue = sheetHiddenOffset
)
val imePlaceables = subcompose("ime") { Spacer(modifier = Modifier.imePadding()) }.fastMap { it.measure(looseConstraints) }
val imeOffsetY = layoutHeight - (imePlaceables.fastMaxBy { it.height }?.height ?: 0)
val maxHeightAffectedByImeOffsetY = imeOffsetY.coerceAtMost(
maximumValue = sheetHiddenOffset.fastRoundToInt()
)
it.measure(
looseConstraints.copy(
maxHeight = minOf(
maxHeightAffectedBySheetOffsetY.fastRoundToInt(),
maxHeightAffectedByImeOffsetY
)
)
)
}
...
}
}
}
Jetpack compose์์ X์ถ offset์ →์ชฝ์ผ๋ก ๊ฐ์๋ก ์ปค์ง๊ณ Y์ถ offset์ ↓์ชฝ์ผ๋ก ๊ฐ์๋ก ์ปค์ง๋ค. ๊ทธ๋ฆฌ๊ณ sheetOffsetY๋ sheet์ Y์ถ ์์น offset์ ์๋ฏธํ๋ ๋ณ์๋ค. sheetOffsetY๊ฐ ์ปค์ง์๋ก (= sheet๊ฐ ↓์ชฝ์ผ๋ก ๊ฐ์๋ก) content์ ๋์ด ๋ํ ์ปค์ง๊ณ , sheetContent์ ๋์ด๋ ์์์ง๋ค. ๋ฐ๋๋ก sheetOffsetY๊ฐ ์์์ง์๋ก content์ ๋์ด๋ ์์์ง๊ณ sheetContent์ ๋์ด๋ ์ปค์ง๋ค. ์ด ๋, content์ ๋์ด์๋ ํํ์ ์ ์ค์ ํ๋ค. ์ด ํํ์ ์ sheet๊ฐ partiallyExpanded ์ผ๋์ content ๋์ด๋ค. ๊ทธ๋ฌ๋, ime(์ํํธ ํค๋ณด๋)๋ ์์คํ
์ฐจ์์ UI์ด๋ฏ๋ก ime๋ ํํ์ ์ ๋ฌด์ํ ์ ์์ด์ผ ํ๋ค (ํํ์ ์ ์ค์ ํ์ง ์์๋ค).
๊ฒฐ๋ก ์, Layout Phase์ Measurement ๋จ๊ณ์์ sheet์ ์ํฅ(affect)์ ๋ฐ๋ Constraint.maxHeight (์ฝ๋์ ์๋ maxHeightAffectedBySheetOffsetY)์ ime์ ์ํฅ์ ๋ฐ๋ Constraint.maxHeight (์ฝ๋์ ์๋ maxHeightAffectedByImeOffsetY)๋ฅผ ๋์์ ๊ณ ๋ คํด์ผ ํ๋ค๋ ๊ฒ์ด๋ค. ๋ ์ค์์ ๋ ์์ ๊ฐ์ ์ทจํ๋ค.
#4-3 hasContentNavigationBarPadding ๊ด๋ จ
์ด ์ธ์๋ ์ ์กด์ฌํ ๊น? ์๋ ์คํฌ๋ฆฐ์ท๊ณผ ๊ฐ์ ์ผ์ด ๋ฐ์ํ๊ธฐ ๋๋ฌธ์ด๋ค.

sheet๊ฐ hidden ์ํ ์ผ ๋๋ ์์ฐ์ค๋ฝ์ง๋ง, hidden ์ํ๊ฐ ์๋๋๋ ๋ถ์์ฐ์ค๋ฌ์ด(์๋ฏธ์๋) bottomPadding์ ๊ฐ์ง๊ณ ์๊ฒ ๋๋ค. ์ด ์๋ฏธ์๋ bottomPadding์ ๊ฐ๋ฆฌ๋ ค๋ฉด content Measurement ์์ Constraint.maxHeight๋ฅผ bottomPadding๋งํผ ๋๋ ค์ฃผ๋ฉด ๋ ๊ฒ์ด๋ค. sheet๋ content๋ณด๋ค zIndex๊ฐ ๋๊ฒ๋ ์ค์ ํด๋์๋ค. ๊ทธ๋์ sheet์ content๊ฐ ๊ฒน์น๋ฉด ๊ฒน์น ๋ถ๋ถ์ ๋ณด์ด์ง ์๊ฒ ํ ์ ์๋ค. '๋ถ์์ฐ์ค๋ฌ์ด ๋ถ๋ถ'์ ๋ณด์ด์ง ์๊ฒ ํ ์ ์๋ค๋ ๋ง์ด๋ค.
#4-4 content์ maxHeight ๊ฒฐ์ 2
#4-3๋ฅผ ๊ณ ๋ คํด์ #4-2์ ์ฝ๋๋ฅผ ์
๋ฐ์ดํธํ๋ฉด ์๋์ ๊ฐ๋ค.
...
@Composable
private fun ContentWithSwipeableBottomSheetLayout(
anchoredDraggableState: AnchoredDraggableState<SheetValue>,
body: @Composable (() -> Unit),
bottomSheet: @Composable (() -> Unit),
hasBodyNavigationBarPadding: Boolean
) {
if (...) {
// ↓ โ
val density = LocalDensity.current
val navigationBarHeight = if (hasBodyNavigationBarPadding) {
with(density) { WindowInsets.navigationBars.getBottom(density) }
} else {
null
}
SubcomposeLayout { ... ->
...
val bodyPlaceables = subcompose("body") { body() }.fastMap {
...
val maxHeightAffectedBySheetOffsetY = (sheetOffsetY + (navigationBarHeight ?: 0)).coerceIn( // ← โ
minimumValue = sheetPartiallyExpandedOffset + (navigationBarHeight ?: 0), // ← โ
maximumValue = sheetHiddenOffset
)
...
val maxHeightAffectedByImeOffsetY = (imeOffsetY + (navigationBarHeight ?: 0)).coerceAtMost( // ← โ
maximumValue = sheetHiddenOffset.fastRoundToInt()
)
it.measure(
...
)
}
...
}
}
}
๋ฌ๋ผ์ง ์ ์ โ
์ฃผ์์ ๋ฌ์๋ค.
#4-5 ๋ฌธ์ ๊ฐ ํด๊ฒฐ๋ ์คํฌ๋ฆฐ์ท

๋ถ์์ฐ์ค๋ฌ์ด ์ฌ๋ฐฑ์ด ์ฌ๋ผ์ก๋ค.
#5 ContentWithSwipeableBottomSheetState
enum class BottomOccupier { None, Ime, BottomSheet }
class ContentWithSwipeableBottomSheetState(
val anchoredDraggableState: AnchoredDraggableState<SheetValue>,
val isImeAppearingState: State<Boolean>
) {
suspend fun hide() = anchoredDraggableState.animateTo(
targetValue = SheetValue.Hidden,
animationSpec = BottomSheetAnimationSpec
)
suspend fun partiallyExpand() = anchoredDraggableState.animateTo(
targetValue = SheetValue.PartiallyExpanded,
animationSpec = BottomSheetAnimationSpec
)
suspend fun expand() = anchoredDraggableState.animateTo(
targetValue = SheetValue.Expanded,
animationSpec = BottomSheetAnimationSpec
)
val currentValue: SheetValue
get() = anchoredDraggableState.currentValue
val settledValue: SheetValue
get() = anchoredDraggableState.settledValue
val targetValue: SheetValue
get() = anchoredDraggableState.targetValue
val bottomOccupier: BottomOccupier
get() = if (isImeAppearingState.value) {
BottomOccupier.Ime
} else {
if (anchoredDraggableState.targetValue == SheetValue.Hidden) {
BottomOccupier.None
} else {
BottomOccupier.BottomSheet
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun rememberContentWithSwipeableBottomSheetState(
anchoredDraggableState: AnchoredDraggableState<SheetValue> = AnchoredDraggableState(SheetValue.Hidden),
): ContentWithSwipeableBottomSheetState {
val imeAppearingState = remember { mutableStateOf(false) }
val density = LocalDensity.current
val imeTargetHeight by rememberUpdatedState(WindowInsets.imeAnimationTarget.getBottom(density))
LaunchedEffect(density) {
snapshotFlow { 0 < imeTargetHeight }
.distinctUntilChanged()
.collectLatest { imeAppearingState.value = it }
}
return remember {
ContentWithSwipeableBottomSheetState(
anchoredDraggableState = anchoredDraggableState,
isImeAppearingState = imeAppearingState
)
}
}
anchoredDraggableState์ Wrapper๋ค. Wrapper๋ก์ ์ถ๊ฐ๋ ๊ธฐ๋ฅ์, bottomOccupier ํ๋กํผํฐ๋ฅผ ๋ณด์ ํ๋ค๋ ์ ์ด๋ค. ์ด ํ๋กํผํฐ๋ ํ์ฌ ํ๋ฉด์ bottomSheet๊ฐ ํ์๋๋์ง, ์๋๋ฉด ime๊ฐ ํ์๋๋์ง, ๊ทธ๊ฒ๋ ์๋๋ฉด ์๋ฌด๊ฒ๋ ํ์ํ์ง ์๋์ง๋ฅผ ์๋ ค์ค๋ค. ime๋ ์์คํ
UI๋ก ํญ์ bottomSheet๋ณด๋ค ์์ ํ์๋๊ธฐ ๋๋ฌธ์ ime์ bottomSheet๊ฐ ๋์์ ๋ ์๋ ๊ฒฝ์ฐ bottomOccupier๋ ime๊ฐ ๋๋๋ก ๋ถ๊ธฐ๋ฅผ ์งฐ๋ค.
ํ์ง๋ง, bottomSheet์ ime๋ ์๋ก ๋ฐฐํ์ (exclusive)์ด์ด์ผ ์ฌ์ฉ์ ๊ฒฝํ์ด ์ข๋ค. bottomSheet๊ฐ ime๊ฐ ์์์ ๋ฐฐํ์ (๋ ์ค ํ๋๋ง ํ์๋๊ฒ) ๋ง๋ค์ด์ฃผ๋ ImeSheetExclusivityHandler()๋ฅผ ๋ง๋ค์ด ์ํ ์ฑ(#6)์ ์ฌ์ฉํ๋ค (#6-4 ์ฐธ๊ณ ).
#6 ์ํ ์ฑ
#6-1 ๊ฐ์
ContentWithSwipeableBottomSheet()๋ฅผ ์ด์ฉํ ์ํ ์ฑ์ด๋ค. ์ ์ฒด ์์ค ์ฝ๋๋ #6-5์์ ๋ค์ด๋ก๋ ํ ์ ์๋ค. ์ ์ฒด ์์ค ์ฝ๋๋ฅผ ๋ณด๋ฉด์ ์ฝ๋ ๊ฑธ ์ถ์ฒํ๋ค.
#6-2 ์ฝ๋ - MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
setContent {
ContentWithSwipeableBottomSheetTheme {
SampleScreen()
}
}
}
}
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)๋ฅผ ์ฐ์ง ์์ผ๋ฉด, ContentWithSwipeableBottomSheet() ์ฌ์ฉ ์ ์๋ฌ๊ฐ ๋๊ฒ ๋ง๋ค์๋ค. ContentWithSwipeableBottomSheet() ๋ด๋ถ์ ์ฐ์ธ ์๋์ ํจ์ ๋๋ฌธ์ด๋ค.
@Composable
private fun AssertSoftInputModeIsAdjustNothing() {
val context = LocalView.current.context
val activity = context as Activity
val window = activity.window
val softInputAdjustType by remember { mutableIntStateOf(window.attributes.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST) }
LaunchedEffect(window) {
check(softInputAdjustType == WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) {
"""
Expected SOFT_INPUT_ADJUST_NOTHING but was $softInputAdjustType
ํด๊ฒฐ๋ฒ: Activity์ setContent {} ํธ์ถ ์ ์, window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)์ ๊ฐ์ ์ฝ๋๋ฅผ ์ฌ์ฉํ์ธ์.
""".trimIndent()
}
}
}
ContentWithSwipeableBottomSheet()๋ ime์ ์ํด ๋ฐ๋ ค๋์๋ ์ ๋๋ค. bottomSheet์ ime๊ฐ ์๋ก ๊ฐ์ ๊ณต๊ฐ์ ์ ์ ํด์ผํ๊ธฐ ๋๋ฌธ์ด๋ค.
#6-3 ์ฝ๋ - SampleScreen()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SampleScreen() {
val scope = rememberCoroutineScope()
val sheetState = rememberContentWithSwipeableBottomSheetState()
ImeSheetExclusivityHandler(sheetState.anchoredDraggableState)
var sheetExpandedAnchorOffset by remember { mutableStateOf(0.dp) }
ContentWithSwipeableBottomSheet(
sheetContent = {
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
repeat(30) {
item {
Text("${it + 1}")
}
}
item {
Spacer(Modifier.navigationBarsPadding())
}
}
BottomSheetBackHandler(sheetState.anchoredDraggableState)
},
modifier = Modifier
.fillMaxSize()
.background(BottomAppBarDefaults.containerColor),
sheetState = sheetState,
sheetExpandedAnchorOffset = sheetExpandedAnchorOffset,
sheetPartiallyExpandedHeight = storedImeMaxHeight(),
sheetSwipeEnabled = true,
hasContentNavigationBarPadding = true
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text("์ ๋ชฉ")
},
actions = {
IconButton(
onClick = {
scope.launch {
sheetState.expand()
}
}
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.keyboard_double_arrow_up_24dp_1f1f1f_fill0_wght400_grad0_opsz24),
contentDescription = "Bottom Sheet Expand"
)
}
IconButton(
onClick = {
scope.launch {
when (sheetState.currentValue) {
SheetValue.Hidden -> sheetState.partiallyExpand()
SheetValue.PartiallyExpanded -> sheetState.expand()
SheetValue.Expanded -> {}
}
}
}
) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = "Bottom Sheet Up"
)
}
IconButton(
onClick = {
scope.launch {
when (sheetState.currentValue) {
SheetValue.Hidden -> {}
SheetValue.PartiallyExpanded -> sheetState.hide()
SheetValue.Expanded -> sheetState.partiallyExpand()
}
}
}
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Bottom Sheet Down"
)
}
IconButton(
onClick = {
scope.launch {
sheetState.hide()
}
}
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.keyboard_double_arrow_down_24dp_1f1f1f_fill0_wght400_grad0_opsz24),
contentDescription = "Bottom Sheet Hide"
)
}
}
)
},
bottomBar = {
BottomAppBar(
containerColor = BottomAppBarDefaults.containerColor
) {
val imeController = LocalSoftwareKeyboardController.current
IconButton(
onClick = {
scope.launch {
when (sheetState.bottomOccupier) {
BottomOccupier.None, BottomOccupier.Ime -> sheetState.partiallyExpand()
BottomOccupier.BottomSheet -> imeController?.show()
}
}
}
) {
Icon(
imageVector = when (sheetState.bottomOccupier) {
BottomOccupier.None, BottomOccupier.Ime ->
ImageVector.vectorResource(id = R.drawable.bottom_drawer_24dp_1f1f1f_fill0_wght400_grad0_opsz24)
BottomOccupier.BottomSheet ->
ImageVector.vectorResource(id = R.drawable.keyboard_24dp_1f1f1f_fill0_wght400_grad0_opsz24)
},
contentDescription = ""
)
}
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
TextFieldAutoFocusOnceWithoutIme(
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
interactionSource = interactionSource
)
}
},
) { innerPadding ->
LaunchedEffect(innerPadding) {
sheetExpandedAnchorOffset = innerPadding.calculateTopPadding()
}
Column {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding),
reverseLayout = true
) {
repeat(30) {
item {
Text("${it + 1}")
}
}
}
}
}
}
}
ImeSheetExclusivityHandler(), BottomSheetBackHandler(), storedImeMaxHeight(), TextFieldAutoFocusOnceWithoutIme() ๋ฑ์ ์ปดํฌ์ ๋ธ ํจ์(์ปดํฌ๋ํธ)๋ค์ #6-4์์ ํ๋ํ๋ ์ค๋ช
ํ๋ค.
#6-4 ์ฌ์ฉ๋ Composable๋ค
(1) ImeSheetExclusivityHandler()
@Composable
fun ImeSheetExclusivityHandler(
anchoredDraggableState: AnchoredDraggableState<SheetValue>
) {
val imeController = LocalSoftwareKeyboardController.current
val isImeSettledVisible by rememberIsImeSettledVisible()
// ↓ LaunchedEffect ์์ฝ: ime๊ฐ ์ ๋ถ ์ฌ๋ผ์จ ์งํ ํธ๋ฆฌ๊ฑฐ, bottomSheet๋ฅผ hidden ์ํ๋ก
LaunchedEffect(Unit) {
snapshotFlow { isImeSettledVisible }
.filter { it } // ime๊ฐ ์์ ํ(settled) ์ฌ๋ผ์๋๋ฐ(visible),
.filter { anchoredDraggableState.settledValue != SheetValue.Hidden } // bottomSheet๊ฐ ์จ๊ฒจ์ง(hidden) ์ํ๊ฐ ์๋๋ผ๋ฉด (= ime์ bottomSheet๊ฐ ๊ฒน์ณ์ ธ ์๋ ์ํ๋ผ๋ฉด),
.collectLatest {
anchoredDraggableState.snapTo(SheetValue.Hidden)
}
}
// ↓ LaunchedEffect ์์ฝ: bottomSheet๊ฐ ์กฐ๊ธ์ด๋ผ๋ ์ฌ๋ผ์ค๊ธฐ ์ง์ ํธ๋ฆฌ๊ฑฐ, bottomSheet๋ฅผ ์ฆ์ ๋ฐฐ์น ํ ime๋ฅผ hide
val isBottomSheetTransitioningFromHidden by remember {
derivedStateOf {
anchoredDraggableState.settledValue == SheetValue.Hidden
&& anchoredDraggableState.targetValue != SheetValue.Hidden
}
}
LaunchedEffect(Unit) {
snapshotFlow { isBottomSheetTransitioningFromHidden }
.filter { it } // bottomSheet๊ฐ ์จ๊ฒจ์ง(hidden) ์ํ์์ ๋ฒ์ด๋๋ ค ํ๋๋ฐ,
.filter { isImeSettledVisible } // ์ด๋ฏธ ์์ ํ(settled) ์๋ฆฌ์ก์(visible) ime๊ฐ ์๋ค๋ฉด,
.collectLatest {
when (anchoredDraggableState.targetValue) {
SheetValue.PartiallyExpanded -> {
// ๋จผ์ bottomSheet๋ฅผ ์ฆ์ ๋ฐฐ์นํ๊ณ ,
anchoredDraggableState.snapTo(SheetValue.PartiallyExpanded)
// ime๋ฅผ ์จ๊น (์ด๋ ๊ฒ ํด์ผ ์์ฐ์ค๋ฝ๊ฒ ์ค๋ฅด๋ฅต bottomSheet๊ฐ ๋๋ฌ๋๋ ํ๋ฉด์ด ๋์ด. ime๋ ์์คํ
window๋ผ ํญ์ bottomSheet๋ณด๋ค ์์ ํ์๋๊ธฐ ๋๋ฌธ)
imeController?.hide()
}
SheetValue.Expanded -> {
anchoredDraggableState.snapTo(SheetValue.PartiallyExpanded)
imeController?.hide()
anchoredDraggableState.animateTo(SheetValue.Expanded)
}
else -> {}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun rememberIsImeSettledVisible(): State<Boolean> {
val density = LocalDensity.current
val source = WindowInsets.imeAnimationSource
val target = WindowInsets.imeAnimationTarget
return remember {
derivedStateOf {
val sourceHeight = source.getBottom(density)
val targetHeight = target.getBottom(density)
sourceHeight > 0 && sourceHeight == targetHeight
}
}
}
ime์ bottomSheet๋ ์๋ก ๊ฐ์ ๊ณต๊ฐ์ ์ ์ ํ์ง๋ง, ๋์์ ๋ฐฐํ์ ์ด์ด์ผ ํ๋ค. ime๊ฐ ์ฌ๋ผ์ฌ ๋ bottomSheet๊ฐ ์ด๋ฏธ ์ฌ๋ผ์์์ผ๋ฉด bottomSheet๋ฅผ ๋ด๋ ค์ฃผ๊ณ , ๋ฐ๋๋ก bottomSheet๊ฐ ์ฌ๋ผ์ฌ ๋ ime๊ฐ ์ด๋ฏธ ์ฌ๋ผ์์์ผ๋ฉด ime๋ฅผ ๋ด๋ ค์ฃผ๋ ์ปดํฌ๋ํธ๋ค.
(2) BottomSheetBackHandler()
@Composable
fun BottomSheetBackHandler(
anchoredDraggableState: AnchoredDraggableState<SheetValue>
) {
val scope = rememberCoroutineScope()
BackHandler(enabled = (anchoredDraggableState.currentValue != SheetValue.Hidden)) {
scope.launch {
when (anchoredDraggableState.currentValue) {
SheetValue.Expanded -> anchoredDraggableState.animateTo(SheetValue.PartiallyExpanded)
SheetValue.PartiallyExpanded -> anchoredDraggableState.animateTo(SheetValue.Hidden)
else -> {}
}
}
}
}
bottomSheet์ ์ํ์ ๋ฐ๋ฅธ ๋ค๋ก๊ฐ๊ธฐ ๋ฒํผ ๋์ ์ ์
(3) storedImeMaxHeight()
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun storedImeMaxHeight(): Dp {
val dimensDataStore = (LocalContext.current.applicationContext as CwsbsApplication).dimensDataStore
val density = LocalDensity.current
val imeTargetHeight by rememberUpdatedState(WindowInsets.imeAnimationTarget.getBottom(density))
LaunchedEffect(density) {
snapshotFlow { imeTargetHeight }
.filter { 0 < it }
.distinctUntilChanged()
.collectLatest {
with(density) {
dimensDataStore.setImeMaxHeight(it.toDp())
}
}
}
return dimensDataStore.imeMaxHeightStateFlow.collectAsState().value
}
bottomSheet๊ฐ partiallyExpanded์ผ ๋์ ๋์ด๋ฅผ ime๊ฐ ๋๊น์ง ์ฌ๋ผ์์ ๋์ ๋์ด์ ๋ง์ถ๊ธฐ ์ํ ์ปดํฌ๋ํธ๋ค. DataStore๋ฅผ ์ฌ์ฉํ๋ค.
(4) TextFieldAutoFocusOnceWithoutIme()
@Composable
fun TextFieldAutoFocusOnceWithoutIme(
value: String = "",
onValueChange: (String) -> Unit = {},
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource,
) {
val focusRequester = remember { FocusRequester() }
val imeController = LocalSoftwareKeyboardController.current
var showKeyboardOnFocus by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = {
onValueChange(it)
},
modifier = modifier.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions.Default.copy(
showKeyboardOnFocus = showKeyboardOnFocus
),
interactionSource = interactionSource
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
imeController?.hide() // showKeyboardOnFocus๊ฐ false์ฌ๋ ์๋ฒฝํ ๋์ํ์ง ์์. ์ด ๋ฌธ์ ๊ฐ ํด๊ฒฐ๋ ๋๊น์ง๋ ์ด ์ฝ๋๋ฅผ ํตํด ๋ณด์กฐํจ.
showKeyboardOnFocus = true
}
}
์ต์ด ์คํ ์ TextField์ focus๋ฅผ ์ฃผ๋, ime๊ฐ ์ฌ๋ผ์ค์ง๋ ์๊ฒํ๋ TextField๋ค. ์ ๊ตณ์ด ์ด๋ฐ ์ปดํฌ๋ํธ๋ฅผ ์ผ๋๋ฉด focus๋ฅผ ์ต์ด๋ก ํ ๋ฒ์ ๊ฑธ์ด์ฃผ์ด์ผ (#6-3์ ์๋ ์ฝ๋์ธ) imeController?.show()๊ฐ ์ ๋๋ก ๋์ํ๊ธฐ ๋๋ฌธ์ด๋ค.
#6-5 ์์ฑ๋ ์ฑ
android-practice/playground/ContentWithSwipeableBottomSheet at 5f2073d84b19d38c06dda94957b127c9314a4ea4 · Kanmanemone/android-p
Contribute to Kanmanemone/android-practice development by creating an account on GitHub.
github.com
#7 ๋ฆฌํฉํ ๋ง๋ ์ํ ์ฑ
#7-1 ๋ชจ๋ํ
[Android] Module ๊ตฌ์กฐ
#1 Module?#1-1 Whatํ๋์ build.gradle์ ์ข ์๋ ์ฝ๋์ ๋ฒ์= ๋น๋์ ์ต์ ๋จ์= ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ์ต์ ๋จ์์๋๋ก์ด๋ ์ฑ์ Gradle๋ก ๋น๋๋๊ณ , ๊ทธ ๋น๋์ ๋ช ์ธ๋ build.gradle์ ์ ํ๋ค. ๋ชจ๋์ด build.gradle ํ
kenel.tistory.com
์ ๊ฒ์๊ธ์ ๊ธฐ๋ฐํด์ "app" ๋ชจ๋์ ์ถ๊ฐ๋ก "ui" ๋ฐ "datastore" ๋ชจ๋์ ์ถ๊ฐํด ์ฝ๋๋ฅผ ๋ถ์ฐ์์ผฐ๋ค. ๊ทธ ๊ฒฐ๊ณผ ๋ ๊น๋ํ ์ฝ๋๋ก ๋ฆฌํฉํ ๋ง ๋์๋ค.
#7-2 ๋ณ๊ฒฝ ์ฌํญ
1. "ui" ๋ชจ๋ ์์ฑ ํ ContentWithSwipeableBottomSheet() ๊ด๋ จ ์ฝ๋ ์ฎ๊น
2. @OptIn(ExperimentalMaterial3Api::class) ์ด๋ ธํ ์ด์ ์ ์ฃผ๋ ์ฃผ๋ ๋ฌ๊ธฐ ์ซ์ด์, androidx.compose.material3.SheetValue ๋์ ์ปค์คํ SheetValue๋ฅผ ์ ์ํด ์ฌ์ฉํด์์. ๊ทธ๋ฌ๋, ์ด๋ ๊ฒ ์ด๋ฆ์ด ๊ฐ์ ๊ฒฝ์ฐ๋ (๋ค๋ฅธ ๊ฐ๋ฐ์ ์ ์ฅ์์) ํผ๋์ ์ค ์ฌ์ง๊ฐ ๋ค๋ถํจ. ๋ฐ๋ผ์ ๊ทธ๋ฅ androidx.compose.material3.SheetValue์ ์ฌ์ฉํ๊ธฐ๋ก ๊ฒฐ์ ํ๊ณ ์ด์ ๋ง์ถฐ ์ ์ฒด ์ฝ๋๋ฅผ ๋ฆฌํฉํ ๋งํจ
3. BottomSheetBackHandler()์ ImeSheetExclusivityHandler()๋ฅผ "ui" ๋ชจ๋์ "internal" ํจํค์ง๋ก ์ด๋ ๋ฐ internal ์ ๊ทผ์ ์ด์๋ฅผ ๋ถ์. ์ผ๊ด์ฑ์ ์ํด ๋น์ทํ ํจ์์ธ AssertSoftInputModeIsAdjustNothing()๋ ๋ณ๋์ ํ์ผ๋ก ํฌ์ฅํด์ internal ์ ๊ทผ์ ์ด์๋ฅผ ๋ถ์ฌ๋
4. "datastore" ๋ชจ๋ ์ถ๊ฐ ํ, ๊ด๋ จ ์ฝ๋๋ค์ ์ฎ๊น. ๋, DimensDataStore์ ๋ํดํ ์ด๊ธฐํ ์ฝ๋๋ฅผ ์ ๊ฑฐํ๊ณ ๊ณต์ ๊ฐ์ด๋(https://developer.android.com/topic/libraries/architecture/datastore#preferences-datastore)์ ์ฝ๋์ ๊ธฐ๋ฐํด์ ๋ค์ ์. ํด๋น ํด๋์ค์ ์ด๋ฆ์ SystemPreferences๋ก ๋ณ๊ฒฝํ๊ณ , class๊ฐ ์๋๋ผ object๋ก ๋ณ๊ฒฝ
#7-3 ์์ฑ๋ ์ฑ
android-practice/playground/ContentWithSwipeableBottomSheet at 3cd7d083176e5be8a8f919c118ca953a119f1bc9 · Kanmanemone/android-p
Contribute to Kanmanemone/android-practice development by creating an account on GitHub.
github.com