Expo Router を使ってみる

Expo + React Native のプロジェクトで Next.js のようにファイルベースのルーティングができるようになりました。

Expo Router を使ってみます。

Expo Router をインストール

shell
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar react-native-gesture-handler

Entry point を設定する

package.json の エントリーポイントを変更します。

package.json
{
  "main": "expo-router/entry"
}

app.json に scheme を設定する

app.json にディープリンク用の schme を設定します。

app.jso
{
  "scheme": "your-app-scheme"
}

Web で利用する場合は、 bundler の設定も追加します。

app.json
{
  "web": {
    "bundler": "metro"
  }
}

babel.config.js に plugin を追加

plugins'expo-router/babel' を追加します。

babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['expo-router/babel'],
  };
};

app ディレクトリに index.tsx を作成

ルートディレクトリに app ディレクトリを作ります。

app/index.tsx となるように、 index.tsx を作ります。

app/index.tsx
import { Text } from 'react-native';
export default function Page() {
  return <Text>Home Page</Text>;
}

これでアプリを起動すると、 Home Page と表示されます。

App.tsx の内容は表示されなくなっている点に注目です。

<Link> でリンクを張ってみる

以下のようなディレクトリ構成を作ります。

shell
$ tree app
app
├── index.tsx
└── samples
    └── skia-circular-progress.tsx

app/index.tsx は以下のとおりです。

Link href でリンクをはって移動させることができます。

index.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Link } from 'expo-router';

export default function Page() {
  return (
    <View style={styles.container}>
      <Link href={'/samples/skia-circular-progress'} style={styles.linkButton}>
        <Text style={styles.linkText}>Skia Circular Progress</Text>
      </Link>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  linkButton: {
    backgroundColor: '#304FFE',
    paddingVertical: 10,
    paddingHorizontal: 20,
    borderRadius: 4,
    alignItems: 'center',
    justifyContent: 'center',
    borderColor: '#0056b3',
    borderWidth: 1,
  },
  linkText: {
    color: 'white',
    fontSize: 14,
    textAlign: 'center',
  },
});

samples/skia-circular-progress.tsx は以下のようにしています。

skia-circular-progress.tsx
import { Link } from 'expo-router';
import { StyleSheet, View, Text, PixelRatio, Pressable } from 'react-native';
import { Easing, runTiming, useFont, useValue } from '@shopify/react-native-skia';
import CircularProgress from '../../components/CircularProgress';

const RADIUS = PixelRatio.roundToNearestPixel(130);
const STROKE_WIDTH = 12;
export default function Page() {
  const percentageComplete = 0.85;
  const animationState = useValue(0);

  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const font = useFont(require('../../Roboto-Light.ttf'), 60);
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const smallerFont = useFont(require('../../Roboto-Light.ttf'), 25);

  const animateChart = () => {
    animationState.current = 0;

    runTiming(animationState, percentageComplete, {
      duration: 1250,
      easing: Easing.inOut(Easing.cubic),
    });
  };

  const isFontLoaded = font != null && smallerFont != null;

  return (
    <View style={styles.container}>
      <View style={styles.linkContainer}>
        <Link href={'/'} style={styles.linkButton}>
          <Text style={styles.linkText}>Home</Text>
        </Link>
      </View>

      {isFontLoaded && (
        <>
          <View style={styles.donutChartContainer}>
            <CircularProgress
              strokeWidth={STROKE_WIDTH}
              radius={RADIUS}
              percentageCompleted={animationState}
              font={font}
              smallerFont={smallerFont}
              targetPercentage={percentageComplete}
            />
          </View>
          <Pressable onPress={animateChart} style={styles.button}>
            <Text style={styles.buttonText}>Animate !</Text>
          </Pressable>
        </>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'flex-start',
  },
  linkContainer: {
    marginVertical: 16,
  },
  linkButton: {
    backgroundColor: '#FF5722', 
    paddingVertical: 10, 
    paddingHorizontal: 20,
    borderRadius: 4, 
    alignItems: 'center', 
    justifyContent: 'center',
    borderColor: '#F4511E', 
    borderWidth: 1,
  },
  linkText: {
    color: 'white',
    fontSize: 14,
    textAlign: 'center',
  },
  donutChartContainer: {
    height: RADIUS * 2,
    width: RADIUS * 2,
  },
  button: {
    marginTop: 40,
    backgroundColor: 'blue',
    paddingHorizontal: 60,
    paddingVertical: 15,
    borderRadius: 10,
  },
  buttonText: {
    color: 'white',
    fontSize: 20,
  },
});

ファイル名でリンクが設定されていることがわかります。

突然謎のエラーが出た

iOS Bundling failed 1015ms
error: node_modules/expo-router/_ctx.ios.tsx: node_modules/expo-router/_ctx.ios.tsx:Invalid call at line 2: process.env.EXPO_ROUTER_APP_ROOT
First argument of `require.context` should be a string denoting the directory to require.

2023年12月に新たなプロジェクトを作ろうとしたら、同じ手順でエラーが出るようになりました。

原因は謎です。

一番簡単な回避策は初期テンプレートをそのまま流用する形でプロジェクトを作ることです。

shell
npx create-expo-app@latest --template tabs@49

公式ドキュメントには babel.config.js に plugins: ['expo-router/babel'] の記述はいらない、と書いてありますが、テンプレのコードには plugins: ['expo-router/babel'] があるので、もしかしたら babel のプラグインの追加が必要なのかもしれません(未検証)