RN#5-5. Reanimated

avatar
2025.04.30
·
53 min read

Masking Animations

마스킹 기법이란..

마스킹 기법이란, 포토샵에서 처럼, 배경 이미지에 특정한 shape 의 마스크를 입히는 기법입니다. 그리고, 입혀진 마스크 위에서는 색상 변화, 다른 이미지 입히기, 마스크 이동 애니메이션 등이 가능해집니다.

RN 에서 렌더링된 UI 컴포넌트에 직접 마스크를 입힐 수 있는 방법은 아직 없고요.. 렌더링 된 컴포넌트를 임시로 캡쳐해서 그 스냅샷을 사용자가 눈치 채지 못하도록 겹쳐-overlay-놓고는, 그 스냅샷 이미지에 마스킹을 하는 것이, 2025 현재 RN 에서 작동하는 Masking Animation 의 작동 원리입니다.

Dom 에서는 CSS 에서 clipPath, mask 요소 등이 지원되기 때문에 Dom 위에 직접 masking 을 해줄 수 있습니다. 이 매력적인 기능을 가장 잘 활용한 대표적인 웹사이트 중 하나가 바로 DaisyUI 홈페이지 이죠.

하지만 RN 에서는 직접 마스킹을 해줄 수 없습니다. 언급한대로, 앱의 현재 스크린을 켑쳐하고 오버레이한 후, 오버레이 위에서 마스킹을 시작해야 합니다. 그리고 앱이 렌더링된 런타임 스크린을 캡쳐하도록 도와주는 그래픽 엔진이 구글 Skia 입니다.

.

마스킹이 꼭 필요한가?

사실 네이티브 앱에서, ‘꼭 필요한 애니메이션’ 이라는 건 없겠죠. 하지만 대부분의 완성도 높은 네이티브 앱에서는 애니메이션이 활발하게 잘 사용됩니다.

사용자의 기분을 즐겁게 해주는 1포인트짜리 기믹을 하나하나 쌓아 올려서, 궁극적으로는 사용자의 지갑을 열게 만드는 것이 대부분의 앱이 지향하는 바일 것입니다. 기회가 왔을 때 익혀두는 것도 좋겠습니다.

.

이번 주제에서는, 앱의 다크모드 변환시에, 커튼, 써클 효과로 다크-라이트 모드가 마스킹으로 전환되는 애니메이션을 만들어 보겠습니다. 사용자 입장에선 신기하겠지만, 우리 입장에선 어차피 이미지 위에서 맵핵 사기 치는 건데, 써클 커튼 뿐만 아니라 모든 shape 로 전환 애니메이션을 추가해주는 게 가능합니다.

.

Dark Mode 구현 방법들 정리

먼저, 다크모드의 구현방법들 부터 정리하고 진행합시다.

.

Themed Component 방식

Expo 의 디폴트 템플릿이 설치되면, ThemedText, ThemedView 등의 기본 유틸리티 컴포넌트가 함께 설치되는데요, 이들 기본 유틸리티 컴포넌트들은 useThemeColor 가 리턴하는 컨텍스트인 colorScheme 값을 참조해서 현재 앱의 Theme 을 확인하고, 이에 상응하는 Themed 컬러를 적용한 View, Text 들을 반환합니다.

즉, useThemeColor 와 ThemedView, ThemedText 를 함께 사용함으로써 다크모드가 적용되는 방식입니다.

앱에서 사용되는 컬러값들은 /constants/Colors.ts 등의 상수 관리장소에서 관리되고 있고, Themed 컴포넌트를 사용할 수 없는 영역에서는 하드코딩 된 컬러값들을 유연하게 사용하고 있습니다.

Expo 에서 제공하고 있는 Themed Component 외에, Shopify/restyle 에서 제공하고 있는, createBox, createText 도 자주 사용됩니다.

Nativewind 방식

Nativewind 진영에서는, dark:... 접두어를 사용해서 다크모드를 구현하기 때문에, ThemedView, ThemedText 같은 추가 컴포넌트 유틸리티 들을 사용하지 않습니다. 하지만, 네비게이터 컴포넌트 처럼 className 이 지원되지 않는 영역에서는 className 만으로는 해결할 수 없는 문제가 있기도 합니다.

우리는 Nativewind 방식을 사용하겠습니다.

이제, Nativewind 로 다크모드를 적용하는 방법과 그 사각지대, 그리고 그 해결 방법을 알아보죠.

먼저 Nativewind 프로젝트에서는, 시스템에서 사용되는 컬러 팔레트 값들을 모두 tailwind.config.ts 에 지정해둡니다. 앱에서 사용되는 컬러 값들은, 오직 tailwind.config.ts 에서 설정해둔 컬러 상수들만을className 으로 사용합니다. 절대로, 하드코딩된 컬러 값을 사용하지 않습니다.


const index = () => {
  return (
    <View className="flex-1 items-center justify-center bg-background dark:bg-background-dark">
      <Text className="text-2xl font-bold text-foreground dark:text-foreground-dark">
        index
      </Text>
    </View>
  );
};

Nativewind 프로젝트에서는, 위와 같이 text-foreground dark:text-foreground-dark 이렇게 사전 정의된 클래스를 접두어와 함께 사용함으로써 다크모드가 구현됩니다. 라이트 모드에서는 text-foreground 클래스가 적용되고, 다크 모드에서는 text-foreground-dark 클래스가 적용됩니다. 해당 클래스들은 모두 tailwind.config.ts 에서 선언되고 있습니다. 라이트 모드에서 적용되는 text-foreground 에는 foreground.DEFAULT 의 밸류가 적용되고, 다크모드에서 적용되는 text-foreground-dark 에는 foreground-dark 의 밸류가 적용됩니다.

// tailwind.config.ts
	...
  colors: {
    // 라이트 모드와 다크 모드에 따른 배경 및 전경 색상
    background: {
      DEFAULT: "#e6ebe3", // 라이트 모드 기본 배경 (연한 세이지)
      blank: "#f2f5f0", // 라이트 모드 빈 배경 (밝은 오프화이트)
      dark: "#121815", // 다크 모드 기본 배경 (어두운 올리브 블랙)
      secondary: "#d5dfd0", // 라이트 모드 보조 배경 (더 연한 세이지)
      secondaryDark: "#1a201c", // 다크 모드 보조 배경 (어두운 올리브)
      tertiary: "#c4d1bd", // 라이트 모드 3차 배경 (가장 연한 세이지)
      tertiaryDark: "#212a25", // 다크 모드 3차 배경 (중간 어두운 올리브)
    },
    foreground: {
      DEFAULT: "#2a352e", // 라이트 모드 기본 전경 (어두운 세이지)
      dark: "#f2f5f0", // 다크 모드 기본 전경 (밝은 오프화이트)
      secondary: "#3a4a3e", // 라이트 모드 보조 전경 (더 어두운 세이지)
      secondaryDark: "#e6ebe3", // 다크 모드 보조 전경 (약간 어두운 오프화이트)
      tertiary: "#4d5e52", // 라이트 모드 3차 전경 (가장 어두운 세이지)
      tertiaryDark: "#a8b9a5", // 다크 모드 3차 전경 (연한 세이지)
    },
  },

