하단 시트를 동적으로 리사이즈하는 데 겪은 시행착오

김민석·2025년 10월 10일
0

Tech Deep Dive

목록 보기
51/58

Intro

  • 바텀시트 안에 들어가는 콘텐츠가 매번 달라지다 보니, 고정 스냅 포인트로는 사용성을 맞출 수가 없었어요.
  • 저는 @gorhom/bottom-sheet를 기반으로 콘텐츠 높이에 맞춰 스냅 포인트를 계산하고, iOS/Android 오버레이를 분리했습니다.

핵심 아이디어 요약

  • 콘텐츠 레이아웃 이벤트에서 높이를 측정하고, 안전 영역과 헤더 높이를 더해 비율을 계산합니다.
  • 스냅 포인트를 3단계(피크/콘텐츠 맞춤/최대)로 강제 정렬해 흔들리지 않는 UX를 만들었습니다.
  • 포털과 블러 뷰를 이용해 모달 레이어와 겹치지 않도록 관리했습니다.

준비와 선택

  • 공용 컴포넌트이기 때문에 Portal을 적용해 어디서든 동일한 최상위 레이어로 띄우게 했습니다.
  • iOS에서만 블러 오버레이가 자연스러워 BlurView를 조건부로 적용했고, Android에는 기본 백드롭을 활용했습니다.
  • handleComponent는 공통 헤더로 교체하면서 여닫기 제스처를 상황에 따라 막는 옵션도 넣었습니다.

구현 여정

  1. 콘텐츠 높이 측정: onLayout 이벤트에서 높이를 저장하고, 변경될 때마다 스냅 포인트를 재계산했습니다.
  2. 스냅 포인트 산식: 최소 피크, 최대 92%를 기준으로 콘텐트 비율에 맞춰 반올림한 값을 사용했습니다.
  3. 대형 콘텐츠 판별: 높이가 70%를 넘으면 최초 위치를 최상단에 맞춰 긴 스크롤에서 헤더가 바로 드러나게 했습니다.
  4. 블러 백드롭: iOS에선 BlurView에 반투명 배경을 추가하고, Android에서는 BottomSheetBackdrop을 그대로 사용했습니다.
  5. 제스처 제어: freeze 옵션이 true면 pan 제스처를 막아 모달처럼 사용할 수 있게 했습니다.
// src/shared/components/BottomSheet/ui/BaseBottomSheet.tsx:36-255
export default function BaseBottomSheet({
  isOpen,
  onClose,
  children,
  noPadding = false,
  disableOverlayClick = false,
  blurOverlay = false,
  freeze = false,
  portalName,
}: BaseBottomSheetProps) {
  const bottomSheetRef = useRef<BottomSheetCore>(null);
  const [contentHeight, setContentHeight] = useState(0);
  const screenHeight = useMemo(() => Dimensions.get('window').height, []);
  const insets = useSafeAreaInsets();
  const generatePortalName = useSetAtom(generatePortalNameAtom);
  const finalPortalName = useMemo(
    () => portalName || generatePortalName(),
    [portalName, generatePortalName],
  );

  const snapPoints = useMemo(() => {
    if (contentHeight === 0) return ['33%', '60%', '92%'];
    const headerAndPaddingPx = 72;
    const totalNeededHeight = contentHeight + headerAndPaddingPx + insets.bottom;
    const rawFitPercent = (totalNeededHeight / screenHeight) * 100;
    const maxPercent = 92;
    const minPeekPercent = 25;
    const defaultPeekPercent = 33;

    const clamp = (value: number, min: number, max: number) =>
      Math.min(Math.max(value, min), max);
    const roundTo5 = (value: number) => Math.round(value / 5) * 5;

    const fitPercent = clamp(roundTo5(rawFitPercent), minPeekPercent, maxPercent);
    let points: number[];

    if (fitPercent <= 40) {
      const peek = Math.min(defaultPeekPercent, Math.max(minPeekPercent, fitPercent - 10));
      const mid = fitPercent;
      const top = clamp(roundTo5(fitPercent + 20), mid + 5, maxPercent);
      points = [peek, mid, top];
    } else if (fitPercent <= 70) {
      points = [defaultPeekPercent, fitPercent, maxPercent];
    } else {
      const mid = roundTo5((defaultPeekPercent + fitPercent) / 2);
      points = [
        defaultPeekPercent,
        clamp(mid, defaultPeekPercent + 5, fitPercent - 5),
        fitPercent,
      ];
    }

    const uniqueSorted = Array.from(new Set(points)).sort((a, b) => a - b);
    return uniqueSorted.slice(0, 3).map(v => `${v}%`) as [string, string, string];
  }, [contentHeight, insets.bottom, screenHeight]);

  const renderBackdrop = useCallback(
    (props: any) => {
      if (!isOpen) return null;
      if (blurOverlay) {
        return (
          <BlurView
            intensity={20}
            style={[props.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)' }]}
            onTouchStart={disableOverlayClick ? undefined : onClose}
          />
        );
      }
      return (
        <BottomSheetBackdrop
          {...props}
          appearsOnIndex={0}
          disappearsOnIndex={-1}
          opacity={0.6}
          style={[props.style, { backgroundColor: 'rgba(0, 0, 0, 0.5)' }]}
          onPress={disableOverlayClick ? undefined : onClose}
        />
      );
    },
    [blurOverlay, disableOverlayClick, isOpen, onClose],
  );

  return (
    <Portal hostName='root' name={finalPortalName}>
      <BottomSheetCore
        ref={bottomSheetRef}
        backdropComponent={renderBackdrop}
        backgroundStyle={{
          backgroundColor: colors.white,
          borderTopLeftRadius: 24,
          borderTopRightRadius: 24,
        }}
        enableContentPanningGesture={!freeze}
        enableHandlePanningGesture={!freeze}
        enablePanDownToClose={!freeze}
        handleComponent={BottomSheetHeader}
        index={-1}
        snapPoints={snapPoints}
        onChange={index => index === -1 && onClose()}
      >
        <BottomSheetView style={[tw`flex-1`, { paddingBottom: insets.bottom + 16 }]}>
          <View style={tw`${clsx('flex-1', !noPadding && 'px-4')}`} onLayout={event => {
            setContentHeight(event.nativeEvent.layout.height);
          }}>
            {children}
          </View>
        </BottomSheetView>
      </BottomSheetCore>
    </Portal>
  );
}

결과와 회고

  • 콘텐츠에 따라 자연스럽게 크기가 바뀌는 하단 시트를 만들었고, 작은 목록에서는 피크가 너무 높게 잡히던 문제도 해결했습니다.
  • 블러 오버레이 덕분에 iOS에서 모달과 겹칠 때 자연스러운 전환이 되면서 디자인 팀의 피드백도 줄었습니다.
  • 다음에는 스냅 포인트 데이터와 애널리틱스를 연결해 사용자들이 어떤 높이를 가장 많이 쓰는지 분석해볼 생각입니다.
  • 여러분은 바텀시트를 얼마나 커스터마이즈하고 계신가요? 다른 아이디어가 있다면 댓글로 공유해주세요.

Reference

profile
동업자와 함께 창업 3년차입니다. Nextjs 위주의 프로젝트를 주로 하며, React Native, Supabase, Nestjs를 주로 사용합니다. 인공지능 야간 대학원을 다니고 있습니다.

0개의 댓글