Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Stackflow cleanup #502

Open
wants to merge 23 commits into
base: wip
Choose a base branch
from
Open

feat: Stackflow cleanup #502

wants to merge 23 commits into from

Conversation

malangcat
Copy link
Contributor

@malangcat malangcat commented Jan 7, 2025

동기

Stackflow를 사용하는 기존 프로젝트는 Basic UI Plugin을 확장하는 방식으로 작성되었습니다.

하지만, Basic UI를 확장하기 어려운 지점들이 있었고, 다음과 같은 문제를 관찰했습니다.

  • basic-ui는 스타일을 내장하고 있습니다.
    • 다양한 configuration을 열어두고 있지만, SEED Design의 UI 요구사항과 일치하지 않는 경우 수정이 어렵습니다.
    • 스타일과 기능이 긴밀하게 연결되어 있습니다. 기능이 런타임에 스타일을 직접 제어하고, 스타일시트 또한 이를 전제로 작성되고 있습니다.
    • 런타임에 스타일을 제어하는 방식은 클라이언트 분기가 있을 때 hydration mismatch의 원인이 됩니다.
  • 기능을 분리하기 위해 react-ui-core 패키지를 분리했으나, 여전히 스타일을 직접 제어하는 코드가 있어 확장에 어려움을 겪었습니다.
  • 모든 요소를 하나의 컴포넌트인 <AppScreen>으로 묶어서 제공하고 있습니다.
    • 이 방식은 기본적인 사용에는 편리하나, 커스터마이징이 어렵습니다.
    • 예를 들어, 특정 이벤트가 발생할 때 스크롤을 끌어올리고 싶어도, 컨테이너의 ref를 가져올 방법이 없습니다.
    • 이런 이유로 많은 프로젝트에서 <div style={{ height: "100%", overflow: "auto" }}> 와 같은 스크롤 컨테이너를 매번 추가해서 사용하는 패턴이 관찰됩니다.
  • 따라서, basic-ui보다 더 opinionated한 seed-design/stackflow를 만들기 위해서는 오히려 basic-ui보다 unopinionated한 primitive가 필요하다는 결론을 내렸습니다.

목표

  • "스택형 UI에 필수적인 기능"과 "스택형 UI의 스타일"을 분리합니다.
  • "실제로 자유롭게 조합해서 사용할 수 있는" Stackflow UI Primitive를 제공합니다.
  • Stackflow UI Primitive에 손쉽게 스타일을 적용할 수 있는 스타일시트 패턴을 제시합니다.
  • 궁극적으로, SEED Design이 Stackflow의 기능에만 의존하며 UI를 발전시킬 수 있도록 개선합니다.

접근

기존 Basic UI에서 제시하는 주요 기능은 다음과 같습니다.

  • push/pop시 최상단 Activity 및 하단 Activity 전환 효과
  • Swipe back 인터랙션 및 전환 효과
  • 상단 내비게이션 바와 컨텐츠간 개별적인 전환 효과

이 기능들을 그대로 제공하면서,

  • 사용자가 직접 테마 및 전환 효과를 추가할 수 있도록
  • 컴포넌트의 각 요소를 개별적으로 제어할 수 있도록

설계해야 합니다.

Primitive와 스타일 분리

  • 런타임은 HTMLElement의 스타일을 직접 조작하지 않습니다. 대신, 스타일시트가 판단할 수 있도록 상태를 전달합니다.
  • 유한 상태는 Data Attribute를 통해 스타일시트에 전달합니다.
  • 무한 상태는 CSS Variable을 통해 스타일시트에 전달합니다.
  • Stack-wide 상태는 Stack 요소에 전달됩니다.
    • 유한 상태: top-activity-type, global-transition-state, swipe-back-state
    • 무한 상태: swipe-back-displacement, swipe-back-displacement-ratio
    • <GlobalInteraction> 컴포넌트로 추상화되며, Stackflow plugin 형태로 wrapStack에 전달됩니다.
  • Activity-wide 상태는 Activity 요소에 전달됩니다.
    • 유한 상태: activity-is-top, transition-state
    • <AppScreen> 컴포넌트로 추상화되며, 필요하다면 <Modal>과 같은 다른 컴포넌트도 추가될 수 있습니다.

스타일시트 - 전환 상태

Primitive에서 제공하는 Data Attribute를 활용해 전환 효과를 구현할 수 있습니다.

export const push = "[data-global-transition-state=enter-active] &[data-activity-is-top]";
export const pop = "[data-global-transition-state=exit-active] &[data-activity-is-top]";
export const idle = "[data-global-transition-state=enter-done] &[data-activity-is-top]";
export const pushBehind =
  '[data-global-transition-state=enter-active][data-top-activity-type="full-screen"] &:not([data-activity-is-top])';