이제부터는, 앱의 모든 컴포넌트에서, tailwind.config.ts 에서 정의한 디자인 시스템을 준용합니다. 컬러 뿐만 아니라, 폰트 패밀리, 폰트 사이즈, 스페이싱 등 모든 디자인 요소에 tailwind class 만을 사용해서 UI 가 구성됩니다.

// /app/(drawer)/(tabs)/people/[id].tsx

import { View, Text, Pressable, Image } from "react-native";
import React from "react";
import shadowStyle from "@/components/shadowStyle";
import { useGlobalSearchParams } from "expo-router";

const PersonDetail = () => {
  const params = useGlobalSearchParams();

  return (
    <View className="flex-1 p-md bg-background dark:bg-background-dark">
      <View
        className="flex-col w-full 
    bg-background dark:bg-background-tertiaryDark rounded-lg p-4 gap-lg"
        style={shadowStyle.shadowThin}
      >
        <View className="flex-row gap-md">
          <Image
            source={{ uri: params.image as string }}
            className="w-40 h-40 rounded-full"
          />
          <View className="gap-sm justify-center">
            <Text className="text-lg font-bold text-foreground dark:text-foreground-dark  ">
              {params.name}
            </Text>
            <Text className="text-sm text-foreground-tertiary dark:text-foreground-dark">
              {params.jobTitle}
            </Text>
            <Text className="text-sm text-foreground-tertiary dark:text-foreground-dark">
              {params.email}
            </Text>
          </View>
        </View>
        <View className="flex-row gap-md">
          <Pressable className="bg-primary rounded-full p-sm">
            <Text className="text-sm text-foreground-dark">Edit</Text>
          </Pressable>
          <Pressable className="bg-primary rounded-full p-sm">
            <Text className="text-sm text-foreground-dark">Edit</Text>
          </Pressable>
        </View>
        <View className="flex-row gap-md">
          <Text className="text-lg text-foreground-tertiary dark:text-foreground-dark">
            Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
            quos. Lorem ipsum dolor sit amet consectetur adipisicing elit.
            Quisquam, quos. Lorem ipsum dolor sit amet consectetur adipisicing
            elit. Quisquam, quos. Lorem ipsum dolor sit amet consectetur
            adipisicing elit. Quisquam, quos.
          </Text>
        </View>
      </View>
    </View>
  );
};

export default PersonDetail;

엄격한 디자인 상수만을 사용했을 때, 디자인과 레이아웃이 절대적인 안정감과 통일성을 유지할 수 있다는 장점이 있습니다.

하지만, 아직은 모든 컴포넌트에서 className 프롭을 사용할 수는 없습니다. 특히 네비게이션 컴포넌트에서는 className 프롭을 전혀 사용할 수가 없죠. 이 때는 className 대신에, tailwind.config.ts 의 설정 Json 을 읽어와서 컬러 팔렛을 오브젝트로 만들어서 사용합니다.

// /utils/tailwindColors.ts
const tailwindVars = require("@/tailwind.config.ts").theme.extend;

const tailwindColors = tailwindVars.colors;
export default tailwindColors;

export const tailwindSpacing = tailwindVars.spacing;

export const tailwindFontFamily = tailwindVars.fontFamily;

그리고, tailwindVars 를 앱에서 로드합니다.

// /app/(drawer)/_layout.tsx

import tailwindColors from "@/utils/tailwindColors";
import { useColorScheme } from "nativewind";

const DrawerLayout = () => {
  const { colorScheme } = useColorScheme();
  const isDark = colorScheme === "dark";

  const backgroundTheme =
    tailwindColors.background[isDark ? "dark" : "DEFAULT"];
  const foregroundTheme =
    tailwindColors.foreground[isDark ? "dark" : "DEFAULT"];
  const drawerActiveBackgroundColor =
    tailwindColors.primary[isDark ? "activeDark" : "active"];
  const drawerActiveTintColor =
    tailwindColors.primary[isDark ? "activeDark" : "active"];
  const drawerInactiveTintColor =
    tailwindColors.foreground[isDark ? "secondaryDark" : "secondary"];

  return (
    <Drawer
      screenOptions={{
        headerShown: false,
        drawerStyle: {
          width: 280, // Set the desired width
          backgroundColor: backgroundTheme,
        },
        drawerHideStatusBarOnOpen: false,
        drawerActiveBackgroundColor: drawerActiveBackgroundColor,
        drawerActiveTintColor: drawerActiveTintColor,
        drawerInactiveTintColor: drawerInactiveTintColor,
        drawerLabelStyle: {
          marginLeft: Platform.OS === "ios" ? -20 : 0,
          color: foregroundTheme,
        },
      }}
      drawerContent={CustomDrawerContent}
    >
      <Drawer.Screen name="(tabs)" />
    </Drawer>
  );
};

어떻게 사용하는 지 감이 오죠? 설정 파일을 읽어와서 현재 선택된 테마에 따라 변수 값으로 할당해주고 있습니다. 그리고 drawer 의 옵션들로 하나씩 추가해주었습니다. tabs 와 stack 에 대해서도 같은 방법으로 추가해주면 되겠습니다.

이렇게 해서 완성된 다크모드의 작동을 보시죠.

5774

다크 모드가 예쁘게 잘 나왔군요. 다크 모드 토글러를 drawer 의 상단에 위치시켜 줬습니다. 이제, 앱의 어느 페이지에서든 드로어가 열리는 곳이라면 다크모드 전환을 해줄 수 있습니다.

.

Reanimated-Tailwind Problem

그리고 한가지 더..

"react-native-reanimated": "3.16.x", 버전에서, Animated. 컴포넌트의 Tailwind 클래스가 제대로 먹히지 않는 이슈가 있습니다.

https://github.com/software-mansion/react-native-reanimated/issues/6665

비단 Animated 컴포넌트 뿐만 아니라, Animated 이하 RN 컴포넌트의 Tailwind 클래스에도 영향이 있습니다. 때문에 interop 등의 우회도 먹히지 않습니다.

Reanimated v.4 에서 우선적으로 픽스될 예정이라고 하고, 깃헙 스레드에 임시로 미공개 버전인 3.17 버전이 올라왔는데, 제 환경에서는 에러가 발생해서 사용할 수가 없었습니다.

문제가 되는 부분을 어쩔 수 없이 styles 객체로 대체해서 땜빵을 해야 했는데요.. 위의 네비게이터 컴포넌트에서 tailwind.config.ts 파일을 오브젝트로 로드해서 사용했던 것과 같은 방식입니다. 불만족스럽긴 하지만, 지금은 이게 최선이군요. 문제가 된 컴포넌트는, /components/app/RenderPersonItem.tsx 이고, 렌더링 오류를 발생시키는 클래스들은 주로 컬러와 포지션 종류의 클래스 들이었습니다.

Reanimated 와 Nativewind 를 사용하면서 같은 문제를 겪는 분들은 해당 코드를 참조하시면 도움이 될 것 같습니다. 하단에 깃헙 리포지토리가 있습니다.

.

Install Skia

다크 모드가 완성되었으니, 이제 마스크를 씌우고 Animation 을 적용해봅시다.

마스킹 애니메이션에 필요한 라이브러리들을 설치합니다.

Skia Official:

https://shopify.github.io/react-native-skia/docs/getting-started/installation

Expo:

https://docs.expo.dev/versions/latest/sdk/skia/

Install:

npx expo install @shopify/react-native-skia

skia 와 함께 restyle 도 종종 사용됩니다.

npx expo install @shopify/restyle

.

Skia 의 await makeImageFromView(*ref*) API 는, 해당 ref 가 랜더링 된 아웃풋을 이미지로 리턴 하는 프라미스를 반환합니다. 앱의 어떤 요소에 대해서든 ref 를 추가해서 사용할 수 있는데요… 앱 스크린의 전체 영역을 캡쳐해서 리턴한다면, 우리가 원하는 마스킹 애니메이션을 그 위에 덧입혀줄 수 있습니다.

.

Pixel Density 보정

디바이스의 해상도와 앱의 스크린 해상도가 일치하지 않을 때, 캡쳐된 스냅샷의 크기가 스크린과 맞지 않을 수 있습니다. 해상도 보정치를 적용한 오버레이 사이즈를 강제할당 합니다.


import { PixelRatio } from "react-native";

  // Pixel Density for Snapshot Image Scaling
  const pd = PixelRatio.get();
	...

      <Mask>
        <Image
          image={overlay1}
          x={0}
          y={0} 
          width={overlay1.width() / pd}
          height={overlay1.height() / pd} // Device Pixel Ratio Correction
          fit="cover"
        />
      </Mask>
  
 

Mask 의 두가지 모드: alpha & luminance

Skia 의 Mask 컴포넌트는 두가지 모드를 지원합니다. ‘alpha’ 와 ‘luminence’ 이죠.

Official:

https://shopify.github.io/react-native-skia/docs/mask/

alpha 는 ‘불투명도-transparency’ 를 기준으로 마스킹 됩니다… 솔직히 저도 그 쓰임새를 잘 모르겠습니다. 여기서는 그런게 있다.. 는 정도만 알고 넘어갑시다.

luminance 는 ‘밝기’ 를 기준으로 마스킹 됩니다.

흰색(밝기 255) : 완전히 보여집니다. == opacity 0

검정색(밝기 0) : 완전히 안보입니다. == opacity 1

회색(밝기 128) : 반쯤 보임. == opacity 0.5

완전히 보이는데 왜 opacity 0 이라고 했는지 헤깔릴 수 있는 지점입니다. 이 점이 Mask 를 이해하는 데 가장 헤깔리는 부분인데요.. Mask 는 ‘진짜’ 배경을 뒤덮고 있는 ‘불투명 가림막’ 이고, 우리는 이 가림막의 불투명도를 제어하려 하고 있기 때문입니다.

Mask 는, 배경의 진짜 — after — 스크린을 덮고 있는 완전히 안보임 — before — 마스크 이고, 이 마스크를 컨트롤 하면서 after 스크린이 등장하는 애니메이션을 구현하는 것이, 최소한 우리에게는 Mask 를 사용하려는 목적입니다.

마스크 기법의 코드 구조는 이렇습니다.


  <Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
    <Mask
      mode="luminance"
      mask={
        <Group>
          <Rect color="white"/>
          <Rect width={mask} color="black"/>
        </Group>
      }
    >
      <Image image={overlay1} />
    </Mask>
  </Canvas>

컴포넌트의 계층 구조가 위와 같다는 점을 기억해야 합니다. Mask 기법에서는, 컨트롤 목적 대상인 스냅샷 이미지가 Mask 내부에 포합됩니다. Mask 의 자식 컴포넌트로 오버레이 Image 가 주어지고 있습니다.

즉, Mask 에 의해, 오버레이 이미지가 보여질지 말지가 결정됩니다. 그리고, Mask 는 전경 블랙, 배경 화이트로 구성되어 있습니다. 그리고 마지막으로, 전경 블랙에 SharedValue 가 할당되었습니다. SharedValue 에 따라서 전경 블랙-완전 안보임- 이 서서히 슬라이드 되면서 퇴장합니다. 배경인 화이트-완전 보임 밑에는, 이미 다크모드가 전환된 after 스크린이 대기하고 있습니다.

처음 접할 때는 직관적으로 와닿기가 쉽지 않을텐데요.. 이해가 될 때까지 반복하면서 익히는 수 외에는 방법이 없을 것 같습니다..

.

.

Curtain Masking

기초적인 Mask 기법에 대한 이해가 갖춰졌으니, 이제 실전에 응용해봅니다. 다크모드가 트리거 되면, 커튼 효과로, 스크린의 좌에서 우로 마스크가 서서히 슬라이드 아웃 됩니다. 마스크의 블랙 에는 캡쳐된 스냅샷 before 이미지가 올려져 있고, 배경인 화이트 에는 after 스크린이 대기하고 있으며, 애니메이션 프로세스가 완전히 종료되면 React.State 가 업데이트 되면서 마스크 관련 렌더링 코드가 모두 제거됩니다.

이 기법의 핵심 코드 부터 보죠.

아래 코드에서는 겹쳐진 두개의 Rect 로 애니메이션이 구현됩니다. 즉, 흰색(완전히 보여짐) 배경 위에 검은색(완전히 가려짐) Rect 전경이 덮여있는 구조에서, 위의 검은색 Rect 를 천천히 슬라이드 시키면서 흰색 배경이 서서히 드러나는 효과입니다.


const MaskAnimationProvider = ({ children }: ColorSchemeProviderProps) => {

  // Pixel Density for Snapshot Image Scaling
  const pd = PixelRatio.get();

  // SharedValue for Mask Animation
  const curtainWidth = useSharedValue(0);

  // create ref
  const ref = useRef<View>(null);

  // Zustand hook
  const {
    statusBarStyle,
    curtainOverlay,
    setRef,
  } = useMaskAnimationStore();

  // set ref
  useEffect(() => {
    setRef(ref);
    setCurtainOverlay(curtainOverlay);
    setCurtainWidth(curtainWidth);
    return () => {
      setRef(null);
      setCurtainOverlay(null);
      setCurtainWidth(curtainWidth);
    };
  }, []);

  return (
    <SafeAreaProvider>
      <View ref={ref} style={StyleSheet.absoluteFill} collapsable={false}>
        <View style={{ flex: 1 }} collapsable={false}>
          {children}
        </View>

        {curtainOverlay && (
          <Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
            <Mask
              mode="luminance"
              mask={
                <Group>
                  <Rect
                    x={0}
                    y={0}
                    width={SCRN_WIDTH}
                    height={SCRN_HEIGHT}
                    color="white"
                  />
                  <Rect
                    x={0}
                    y={0}
                    // Give the sharedValue itself, not sharedValue.value
                    width={curtainWidth}
                    height={SCRN_HEIGHT}
                    color="black"
                  />
                </Group>
              }
            >
              <Image
                image={curtainOverlay}
                x={0}
                y={0}
                width={curtainOverlay.width() / pd}
                height={curtainOverlay.height() / pd} // Device Pixel Ratio Correction
                fit="cover"
              />
            </Mask>
          </Canvas>
        )}

      </View>
    </SafeAreaProvider>
  );
};

export default MaskAnimationProvider;

// 주의해야 할 점은,
// width={curtainWidth}  <-- 요 부분입니다.
// {curtainWidth.value} 가 아니라 {curtainWidth} 로, sharedValue 가 통째로 전달됩니다.

전역상태로 관리되는 curtainOverlay 가 생성되었을 때 -skia 이미지가 캡쳐되었을 때-, Canvas 이하의 코드가 렌더링 됩니다.

