Expo と React Navigation で Twitter のようなスライドメニューを作る

10 min read読了の目安(約9400字

Twitter の iOS アプリは左サイドメニューがスライドによって開閉できるようになっている。

TwitterSlideMenu

普段使い慣れていることもあり、これを ExpoReact Native で実装できるか試してみたい。

React Navigation

Expo でナビゲーションやルーティングを実装するときは React Navigation を使うことが推奨されている

以降では、用語を公式ドキュメントと合わせるために、サイドメニューを「ドロワー」、メインの画面を「スクリーン」と書きます。

ただ、公式のガイドは非常に基本的な内容に留まっており、これだけだと期待しているようなメニューの実装ができない。実は、ここに書かれている以外にも多くの API が用意されており、これらを組み合わせれば、以下のようなカスタマイズが可能だ。

  • ドロワーを開くとき一緒にスクリーンもスライドさせる
  • ドロワーの幅を調整
  • ドロワーの内容を独自のビューに置き換える

最終的には次のようなアプリケーションが作れるようになる予定である。

CustomDrawer

新規プロジェクトの準備

新規に Expo プロジェクトを作成する。テンプレートは Managed workflow の "blank" を選択する。

$ expo init
✔ What would you like to name your app? … my-slide-menu
✔ Choose a template: › blank (TypeScript)    same as blank but with
TypeScript configuration
✔ Downloaded and extracted project files.
🧶 Using Yarn to install packages. Pass --npm to use npm instead.
⠙ Installing JavaScript dependencies.
...

作られたプロジェクトのディレクトリに移動して、起動できることを確認しておこう。

$ expo start

必要なパッケージのインストール

次に、必要なパッケージのインストールをしていく。まずは React Navigation をインストールする。

$ expo install @react-navigation/native

今回は Expo の Managed workflow で開発しているので、さらに追加のパッケージも必要だ。

$ expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view

また、ドロワーは @react-navigation/drawer で提供されているので、最後にこれを追加しよう。

$ expo install @react-navigation/drawer

基本的なメニューを作る

次に、公式ガイドに従って、基本的なドロワーを実装する。ドキュメントのサンプルコードに TypeScript の型アノテーションを追加したコードが以下になる。[1]

App.tsx
import React from "react";
import { StyleSheet, Button, Text, View } from "react-native";
import {
  createDrawerNavigator,
  DrawerNavigationProp,
} from "@react-navigation/drawer";
import { NavigationContainer } from "@react-navigation/native";
import { StatusBar } from "expo-status-bar";

type HomeNavigationParamList = {
  Home: undefined;
  Notifications: undefined;
};

type HomeScreenNavigationProp = DrawerNavigationProp<
  HomeNavigationParamList,
  "Home"
>;

type NotificationsScreenNavigationProp = DrawerNavigationProp<
  HomeNavigationParamList,
  "Notifications"
>;

function HomeScreen({ navigation }: { navigation: HomeScreenNavigationProp }) {
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <Button
        onPress={() => navigation.navigate("Notifications")}
        title="Go to notifications"
      />
    </View>
  );
}

function NotificationsScreen({
  navigation,
}: {
  navigation: NotificationsScreenNavigationProp;
}) {
  return (
    <View style={styles.container}>
      <Button onPress={() => navigation.goBack()} title="Go back home" />
    </View>
  );
}

const Drawer = createDrawerNavigator<HomeNavigationParamList>();

export default function App() {
  return (
    <NavigationContainer>
      <Drawer.Navigator initialRouteName="Home">
        <Drawer.Screen name="Home" component={HomeScreen} />
        <Drawer.Screen name="Notifications" component={NotificationsScreen} />
      </Drawer.Navigator>
      <StatusBar style="auto" />
    </NavigationContainer>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
});

とりあえず動くドロワーを実装できた。起動してみよう。

InitialVersion

しかし、このままでは思い描いた動作とは程遠い。

  1. ドロワーがスクリーンに重なっている(一緒に移動するようにしたい)
  2. ドロワーの幅が狭すぎる(もっと広くしたい)
  3. ドロワーの内容が固定(登録済みの画面一覧が表示されるだけ)

順番に解決していこう。

ドロワーが開閉するときにスクリーンも動かすようにする

ドロワーの動きは drawerType プロパティで変更できる。期待する動きにするためには、drawerType"slide" に変更する。

<NavigationContainer>
  <Drawer.Navigator drawerType="slide">
    ...
  </Drawer.Navigator>

早速動かしてみよう。

SlideDrawer

期待通りに動いているようだ。次はドロワーの幅を広くしたい。

ドロワーの幅を調整する