export const popBehind =
  '[data-global-transition-state=exit-active][data-top-activity-type="full-screen"] &:not([data-activity-is-top])';
export const idleBehind =
  '[data-global-transition-state=enter-done][data-top-activity-type="full-screen"] &:not([data-activity-is-top])';
  • 전환 효과는 push, pop, idle로 구분할 수 있습니다.
    • push 전환 중에는 최상단 activity의 transitionState가 enter-active입니다.
    • pop 전환 중에는 최상단 activity의 transitionState가 exit-active입니다.
    • 전환이 완료되면 최상단 activity의 transitionState가 enter-done입니다.
  • 하단 Activity는 최상단 Activity가 Modal이 아닌 경우에만 전환 효과가 필요합니다. 따라서 top-activity-type selector를 추가합니다.

스타일시트 - 애니매이션 선언

애니메이션을 선언을 추상화하는 createPresence 함수를 제공합니다.

  • enter 시의 duration 및 timing function
  • exit 시의 duration 및 timing function
  • enter 완료시의 목표 스타일
  • exit 완료시의 목표 스타일
  • swipeBack 등 인터랙션 중의 스타일 계산식

을 전달하면, push/idle/pop/interaction 4가지 상태에 대한 애니메이션을 생성합니다.

const TransitionIOS = {
  duration: "300ms",
  timingFunction: "cubic-bezier(0.22, 0.1, 0.3, 0.85)", // approximates iOS spring animation
};

const iOSPresence = createPresence(TransitionIOS, TransitionIOS);

export const iOSAnimations = {
  layer: iOSPresence.getAnimations({
    in: {
      translateX: "0",
    },
    interaction: {
      translateX: "var(--swipe-back-displacement, 0)",
    },
    out: {
      translateX: "100%",
    },
  }),
  layerBehind: iOSPresence.getAnimations({
    in: {
      translateX: "0",
    },
    interaction: {
      translateX: "calc(-30% + var(--swipe-back-displacement, 0) * 0.3)",
    },
    out: {
      translateX: "-30%",
    },
    gravity: "out",
  }),
  dim: iOSPresence.getAnimations({
    in: {
      opacity: "1",
    },
    interaction: {
      opacity: "calc(1 - var(--swipe-back-displacement-ratio, 0))",
    },
    out: {
      opacity: "0",
    },
  }),
  ...
}

스타일시트 - 선언

위 selector와 애니매이션 선언을 조합해 transition style 테마를 자유롭게 추가할 수 있습니다.

이때, OS별 theme 선언과 transitionStyle 선언을 엄격하게 분리합니다. 두 선언이 섞이면 가독성이 저하되며, 동작을 예측하기 어려워집니다.

slideFromRightIOS: {
    root: {
        "--z-index-dim": "calc(var(--z-index-base) + 0)",
        "--z-index-layer": "calc(var(--z-index-base) + 2)",
        "--z-index-edge": "calc(var(--z-index-base) + 4)",
        "--z-index-app-bar": "calc(var(--z-index-base) + 7)",
    },
    layer: {
        // top
        [push]: iOSAnimations.layer.push,
        [pop]: iOSAnimations.layer.pop,
        [idle]: iOSAnimations.layer.idle,
        [swipeBackSwiping]: iOSAnimations.layer.interaction,

        // behind
        [pushBehind]: iOSAnimations.layerBehind.push,
        [popBehind]: iOSAnimations.layerBehind.pop,
        [idleBehind]: iOSAnimations.layerBehind.idle,
        [swipeBackSwipingBehind]: iOSAnimations.layerBehind.interaction,
    },
    dim: {
        [push]: iOSAnimations.dim.push,
        [pop]: iOSAnimations.dim.pop,
        [idle]: iOSAnimations.dim.idle,
        [swipeBackSwiping]: iOSAnimations.dim.interaction,
    },
},

컴포넌트 - Composable API

기존 <AppScreen> 컴포넌트는 dim, layer, edge, appBar 요소를 하나의 컴포넌트로 묶어 제공합니다. 이를 각각의 컴포넌트로 분리해서 직접 조합할 수 있도록 합니다.

각 컴포넌트는 asChild 패턴을 지원합니다. HTML tag를 바꿔야 하거나, 다른 컴포넌트와 합성해야 하는 경우 유용합니다.

예를 들어:

export interface AppScreenProps extends SeedAppScreen.RootProps {}

export const AppScreen = forwardRef<HTMLDivElement, AppScreenProps>(
  ({ children, onSwipeBackEnd, ...otherProps }, ref) => {
    const { pop } = useActions();

    return (
      <SeedAppScreen.Root
        ref={ref}
        onSwipeBackEnd={({ swiped }) => {
          if (swiped) {
            pop();
          }
          onSwipeBackEnd?.({ swiped });
        }}
        {...otherProps}
      >
        <SeedAppScreen.Dim />
        {children}
        <SeedAppScreen.Edge />
      </SeedAppScreen.Root>
    );
  },
);
AppScreen.displayName = "AppScreen";

