My Boundary As Much As I Experienced

RN) Scroll에 따라 숨겨지고 노출되는 모바일 헤더 만들기 (with react-native-reanimated) 본문

FrontEnd/React Native

RN) Scroll에 따라 숨겨지고 노출되는 모바일 헤더 만들기 (with react-native-reanimated)

Bumang 2024. 9. 15. 11:27

구현해야되는 기능:

일기주제 페이지에서 스크롤을 내릴 때 배너를 숨겨야한다.

그러나 (한참을 내렸어도) 살짝만 올리면 배너가 다시 노출되어야한다.

 

 

 

 

구현 아이디어:

스크롤 애니메이션을 탐지하여

스크롤을 아래로 내릴 때 헤더 높이만큼 배너를 올려주고,

올릴 때 헤더 높이만큼 배너 높이만큼 배너를 내려준다.

 

reanimated를 쓰는 이유?

 

나는 이 기능을 구현하기 위해 리액트네이티브에서 주로 쓰이는 애니메이션 라이브러리인

ReactNative-reanimated를 사용하였다.

 

RN에서 기본적으로 제공하는 animated API도 있긴하나

이는 자바스크립트 모듈로 네이티브 모듈을 조작하는거기 때문에

성능이 좋지않다고 한다.

 

앱에서 처리하고 있는 비즈니스 로직도 많은데

애니메이션까지 자바스크립트 모듈로 처리해버리면

앱의 메모리 사용도 매우 많아지고 버벅이게 된다..

 

이런 버벅임을 방지하기 위해 reanimated는

자바스크립트 모듈이 아니라 worklet이라는 다른 thread를 이용하여

네이티브 모듈을 이용하여 직접 애니메이션을 처리한다.

(애니메이션을 전문적으로 처리하는 service worker같은 느낌?!)

기존 animated API:
- 자바스크립트 thread(비즈니스 로직 + 애니메이션 조작 모두 처리) <-> 브릿지(with hermes엔진) <-> 메인 모듈

reanimated:
- worklet(애니메이션 조작) <-> 메인 모듈
- 자바스크립트 thread(비즈니스 로직에 집중 가능^^)

 

https://docs.swmansion.com/react-native-reanimated/

(reanimated 세팅법은 생략하겠다.)

 

 

0. 배너는 Absolute로 전환하고, flatList는 배너높이만큼 상단 마진을 줌

<View>
  <Banner> // Absolute 적용
  <Animated.FlatList style={...}> // 배너만큼 상단 마진 줌
<View>

 

 

1. Animated.FlatList로 전환

나는 공개일기 부분을 대규모 배열 리스트를 처리할 수 있는 FlatList로 만들었는데

이를 React Native Reanimated가 제공하는 Animated.FlatList 컴포넌트로 전환했다.

말그대로 애니메이션을 처리할 수 있게 만든 컴포넌트다.

      <Animated.FlatList<ItemType>
        contentContainerStyle={styles.contentContainer} // 내부 스타일 적용
        onScroll={scrollYHandler}
        scrollEventThrottle={16}
        onScrollEndDrag={handleScrollEnd} // 스크롤 종료 감지
        data={diaries}
        renderItem={DiaryItem(locatedAt)}
        keyExtractor={(item: { id: number }) => item.id.toString()}
        ItemSeparatorComponent={ItemSeparator}
      />

 

 

2. 배너의 Y값 위치 / 마지막 스크롤 위치를 저장할 값 생성

각각 값들을 useSharedValue로 만들었다.

 

useSharedValue가 뭐냐고?

ReactNative Reanimated에서 사용하는 일종의 '상태state'라고 보면 된다.

네이티브 모듈에서 직접 처리될 수 있는 데이터로 구성되어 있다.

 

또한 useSharedValue는 재렌더가 일어나더라도 값을 유지하며,

값이 변하더라도 재렌더를 일으키지 않는다는 특징도 있다.

(useRef와 거의 흡사하긴 하다.. 나 역시 네이티브 모듈에서 처리되는 useRef라고 이해하고 있다.)

  const translateY = useSharedValue(0); // 헤더의 위치를 저장하는 값
  const lastScrollY = useSharedValue(0); // 마지막 스크롤 위치

 

 

3. 스크롤을 내릴 때/올릴 때에 맞춰 헤더 on/off 로직 설정

currentScrollY(현지 스크롤 좌상단 위치)가 lastScrollY(마지막 스크롤 Y좌표) + 50px보다 크면

스크롤을 의도적으로 내린걸로 치고 translateY(배너 좌표)를 배너 높이만큼 위로 올려 숨겨준다.

 

currentScrollY(현지 스크롤 좌상단 위치)가 lastScrollY(마지막 스크롤 Y좌표)보다 -50px 보다 작으면

스크롤을 의도적으로 올린걸로 치고 translateY(배너 좌표)를 0(원래상태)로 되돌린다.

 

왜 모든 값에 50px 만큼 여유값을 줬는가?

바로 안드로이드의 스크롤 애니메이션에는