Canvas 는 가장 바깥에서 Mask 를 감싸주면서 pointerEvents='none' 으로 이벤트를 막아주고 있습니다. Canvas 안쪽의 마스크 컴포넌트는, 'luminence' 모드로 내부에 두개의 Rect 그룹을 갖고 있습니다. Rect 는 화이트와 블랙 두개의 요소로, white 는 스크린 전체를 뒤덮고 있고, black 은 sharedValue 로 width 가 제어되고 있습니다.

Rect 의 시작 좌표가 {0, 0} 으로 선언된 의미는, 좌표 {0, 0} 에서부터 width 와 height 가 시작된다는 의미이고, 추가로 height 는 고정값으로 주어졌습니다. 따라서, 결과적으로 width 의 변화가 커튼효과로 나타나게 되는 것입니다.

코드가 어찌.. 이해가 되시는지요….

터널을 지나온 자들은 모히또에서 몰디브를 마시며 볼 수 있는 코드이겠지만, 아직 터널에 계신 분들께는 많이 힘겨울 수 있습니다. 힘을 내 봅시다.

.

.

Zustand Global Context

마스크 애니메이션의 프로세스가 다소 복잡하고 다뤄야 할 전역상태들이 좀 여러개 있습니다. 이들 전역상태 값들과 메서드들을 zustand 로 관리해주겠습니다.

// /cntexts/useMaskAnimationStore.ts
export type ColorSchemeName = "light" | "dark";
type ColorSchemeState = {
  active: boolean;
  ref: any | null;
  colorScheme: ColorSchemeName;
  statusBarStyle: ColorSchemeName;
  curtainOverlay: SkImage | null;
  circleOverlay: SkImage | null;
  circleRadius: SharedValue<number> | null;
  curtainWidth: SharedValue<number> | null;
  circleCoordX: SharedValue<number> | null;
  circleCoordY: SharedValue<number> | null;
  setActive: (active: boolean) => void;
  setRef: (ref: any) => void;
  setColorScheme: (scheme: ColorSchemeName) => void;
  setCurtainOverlay: (image: SkImage | null) => void;
  setCircleOverlay: (image: SkImage | null) => void;
  resetOverlays: () => void;
  updateColorScheme: (colorScheme: ColorSchemeName) => void;
  setCurtainWidth: (width: SharedValue<number>) => void;
  setCircleRadius: (radius: SharedValue<number>) => void;
  setCircleCoordX: (x: SharedValue<number>) => void;
  setCircleCoordY: (y: SharedValue<number>) => void;
};

export const useMaskAnimationStore = create<ColorSchemeState>((set) => ({
  active: false,
  ref: null,
  colorScheme: Appearance.getColorScheme() ?? "light",
  statusBarStyle: Appearance.getColorScheme() === "light" ? "dark" : "light",
  curtainOverlay: null,
  circleOverlay: null,
  circleRadius: null,
  curtainWidth: null,
  circleCoordX: null,
  circleCoordY: null,
  setActive: (active) => set({ active }),
  setRef: (ref) => set({ ref }),
  setColorScheme: (colorScheme) => set({ colorScheme }),
  setCurtainOverlay: (curtainOverlay) => set({ curtainOverlay }),
  setCircleOverlay: (circleOverlay) => set({ circleOverlay }),
  resetOverlays: () => set({ curtainOverlay: null, circleOverlay: null }),
  updateColorScheme: (colorScheme) =>
    set({
      colorScheme,
      statusBarStyle: colorScheme === "light" ? "dark" : "light",
    }),
  setCurtainWidth: (curtainWidth) => set({ curtainWidth }),
  setCircleRadius: (circleRadius) => set({ circleRadius }),
  setCircleCoordX: (circleCoordX) => set({ circleCoordX }),
  setCircleCoordY: (circleCoordY) => set({ circleCoordY }),
}));

관리 항목들이 많아서 좀 복잡해 보이긴 하지만, global state 들과 이들 state 에 대한 setState 정도의 메서드 뿐입니다.

.

Mask Trigger

이제, 마스크 애니메이션이 어떻게 트리거 되고, 또 어떻게 관리되는 지 살펴봅시다.

// /components/app/CurtainMaskTriggerButton.tsx

export const CurtainMaskTriggerButton = () => {
  const { colorScheme } = useColorScheme();
  const { nativewindColorScheme, nativeWindSetTheme } = useThemeProvider();
  const { active, ref, setCurtainOverlay, setActive, curtainWidth } =
    useMaskAnimationStore();

  const isDark = colorScheme === "dark";
  const foregroundTheme =
    tailwindColors.foreground[isDark ? "dark" : "DEFAULT"];

  const tap = Gesture.Tap()
    .runOnJS(true)
    .onStart(async (e) => {
      if (!active) {
        setActive(true);
        const snapshot1 = await takeSnapshot(ref);
        if (snapshot1) {
          setCurtainOverlay(snapshot1);
        }
        // await wait(80);
        nativeWindSetTheme(nativewindColorScheme === "dark" ? "light" : "dark");

        if (curtainWidth) {
          curtainWidth.value = withTiming(SCRN_WIDTH, {
            duration: MASK_ANIMATE_DURATION,
          });
        }
        await wait(MASK_ANIMATE_DURATION);
        setCurtainOverlay(null);
        if (curtainWidth) curtainWidth.value = 0;
        setActive(false);
      }
    });
  return (
    <GestureDetector gesture={tap}>
      <View collapsable={false}>
        <Feather
          name={colorScheme === "dark" ? "moon" : "sun"}
          color={foregroundTheme}
          size={THEME_TOGGLER_BUTTON_SIZE}
        />
      </View>
    </GestureDetector>
  );
};

CurtainMaskTriggerButton 트리거는, 단순한 트리거 역할 뿐만 아니라 애니메이션 프로세스 전체를 컨트롤합니다. TouchableOpacity 가 아니라 Gesture(tap) 가 사용된 이유는, 이벤트 좌표를 확보하기 위해서입니다. TouchableOpacity 에서도 이벤트 좌표를 구할 수 있지만, TouchableOpacity 가 제공하는 좌표는 ‘상대좌표’ 이고, 우리에게 필요한 좌표는 ‘절대좌표’ 입니다.

어떤 일을 하고 있는지, 순서대로 살펴보죠.

  1. runOnJS() : 터치 이벤트 좌표를 JS 영역으로 바로 보냅니다.

  2. if (!active) : 애니메이션 프로세스가 아직 진행중일 때, 중복 트리거 요청의 발생을 방지합니다. 없으면 크리티컬 앱 크래시를 발생시킵니다.

  3. await takeSnapshot(ref) : 스냅샷 이미지를 생성합니다. 유틸을 만들어서 사용했습니다. 내부적으로는 skia 의 await makeImageFromView(*ref*) 를 사용합니다.

  4. setCurtainOverlay(snapshot1) : 생성된 스냅샷 이미지를 전역상태 값으로 올려줍니다. 오버레이 전역상태 값이 유효해 졌으므로, 프로바이더 컴포넌트의 Canvas-Mask 코드가 렌더링 됩니다.

  5. nativeWindSetTheme(...) : 실제 다크모드 전환을 트리거 해줍니다.

  6. curtainWidth.value = withTiming(SCRN_WIDTH) : 중심 sharedValue 값을 worklet 으로 할당해줍니다. UI 스레드에서 MASK_ANIMATE_DURATION 시간동안 애니메이션이 진행됩니다.

  7. await wait(MASK_ANIMATE_DURATION) : 마스크 애니메이션의 작동이 끝날 때 까지 기다려줘야 합니다.

  8. setCurtainOverlay(null) : 애니메이션 작동이 종료되면, 오버레이 전역상태를 무력화 합니다. 프로바이더 컴포넌트에서 Canvas-Mask 코드가 제거됩니다.

  9. setActive(false) : 중복 요청 방지용 플래그를 초기화 합니다.

이해하기 쉽게 분석을 잘 해 놓은 것 같군요. 설명을 읽고 코드를 다시 보면 이해에 도움이 많이 될 것 같습니다.

커튼 마스크 애니메이션은 이정도로 충분한 설명이 된 것 같습니다.

.

.

Circle Masking

매운 맛으로 한가지 더 해보죠. 슬라이드 커튼은 왠지 스파이시한 맛이 부족하다는 느낌이 듭니다.

다크모드 스위치의 이벤트 좌표에서부터 화면 전체로 번져나가는 써클 마스크 애니메이션을 적용한 다크모드를 만들어 보겠습니다.

로직의 기본 얼개는 커튼 마스크와 다르지 않습니다. 커튼 마스크에서의 화이트 Rect, 블랙 Rect 구조를, 화이트 Rect, 블랙 Circle 로 대체하면 될 것 같습니다.

그런데 문제가 한가지 남아있죠. 블랙 Rect 의 sharedValue 값의 max.value 는 SCREEN_WIDTH 로 단순했습니다만, Circle 의 Radius 값은 계산이 필요합니다. 이벤트 좌표로부터 가장 먼 스크린.꼭지점 까지의 거리를 계산해야 합니다. 하지만 이런 계산을 잘 해주는 친구를, 우리 모두는 잘 알고 있죠.


const maxDistance = Math.max(
  distance(touchX, touchY, 0, 0),
  distance(touchX, touchY, SCRN_WIDTH, 0),
  distance(touchX, touchY, 0, SCRN_HEIGHT),
  distance(touchX, touchY, SCRN_WIDTH, SCRN_HEIGHT)
)

const distance = (x1, y1, x2, y2) =>
  Math.hypot(x2 - x1, y2 - y1)

그리고 Mask 에 Circle 을 추가해봅시다.

			
		<Mask
		  mode="luminance"
		  mask={
		    <Group>
		      <Rect
		        x={0}
		        y={0}
		        width={SCRN_WIDTH}
		        height={SCRN_HEIGHT}
		        color="white"
		      />
		      <Circle
		        cx={touchX}
		        cy={touchY}
		        r={circleRadius}
		        color="black"
		      />
		    </Group>
		  }
		>
		  <Image ... />
		</Mask>
	

생각보다 아주 간단하게 해결되고 있군요. 이제, 스크린 화면의 토글 스위치에는 curtainMask 애니메이션을 걸어주고, 드로어의 토글 스위치에는 circleMask 애니메이션을 걸어주겠습니다.

CircleMaskAnimation 의 토글 스위치 컴포넌트의 코드입니다.

// /components/app/CircleMaskAnimation.tsx

import { View, Text } from "react-native";
import React from "react";
import { useColorScheme } from "nativewind";
import { useThemeProvider } from "@/contexts/NativewindThemeProvider";
import tailwindColors from "@/utils/tailwindColors";
import { useMaskAnimationStore } from "@/contexts/maskAnimationZustand";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { wait } from "@/utils/timeFunctions";
import { takeSnapshot } from "@/utils/takeSnapshot";
import { withTiming } from "react-native-reanimated";
import {
  MASK_ANIMATE_DURATION,
  THEME_TOGGLER_BUTTON_SIZE,
} from "@/constants/constants";
import { Feather } from "@expo/vector-icons";
import { calculateMaxRadius } from "@/utils/calculateMaxRadius";

export const CircleMaskTriggerButton = () => {
  const { colorScheme } = useColorScheme();
  const { nativewindColorScheme, nativeWindSetTheme } = useThemeProvider();
  const {
    active,
    ref,
    setCircleOverlay,
    setActive,
    circleRadius,
    circleCoordX,
    circleCoordY,
  } = useMaskAnimationStore();

  const isDark = colorScheme === "dark";
  const foregroundTheme =
    tailwindColors.foreground[isDark ? "dark" : "DEFAULT"];

  const tap = Gesture.Tap()
    .runOnJS(true)
    .onStart(async (e) => {
      if (circleCoordX) {
        circleCoordX.value = e.absoluteX;
      }
      if (circleCoordY) {
        circleCoordY.value = e.absoluteY;
      }

      if (!active) {
        setActive(true);
        const snapshot2 = await takeSnapshot(ref);
        if (snapshot2) {
          setCircleOverlay(snapshot2);
        }
        // Wait for just 1 frame
        await wait(16);

        if (circleRadius) {
          const maxRadius = calculateMaxRadius(e.absoluteX, e.absoluteY);
          circleRadius.value = withTiming(maxRadius, {
            duration: MASK_ANIMATE_DURATION,
          });
        }
        nativeWindSetTheme(nativewindColorScheme === "dark" ? "light" : "dark");

        await wait(MASK_ANIMATE_DURATION);

        setCircleOverlay(null);
        if (circleRadius) circleRadius.value = 0;
        if (circleCoordX) circleCoordX.value = 0;
        if (circleCoordY) circleCoordY.value = 0;
        setActive(false);
      }
    });
  return (
    <GestureDetector gesture={tap}>
      <View collapsable={false}>
        <Feather
          name={colorScheme === "dark" ? "moon" : "sun"}
          color={foregroundTheme}
          size={THEME_TOGGLER_BUTTON_SIZE}
        />
      </View>
    </GestureDetector>
  );
};

써클의 좌표와 radius 를 계산하고 sharedValue 로 할당해주는 코드가 추가됐죠. 나머지 코드는 CurtainMaskTriggerButton 과 거의 동일합니다.

이제 마지막으로, 완성된 MaskAnimationProvider 의 전체 코드입니다.

// MaskAnimationProvider.tsx
import {
  View,
  StyleSheet,
  AppState,
  SafeAreaView,
  PixelRatio,
  Dimensions,
  Platform,
  StatusBar,
} from "react-native";
import React, { ReactNode, useEffect, useRef, useState } from "react";
import {
  useDerivedValue,
  useSharedValue,
  withTiming,
} from "react-native-reanimated";
import {
  Image,
  Canvas,
  Circle,
  Fill,
  Mask,
  Group,
  Rect,
} from "@shopify/react-native-skia";
import { StatusBar as ExpoStatusBar } from "expo-status-bar";
import { useColorScheme as useNativewindColorScheme } from "nativewind";
import { useMaskAnimationStore } from "@/contexts/maskAnimationZustand";
import {
  SafeAreaProvider,
  useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useColorScheme } from "nativewind";

import { SystemBars } from "react-native-bars";
import tailwindColors from "@/utils/tailwindColors";
// import { SystemBars } from "react-native-edge-to-edge";

export const { width: SCRN_WIDTH, height: SCRN_HEIGHT } =
  Dimensions.get("screen");
export const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } =
  Dimensions.get("window");

const safeAreaViewAndroid =
  Platform.OS === "android"
    ? StatusBar.currentHeight
      ? StatusBar.currentHeight
      : 0
    : 0;