export interface AppScreenContentProps extends SeedAppScreen.LayerProps {}

export const AppScreenContent = SeedAppScreen.Layer;

AS-IS:

<AppScreen appBar={{
    title: "Title",
    renderLeft: <BackButton />,
    renderRight: <MenuButton />,
}}>
  ...
</AppScreen>

TO-BE:

<AppScreen>
  <AppBar>
    <AppBarLeft>
      <BackButton />
    </AppBarLeft>
    <AppBarTitle>Title</AppBarTitle>
    <AppBarRight>
      <MenuButton />
    </AppBarRight>
  </AppBar>
  <AppScreenContent>
    ...
  </AppScreenContent>
</AppScreen>

API에는 호불호가 있을 수 있습니다. 이는 하나의 예시일 뿐이며, 제공되는 요소별 컴포넌트를 조합해 기존과 동일한 방식으로도 사용할 수 있습니다.

컴포넌트 - Pull To Refresh

웹뷰에서 자주 사용되는 패턴인 Pull To Refresh 컴포넌트를 함께 제공합니다. Pull To Refresh는 Stackflow 의존성은 없으나, 제시된 Composable API와 함께 사용하기 좋습니다.

예를 들어:

export interface AppScreenContentProps extends SeedAppScreen.LayerProps {
  ptr?: boolean;

  onPtrReady?: () => void;

  onPtrRefresh?: () => Promise<void>;
}

export const AppScreenContent = forwardRef<HTMLDivElement, AppScreenContentProps>(
  ({ children, ptr, onPtrReady, onPtrRefresh, ...otherProps }, ref) => {
    if (!ptr) {
      return (
        <SeedAppScreen.Layer ref={ref} {...otherProps}>
          {children}
        </SeedAppScreen.Layer>
      );
    }

    return (
      <PullToRefresh.Root asChild onPtrReady={onPtrReady} onPtrRefresh={onPtrRefresh}>
        <SeedAppScreen.Layer ref={ref} {...otherProps}>
          <PullToRefresh.Indicator>
            {(props) => <ProgressCircle tone="brand" {...props} />}
          </PullToRefresh.Indicator>
          <PullToRefresh.Content asChild>{children}</PullToRefresh.Content>
          <Debug />
        </SeedAppScreen.Layer>
      </PullToRefresh.Root>
    );
  },
);
<AppScreen>
  ...
  <AppScreenContent
    ptr
    onPtrRefresh={async () => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }}
  >
    ...
  </AppScreenContent>
</AppScreen>

PTR이 자주 사용되는 프로젝트라면 위와 같이 PTR을 사전에 조합한 AppScreen을 활용하는 것을 고려할 수 있습니다.

코드

Primitive

stackflow/primitive/ 디렉토리는 Stackflow UI를 구성하기 위한 headless 컴포넌트를 제공합니다.

Styling

qvism-preset/stackflow 디렉토리는 Primitive 위에 SEED Design의 스타일을 적용하는 스타일시트 및 애니매이션 유틸리티를 제공합니다.

Components

stackflow/components 디렉토리는 위 두 패키지를 조합해 Stackflow 위에서 동작하는 SEED Design 컴포넌트를 제공합니다.

Stackflow 개선 가능성

  • transition state는 기본적으로 stack-wide 관심사입니다. 동시에 여러 Activity에서 transition이 발생해서는 안됩니다.
    • 현재는 stack.activities를 순회하며 top activity를 가져오지만, 추후 stack에서 직접 제공되어야 할 가능성이 높습니다.
  • transitionDuration이 전역으로 고정된 값이기 때문에 자유로운 transition 테마 구현이 어렵습니다.
    • transitionDuration을 Activity별로, 그리고 enter/exit별로 지정할 수 있도록 확장되어야 합니다.
    • 혹은, animationend event를 활용해 active -> done을 감지할 수 있도록 하고, transitionDuration을 제거하는 것도 가능할 수 있습니다. 참고: Radix UI Presence
  • SSR 환경의 hydration 과정에서 activity.id가 다시 계산되어 hydration mismatch가 발생합니다. 일단 suppress 해두었으나, stackflow/core에서 해결되어야 하는 버그이며, suppress는 제거되어야 합니다.

Copy link

changeset-bot bot commented Jan 7, 2025

⚠️ No Changeset found

Latest commit: 3827bf5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@malangcat malangcat changed the base branch from main to wip January 7, 2025 11:34
@malangcat malangcat changed the title Stackflow cleanup (WIP) feat: Stackflow cleanup Jan 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant