Bottom Sheet
Bottom Sheet를 Stackflow와 함께 사용하는 방법을 안내합니다.
Bottom Sheet
Bottom Sheet 컴포넌트에 대해 자세히 알아봅니다.
Making a Bottom Sheet Activity
일반적인 경우 Bottom Sheet를 Activity로 만들어 사용하는 것을 권장합니다.
- Activity로 관리되므로, 하위 Activity보다 높고 상위 Activity보다는 낮은 z-index를 갖도록 관리하기 쉽습니다.
- 딥링킹이 가능합니다. (URL 접속으로 Bottom Sheet를 열 수 있습니다.)
- @stackflow/plugin-basic-ui
BottomSheet에서의 마이그레이션이 쉽습니다.
import { VStack } from "@seed-design/react";
import { useFlow, type StaticActivityComponentType } from "@stackflow/react/future";
import {
AppBar,
AppBarBackButton,
AppBarLeft,
AppBarMain,
AppBarIconButton,
AppBarRight,
} from "seed-design/ui/app-bar";
import { AppScreen, AppScreenContent } from "seed-design/ui/app-screen";
import { ActionButton } from "seed-design/ui/action-button";
import { IconHouseLine } from "@karrotmarket/react-monochrome-icon";
declare module "@stackflow/config" {
interface Register {
ActivityBottomSheetActivity: {};
}
}
const ActivityBottomSheetActivity: StaticActivityComponentType<
"ActivityBottomSheetActivity"
> = () => {
const { push } = useFlow();
return (
<AppScreen>
<AppBar>
<AppBarLeft>
<AppBarBackButton />
</AppBarLeft>
<AppBarMain title="Activity" />
<AppBarRight>
<AppBarIconButton aria-label="Home" onClick={() => push("ActivityHome", {})}>
<IconHouseLine />
</AppBarIconButton>
</AppBarRight>
</AppBar>
<AppScreenContent>
<VStack p="x5" justify="center" gap="x4">
<ActionButton
variant="neutralSolid"
flexGrow
onClick={() => push("ActivityBottomSheet", {})}
>
ActivityBottomSheet을 Push
</ActionButton>
<ActionButton
variant="neutralWeak"
flexGrow
onClick={() => push("ActivityBottomSheetActivity", {})}
>
지금 열린 이 Activity를 Push
</ActionButton>
</VStack>
</AppScreenContent>
</AppScreen>
);
};
export default ActivityBottomSheetActivity;import { Box, Divider, HStack, VStack } from "@seed-design/react";
import { useActivity, useFlow, type StaticActivityComponentType } from "@stackflow/react/future";
import { useRef, useState } from "react";
import { ActionButton } from "seed-design/ui/action-button";
import {
BottomSheetBody,
BottomSheetContent,
BottomSheetFooter,
BottomSheetRoot,
} from "seed-design/ui/bottom-sheet";
import { Checkbox } from "seed-design/ui/checkbox";
import { Snackbar, useSnackbarAdapter } from "seed-design/ui/snackbar";
import { TextField, TextFieldInput } from "seed-design/ui/text-field";
import { useActivityZIndexBase } from "@seed-design/stackflow";
import { Switch } from "seed-design/ui/switch";
import { SegmentedControl, SegmentedControlItem } from "seed-design/ui/segmented-control";
import { appScreenVariantMap, type AppScreenVariant } from "@seed-design/css/recipes/app-screen";
declare module "@stackflow/config" {
interface Register {
ActivityBottomSheet: {};
}
}
const ActivityBottomSheet: StaticActivityComponentType<"ActivityBottomSheet"> = () => {
const { push, pop } = useFlow();
const activity = useActivity();
const form = useRef<HTMLFormElement>(null);
const snackbar = useSnackbarAdapter();
const [nameError, setNameError] = useState<string | null>(null);
const [keepMounted, setKeepMounted] = useState(false);
const [transitionStyle, setTransitionStyle] =
useState<AppScreenVariant["transitionStyle"]>("slideFromRightIOS");
const handleSubmit = () => {
if (!form.current) return;
const formData = new FormData(form.current);
if (!formData.get("name")) {
setNameError("이름을 입력해주세요.");
return;
}
setNameError(null);
pop();
snackbar.create({
render: () => (
<Snackbar
variant="positive"
message={JSON.stringify({
name: formData.get("name"),
subscribe: formData.get("subscribe"),
})}
/>
),
});
};
const open = keepMounted
? activity.transitionState === "enter-active" || activity.transitionState === "enter-done"
: activity.isActive;
const onOpenChange = keepMounted
? (open: boolean) => !open && activity.isActive && pop()
: (open: boolean) => !open && pop();
return (
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetContent
showHandle
showCloseButton={false}
title="정보 입력"
layerIndex={useActivityZIndexBase()}
>
<form
ref={form}
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<BottomSheetBody>
<VStack gap="spacingY.componentDefault">
<TextField
required
showRequiredIndicator
name="name"
label="이름"
description="본명이 아니어도 괜찮아요."
invalid={!!nameError}
errorMessage={nameError}
>
<TextFieldInput placeholder="이름을 입력하세요" />
</TextField>
<Checkbox
label="뉴스레터 구독하기"
tone="neutral"
inputProps={{ name: "subscribe" }}
/>
</VStack>
</BottomSheetBody>
<BottomSheetFooter>
<VStack gap="x4">
<HStack gap="x2">
<ActionButton type="button" variant="neutralWeak" onClick={pop}>
닫기
</ActionButton>
<ActionButton type="submit" variant="neutralSolid" flexGrow>
제출
</ActionButton>
</HStack>
<Divider as="div" />
<VStack gap="x2">
<Switch
tone="neutral"
size="16"
label="Push 이후에도 BottomSheet 마운트 유지"
checked={keepMounted}
onCheckedChange={setKeepMounted}
style={{ alignSelf: "center" }}
/>
<Box alignSelf="center">
<SegmentedControl
value={transitionStyle}
onValueChange={(style) =>
setTransitionStyle(style as AppScreenVariant["transitionStyle"])
}
>
{appScreenVariantMap.transitionStyle.map((style) => (
<SegmentedControlItem key={style} value={style}>
{style}
</SegmentedControlItem>
))}
</SegmentedControl>
</Box>
<ActionButton
flexGrow
type="button"
variant="neutralSolid"
onClick={() =>
push("ActivityDetail", {
title: "ActivityDetail",
body: keepMounted
? "BottomSheet가 언마운트되지 않았으므로, 현재 Activity를 pop하는 경우 uncontrolled 상태의 TextField와 Checkbox 값이 유지되며 BottomSheet가 열린 상태로 표시됩니다."
: "BottomSheet가 언마운트되었으므로, 현재 Activity를 pop하는 경우 uncontrolled 상태의 TextField와 Checkbox 값이 초기화되며 BottomSheet가 다시 enter 트랜지션을 재생하며 마운트됩니다.",
transitionStyle,
})
}
>
ActivityDetail
</ActionButton>
<ActionButton
flexGrow
type="button"
variant="neutralSolid"
onClick={() => push("ActivityHome", { transitionStyle })}
>
ActivityHome
</ActionButton>
</VStack>
</VStack>
</BottomSheetFooter>
</form>
</BottomSheetContent>
</BottomSheetRoot>
);
};
export default ActivityBottomSheet;Usage
import { useActivityZIndexBase } from "@seed-design/stackflow";
import { useActivity, useFlow, type ActivityComponentType } from "@stackflow/react/future";
// ... more imports
const ActivityBottomSheetSimple: ActivityComponentType<"ActivityBottomSheetSimple"> = () => {
const { pop } = useFlow();
return (
<BottomSheetRoot open={useActivity().isActive} onOpenChange={(open) => !open && pop()}>
<BottomSheetContent
title="Activity로 만들어진 BottomSheet"
layerIndex={useActivityZIndexBase()}
>
<BottomSheetFooter>
<ActionButton onClick={pop}>
확인
</ActionButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetRoot>
);
};openprop에useActivity().isActive를 전달하여 Activity가 활성화될 때 Bottom Sheet가 열리도록 합니다.onOpenChange를 통해 Bottom Sheet가 닫힐 때pop()을 실행하여 Activity를 종료합니다.layerIndex={useActivityZIndexBase()}로 Bottom Sheet Activity의 z-index 기준점을 전달합니다.
Keeping Bottom Sheet Mounted
Bottom Sheet Activity 위에 다른 Activity를 push할 때 Bottom Sheet가 unmount되는 것을 방지하려면,
open상태를isActive대신transitionState로 관리하고modalprop을isActive로 설정하고onOpenChange핸들러에서!isOpen && isActive인 경우pop()을 실행하도록 합니다.
이 패턴은 다음 상황에서 유용합니다.
- Bottom Sheet 액티비티 위에 다른 오버레이 컴포넌트 액티비티를 중첩하여 표시하고 싶은 경우
- 하위 Bottom Sheet 액티비티 내부에 존재하는 Uncontrolled 폼 요소의 상태를 유지하고 싶은 경우
const { isActive, transitionState } = useActivity();
return (
<BottomSheetRoot
open={
transitionState === "enter-active" || transitionState === "enter-done"
}
modal={isActive}
onOpenChange={(open) => !open && isActive && pop()}
>
{/* ... */}
</BottomSheetRoot>
);open을transitionState로 관리하여 다른 Activity가 위에 push되어도 Bottom Sheet가 unmount되지 않도록 합니다.modal={isActive}로 Bottom Sheet Activity가 비활성 상태일 때modal을false로 설정합니다. 이렇게 하지 않으면, 위에 push된 Activity에서 스크롤 등의 상호작용이 동작하지 않습니다.onOpenChange핸들러에서isActive인 경우에만pop()을 실행하여, 비활성 상태에서의 의도치 않은 Activity 종료를 방지합니다.
Syncing BottomSheet State with a Step
Bottom Sheet를 Activity로 만들 수 없는 경우, Bottom Sheet가 표시된 상태를 Step으로 만들 수 있습니다.
- 현재 Activity를 유지하면서도, 뒤로 가기 버튼 등으로 Bottom Sheet를 닫을 수 있습니다.
BottomSheetTrigger를 사용하여 Bottom Sheet를 열고 닫을 수 있습니다.
제약 사항
Activity로 만들지 않은 Bottom Sheet에서 다른 Activity를 push하기 전, z-index 문제를 방지하기 위해 Bottom Sheet를 닫으세요.
Bottom Sheet를 닫을 수 없거나, Bottom Sheet를 연 Activity로 돌아왔을 때 Bottom Sheet가 열린 상태를 유지해야 하는 경우 Bottom Sheet를 Activity로 만들어 사용하는 것을 권장합니다.
Activity 간 유려한 트랜지션을 제공하기 위해 하위 AppScreen 요소 중 일부가 상위 AppScreen 요소보다 위에 위치합니다. 이 제약으로 인해, 열린 상태의 Bottom Sheet는 독립적인 Activity로 만들지 않는 경우 하위 Activity와 상위 Activity 사이에 위치시키는 것이 불가능합니다.
import { HStack, Portal, VStack } from "@seed-design/react";
import { useActivityZIndexBase } from "@seed-design/stackflow";
import {
useActivityParams,
useFlow,
useStepFlow,
type StaticActivityComponentType,
} from "@stackflow/react/future";
import { useEffect, useState } from "react";
import { ActionButton } from "seed-design/ui/action-button";
import { AppBar, AppBarMain, AppBarIconButton, AppBarRight } from "seed-design/ui/app-bar";
import { AppScreen, AppScreenContent } from "seed-design/ui/app-screen";
import { IconHouseLine } from "@karrotmarket/react-monochrome-icon";
import {
BottomSheetContent,
BottomSheetFooter,
BottomSheetRoot,
BottomSheetTrigger,
} from "seed-design/ui/bottom-sheet";
declare module "@stackflow/config" {
interface Register {
ActivityBottomSheetStep: {
"bottom-sheet"?: "open";
};
}
}
const ActivityBottomSheetStep: StaticActivityComponentType<"ActivityBottomSheetStep"> = () => {
const [open, setOpen] = useState(false);
const { push } = useFlow();
const { pushStep, popStep } = useStepFlow("ActivityBottomSheetStep");
const params = useActivityParams<"ActivityBottomSheetStep">();
const isOverlayOpen = params["bottom-sheet"] === "open";
useEffect(() => setOpen(isOverlayOpen), [isOverlayOpen]);
const onOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (newOpen && !isOverlayOpen) {
pushStep((params) => ({ ...params, "bottom-sheet": "open" }));
return;
}
if (!newOpen && isOverlayOpen) {
popStep();
return;
}
};
return (
<AppScreen>
<AppBar>
<AppBarMain title="Step" />
<AppBarRight>
<AppBarIconButton aria-label="Home" onClick={() => push("ActivityHome", {})}>
<IconHouseLine />
</AppBarIconButton>
</AppBarRight>
</AppBar>
<AppScreenContent>
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetTrigger asChild>
<VStack p="x5" justify="center" gap="x4">
<ActionButton variant="neutralSolid" flexGrow>
Bottom Sheet 열기
</ActionButton>
</VStack>
</BottomSheetTrigger>
<Portal>
<BottomSheetContent
showHandle
title="Step"
description="Bottom Sheet가 Step으로 만들어져 있기 때문에 뒤로 가기로 닫을 수 있습니다."
layerIndex={useActivityZIndexBase({ activityOffset: 1 })}
>
<BottomSheetFooter>
<HStack gap="x2">
<ActionButton onClick={() => setOpen(false)} variant="neutralWeak">
닫기
</ActionButton>
<ActionButton
flexGrow
variant="neutralSolid"
onClick={() => {
// 이 Bottom Sheet는 Activity로 만들어지지 않았기 때문에, z-index 정리를 위해
// BottomSheet를 먼저 닫고 다음 Activity를 push해야 합니다.
setOpen(false);
push("ActivityDetail", {
title: "Bottom Sheet에서 이동한 화면",
body: "Bottom Sheet를 닫고 이동했습니다.",
});
}}
>
Push
</ActionButton>
</HStack>
</BottomSheetFooter>
</BottomSheetContent>
</Portal>
</BottomSheetRoot>
</AppScreenContent>
</AppScreen>
);
};
export default ActivityBottomSheetStep;Usage
import { useActivityZIndexBase } from "@seed-design/stackflow";
import { Portal } from "@seed-design/react";
import {
useActivity,
useActivityParams,
useFlow,
useStepFlow,
type ActivityComponentType,
} from "@stackflow/react/future";
import { useEffect, useState } from "react";
// ... more imports
declare module "@stackflow/config" {
interface Register {
ActivityHome: {
"bottom-sheet"?: "open";
};
}
}
const ActivityHome: ActivityComponentType<"ActivityHome"> = () => {
const [open, setOpen] = useState(false);
const { push } = useFlow();
const { pushStep, popStep } = useStepFlow("ActivityHome");
const params = useActivityParams<"ActivityHome">();
const isOverlayOpen = params["bottom-sheet"] === "open";
useEffect(() => {
if (!isOverlayOpen) {
setOpen(false);
}
}, [isOverlayOpen]);
const onOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (newOpen && !isOverlayOpen) {
pushStep((params) => ({ ...params, "bottom-sheet": "open" }));
return;
}
if (!newOpen && isOverlayOpen) {
popStep();
return;
}
};
return (
<AppScreen>
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetTrigger asChild>
<ActionButton>Open</ActionButton>
</BottomSheetTrigger>
<Portal>
<BottomSheetContent
title="Step으로 관리되는 Bottom Sheet"
layerIndex={useActivityZIndexBase({ activityOffset: 1 })}
>
<BottomSheetFooter>
<HStack gap="x2">
<ActionButton onClick={() => setOpen(false)}>취소</ActionButton>
<ActionButton
onClick={() => {
setOpen(false); // 다른 Activity로 이동하기 전에는 Bottom Sheet를 닫으세요.
push("ActivityNext");
}}
>
다음
</ActionButton>
</HStack>
</BottomSheetFooter>
</BottomSheetContent>
</Portal>
</BottomSheetRoot>
</AppScreen>
);
};Portal을 사용하여 Bottom Sheet가 DOM 상 현재 Activity 밖에 렌더링되도록 합니다.openprop를 관리하고,onOpenChange핸들러를 통해 Step 상태와 동기화합니다.- 뒤로 가기 버튼 등을 통해 Activity 파라미터가 변경될 때 Bottom Sheet의
open상태를 동기화합니다. layerIndex={useActivityZIndexBase({ activityOffset: 1 })}로 현재 Activity보다 한 단계 높은 z-index 기준점을 전달합니다.
useStepOverlay
#2와 #3을 일반화하여 useStepOverlay를 사용하면 편리합니다. useStepOverlay 구현 예시는 코드를 참고하세요.
import { useActivityZIndexBase } from "@seed-design/stackflow";
import { Portal } from "@seed-design/react";
import { useStepOverlay } from "./use-step-overlay";
// ... more imports
const MyActivity: ActivityComponentType = () => {
const { overlayProps, setOpen } = useStepOverlay();
const { popStep } = useStepFlow("MyActivity");
const { push } = useFlow();
return (
<AppScreen>
<BottomSheetRoot {...overlayProps}>
<BottomSheetTrigger asChild>
<ActionButton>Open</ActionButton>
</BottomSheetTrigger>
<Portal>
<BottomSheetContent
title="Step으로 관리되는 Bottom Sheet"
layerIndex={useActivityZIndexBase({ activityOffset: 1 })}
>
<BottomSheetFooter>
<HStack gap="x2">
<ActionButton onClick={() => setOpen(false)}>취소</ActionButton>
<ActionButton
onClick={() => {
setOpen(false); // 다른 Activity로 이동하기 전에는 Bottom Sheet를 닫으세요.
push("ActivityNext");
}}
>
다음
</ActionButton>
</HStack>
</BottomSheetFooter>
</BottomSheetContent>
</Portal>
</BottomSheetRoot>
</AppScreen>
);
};About useActivityZIndexBase
useActivityZIndexBase는 각 Activity의 z-index 기준점을 반환하는 훅입니다.
Last updated on