기본적으로 약간의 튕김 효과(spring애니메이션)가 있기 때문이다..

스크롤 제스처가 끝날 때쯤 반대 방향으로 살짝 튕긴다.. 

 

그래서 여유값을 안 주면 반대방향으로 조금 말려들어갈 때

헤더가 다시 노출되거나, 다시 숨어버리는 현상이 나타난다.

  const scrollYHandler = useAnimatedScrollHandler({
    onScroll: (event) => {
      const currentScrollY = event.contentOffset.y;

      console.log(currentScrollY, "currentScrollY");
	
      if (
        currentScrollY < 100 ||
        currentScrollY < lastScrollY.value - 50
      ) {
        // 스크롤을 위로 올릴 때
        translateY.value = withTiming(0); // 헤더 표시
      } else if (
        currentScrollY > lastScrollY.value &&
        currentScrollY > lastScrollY.value + 50
      ) {
        // 스크롤을 아래로 내릴 때
        translateY.value = withTiming(-180); // 헤더 숨김
      }
    },
  });

 

또한 FlatList에는 onScrollEnd라는 이벤트도 받을 수 있다.

스크롤이 끝날 때를 감지해서 이때 마지막 스크롤Y좌표(lastScrollY)의 값을 바꿔줬다.

  const handleScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
    const currentScrollY = event.nativeEvent.contentOffset.y;

    lastScrollY.value = currentScrollY;
  };

 

 

4. useAnimatedStyle함수에 애니메이션이 적용된 style을 반환하는 콜백을 제공

이것도 reanimated에서 제공하는 함수인데,

useSharedValue로 신나게 조작한 값을 실제 스타일에 적용하는 방법으로 쓰인다.

 

나는 현재 배너의 Y값을 조작할 수 있도록 top속성에

translateY value를 제공하는 yStyle이란 스타일을 만들어서

  const yStyle = useAnimatedStyle(() => {
    return {
      top: translateY.value, // SharedValue를 사용할 때는 .value로 접근
    };
  });

 

배너의 스타일로 제공하였다.

      <ScrollHideBanner
        yStyle={yStyle}
        id={topic.id}
        title={topic.title}
        navigation={navigation}
      />

 

 

5. 스크롤이 100px이하일때는 배너 항시 노출

또한 애플 기기들을 위해 스크롤이 100px이하일때는 헤더가 숨겨지지않게 조정하기도 했다.

 

왜냐하면, 애플 기기들은 기본적으로 scrollY값이 0이어도 -10, -50, -100, ... 까지 쭈욱 당겨지더라.

그래서 아래 스크린샷처럼 0으로 다시 돌아가기 위해 올라가는 것 뿐인데 헤더가 hide된다...

이걸 방지하기 위해서 스크롤이 100px이하일 때는 그냥 계속 banner가 노출되게 해놓았다.

 

 

전체코드:

전체 코드는 이렇다.

const PublicDiaries = ({ navigation }: PublicDiariesProps) => {
  const locatedAt = "PublicDiaries";
  const { topic, diaries } = topicExample;

  const translateY = useSharedValue(0); // 헤더의 위치를 저장하는 값
  const lastScrollY = useSharedValue(0); // 마지막 스크롤 위치
  const currentDirection = useSharedValue(0); // 현재 스크롤 방향 (0 = 상향, 1 = 하향)

  const scrollYHandler = useAnimatedScrollHandler({
    onScroll: (event) => {
      const currentScrollY = event.contentOffset.y;

      if (currentScrollY < 100 || currentScrollY < lastScrollY.value - 50) {
        // 스크롤을 위로 올릴 때
        translateY.value = withTiming(0); // 헤더 표시
        currentDirection.value = 0;
      } else if (currentScrollY > lastScrollY.value) {
        // 스크롤을 아래로 내릴 때
        if (
          currentDirection.value !== 1 &&
          currentScrollY > lastScrollY.value + 50
        ) {
          translateY.value = withTiming(-1 * BANNER_HEIGHT); // 헤더 숨김
          currentDirection.value = 1;
        }
      }
    },
  });

  const handleScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
    const currentScrollY = event.nativeEvent.contentOffset.y;

    lastScrollY.value = currentScrollY;
  };

  const yStyle = useAnimatedStyle(() => {
    return {
      top: translateY.value, // SharedValue를 사용할 때는 .value로 접근
    };
  });

  return (
    <View style={styles.container}>
      <ScrollHideBanner
        yStyle={yStyle}
        id={topic.id}
        title={topic.title}
        navigation={navigation}
      />
      <Animated.FlatList<ItemType>
        contentContainerStyle={styles.contentContainer} // 내부 스타일 적용
        onScroll={scrollYHandler}
        scrollEventThrottle={16}
        onScrollEndDrag={handleScrollEnd} // 스크롤 종료 감지
        data={diaries}
        renderItem={DiaryItem(locatedAt)}
        keyExtractor={(item: { id: number }) => item.id.toString()}
        ItemSeparatorComponent={ItemSeparator}
      />
    </View>
  );
};