Intro
모바일 환경에서 구동하는 웹뷰 형식의 프로젝트 구성을 위한 자료 수집을 하던 중 “2024 당근 테크 밋업”을 통해 모바일 환경에 적합한 Routing 라이브러리를 발견했다.
🕑 10:20 참고
🔗 프레임워크부터 플랫폼까지: 당근 웹뷰 플랫폼 | 2024 당근 테크 밋업
Stackflow
Stackflow는 모바일 디바이스(iOS/Android 등)에서 주로 활용되는 Stack Navigation UX를 JavaScript 환경에서 구현하고, 이를 통해 하이브리드 앱과 웹뷰 개발을 쉽게 할 수 있도록 돕는 🥕 당근에서 만든 프로젝트이다.
- 화면을 쌓고 스크롤을 유지
- 화면이 쌓이는 전환 효과와 뒤로가기 시 화면이 사라지는 전환 효과를 지원
- iOS 스타일의 스와이프백을 통한 뒤로가기 동작 지원
- 전환되는 화면에 필요한 파라미터를 전달
🔗 Stackflow 공식 홈페이지
Docs의 좌측 최하단에 English, 한국어 버전의 문석서를 지원한다.
Stackflow 시작하기
공식문서가 잘 되어있어 그대로 진행하면 되기 때문에 대부분의 내용을 그대로 가져왔으며 직접 실행한 GIF를 첨부했다.
vite 프로젝트 생성
vite를 활용해 원하는 환경으로 새로운 프로젝트를 생성할 수 있다.
- React + TypeScript
npm create vite@latest
🔗 Vite 공식 홈페이지
Stackflow 설치하기
설치하기
- npm, pnpm, yarn, bun 으로 설치할 수 있으며 공식 홈페이지 참조
npm install @stackflow/core @stackflow/react
Stackflow 초기화하기
- 프로젝트 내에 JavaScript(TypeScript) 파일을 하나 생성하고, stackflow() 함수를 호출해 <Stack />과 useFlow() 함수를 생성한다.
- 다른 컴포넌트에서 <Stack />과 useFlow()를 활용할 수 있도록 export ... 해준다.
// src/stackflow.ts
import { stackflow } from "@stackflow/react";
export const { Stack, useFlow } = stackflow({
transitionDuration: 350,
activities: {},
plugins: [],
});
기본 UI 확장 설치하기
Stackflow는 기본적으로 UI(DOM과 CSS) 구현을 포함하지 않는다. 원하는 렌더링 결과를 얻기 위해서는 플러그인 추가가 필요하다. 다음 명령어로 @stackflow/plugin-renderer-basic 플러그인과 @stackflow/plugin-basic-ui 확장을 설치한다.
설치하기
npm install @stackflow/plugin-renderer-basic @stackflow/plugin-basic-ui
UI 플러그인 초기화하기
다음과 같이 stackflow() 함수의 plugins 필드에 @stackflow/plugin-renderer-basic에 들어있는 basicRendererPlugin() 플러그인과 @stackflow/plugin-basic-ui의 basicUIPlugin() 플러그인을 초기화 해준다.
// src/stackflow.ts
import { stackflow } from "@stackflow/react";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import { basicUIPlugin } from "@stackflow/plugin-basic-ui";
export const { Stack, useFlow } = stackflow({
transitionDuration: 350,
activities: {},
plugins: [
basicRendererPlugin(),
basicUIPlugin({
theme: "cupertino",
}),
],
});
CSS 삽입하기
@stackflow/plugin-basic-ui에서 제공하는 CSS도 내 코드 어딘가에 삽입하라고 하는데 App.tsx 상단에 import 해줬다.
// src/App.tsx
import "@stackflow/plugin-basic-ui/index.css";
<Stack /> 컴포넌트 렌더링하기
원하는 렌더링 위치에 다음과 같이 <Stack /> 컴포넌트를 초기화 해준다.
// src/App.tsx
import "@stackflow/plugin-basic-ui/index.css";
import { Stack } from "./stackflow";
function App() {
return (
<>
<Stack />
</>
);
}
export default App;
액티비티
화면에 하나씩 쌓이는 화면 하나하나를 액티비티라고 한다. 액티비티는 다음과 같은 속성을 갖고, 필요한 경우 useActivity() 훅을 통해 가져올 수 있다.
Option | Type | Description |
id | string | 활성화되는 액티비티마다 가지는 고유한 ID 값 |
name | string | 등록된 액티비티의 이름 |
transitionState | enter-active, enter-done, exit-active, exit-done | 현재 액티비티의 전환 상태 |
액티비티 등록하기
사용하기 전 stackflow() 함수에 등록이 필요하다. 액티비티는 ActivityComponentType이라는 타입으로 선언되는 리액트 컴포넌트이다.
// src/pages/MyActivity.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
const MyActivity: ActivityComponentType = () => {
return (
<AppScreen appBar={{ title: "My Activity" }}>
<div>My Activity</div>
</AppScreen>
);
};
export default MyActivity;
- ActivityComponentType은 React.ComponentType와 호환된다. 따라서 기존에 활용하던 React.FC, React.Component 등을 그대로 활용할 수 있다.
- Stackflow는 기본적으로 UI를 제공하지 않는다. 대신 @stackflow/plugin-basic-ui에서 기본 iOS(cupertino), Android(android) UI를 제공하고 있다.
액티비티를 선언했다면, 다음과 같이 stackflow() 함수의 activities 필드에 등록한다.
// src/stackflow.ts
import { stackflow } from "@stackflow/react";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import MyActivity from "./pages/MyActivity";
export const { Stack, useFlow } = stackflow({
transitionDuration: 350,
plugins: [
basicRendererPlugin(),
basicUIPlugin({
theme: "cupertino",
}),
],
activities: {
MyActivity,
},
});
초기 액티비티 등록하기
액티비티를 성공적으로 등록해도 이전에 초기화해놓은 <Stack /> 컴포넌트에는 아무것도 렌더링되고 있지 않는다. 초기 액티비티를 설정해주지 않았기 때문이다. 초기에 특정 액티비티를 로드하고 싶다면, 다음과 같이 옵션에 initialActicity를 추가한다.
// src/stackflow.ts
import { stackflow } from "@stackflow/react";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import MyActivity from "./pages/MyActivity";
export const { Stack, useFlow } = stackflow({
transitionDuration: 350,
plugins: [
basicRendererPlugin(),
basicUIPlugin({
theme: "cupertino",
}),
],
activities: {
MyActivity,
},
initialActivity: () => "MyActivity",
});
액티비티에 필요한 파라미터 등록하기
해당 액티비티가 사용될 때, 특정 파라미터가 필요한 경우가 있다. 이런 경우 다음과 같이 액티비티의 Props로 해당 파라미터를 선언한다.
// src/pages/Article.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
type ArticleParams = {
title: string;
};
const Article: ActivityComponentType<ArticleParams> = ({ params }) => {
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{params.title}</h1>
</div>
</AppScreen>
);
};
export default Article;
또는
// src/pages/Article.tsx
import { AppScreen } from "@stackflow/plugin-basic-ui";
type ArticleParams = {
params: {
title: string;
};
};
const Article: React.FC<ArticleParams> = ({ params: { title } }) => {
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{title}</h1>
</div>
</AppScreen>
);
};
export default Article;
⚠️ 주의 - 만약 꼭 필요한 파라미터를 이전 화면이 넘겨주지 않은 경우 치명적인 오류가 발생할 수 있다.
🚫 경고 - 초기 액티비티에는 필수 파라미터가 존재하면 안된다.
액티비티 탐색하기
액티비티 사이를 이동하기 위해 Stackflow에서 제공하는 useFlow()를 통해 액티비티를 쌓거나, 교체하거나, 삭제할 수 있다.
새 액티비티 쌓기
stackflow.ts에서 생성했던 useFlow() 훅을 사용한다. 해당 훅 내에 push() 함수를 통해 다음과 같이 새 액티비티를 쌓을 수 있다.
// src/pages/MyActivity.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useFlow } from "../stackflow";
const MyActivity: ActivityComponentType = () => {
const { push } = useFlow();
const onClick = () => {
push("Article", {
title: "Hello",
});
};
return (
<AppScreen appBar={{ title: "My Activity" }}>
<div>
My Activity
<button onClick={onClick}>Go to article page</button>
</div>
</AppScreen>
);
};
export default MyActivity;
그러나 '"Article"' 형식의 인수는 '"MyActivity"' 형식의 매개 변수에 할당될 수 없습니다. 라는 오류가 발생한다.
모든 액티비티는 stackflow.ts의 activities에 등록해야 사용할 수 있기 때문에 Article을 등록 후 오류가 사라지는 것을 확인할 수 있다.
// src/stackflow.ts
import { stackflow } from "@stackflow/react";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import { basicUIPlugin } from "@stackflow/plugin-basic-ui";
import MyActivity from "./pages/MyActivity";
import Article from "./pages/Article";
export const { Stack, useFlow } = stackflow({
transitionDuration: 350,
plugins: [
basicRendererPlugin(),
basicUIPlugin({
theme: "cupertino",
}),
],
activities: {
MyActivity,
Article,
},
initialActivity: () => "MyActivity",
});
다시 MyActivity.tsx 파일로 돌아가서 push()는 첫번째 파라미터로 이동할 액티비티의 이름, 두번째 파라미터로 이동할 액티비티의 파라미터, 세번째 파라미터로 추가 옵션을 받는다. 세번째 파라미터인 추가 옵션은 선택적으로 넘기지 않을 수 있다. (기본값 사용)
push("액티비티_이름", {
/* 액티비티 파라미터 */
});
// 또는
push(
"액티비티_이름",
{
/* 액티비티 파라미터 */
},
{
/* 추가 옵션 */
},
);
push() 함수의 세번째 파라미터인 추가 옵션에는 다음과 같은 값이 있다.
Option | Type | Description | Default |
animate | boolean | 애니메이션을 켜거나 끈다 | true |
현재 액티비티 교체하기
스택에 새로운 액티비티를 추가하지 않고 현재 액티비티를 교체하려면 push()대신 replace()를 사용하면 된다. stackflow.ts에서 생성했던 useFlow() 훅의 replace() 함수를 통해 다음과 같이 현재 액티비티를 교체할 수 있다.
// src/pages/MyActivity.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useFlow } from "../stackflow";
const MyActivity: ActivityComponentType = () => {
const { replace } = useFlow();
const onClick = () => {
replace("Article", {
title: "Hello",
});
};
return (
<AppScreen appBar={{ title: "My Activity" }}>
<div>
My Activity
<button onClick={onClick}>Go to article page</button>
</div>
</AppScreen>
);
};
export default MyActivity;
replace()는 push()와 비슷한 API를 갖고 있다. 첫번째 파라미터로 이동할 액티비티의 이름, 두번째 파라미터로 이동할 액티비티의 파라미터, 세번째 파라미터로 추가 옵션을 받는다. 세번째 파라미터인 추가 옵션은 선택적으로 넘기지 않을 수 있다. (기본값을 사용 한다)
replace("액티비티_이름", {
/* 액티비티 파라미터 */
});
// 또는
replace(
"액티비티_이름",
{
/* 액티비티 파라미터 */
},
{
/* 추가 옵션 */
},
);
replace() 함수의 세번째 파라미터인 추가 옵션에는 다음과 같은 값이 있다.
Option | Type | Description | Default |
animate | boolean | 애니메이션을 켜거나 끈다 | true |
현재 액티비티 삭제하기
현재 액티비티를 삭제하고 이전 액티비티로 돌아려면 stackflow.ts에서 생성했던 useFlow() 훅의 pop() 함수를 통해 다음과 같이 현재 액티비티를 삭제할 수 있다.
// src/pages/Article.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useFlow } from "../stackflow";
type ArticleParams = {
title: string;
};
const Article: ActivityComponentType<ArticleParams> = ({ params }) => {
const { pop } = useFlow();
const onClick = () => {
pop();
};
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{params.title}</h1>
<button onClick={onClick}>back</button>
</div>
</AppScreen>
);
};
export default Article;
pop()은 첫번째 파라미터로 추가 옵션을 받는다. 첫번째 파라미터인 추가 옵션은 선택적으로 넘기지 않을 수 있다. (기본값을 사용한다)
pop();
// 또는
pop({
/* 추가 옵션 */
});
pop() 함수의 첫번째 파라미터인 추가 옵션에는 다음과 같은 값이 있다.
Option | Type | Description | Default |
animate | boolean | 애니메이션을 켜거나 끈다 | true |
pop을 하기 전에 replace를 사용해 페이지를 대체했던 부분을 push로 수정해야한다. 그래야 pop으로 삭제 후 돌아갈 페이지가 stack에 남아있을 수 있다.
// src/pages/MyActivity.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useFlow } from "../stackflow";
const MyActivity: ActivityComponentType = () => {
const { push } = useFlow();
const onClick = () => {
push("Article", {
title: "Hello",
});
};
return (
<AppScreen appBar={{ title: "My Activity" }}>
<div>
My Activity
<button onClick={onClick}>Go to article page</button>
</div>
</AppScreen>
);
};
export default MyActivity;
스텝 탐색하기
1개의 액티비티 내부에서 가상의 스택 상태를 가지고 싶을때 스텝을 사용할 수 있다. 스텝은 기본적으로 액티비티의 파라미터를 바꾸는 식으로 동작한다.
💡@stackflow/plugin-history-sync는 스텝을 지원한다. 만약 모바일에서 특정 상태 조작과 함께 안드로이드 백버튼 지원이 필요한 경우 history.pushState()보다 스텝 기능을 활용하면 더 좋다.
새 스텝 쌓기
stackflow.ts에서 생성할 수 있는 useStepFlow() 훅을 사용한다. 해당 훅 내에 stepPush() 함수를 통해 다음과 같이 새 스텝을 쌓을 수 있다.
// src/pages/Article.tsx
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useStepFlow } from "../stackflow";
type ArticleParams = {
title: string;
};
const Article: ActivityComponentType<ArticleParams> = ({ params }) => {
// 타입 안정성을 위해 현재 액티비티의 이름을 넣어줘요
const { stepPush } = useStepFlow("Article");
const onNextClick = () => {
// `stepPush()`을 호출하면 params.title이 변경돼요.
stepPush({
title: "Next Title",
});
};
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{params.title}</h1>
<button onClick={onNextClick}>next</button>
</div>
</AppScreen>
);
};
export default Article;
사용전에 stackflow.ts 파일의 export 항목에 useStepFlow 를 추가해야 기능을 사용할 수 있다.
// src/stackflow.ts
import { stackflow } from "@stackflow/react";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import { basicUIPlugin } from "@stackflow/plugin-basic-ui";
import MyActivity from "./pages/MyActivity";
import Article from "./pages/Article";
export const { Stack, useFlow, useStepFlow } = stackflow({
transitionDuration: 350,
plugins: [
basicRendererPlugin(),
basicUIPlugin({
theme: "cupertino",
}),
],
activities: {
MyActivity,
Article,
},
initialActivity: () => "MyActivity",
});
스텝 교체하기
useStepFlow()의 stepReplace() 함수를 활용하면 현재 스텝을 교체할 수 있다.
// src/stackflow.ts
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useStepFlow } from "../stackflow";
type ArticleParams = {
title: string;
};
const Article: ActivityComponentType<ArticleParams> = ({ params }) => {
// 타입 안정성을 위해 현재 액티비티의 이름을 넣어줘요
const { stepReplace } = useStepFlow("Article");
const onChangeClick = () => {
// `stepReplace()`을 호출하면 params.title이 변경돼요
stepReplace({
title: "Next Title",
});
};
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{params.title}</h1>
<button onClick={onChangeClick}>change</button>
</div>
</AppScreen>
);
};
export default Article;
스텝 삭제하기
useStepFlow()의 stepPop() 함수를 활용하면 현재 스텝을 삭제할 수 있다.
- 만약 삭제할 스텝이 없는 상태라면, 아무것도 일어나지 않는다.
- 여러개의 스텝이 푸시된 상태에서 useFlow().pop()을 활용하면 액티비티 내부에 쌓여져있는 모든 스텝들이 한번에 없어진다.
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useStepFlow } from "../stackflow";
type ArticleParams = {
title: string;
};
const Article: ActivityComponentType<ArticleParams> = ({ params }) => {
// 타입 안정성을 위해 현재 액티비티의 이름을 넣어줘요
const { stepPop } = useStepFlow("Article");
const onPrevClick = () => {
// `stepPop()`을 호출하면 이전 params.title로 돌아가요
stepPop();
};
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{params.title}</h1>
<button onClick={onPrevClick}>prev</button>
</div>
</AppScreen>
);
};
export default Article;
💡 만약 삭제할 스텝이 없는 상태라면, 아무것도 일어나지 않는다.
💡여러개의 스텝이 푸시된 상태에서 useFlow().pop()을 활용하면 액티비티 내부에 쌓여져있는 모든 스텝들이 한번에 없어진다.
스탭 추가와 삭제 버튼을 동시에 만들어봤다.
import { ActivityComponentType } from "@stackflow/react";
import { AppScreen } from "@stackflow/plugin-basic-ui";
import { useStepFlow } from "../stackflow";
type ArticleParams = {
title: string;
};
const Article: ActivityComponentType<ArticleParams> = ({ params }) => {
// 타입 안정성을 위해 현재 액티비티의 이름을 넣어줘요
const { stepPop, stepPush } = useStepFlow("Article");
const onPrevClick = () => {
// `stepPop()`을 호출하면 이전 params.title로 돌아가요
stepPop();
};
const onNextClick = () => {
// `stepPush()`을 호출하면 params.title이 변경돼요.
stepPush({
title: "Next Title",
});
};
return (
<AppScreen appBar={{ title: "Article" }}>
<div>
<h1>{params.title}</h1>
<button onClick={onPrevClick}>prev</button>
<button onClick={onNextClick}>Next</button>
</div>
</AppScreen>
);
};
export default Article;
마무리
모바일 환경에서는 웹환경과 다르게 단말기의 뒤로가기 버튼 및 이전 상태 정보를 기억해야 하는 부분이 고민이었다. Stackflow 공식 문서에 있는 기본 내용들을 따라가 봤는데 이밖에도 다양한 기능들과 내용이 존재하니 자세한 내용은 공식 문서를 참고하면 웹뷰를 구현하는데 도움이 될 것 같다.
🔗 Stackflow 공식 홈페이지
Docs의 좌측 최하단에 English, 한국어 버전의 문석서를 지원한다.
'React' 카테고리의 다른 글
[Vite] 절대경로 설정하기 (0) | 2025.01.20 |
---|---|
[React] input 태그의 왼쪽 0 제거하기 (0) | 2024.12.05 |
[React] React Mobile Picker로 날짜 선택하기 (0) | 2024.12.05 |
[React] 근태(출퇴근) 관리 달력 만들기 (with. date-fns) (2) | 2024.11.27 |
[Webpack] 웹팩 환경에서 favicon 추가하는 방법 (1) | 2024.11.15 |