type ColorSchemeProviderProps = {
  children: ReactNode;
};

const MaskAnimationProvider = ({ children }: ColorSchemeProviderProps) => {
  const { colorScheme } = useColorScheme();
  const { setColorScheme } = useNativewindColorScheme();

  const isDark = colorScheme === "dark";

  const backgroundTheme =
    tailwindColors.background[isDark ? "secondaryDark" : "secondary"];
  const foregroundTheme =
    tailwindColors.foreground[isDark ? "secondaryDark" : "secondary"];

  // Pixel Density for Snapshot Image Scaling
  const pd = PixelRatio.get();

  // SharedValue for Mask Animation
  const curtainWidth = useSharedValue(0);
  const circleRadius = useSharedValue(0);
  const circleCoordX = useSharedValue(0);
  const circleCoordY = useSharedValue(0);

  // Derived Values
  const derivedCircleX = useDerivedValue(() => circleCoordX?.value ?? 0);
  const derivedCircleY = useDerivedValue(() => circleCoordY?.value ?? 0);
  const derivedCircleRadius = useDerivedValue(() => circleRadius?.value ?? 0);

  // create ref
  const ref = useRef<View>(null);

  // Zustand hook
  const {
    statusBarStyle,
    curtainOverlay,
    circleOverlay,
    setRef,
    setCurtainOverlay,
    setCircleOverlay,
    setCurtainWidth,
    setCircleRadius,
    setCircleCoordX,
    setCircleCoordY,
  } = useMaskAnimationStore();

  // set ref
  useEffect(() => {
    console.log("safeAreaViewAndroid", safeAreaViewAndroid);
    setRef(ref);
    setCurtainOverlay(curtainOverlay);
    setCurtainWidth(curtainWidth);
    setCircleOverlay(circleOverlay);
    setCircleRadius(circleRadius);
    setCircleCoordX(circleCoordX);
    setCircleCoordY(circleCoordY);
    return () => {
      setRef(null);
      setCurtainOverlay(null);
      setCurtainWidth(curtainWidth);
      setCircleOverlay(null);
      setCircleRadius(circleRadius);
      setCircleCoordX(circleCoordX);
      setCircleCoordY(circleCoordY);
    };
  }, []);

  return (
    <>
      <SafeAreaView>
        <StatusBar
          animated={false}
          backgroundColor={backgroundTheme}
          // translucent={true}
          barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
        />
      </SafeAreaView>

      <View ref={ref} style={{ flex: 1 }} collapsable={false}>
        {children}
      </View>

      {curtainOverlay && (
        <Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
          <Mask
            mode="luminance"
            mask={
              <Group>
                <Rect
                  x={0}
                  y={0}
                  width={SCRN_WIDTH}
                  height={SCRN_HEIGHT}
                  color="white"
                />
                <Rect
                  x={0}
                  y={0}
                  // Give the sharedValue itself, not sharedValue.value
                  width={curtainWidth}
                  height={SCRN_HEIGHT}
                  color="black"
                />
              </Group>
            }
          >
            <Image
              image={curtainOverlay}
              x={0}
              y={0}
              width={curtainOverlay.width() / pd}
              height={curtainOverlay.height() / pd} // Device Pixel Ratio Correction
              fit="cover"
            />
          </Mask>
        </Canvas>
      )}

      {circleOverlay && (
        <Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
          <Mask
            mode="luminance"
            mask={
              <Group>
                <Rect
                  x={0}
                  y={0}
                  width={SCRN_WIDTH}
                  height={SCRN_HEIGHT}
                  color="white"
                />
                <Circle
                  cx={derivedCircleX}
                  cy={derivedCircleY}
                  r={derivedCircleRadius}
                  color="black"
                />
              </Group>
            }
          >
            <Image
              image={circleOverlay}
              x={0}
              y={0}
              width={circleOverlay.width() / pd}
              height={circleOverlay.height() / pd} // Device Pixel Ratio Correction
              fit="cover"
            />
          </Mask>
        </Canvas>
      )}
    </>
  );
};

export default MaskAnimationProvider;

curtainOverlay 와 circleOverlay 의 Canvas 이하 코드가 중복되는 부분이 좀 보이죠. Group 의 두번째 요소에만 조건분기를 해주면 코드가 더 짧아질 수는 있겠습니다만, 저는 가독성을 위해서 그냥 남겨둡니다. 리팩토링은 각자의 취향대로 하시면 될 것 같습니다.

그리고, 루트 레이아웃인, /app/_layout.tsx 입니다. StatusBar 의 위치가 MaskAnimationProvider 내부로 옮겨졌습니다.


  // Render Main Screen when onBoardingFlag is false
  if (!onBoardingFlag) {
    return (
      <GestureHandlerRootView>
        <MaskAnimationProvider>
          <NativewindThemeProvider>
            <Stack initialRouteName="(drawer)">
              <Stack.Screen
                name="(drawer)"
                options={{
                  headerShown: false,
                }}
              />
              <Stack.Screen name="settings" />
              <Stack.Screen name="logout" />
              <Stack.Screen name="+not-found" />
            </Stack>
          </NativewindThemeProvider>
        </MaskAnimationProvider>
      </GestureHandlerRootView>
    );
  }

  // Send to OnBoarding for onBoarding case
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <OnBoardingIndex />
    </GestureHandlerRootView>
  );
}

그리고 완성된 다크모드 애니메이션입니다.

5775

써클-마스크-애니메이션에서는 크게 티가 안나지만, 커튼-마스크-애니메이션에서는 티가 너무 나죠? 현재 StatusBar 영역이 Skia 의 스냅샷 캡쳐 영역에서 제외되고 있습니다. 과거 버전에서는 안보이던 특성인데, Android 15 & API 35 에서 도입되는 edge-to-edge 표준 때문에 약간의 혼선이 있기 때문인 것 같습니다. 이 문제는 8월에 edge-to-edge 표준이 정착하기 전까지는 해결될 것이라고 생각합니다.

암튼, 다크모드 테마 토글 애니메이션을 구현하기 위해서 여기까지 험난한 길을 걸어왔는데요.. 막상 도착해보니 그 결과물이 조금은 자랑스럽지 않아 보입니다. 너무 일찍 왔거나, 너무 늦게 왔기 때문인거죠 뭐... 하지만 다음 안정화 버전 skia 에서는 문제가 해결될 것이라고 생각합니다. 그리고 이번 테마 스위치 애니메이션은, 다른 전용 라이브러리를 사용해서 구현하는 방법이 하나 더 남아 있는데요.. 아래에서 잠깐 다뤄보겠습니다.

그리고 한가지 더 주의해야 할 점, 버그와 오작동 경험을 추가합니다.

  • Skia 캡쳐 이미지의 스타일 반영에 누락과 오류가 있을 수 있습니다. 깃헙에 아직 해당 이슈가 열려있고, 특히 overflow 속성이 문제가 되는 것 같습니다. 윌 칸디용 형님이 적극적으로 애쓰고는 있지만, 힘이 달리는 모양새입니다. 제 경우, import {Image} from ‘expo-image’ 를, import {Image} from ‘react-native’ 로 바꿔서 해결되었지만, expo-image 를 사용해야만 하는 프로젝트에서는 아직 해결책이 없는 것 같습니다.

  • https://github.com/Shopify/react-native-skia/issues/1995

  • 추가적으로, StatusBar 영역의 캡쳐 문제와 관련해서, ‘react-native’ 의 StatusBar 를 제외한 다른 StatusBar, SystemBar 에서는 캡쳐 영역에서 더 복잡한 문제가 발생하고 있는데요.. ‘react-native’ 의 StatusBar 에서만 안정적인 캡쳐 결과물을 보여주고 있습니다. 주의가 필요해요.

