🪗

React Native でアコーディオンを実装

2023/12/15に公開

この記事は React Native Advent Calendar 2023 の15日目の記事です。

ラベルタップで表示・非表示を切り替える View を作る

"アコーディオン” と言っていますが一般的にはなんと呼ばれているんでしょうか。ググると Expandable や Collapsible といった名称でも呼ばれているようです。この記事では簡易的な名称で Expand といった呼び方で実装を進めたいと思います。

まずは表示・非表示を切り替えるだけの仕組みを実装

クリックするたびにフラグを反転させ children の表示・非表示を切り替えるようにします。

Expand.js
import { useState } from 'react';
import { Text, View, Pressable, StyleSheet } from 'react-native';

export const Expand = ({ title = '', children}) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <View style={styles.frame}>
      <Pressable
        onPress={() => {
          setIsOpen(!isOpen);
        }}>
        <Text style={styles.title}>{title}</Text>
      </Pressable>
      <View style={isOpen ? {} : styles.hide}>
        {children}
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  frame: {
    width: "100%",
    overflow: 'hidden',
    borderColor: "black",
    borderWidth: 1,
    borderRadius: 8,
    padding: 12
  },
  title: {
    marginTop: 12,
    marginBottom: 12,
    color: "black",
    fontSize: 24
  },
  hide: {
    height: 0,
    overflow: "scroll"
  }
});
App.js
import { SafeAreaView, StyleSheet, Text } from 'react-native';
import { Expand } from './Expand';

export default function App() {
  return (
    <SafeAreaView style={styles.container}>
      <Expand title='React Native'>
        <Text>React Native combines the best parts of native development with React, a best-in-class JavaScript library for building user interfaces.</Text>
      </Expand>
      <Expand title='Flutter'>
        <Text>Flutter is an open source framework by Google for building beautiful, natively compiled, multi-platform applications from a single codebase.</Text>
      </Expand>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    backgroundColor: '#fff',
    margin: 16,
    gap: 20
  }
});

現時点ではただ表示・非表示が切り替わっているのみです。

開閉アニメーションを追加

開閉時のアニメーションは LayoutAnimation を使用します。
Animated より自由度は少ないですが、ビフォーアフターの状態変化を自動でアニメーション化してくれるので View にちょっとした動きを追加したい場合に便利です。

https://reactnative.dev/docs/layoutanimation

status を更新する直前で configureNext を設定します。

Expand.js
- import { Text, View, Pressable, StyleSheet } from 'react-native';
+ import { Text, View, Pressable, StyleSheet, LayoutAnimation, UIManager, Platform } from 'react-native';

+ // Android 用の設定
+ if (Platform.OS === 'android') {
+   if (UIManager.setLayoutAnimationEnabledExperimental) {
+     UIManager.setLayoutAnimationEnabledExperimental(true);
+   }
+ }

export const Expand = ({ title = '', children}) => {
      ・・・
      <Pressable
        onPress={() => {
+         LayoutAnimation.configureNext(LayoutAnimation.create(300, 'easeInEaseOut', 'opacity'));         
          setIsOpen(!isOpen);
        }}>

動作確認

開閉がいい感じでアニメーションされるようになりました。

https://snack.expo.dev/@tadaedo/expand1

複数のアコーディオンを連動させる

一つのアコーディオンを開いたら他のアコーディオンが閉じるようにしてみたいと思います。

アコーディオン間で状態を共有するため Context を使用します。

Expand.js
- import { useState } from 'react';
+ import { useState, createContext, useRef } from 'react';

+ const ExpandGroupContext = createContext(null);

+ export const ExpandGroup = ({ children }) => {
+   let context = useRef([]).current;
+
+   return (
+     <ExpandGroupContext.Provider value={context}>
+       {children}
+     </ExpandGroupContext.Provider>
+   )
+ }

Expand 側で Context が取得できれば ExpandGroup の子要素と判断し Context に setIsOpen を追加し、開閉時に他の Expand をすべて閉じるようにします。

Expand.js
- import { useState, createContext, useRef } from 'react';
+ import { useState, createContext, useRef, useContext, useEffect } from 'react';

 export const Expand = ({ title = '', children}) => {
   const [isOpen, setIsOpen] = useState(false);
+  const context = useContext(ExpandGroupContext);
+ 
+  useEffect(() => {
+    // stateを登録
+    if (context != null && !context.includes(setIsOpen)) {
+      context.push(setIsOpen);
+    }
+    return () => {
+      // stateを解除
+      if (context != null && context.includes(setIsOpen)) {
+        context.splice(context.indexOf(setIsOpen), 1);
+      }
+    }
+ }, []);

  return (
    <View style={styles.frame}>
      <Pressable
        onPress={() => {
          LayoutAnimation.configureNext(LayoutAnimation.create(300, 'easeInEaseOut', 'opacity'));
+         // 他のアコーディオンを閉じる
+         if (context != null) {
+           context.filter(s => s != setIsOpen).forEach(setter => setter(false));
+         }
          setIsOpen(!isOpen);
        }}>

連動させたい ExpandExpandGroup で囲みます。

App.js
- import { Expand } from './Expand';
+ import { Expand, ExpandGroup } from './Expand';

・・・

+     <ExpandGroup>
        <Expand title='React Native'>
          <Text>React Native combines the best parts of native development with React, a best-in-class JavaScript library for building user interfaces.</Text>
        </Expand>
        <Expand title='Flutter'>
          <Text>Flutter is an open source framework by Google for building beautiful, natively compiled, multi-platform applications from a single codebase.</Text>
        </Expand>
+     </ExpandGroup>

動作確認

一方を開くともう一方が閉じるようになりました👍

https://snack.expo.dev/@tadaedo/expand2

Discussion