본문 바로가기

리액트 네이티브

리액트 네이티브에서 IntersectionObserver를 대체하는 방법

브라우저의 IntersectionObserver를 대체하기 위해 "직접" 제작한 react-native-observable-list에 대한 내용입니다.

목차

1. 필요성

2. 대체재

  2-1. react-native-intersection-observer

  2-2. setInterval 방식

3. react-native-observable-list

4. demo

5. workflow

  5-1. viewableKeys

  5-2. isInViewPortRecursively

  5-3. store

1. 필요성

광고 노출 여부(+ 얼마나 오래, 일정 비율 이상 노출되었는지), 애니메이션, 최적화(예: 동영상이 뷰포트 밖으로 나가면 정지) 등 요청사항이 있을 때, 브라우저 환경에선 IntersectionObserver API를 사용할 수 있습니다.

 

그런데 리액트 네이티브 환경에선 제공되지 않는 API입니다.

2. 대체재

이를 극복하기 위해 다양한 시도가 있었습니다.

2-1. react-native-intersection-observer

onLayout 속성을 사용해 InView 컴포넌트들의 레이아웃을 저장해 놓고, 스크롤될 때마다 모든 InViewPort 레이아웃이 뷰포트 안에 있는지 검사하는 방법입니다.

https://github.com/zhbhun/react-native-intersection-observer/blob/0.1.0/src/IntersectionObserver.ts#L103-L162

 

1픽셀만 움직여도 검사 로직이 발생하지만, throttle로 발생 빈도를 줄였고, 리랜더링처럼 큰 JS 부하를 발생시키지도 않는 간단한 로직이라 큰 문제는 없어보였습니다.

 

그런데 중첩된 반대 방향 스크롤뷰와 연계가 안된다는 단점이 있었습니다.

 

예를 들어 [1, [2-1, 2-2, 2-3, 2-4], 3, 4] 데이터를 바깥 배열을 세로 스크롤뷰로, 두 번째 아이템인 안쪽 배열을 가로 스크롤뷰로 랜더링한다고 가정해 보겠습니다.

 

세로 스크롤뷰에서 가로 스크롤뷰가 보여지거나 감춰졌을 때, [2-1, 2-2, 2-3, 2-4]가 한 번에 보여지거나 감춰진 것을 인식하지 못합니다.

2-2. setInterval 방식

컴포넌트를 View로 래핑하고, setInterval을 사용하여 주기적으로 래핑한 View의 measure 메서드를 호출하여, 컴포넌트가 뷰포트 안에 있는지 확인하는 방법입니다.

 

react-native-visibility-sensor, react-native-inviewport, react-native-viewport-detector 라이브러리가 이러한 방법을 사용하고 있습니다.

 

이 방식은 사용법은 간단하지만, setInterval로 인해 불필요하게 많은 동작이 수반되며, 많이 사용되면 JS 스래드를 점유할 수 있고, 언제 어디서 사용될지 예측/관리가 안되는 단점이 있습니다.

 

그리고 2-1, 2-2 두 방식 모두 View로 래핑되기 때문에, 기존 컴포넌트에 추적 기능을 추가할 때, 스타일을 래핑한 View로 옮겨야 할 수 있습니다.

3. react-native-observable-list

기존 라이브러리의 단점을 극복할 수 있는 라이브러리를 만들어 봤습니다.

 

npm에 등록해서 다운받아 사용할 수 있습니다.

 

요즘 "좋은 코드란 무엇일까?"라는 생각을 자주합니다.

 

여러 가지가 있겠지만, 이번 라이브러리를 만들 때는 "개방성"에 초점을 맞췄습니다.

 

기존 코드에 적용할 때, 변화되는 부분을 최소화해서 버그 가능성을 줄이고, 제거하기도 쉽도록 구성했습니다.

 

사용 방법은 간단합니다.

 

observe를 사용해 FlatList 인터페이스 기반 스크롤뷰에 추적 기능을 추가해 줍니다. (FlatList 인터페이스가 아닌 컨테이너에도 적용할 수도 있습니다.)

import { FlatList } from 'react-native';
import { FlashList } from '@shopify/flash-list';

const ObservableFlatList = observe(FlatList);

// FlatList와 인터페이스가 동일한 FlashList도 가능
const ObservableFlashList = observe(FlashList);

 

그리고 내부 아이템에서 useInViewPort 훅만 사용하면 됩니다.

import { useInViewPort } from 'react-native-observable-list';

const Item = ({ id }) => {
  useInViewPort(() => {
    const start = Date.now()
    console.log(`id: ${id} is visible.`);

    return () => {
      const duration = Date.now() - start;
      console.log(`id: ${id} has been hidden after ${duration}.`);
    };
  }, []);
  return <View style={{ height: 100 }} />;
};

 

이 방법은 HOC를 사용하여 기존 리스트의 기능을 유지하고, 훅을 사용하여 콜백/클린 함수를 등록하기 때문에, 기존 로직에 영향을 미치지 않습니다.

추가적으로 다음과 같은 장점이 있습니다.