.

react-native-theme-switch-animation

다크모드 마스크 써클 애니메이션을 완성하기 까지, 여정이 좀 험난했습니다. 우선 Reanimated 의 sharedValue 와 GestureHadler 를 익혀야 했고, 픽셀 보정과 StatusBar 문제도 넘어서야 비로소 Skia-Mask-Theme-Animation 을 완성할 수 있었죠. 험난했던 이 모든 여정이 오직 다크모드 애니메이션의 구현만을 위한 것이었다면 조금 허탈해질 지도 모르겠습니다. 하지만, 사용자의 액션에 대응하는 다양한 마이크로 인터랙션에서도 Mask-animation 은 활발하게 사용되고 있습니다. 예를 들어, 버튼 클릭 이벤트에도 버튼에 써클-모드 전환 애니메이션을 추가해줄 수도 있겠죠. 카드 엘리먼트에서도 특별한 마스크 애니메이션 효과를 연출해줄 수 있을 것입니다.

마스크 애니메이션은 구현 그 자체가 목적이 아니라, 상상력의 밑재료로 작용하면서 다양한 이팩트를 가능하게 해 줄 것입니다.

다크모드 전환 애니메이션 자체가 목적이라면 보다 손쉬운 솔루션이 존재합니다.

React-Native-Theme-Switch-Animation

https://github.com/WadhahEssam/react-native-theme-switch-animation

https://www.npmjs.com/package/react-native-theme-switch-animation

위 레포지토리에 데모 영상이 있습니다.

패키지로 설치해서 사용하면 되고요.. RN 0.67 이전 버전에서는 추가 설정이 필요합니다. 그런데 문서가 부실해서 RN 0.67 로는 시도해보기 어렵습니다. RN 0.68^ 또는 Expo 53 에서 시도해봐야 하는데, Expo 53 은 아직 reanimated 와의 연결고리가 많이 부실합니다. 안정적인 활용을 위해서는 Expo 53 관 Reanimated 4 가 지원될 때까지 시간이 좀 걸릴 것 같네요.

어쨌든 네이티브 코드로 만들어져서 우리가 접근했던 방식과는 전혀 다른 솔루션이고, 이미 만들어진 솔루션을 사용하는 방식이라고 생각하시면 되겠습니다.

다크모드 전환의 퍼포먼스도 매우 부드럽고 경제적이면서 효과도 다양합니다. 테마 전환 애니메이션이 목적이라면 가장 먼저 고려해볼 만 합니다.

사용방법은…

// /components/app/ThemeSwitchAnimationButton.tsx

const ThemeSwitchAnimationButton = () => {
  const { nativewindColorScheme, nativeWindSetTheme } = useThemeProvider();

  const isDark = nativewindColorScheme === "dark";
  const foregroundTheme =
    tailwindColors.foreground[isDark ? "dark" : "DEFAULT"];

  return (
    <TouchableOpacity
      onPress={(e) => {
        e.currentTarget.measure((x1, y1, width, height, px, py) => {
          switchTheme({
            switchThemeFunction: () => {
              nativeWindSetTheme(
                nativewindColorScheme === "light" ? "dark" : "light"
              );
            },
            animationConfig: {
              type: "circular",
              duration: 900,
              startingPoint: {
                cy: py + height / 2,
                cx: px + width / 2,
              },
            },
          });
        });
      }}
    >
      <Feather
        name={nativewindColorScheme === "dark" ? "moon" : "sun"}
        color={foregroundTheme}
        size={THEME_TOGGLER_BUTTON_SIZE}
      />
    </TouchableOpacity>
  );
};

export default ThemeSwitchAnimationButton;

이렇게 단순한데요.. type 으로는, fade, circular, inverted-circular 세가지 종류중 하나를 지정할 수 있습니다. circular 와 inverted-circular 를 교차해서 적용해주면 보다 재미있는 전환효과가 되겠죠.

Skia Mask Animation 이 어렵게 느껴지는 분들께는 향후 좋은 선택지가 될 수 있을 것 같습니다.

.

Mask Animation 에서 사용된 Reanimated & GestureHandler & Skia 조합은, RN 세계관에서의 어벤저스라고 할만 하죠. 명실상부하게 RN 의 꽃이라고 할 수 있는, 이들 어벤저스가 모두 동원된 프로젝트로, 다크모드 전환 애니메이션을 구현해 봤습니다. 이들 어벤저스 3종이 연출해내는 애니메이션은, 점차 마이크로 인터랙션에 까지도 그 사용 영역이 확장되어가는 추세입니다. 마이크로 인터랙션으로 마스크 애니메이션을 적절하게 활용한다면, 앱의 역동성과 완성도 면에서 뚜렷한 성취를 이룰 수 있게 될 것 같습니다.

다소 난이도가 있긴 했지만, 완성도 높고 호감 가는 앱을 개발하기 위해서는 꼭 갖춰야 할 스킬이라고 생각합니다. 시간을 갖고 충분히 익숙해진다면 가장 크게 도움이 되는 기술이 되지 않을까 생각합니다.

-끗-

.

.

.

Reanimated Project

이번 장의 Reanimated 스킬에 대하여 exercise 프로젝트를 별도로 진행해 봅시다. 이전 장에서 진행했던 프로젝트 앱 에서 이번 장의 소주제들을 모두 다루고 연습해보기에는 여러모로 적당치가 않습니다.

새로운 프로젝트 폴더 reanimated 를 추가하고, expo 프로젝트를 생성합니다. 그리고 이제부터는 보일러플레이트 대신에, commands.sh 를 사용하겠습니다.

.

commands.sh

보일러플레이트를 버리는 이유는, 새로운 패키지의 릴리즈와, 패키지 의존성이 변화하는 주기가 너무 빨라서, 보일러플레이트의 유효기간이 1쿼터도 못가고 있기 때문입니다. Dependency 패키지들의 추가 커맨드를, 독립된 파일에 주석과 함께 남겨둔다면, 이후 언제 누구라도 이 프로젝트를 fresh 패키지들로 안전하게 시작할 수 있게 될 것입니다. commands.sh 는 linux/mac 에서 커맨드를 관리하기 위한 bash 스크립트 파일이지만, 우리 프로젝트에서는 패키지 추가 기록 정도로만 사용하겠습니다. 패키지의 환경이 바뀌게 되는 미래에 제 레포지토리를 사용할 분들은, commands.sh 의 커맨드를 CLI 에서 직접 실행해주거나 불러오면 되겠습니다. 한가지 주의하셔야 할 점은, 버전이 강제된 커맨드를 포함하고 있다는 것인데요.. 이 부분에 대해서는 주석을 남겨두었습니다. 버전과 환경이 바뀐 미래에 이 커맨드를 사용할 때에는, 주석의 링크를 참조하셔서 최신 호환 버전을 설치해주시면 되겠습니다. 그리고, 설치 후 다음 스텝으로 넘어가기 전에 config 파일을 수정해줘야 하는 단계들도 있는데요, 이 상황에 대해서도 주석에 링크를 두고 read 를 추가해서 사용자 입력을 대기하도록 해두었습니다. 가급적, 순서를 유지하면서 한 단계씩 진행하시는 게 좋을 것 같습니다.