今のままだとドロワーの幅が狭すぎるので、もう少し広くしたい。これは drawerStyle で自由にスタイルを指定できるので、ここで width を指定してやればいい。

export default function App() {
  return (
    <NavigationContainer>
      <Drawer.Navigator drawerType="slide" drawerStyle={styles.drawer}>
...
const styles = StyleSheet.create({
  ...
  drawer: {
    width: "80%",
  },
});

これで幅を広げることもできた。

DrawerWidth

次はいよいよ、ドロワーの中身を独自のビューに置き換えよう。

ドロワーの中身をカスタマイズ

ドロワーに表示される内容は drawerContentdrawerContentOptions でカスタマイズできる。

drawerContent でドロワーの中身として表示されるビューを上書きできるが、デフォルトでは以下のようになっている。

import {
  DrawerContentScrollView,
  DrawerItemList,
} from '@react-navigation/drawer';

function CustomDrawerContent(props) {
  return (
    <DrawerContentScrollView {...props}>
      <DrawerItemList {...props} />
    </DrawerContentScrollView>
  );
}

デフォルトの表示は drawerContentOptions でカスタマイズできる。たとえば、以下のように書き換えてみよう。

<NavigationContainer>
  <Drawer.Navigator
    drawerType="slide"
    drawerStyle={styles.drawer}
    drawerContentOptions={{
      activeTintColor: "#e91e63",
      itemStyle: { marginVertical: 30 },
    }}
  >
  ...

ドロワーに表示されるメニュー項目の間隔と色が変わったのが分かると思う。他のオプションについては API リファレンスを参考にしてほしい。

DrawerOptions

もちろん、 drawerContentOptions では、そもそもやりたかった「ドロワーの中身を独自のビューで置き換え」は実現できない。それをやるには drawerContent を使って、中身を丸ごと置き換えてやる必要がある。

drawerContent にはドロワーの中身となる React コンポーネントを返す関数を指定する。以下のような感じだ。

export default function App() {
  return (
    <NavigationContainer>
      <Drawer.Navigator
        drawerType="slide"
        drawerStyle={styles.drawer}
        drawerContent={() => <View></View>}
      >
...

また、この関数には navigation オブジェクトを含む props が渡される。

export default function App() {
  return (
    <NavigationContainer>
      <Drawer.Navigator
        drawerType="slide"
        drawerStyle={styles.drawer}
        drawerContent={props => <DrawerMenu {...props} />}
      >
...

ここでは、サンプルとして、アイコン画像とメニュー項目を配置して、それっぽいビューを作ってみる。ここで気をつけたいのは、ドロワーに表示するビューに渡される props の型が DrawerContentComponentProps になることくらいだろうか。

import React from "react";
import {
  StyleSheet,
  Button,
  Text,
  Image,
  View,
  TouchableOpacity,
} from "react-native";
import {
  createDrawerNavigator,
  DrawerNavigationProp,
  DrawerContentComponentProps,
} from "@react-navigation/drawer";
import { NavigationContainer } from "@react-navigation/native";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";

// @ts-ignore
import picture from "./assets/picture.png";
...
function DrawerMenu({ navigation }: DrawerContentComponentProps) {
  return (
    <View style={styles.menuContainer}>
      <Image source={picture} resizeMode="contain" style={styles.picture} />
      <View>
        <Text style={styles.name}>takanori_is</Text>
        <Text style={styles.nickname}>@takanori_is</Text>
      </View>
      <TouchableOpacity
        style={styles.menuItem}
        onPress={() => {
          navigation.navigate("Home");
        }}
      >
        <Ionicons name="home-outline" size={24} />
        <Text style={styles.menuItemLabel}>ホーム</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.menuItem}
        onPress={() => {
          navigation.navigate("Notifications");
        }}
      >
        <Ionicons name="notifications-outline" size={24} />
        <Text style={styles.menuItemLabel}>通知</Text>
      </TouchableOpacity>
    </View>
  );
}
...
const styles = StyleSheet.create({
  ...
  menuContainer: { paddingVertical: 70, paddingHorizontal: 20 },
  picture: {
    width: 64,
    height: 64,
    borderRadius: 64 / 2,
    marginBottom: 7,
  },
  name: { fontSize: 18, fontWeight: "bold" },
  nickname: { fontSize: 15, color: "gray" },
  menuItem: { flexDirection: "row", marginTop: 24 },
  menuItemLabel: { marginLeft: 15, fontSize: 20, fontWeight: "300" },
});

expo start してシミュレーターで動作確認してみよう。たしかに、アイコン画像とメニュー項目を持つ独自のビューが配置されている。

CustomDrawer

脚注
  1. React Navigation の TypeScript による型づけについては Type checking with TypeScript | React Navigation を参考 ↩︎