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 ActivityComponentType } from "@stackflow/react/future";
import { AppBar, AppBarBackButton, AppBarLeft, AppBarMain } from "seed-design/ui/app-bar";
import { AppScreen, AppScreenContent } from "seed-design/ui/app-screen";
import { ActionButton } from "seed-design/ui/action-button";
declare module "@stackflow/config" {
interface Register {
ActivityBottomSheetActivity: {};
}
}
const ActivityBottomSheetActivity: ActivityComponentType<"ActivityBottomSheetActivity"> = () => {
const { push } = useFlow();
return (
<AppScreen>
<AppBar>
<AppBarLeft>
<AppBarBackButton />
</AppBarLeft>
<AppBarMain title="Activity" />
</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 { HStack, VStack } from "@seed-design/react";
import { useActivity, useFlow, type ActivityComponentType } 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";
declare module "@stackflow/config" {
interface Register {
ActivityBottomSheet: {};
}
}
const ActivityBottomSheet: ActivityComponentType<"ActivityBottomSheet"> = () => {
const { push, pop } = useFlow();
const activity = useActivity();
const form = useRef<HTMLFormElement>(null);
const snackbar = useSnackbarAdapter();
const [nameError, setNameError] = useState<string | null>(null);
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"),
})}
/>
),
});
};
return (
<BottomSheetRoot open={activity.isActive} onOpenChange={(open) => !open && pop()}>
<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>
<HStack gap="x2">
<ActionButton type="button" variant="neutralWeak" onClick={pop}>
닫기
</ActionButton>
<ActionButton
type="button"
variant="neutralWeak"
onClick={() =>
push("ActivityDetail", {
title: "Activity",
body: "이 Activity를 pop하면 이전 Activity의 Bottom Sheet가 열린 상태로 표시됩니다.",
})
}
>
Push
</ActionButton>
<ActionButton type="submit" variant="neutralSolid" flexGrow>
제출
</ActionButton>
</HStack>
</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 기준점을 전달합니다.
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 ActivityComponentType,
} from "@stackflow/react/future";
import { useEffect, useState } from "react";
import { ActionButton } from "seed-design/ui/action-button";
import { AppBar, AppBarMain } from "seed-design/ui/app-bar";
import { AppScreen, AppScreenContent } from "seed-design/ui/app-screen";
import {
BottomSheetContent,
BottomSheetFooter,
BottomSheetRoot,
BottomSheetTrigger,
} from "seed-design/ui/bottom-sheet";
declare module "@stackflow/config" {
interface Register {
ActivityBottomSheetStep: {
"bottom-sheet"?: "open";
};
}
}
const ActivityBottomSheetStep: ActivityComponentType<"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(() => {
if (!isOverlayOpen) {
setOpen(false);
}
if (isOverlayOpen) {
setOpen(true);
}
}, [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" />
</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={() => popStep()} variant="neutralWeak">
닫기
</ActionButton>
<ActionButton
flexGrow
variant="neutralSolid"
onClick={() => {
// 이 Bottom Sheet는 Activity로 만들어지지 않았기 때문에, z-index 정리를 위해
// BottomSheet를 먼저 닫고 다음 Activity를 push해야 합니다.
popStep();
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={() => popStep()}>취소</ActionButton>
<ActionButton
onClick={() => {
popStep(); // 다른 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={() => popStep()}>취소</ActionButton>
<ActionButton
onClick={() => {
popStep(); // 다른 Activity로 이동하기 전에는 Bottom Sheet를 닫으세요.
push("ActivityNext");
}}
>
다음
</ActionButton>
</HStack>
</BottomSheetFooter>
</BottomSheetContent>
</Portal>
</BottomSheetRoot>
</AppScreen>
);
};About useActivityZIndexBase
useActivityZIndexBase는 각 Activity의 z-index 기준점을 반환하는 훅입니다.
Last updated on