## /commands.sh

## 2025-03-17 06:14:12
## Init Project
npx create-expo-app@latest . --template default

## Tailwind CSS & Reanimated
## Mind the newest versions command string in officials at:
## <https://www.nativewind.dev/getting-started/installation>
npx expo install nativewind tailwindcss@^3.4.17 react-native-reanimated@3.16.2 react-native-safe-area-context

## config Tailwind
npx tailwindcss init
## Wait for user input
echo "Edit Tailwind config files as instructed and press Enter to continue..."
read
## Edit config files as below
## <https://until.blog/@ganymedian/-1--react-native-%EC%A4%80%EB%B9%84%EC%9E%91%EC%97%85#79caf909-a753-432a-9116-26a716041601>

## Tailwind-Merge & clsx & Class Varience Authority 
npx expo install tailwindcss clsx 
npx expo install tailwind-merge
## 2025-03-17 07:49:57
## cva install occurs an error with npx
npm install class-varience-authority 

## Reanimated
## Installed with Nativewind already. In case not, install with below command.
## npx expo install react-native-reanimated react-native-safe-area-context

## Edit config files as below
## <https://until.blog/@ganymedian/rn-5-1--reanimated#3803f44b-e494-4ca4-9d3f-f0b591207340>

## Wait for user input
echo "Edit Reanimated config files as instructed and press Enter to continue..."
read

## Gesture Handler
npx expo install react-native-gesture-handler

## Gorhom Bottom Sheet
npx expo install @gorhom/bottom-sheet@^5

## Zustand
npx expo install zustand

## Zod
npx expo install zod

## Tanstack Query
npx expo install @tanstack/react-query

## MMKV
npx expo install react-native-mmkv

## 2025-03-21 11:12:52
npx expo install react-native-svg

## 2025-03-21 16:52:48
npx expo install react-native-redash

## 2025-04-04 19:57:15
npx expo install expo-font

## 2025-04-06 17:33:55
npx expo install expo-status-bar

## 2025-04-14 22:34:10
npx expo install @clerk/clerk-expo

## 2025-04-14 22:39:52
npx expo install expo-secure-store

## 2025-04-16 00:46:47
npx expo install @react-navigation/drawer

## 2025-04-16 08:25:43
npx expo install expo-image

## 2025-04-16 15:23:00
npx expo install @faker-js/faker

## 2025-04-22 05:25:51
npx expo install @shopify/react-native-skia

## 2025-04-22 05:27:00
npx expo install @shopify/restyle

## 2025-04-28 10:17:40
npx expo install expo-zustand-persist

## Wait for user input
echo "Exit the Start Build when you see your QR code by pressing ^C. Press Enter if you are ready to start ..."
read

## clear the cache
npx expo start -c

## Wait for user input
echo "Press Enter if you are ready to prebuild..."
read

## prebuild 
npx expo prebuild

## Wait for user input
echo "Now You are ready to go. Good luck!"
read

## build
# npx expo run:android
# npx expo run:ios

.

과제:

  1. OnBoarding 페이지를 생성하고, dot pagination 을 포함한 온보딩 애니메이션을 구현해 봅시다. 앱의 특성을 PR 하는 내용으로 어필할 수 있다면 좋겠죠.

  2. 탭바와 Drawer 를 모두 사용하는 앱으로 바꿔보죠. 햄버거 메뉴 아이콘을 추가하고, 클릭시 Drawer 메뉴를 노출해봅시다.

    1. 드로어의 상단에 다크모드 전환 아이콘을 추가합니다.

  3. 간단한 리스트를 만들어서 List 레이아웃 애니메이션을 적용해 봅시다

    1. 목록 엘리먼트의 스와이프 제스쳐에, Del 아이콘을 노출하고 임계점을 초과할 시, 해당 리스트가 삭제되도록 합시다.

    2. 목록의 대표 Image 를 Shared Element 로 사용합니다. 목록 스크린에서 디테일 스크린으로 전환될 때, Shared Element 애니메이션이 작동되도록 작업해봅시다. 지금은 사용불가

  4. 매스킹 애니메이션으로, 다크모드-라이트모드 전환 애니메이션을 구현해봅시다.

    1. 커튼 매스크로 전환 애니메이션이 작동하도록 합시다.

    2. 써클 매스크로 애니메이션을 추가해봅시다.

.

Source Code & Results

완성된 앱의 소스코드가 깃헙에 올려져 있습니다.

https://github.com/KangWoosung/reanimated_try03

애니메이션 스샷은 본문 곳곳에 이미 올려 두었고, 레포지토리에도 데모 gif 가 올려져 있습니다.

.

.

.

맺으면서..

이번 장에서는 본래 Native API 를 먼저 다룰 계획이었는데요… 하지만, Reanimated 를 정리하다보니, 결국 너무 길어지게 되었습니다. 더이상 아티클이 길어지는 건, 좋지 않을 것 같습니다.

지난 장에서 미루었던 미완의 과제들과 Native API 도 함께, 어쩔 수 없이 다음 장으로 미룹니다.

Expo 진영에서 가장 큰 컨퍼런스인 App.js.Conf 2025 가 지난 3월 말에 진행됐습니다. 아직 자세한 발표 내용을 전해듣진 못했습니다만, Expo 53 의 베타버전이, 컨퍼런스 일정 직후에 공개되었고, 4월 중으로는 Expo 53 의 정식 버전이 릴리즈될 것이라고 알려져 있습니다. 2025.04.26 현재 상황은 Expo 53 베타가 릴리즈 된 상황입니다.

Expo 53-beta 에서 reanimated 코드를 사용해보려는 시도를 잠깐 해봤는데요… 특히 reanimated 와 관련해서 아직은 의존성 충돌이 여러 곳에서 발생하고 있었습니다. Expo53 의 안정화 버전이 발표되면 다시한번 도전해보고 그 결과를 업데이트 해보겠습니다.

Expo 53 와 RN 0.79 에 관련한 소식은 다음 장에서 나눠보겠습니다.

Reanimated 의 sharedValue 는, 네이티브 앱 개발의 파워풀함을 느껴볼 수 있는 매우 매력적인 아키텍쳐 이죠. React 와 네이티브 리소스 가 연동되는, 시작이자 출발선의 몸풀기라고 봐도 좋을 것 같습니다.

다음 장에서는 Native API 와 Notification 을 준비하고 있습니다만, Expo 53 으로의 변화도 반영해야 하기 때문에, 해야 할 일이 많군요.. 만족할만한 정리가 될 지 걱정스럽긴 합니다.

암튼, 이번 장은, RN 의 매력탐구 편이 아니었을까 싶을 정도로, 매력적인 UI, UX 를 생성하는 방법과 가능성 등을 확인할 수 있었던 것 같습니다. 특히, 마이크로 인터랙션이 활발하게 도입되고 있는 트랜드에서, 이번 장의 스킬들은 간과해서는 안될 기술이라는 생각을 하고 있습니다. 모쪼록 많은 분들이 저와 비슷한 느낌을 얻을 수 있었으면 좋겠습니다.

긴 글 따라오시느라 수고하셨습니다.

.

.