Observable 리스트간 중첩을 지원하기 때문에, 외부 리스트의 아이템과 내부 리스트의 아이템은 동일한 방법(useInViewPort)으로 가시성을 확인할 수 있습니다.

FlatList의 기능을 그대로 사용할 수 있으므로, viewableConfig을 사용해 얼마나 많은 영역이 보여야 하는지, 얼마나 오래 보여야 하는지 등도 설정할 수 있습니다.

useEffect와 동일하게 effect 함수의 반환값을 클린 함수로 사용하기 때문에, duration같은 정보도 간단하게 얻을 수 있습니다.

FlatList의 onViewableItemChagned에서 새롭게 추가되거나 사라진 아이템에 대해서만 작업하기 때문에, 기존 라이브러리처럼 불필요한 동작이 없고, 덕분에 throttle로 막을 필요도 없었습니다.

4. demo

외부 스크롤뷰에서 내부 스크롤뷰가 감춰질 때, 외부 아이템과 동일한 방법으로 등록한 콜백과 클린업 함수가 실행됩니다.

 

아래 영상에서 27-6,7,8,9가 한번에 나타나고 사라진 것을 인식되는 것을 확인할 수 있습니다.

5. workflow

어떤 환경에서든 가상화 기능이 적용된 리스트는 뷰포트 안에 보여질 아이템에 대한 정보를 가지고 있습니다.

 

그리고 대부분 외부에 해당 정보를 읽을 수 있는 방법을 제공합니다.

 

브라우저에서 사용되는 react-virtualized 라이브러리의 List 컴포넌트의 onRowsRendered 속성이 그렇고, 리액트 네이티브의 FlatList의 onViewableItemsChanged 속성이 그렇습니다.

 

어차피 내부적으로 필요하기에 계산된 정보이고, 이 정보를 그대로 사용하기 때문에, 기존 라이브러리들 처럼 View를 measure로 측정하거나, onLayout으로 레이아웃 정보를 따로 저장하는 등 추가적인 리소스가 필요 없었습니다.

5-1. viewableKeys

onViewableItemsChanged에서 새로 추가되거나 사라진 아이템을 기록합니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L248-L262

 

key는 아이템 객체이거나, keyExtractor가 있다면, 해당 함수로 아이템 객체에서 추출한 정보입니다.

 

useInViewPort에서 등록한 콜백/클린 함수를 찾아 실행하기 위해 사용합니다.

5-2. isInViewPortRecursively

내부 리스트는 자신이 외부 리스트에 보여지고 있는지 모릅니다.

 

그래서 내부 리스트가 외부 리스트의 뷰포트 밖에서라도, 마운트되기만 하면, 내부 리스트의 onViewableItemsChanged가 작동하면서 아이템이 보인다고 판단하게 됩니다.

 

이를 막기 위해, onViewableItemsChanged로 전달된 key가 자신의 viewableKeys에 존재할 뿐만 아니라, 내부 리스트 자신의 key가 외부 리스트의 viewableKeys에 존재하는지도 확인해야 합니다.

 

이를 위해 외부 리스트는 Context를 통해 내부 리스트에 자신의 viewableKeys를 검사할 수 있는 방법(isInViewPort)을 내부 리스트에 제공합니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L318 

 

isInViewPortRecursively는 외부 리스트에서 받은 isInViewPort를 사용하여 자신(내부 리스트)가 외부 리스트의 화면에 보이고 있는지까지 검사하여,

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L92-L100

 

자신의 onViewableItemsChanged에 아이템이 전달되더라도, 자신이 뷰포트 밖에 있다면 무시합니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L251

5-3. store

useInViewPort에서 등록한 콜백/클린 함수를 저장해 놓는 객체입니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/store.ts#L4-L17

 

아래와 같은 흐름을 만들기 위해 addCallback, removeCallback(s), addClean, removeClean(s) 함수가 제공됩니다. 

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/store.ts#L19-L90

react-native-observable-list

 

1. 마운트됐을 때 뷰포트 안에 있다면, 콜백을 즉시 실행시키고, cleansMap에 저장합니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L115-L117

 

2. 마운트됐을 때 뷰포트 밖에 있다면, 콜백을 callbacksMap에 저장합니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L118-L120

 

3. 화면에 아이템이 나타났을 때, 콜백을 실행시키고, 콜백을 callbacksMap에서 제거한 다음, 콜백 함수로 부터 얻은 클린 함수를 cleansMap에 저장합니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L246-L255

 

4. 화면에서 아이템이 사라졌을 때, 클린 함수를 실행시키고, 클린 함수를 저장할 때 키로 사용했던 콜백 함수를 다시 callbacksMap으로 되돌려 줍니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L261-L267

 

5, 6. useInViewPort는 외부에서 사용되기 위한 용도지만, 리스트 자신이 외부 리스트의 아이템인 경우, 현재 자신의 viewableKeys에 있는 아이템의 콜백/클린 함수를 실행시키기 위해서도 사용됩니다.

https://github.com/JoonDong2/react-native-observable-list/blob/4d3ef1f789ae6c120543df1f9f8794efe8652eb6/src/observe.tsx#L148-L163