SEED Design

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에서의 마이그레이션이 쉽습니다.
Loading...
ActivityBottomSheetActivity.tsx
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;
ActivityBottomSheet.tsx
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>
  );
};
  1. open prop에 useActivity().isActive를 전달하여 Activity가 활성화될 때 Bottom Sheet가 열리도록 합니다.
  2. onOpenChange를 통해 Bottom Sheet가 닫힐 때 pop()을 실행하여 Activity를 종료합니다.
  3. 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 사이에 위치시키는 것이 불가능합니다.

Loading...
ActivityBottomSheetStep.tsx
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>
  );
};
  1. Portal을 사용하여 Bottom Sheet가 DOM 상 현재 Activity 밖에 렌더링되도록 합니다.
  2. open prop를 관리하고, onOpenChange 핸들러를 통해 Step 상태와 동기화합니다.
  3. 뒤로 가기 버튼 등을 통해 Activity 파라미터가 변경될 때 Bottom Sheet의 open 상태를 동기화합니다.
  4. 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