고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

React Native accordion(아코디언) 만들기

고윤태 2023. 11. 7. 16:37
안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 accordion UI를 구현한 방식에 대해 공유하고자 글을 작성하게 되었습니다.

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

매번 같은 말로 시작하는 것 같지만 현재 진행 중인 프로젝트에서 새로운 feature의 디자인이 나왔다는 소식에 Figma를 확인했습니다.

이러한 디자인이 나왔고 정규 회의 시간에 디자이너님의 설명과 함께 디자인을 확인했습니다.

확인하는 도중 디자이너님께서 저한테 말씀하셨습니다.

 

디자이너님 : 윤태 님 아코디언 형태로 알잘딱깔센 하게 만들어주세요!
: 네...

 

이러한 계기로 인해서 아코디언을 구현하게 되었습니다.

🤔 기능 산출 및 개발 계획

일단 첫 번째로 라이브러리가 있는지 체크해 봤습니다.

 

(출처 : https://www.npmjs.com/package/accordion-collapse-react-native)

 

Google 상단에 accordion-collapse-react-native 라이브러리가 나왔고 사용법 및 동작을 확인해 봤습니다.

동작 GIF를 확인해 보니 제가 원하는 건 길이가 스르륵 하고 내려오는 것인데 해당 예제는 보였다 안 보였다만 컨트롤하는 것이

마치 Web에서 display : none을 할당했다 해제했다와 같은 움직임을 보여줬습니다.

사용할만한 라이브러리도 없는 상태이고 직접 구현해야겠다 생각을 했습니다.

하지만 Web 환경에서 개발할 때도 애니메이션 없이 display : none을 이용해서 개발을 했던 나이지만 시간이 많이 지난 지금의 나에게 어려움을 없을 것이다. 하는 마음 가짐을 가지고 시작을 했습니다.

 

 

제가 생각한 개발 진행 단계는 이렇습니다.

  1. 구조 생각하기 ex : Props 및 레이아웃 구조 (뼈대 구축)
  2. UI 구현하기 (살 붙이기)
  3. 애니메이션 구현하기 (디테일 작업)

제가 만들 아코디언의 필요한 Props를 다음과 같다 할 수 있습니다.

  • title : Accordion 컴포넌트의 제목을 설정하는 데 사용됩니다.
  • titleIcon(옵셔녈) : 제목 옆에 표시될 아이콘을 설정하는 데 사용됩니다.
  • isTitleActive(옵셔널) : 제목의 활성 상태를 결정하는 데 사용됩니다.
  • collapseOnStart(옵셔널) : Accordion 컴포넌트가 처음 렌더링될 때 접혀있는 상태로 시작할지 여부를 결정합니다. 이 값이 true면 시작 시에 컴포넌트가 접혀있는 상태로 시작하고, false면 펼쳐진 상태로 시작합니다.
  • children : Accordion 컴포넌트의 자식 요소를 나타냅니다.

여기서 isTitleActive가 왜 필요하지?라고 생각 하실 수 있지만 제가 개발해야 하는 아코디언에서는

 

 

이렇게 값에 여부에 따라서 Text의 Color와 Typo가 다르기 때문에 필요했습니다.

저처럼 활성화 여부가 필요하지 않은 환경을 개발하신다면 사용하지 않아도 괜찮습니다.

 

레이아웃 구성

웹에서는 div를 이용하여 이런 식으로 구조를 잡듯이 React Natvie에서는 View를 이용하여 이런 식으로 구조를 잡도록 하겠습니다.

Title은 Touch가 가능해야 하니 TouchableOpacity를 이용하여 Press가 가능하도록 구현하겠습니다.

 

문제발생

문제없이 개발을 진행하는 도중 드디어 문제가 찾아왔습니다.

문제의 상황을 정리하자면 Contents가 늘어났다 줄어들었다 애니메이션을 구현하기 위해서는 시작 지점 0 끝 지점 (Contents의 높이) 만큼 늘어나도록 구현을 해야 하는데 Contents의 높이를 알기 위해서 Contents를 감싸고 있는 Animated.View의 onLayout을 통해 height를 가져오고자 했지만 높이를 계속 0을 반환했습니다.

 

 

제가 작성했던 코드입니다.

import React, { useEffect, useRef, useState } from 'react';
import {
  View,
  Animated,
  Easing,
  TouchableOpacity,
  StyleSheet,
  Text,
} from 'react-native';
import Color from '../../constants/color';
import Down24 from './icons/Down24';
import TYPOS from './typo';

interface Props {
  title: string;
  titleIcon?: React.ReactNode;
  isTitleActive?: boolean;
  collapseOnStart?: boolean;
  children: React.ReactNode;
}

const Accordion = ({
  collapseOnStart = true,
  isTitleActive = false,
  children,
  title,
  titleIcon,
}: Props): JSX.Element => {
  const dropDownAnimValueRef = useRef(new Animated.Value(0));
  const rotateAnimValueRef = useRef(new Animated.Value(0));
  const fadeItemAnim = useRef(new Animated.Value(0)).current;
  const [itemHeight, setItemHeight] = useState(0);
  const [collapsed, setCollapsed] = useState(collapseOnStart);

  useEffect(() => {
    Animated.timing(fadeItemAnim, {
      toValue: collapsed ? 0 : 1,
      duration: !collapsed ? 300 : 100,
      useNativeDriver: false,
    }).start();
  }, [fadeItemAnim, collapsed]);

  useEffect(() => {
    const targetValue = collapsed ? 0 : 1;

    const config = {
      duration: 200,
      easing: Easing.linear,
      useNativeDriver: false,
      toValue: targetValue,
    };

    Animated.parallel([
      Animated.timing(rotateAnimValueRef.current, config),
      Animated.timing(dropDownAnimValueRef.current, config),
    ]).start();
  }, [collapsed]);

  const toggleElContainer = (
    <Animated.View
      style={[
        {
          transform: [
            {
              rotate: rotateAnimValueRef.current.interpolate({
                inputRange: [0, 1],
                outputRange: ['0deg', '180deg'],
              }),
            },
          ],
        },
      ]}
    >
      <Down24 color={Color.neutral1} />
    </Animated.View>
  );

  return (
    <Animated.View style={[styles.container]}>
      {/* Title */}
      <TouchableOpacity
        onPress={() => setCollapsed(!collapsed)}
        style={[styles.titleContainer]}
      >
        {titleIcon}
        <Text
          style={[
            isTitleActive ? TYPOS.headline4 : TYPOS.body1,
            { color: isTitleActive ? Color.neutral1 : Color.neutral2 },
            styles.titleText,
          ]}
        >
          {title}
        </Text>
        {toggleElContainer}
      </TouchableOpacity>
      {/* Item */}
      <Animated.View
        onLayout={(e) => {
          console.log(e.nativeEvent.layout.height);
          setItemHeight(e.nativeEvent.layout.height);
        }}
        accessibilityState={{ expanded: !collapsed }}
        style={[
          styles.itemContainer,
          {
            opacity: fadeItemAnim,
            height: dropDownAnimValueRef.current.interpolate({
              inputRange: [0, 1],
              outputRange: [0, itemHeight],
            }),
          },
        ]}
      >
        {children}
      </Animated.View>
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  container: {
    backgroundColor: 'transparent',
    overflow: 'hidden',
  },
  titleContainer: {
    height: 56,
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    gap: 8,
  },
  titleText: {
    flex: 1,
  },
  itemContainer: {},
});

export default Accordion;

 

onLayout에서 height를 0을 반환한 이유를 제가 찾은 내용을 정리해서 작성해 보았습니다.

해당 Animated.View가 펼쳐지지 않은 상태(collapsed), 즉 높이가 실제로 0이거나, onLayout 이벤트가 정확하게 높이를 측정할 수 있는 컴포넌트의 렌더링 타이밍 전에 발생하기 때문일 수 있습니다.
코드를 살펴보면 onLayout 콜백은 Animated.View에 바인딩되어 있는데, 이 View는 collapsed 상태일 때 높이가 애니메이션 된 값으로 설정됩니다.
그러므로 collapsed가 true인 상태에서는 onLayout 콜백이 높이를 0으로 측정할 것입니다.
초기에 collapsed 상태가 true (즉, 접혀 있는 상태)라면, 아이템의 실제 높이가 0이 아님에도 onLayout 이벤트에서 0을 반환할 수 있습니다.
이는 Animated.View의 높이가 애니메이션 값으로 제어되고 있기 때문입니다.

 

이러한 문제를 해결하기 위해 제가 생각한 방법은 사용자에게 보일 View와 사용자에게 보이지 않지만 높이만 가져올 View를 구성하여

높이만 가져올 View에서 height를 받아온 후 그 값을 이용하여 사용자에게 보이게 될 View의 애니메이션의 height로 사용하는 것이었습니다.

이러한 방법을 통해, 사용자에게 보이지 않는 뷰의 높이를 미리 측정하고 이를 실제 사용자에게 보이는 뷰의 애니메이션 높이로 활용함으로써, 사용자에게 접고 펴지는 애니메이션을 제공할 수 있었습니다.

👀 컴포넌트 개발(CODE)

Accordion.tsx

import React, { useEffect, useRef, useState } from 'react';
import {
  View,
  Animated,
  Easing,
  TouchableOpacity,
  StyleSheet,
  Text,
} from 'react-native';
import Color from '../../constants/color';
import Down24 from './icons/Down24';
import TYPOS from './typo';

interface Props {
  title: string;
  titleIcon?: React.ReactNode;
  titleActive?: boolean;
  collapseOnStart?: boolean;
  children: React.ReactNode;
}

const Accordion = ({
  collapseOnStart = true,
  titleActive = false,
  children,
  title,
  titleIcon,
}: Props): JSX.Element => {
  const dropDownAnimValueRef = useRef(new Animated.Value(0));
  const rotateAnimValueRef = useRef(new Animated.Value(0));
  const fadeItemAnim = useRef(new Animated.Value(0)).current;
  const [itemHeight, setItemHeight] = useState(0);
  const [collapsed, setCollapsed] = useState(collapseOnStart);

  useEffect(() => {
    Animated.timing(fadeItemAnim, {
      toValue: collapsed ? 0 : 1,
      duration: !collapsed ? 300 : 100,
      useNativeDriver: false,
    }).start();
  }, [fadeItemAnim, collapsed]);

  useEffect(() => {
    const targetValue = collapsed ? 0 : 1;

    const config = {
      duration: 200,
      easing: Easing.linear,
      useNativeDriver: false,
      toValue: targetValue,
    };

    Animated.parallel([
      Animated.timing(rotateAnimValueRef.current, config),
      Animated.timing(dropDownAnimValueRef.current, config),
    ]).start();
  }, [collapsed]);

  const toggleElContainer = (
    <Animated.View
      style={[
        {
          transform: [
            {
              rotate: rotateAnimValueRef.current.interpolate({
                inputRange: [0, 1],
                outputRange: ['0deg', '180deg'],
              }),
            },
          ],
        },
      ]}
    >
      <Down24 color={Color.neutral1} />
    </Animated.View>
  );

  return (
    <Animated.View style={[styles.container]}>
      {/* Invisible: Place it at the top for z-index */}
      <View
        onLayout={(e) => {
          setItemHeight(e.nativeEvent.layout.height);
        }}
        style={styles.invisibleContainer}
      >
        {children}
      </View>

      {/* Title */}
      <TouchableOpacity
        onPress={() => setCollapsed(!collapsed)}
        style={[styles.titleContainer]}
      >
        {titleIcon}
        <Text
          style={[
            titleActive ? TYPOS.headline4 : TYPOS.body1,
            { color: titleActive ? Color.neutral1 : Color.neutral2 },
            styles.titleText,
          ]}
        >
          {title}
        </Text>
        {toggleElContainer}
      </TouchableOpacity>
      {/* Item */}
      <Animated.View
        accessibilityState={{ expanded: !collapsed }}
        style={[
          styles.itemContainer,
          {
            opacity: fadeItemAnim,
            height: dropDownAnimValueRef.current.interpolate({
              inputRange: [0, 1],
              outputRange: [0, itemHeight],
            }),
          },
        ]}
      >
        {children}
      </Animated.View>
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  container: {
    backgroundColor: 'transparent',
    overflow: 'hidden',
  },
  invisibleContainer: {
    position: 'absolute',
    opacity: 0,
  },
  titleContainer: {
    height: 56,
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    gap: 8,
  },
  titleText: {
    flex: 1,
  },
  itemContainer: {},
});

export default Accordion;

코드 설명

props

title Accordion 컴포넌트의 제목을 설정하는데 사용됩니다.
titleIcon(옵셔녈) 제목 옆에 표시될 아이콘을 설정하는데 사용됩니다.
isTitleActive(옵셔널) 제목의 활성 상태를 결정하는데 사용됩니다.
collapseOnStart(옵셔널) Accordion 컴포넌트가 처음 렌더링될 때 접혀있는 상태로 시작할지 여부를 결정합니다. 이 값이 true면 시작 시에 컴포넌트가 접혀있는 상태로 시작하고, false면 펼쳐진 상태로 시작합니다.
children Accordion 컴포넌트의 자식 요소를 나타냅니다.

 

이 컴포넌트에서는 세 가지 애니메이션을 사용하고 있습니다. fadeItemAnim은 아코디언이 펼쳐졌을 때 아이템을 서서히 보여주는 페이드인 애니메이션입니다. rotateAnimValueRef는 아이콘을 회전시키는 애니메이션을 위한 값이며, dropDownAnimValueRef는 아코디언을 펼치고 접는 애니메이션을 위한 값입니다.

 

Invisible View: 아코디언이 펼쳐질 때 아이템의 높이를 알아내기 위해 렌더링 되지 않는 View를 사용합니다. 이 View에서 onLayout 이벤트를 통해 아이템의 높이를 얻어옵니다.

 완성된 Accordion

사용한 예제 코드

import ScrollContainer from '../components/layout/ScrollContainer';
import Header from '../components/ui/Header';
import InputField from '../components/ui/inputs/InputField';
import { View, Text } from 'react-native';
import Accordion from '../components/ui/Accordion';
import Calendar24 from '../components/ui/icons/Calendar24';
import Color from '../constants/color';
import Calendar from '../components/ui/Calender';
import Location24 from '../components/ui/icons/Location24';

const Screen = () => {
  return (
    <>
      <Header title='아코디언 UI' />
      <ScrollContainer>
        <Accordion
          title='산책 날짜'
          titleIcon={<Calendar24 color={Color.neutral1} />}
        >
          <Calendar />
        </Accordion>
        <Accordion
          title='장소'
          titleIcon={<Location24 color={Color.neutral1} />}
        >
          <InputField />
        </Accordion>
        <Accordion title='노아이콘'>
          <View>
            <Text>아무거나 들어가요</Text>
          </View>
        </Accordion>
      </ScrollContainer>
    </>
  );
};

export default Screen;

 

😁 글 작성 후기

시작은 자신 있게 개발을 시작했지만 중간에 막혀버렸었던 개발이었습니다. 하지만 그런 막힌 벽을 뚫고 완료했을 때 느낄 수 있는 뿌듯함이 개발자로서 완성된 프로덕트를 보는 것과는 다른 뿌듯함을 많이 받는 기회가 아닐까 싶기도 합니다.

 

 

이걸 개발하면서 든 생각이 이것을 NPM을 이용하여 라이브러리를 배포해 볼까?라는 생각도 들었습니다. 찾았을 때 저처럼 애니메이션이 있는 것을 찾으시는 분들도 분명 있기에 그런 분들에게 도움을 드리고자 고민 중에 있습니다.

NPM으로 배포하려면 지금 상황에서 당연히 Props는 변경이 되어야 할 것이고 더 신경 쓸 부분이 많아지겠지만 생태계에 기여를 할 수 있다는 것만으로도 충분히 좋은 경험이 될 것 같습니다.

만약 NPM으로 배포를 하게 된다면 히스토리도 한 번 글로 작성해 보도록 하겠습니다.

 

읽어주셔서 감사합니다. 피드백은 언제든지 환영입니다!!!