💡

Expo + React Native + gluestack-uiでTODOアプリを作る

2024/09/03に公開

今後ネイティブアプリを作成する可能性が出てきた。
この記事では下調べのために自身で簡単なアプリを作成してみる。

また、無料Apple Developer Programアカウントでの実機動作確認も行った。他にはあまり言及している記事がなかったため、同様の状況で困っている人にとっては有益な内容だと考えている。

Expo

  • iOSとAndroidに両対応している
  • カメラなどの端末のネイティブ機能を使うためのSDKが利用でき、効率的に開発できる
  • QRコードを読み込むだけで実機で動作確認ができる

https://expo.dev/

gluestack-ui

  • Next.jsでいうShadcn/uiのようなもの
  • Tailwind CSS (=NativeWind)が利用できる
  • 利用することで、ロジック+スタイルが提供されており、短期間で開発できる

https://gluestack.io/

UIコンポーネントライブラリの選定にあったってはこの記事が参考になった。
私はスタイリングにTailwind CSSを利用したかったので、有名どころのTAMAGUIは除外された。
学習コストの観点で、Next.jsとExpoに両対応している点が魅力的に感じたため、gluestack-uiを選んだ。
https://zenn.dev/u_motion_tech/articles/092d09f7cfd592

Expoのプロジェクト作成

以下のドキュメントにしたがってプロジェクトを作成する。
https://docs.expo.dev/get-started/set-up-your-environment/?platform=ios&device=simulated

動作確認

npx expo startからiOS simulatorを起動したかったのだが、下記のエラーが出た。

Logs for your project will appear below. Press Ctrl+C to exit.
› Opening on iOS...
Error: xcrun simctl boot 49496144-EB94-4A0E-B1A8-D8D2674E9E4F exited with non-zero code: 2
An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=2):
Unable to boot device because we cannot determine the runtime bundle.
No such file or directory

エラー文章で調べると、下記の記事がヒットして解決した。
https://zenn.dev/mimototo/articles/c596817e14c12c

単純にXcode内にiOSをインストールし忘れていたようなので、インストールする。

再度npx expo startからiOS simulatorを起動したかったのだが、同様のエラーが出た。
そこで一旦Xcodeを開いて、Xcode -> open developer tool -> Simulatorを押して、手動で立ち上げてから再度ターミナルからコマンドを打つと起動できた。
その後は手動でiOS simulatorを立ち上げなくても、ターミナルから起動できている。

gluestack-uiとNativewindのインストール

下記のページにしたがって、CLIでインストールする。nativewindも同時にインストールされる。
https://gluestack.io/ui/docs/home/getting-started/installation

テスト用の画面を作成する

まずは画面中央にHello Worldを表示してみる。

app/(tabs)/todo.tsx
import React from "react";
import { View, Text } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

const Todo = () => {
  return (
    <SafeAreaView>
      <View className="flex items-center justify-center h-full bg-blue-200">
        <Text className="text-center text-red-500 text-5xl">Hello World</Text>
      </View>
    </SafeAreaView>
  );
};

export default Todo;

以下のエラーが出たので解決する。

エラー解決:react-native-reanimated

gluestack-uiをインストールしてから、シミュレーターを起動しようとすると、以下のコードでエラーが出た。

 ERROR  Error: [Reanimated] Mismatch between JavaScript code version and Reanimated Babel plugin version (3.10.1 vs. 3.15.0).        
See `https://docs.swmansion.com/react-native-reanimated/docs/guides/troubleshooting#mismatch-between-javascript-code-version-and-reanimated-babel-plugin-version` for more details.
Offending code was: `function HelloWaveTsx1(){const{rotationAnimation}=this.__closure;return{transform:[{rotate:rotationAnimation.value+"deg"}]};}`

react-native-reanimatedでこのエラーがよく起こるらしい。
おそらくgluestack-uiが指定したreact-native-reanimatedのバージョンがExpoが指定したものと合っていない。
https://stackoverflow.com/questions/75747258/it-shows-when-i-start-expo-project-so-how-can-i-fix-this

以下のコードでexpoの側にバージョンが合うように再度インストールする。

npx expo install react-native-reanimated

再度起動しようとしても同様のエラーが出た。
キャッシュを消すと起動した。

npx expo start --clear

エラー解決:Watchman

コードを変えて保存しても、シミュレーター上で反映されない時があった。
その場合、npx expo startした際に以下の警告が出る。

Expoのプロジェクト作成の際にインストールしたWatchmanがうまく動作していないようだ。
Watchmanは、ファイルに変更があった際にそれを検知して特定の動作を行わせる際に使うツールで、React Nativeにおいてはコードに変更があった場合に再ビルドを走らせるために使っている。

Logs for your project will appear below. Press Ctrl+C to exit.
Recrawled this watch 11 times, most recently because:
MustScanSubDirs UserDroppedTo resolve, please review the information on
https://facebook.github.io/watchman/docs/troubleshooting.html#recrawl
To clear this warning, run:
watchman watch-del '/Users/ivm-2023-03/development/expo_pj/expo-sample-app' ; watchman watch-project '/Users/ivm-2023-03/development/expo_pj/expo-sample-app'

エラー文の指示にあるコマンドを実行して、Watchmanをリセットする。

watchman watch-del '/Users/ivm-2023-03/development/expo_pj/expo-sample-app' ; watchman watch-project '/Users/ivm-2023-03/development/expo_pj/expo-sample-app'

ここまでで、NativeWindが使えるようになった。
表示を確認してみる。

コードを書く

コード全体
todo.tsx
import { Box } from "@/components/ui/box";
import { Button, ButtonText } from "@/components/ui/button";
import { FormControl } from "@/components/ui/form-control";
import { Heading } from "@/components/ui/heading";
import { Input, InputField } from "@/components/ui/input";
import { VStack } from "@/components/ui/vstack";
import React, { useState } from "react";
import {
  View,
  Text,
  Keyboard,
  TouchableWithoutFeedback,
  ScrollView,
  KeyboardAvoidingView,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

interface TodoData {
  id: number;
  title: string;
  content: string;
  completed: boolean;
}

const TodoItem = ({ todo }: { todo: TodoData }) => {
  const { id, title, content, completed } = todo;
  return (
    <View>
      <Box className="bg-gray-100 p-5 m-5 rounded-lg border-outline-300 border">
        <Text className="text-2xl mb-4">{title}</Text>
        <Text className="mb-2">{content}</Text>
        <Text>{completed ? "Completed" : "Not Completed"}</Text>
      </Box>
    </View>
  );
};

const useTodoForm = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  const resetForm = () => {
    setTitle("");
    setContent("");
  };

  return {
    title,
    setTitle,
    content,
    setContent,
    resetForm,
  };
};

const InputForm = ({
  setTodoData,
  title,
  setTitle,
  content,
  setContent,
  resetForm,
}: {
  setTodoData: React.Dispatch<React.SetStateAction<TodoData[]>>;
  title: string;
  setTitle: React.Dispatch<React.SetStateAction<string>>;
  content: string;
  setContent: React.Dispatch<React.SetStateAction<string>>;
  resetForm: () => void;
}) => {
  const handleSave = () => {
    const newTodo: TodoData = {
      id: Date.now(), // 一意のIDを生成
      title: title,
      content: content,
      completed: false,
    };

    setTodoData((prevTodoData) => [...prevTodoData, newTodo]);

    console.log("Title: ", title, "/", "Content: ", content);
    console.log("保存しました");
    resetForm();
  };

  return (
    <View className="p-4">
      <FormControl className="p-4 border rounded-lg border-outline-300">
        <VStack space="3xl">
          <Heading className="text-typography-900 leading-3 pt-4">
            タスクの追加
          </Heading>
          <VStack space="xs">
            <Text className="text-typography-500 leading-1 w-5/6">Title</Text>
            <Input>
              <InputField
                type="text"
                placeholder="洗剤購入"
                value={title}
                onChangeText={setTitle}
              />
            </Input>
          </VStack>
          <VStack space="xs">
            <Text className="text-typography-500 leading-1 w-5/6">Content</Text>
            <Input>
              <InputField
                type="text"
                placeholder="洗濯用洗剤をスーパーに買いに行く"
                value={content}
                onChangeText={setContent}
              />
            </Input>
          </VStack>
          <VStack space="md">
            <View className="flex items-end">
              <Button className="w-1/3 ml-auto" onPress={handleSave}>
                <ButtonText>Save</ButtonText>
              </Button>
            </View>
          </VStack>
        </VStack>
      </FormControl>
    </View>
  );
};

const Todo = () => {
  const [todoData, setTodoData] = useState<TodoData[]>([]);
  const { title, setTitle, content, setContent, resetForm } = useTodoForm();

  return (
    <SafeAreaView style={{ flex: 1 }}>
      <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
        <ScrollView
          contentContainerStyle={{ flexGrow: 1 }}
          keyboardShouldPersistTaps="handled">
          <Text className="text-3xl font-bold pt-6 pl-4">ToDoアプリ</Text>
          <InputForm
            setTodoData={setTodoData}
            title={title}
            setTitle={setTitle}
            content={content}
            setContent={setContent}
            resetForm={resetForm}
          />
          {todoData.map((todo) => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ScrollView>
      </TouchableWithoutFeedback>
    </SafeAreaView>
  );
};

export default Todo;

gluestack-uiの使い方

入力フォーム部分はドキュメント内のサンプルの「Input type password with FormControl」のコードを活用した。Todoの表示部分はBoxコンポーネントで作成した。
https://gluestack.io/ui/docs/components/input

必要なUIコンポーネントは以下のようにインストールするだけで使える。

npx gluestack-ui add input

ReactとReact Nativeの違い

基本的な書き方は同じだが、少しずつ違いがある。

divの代わりにView、pの代わりにTextを使う。

// React
<div>
  <p>Hello, world!</p>
</div>

// React Native
<View>
  <Text>Hello, world!</Text>
</View>

buttonの代わりにタッチイベントに特化したハンドラを使う。

// React
<button onClick={handleClick}>Click me</button>

// React Native
<TouchableOpacity onPress={handlePress}>
  <Text>Press me</Text>
</TouchableOpacity>

Expoのルーティング

Next.jsと似たような形式で、appディレクトリの階下の構造がそのままルートになる。
例:app/test.tsx は /test としてアクセスできる。

他には特殊な文字を使ったファイル名も使える。

記号 意味
+ (プラス記号) このファイルはグループではなく、独立したルートとして扱われる。 +html.tsx, +not-found.tsx
_ (アンダースコア) このファイルは非表示のルートとして扱われる。URL から直接アクセスできない。 _layout.tsx
[] (角括弧) 動的ルートを表す。URL パラメータとして扱われる。 [id].tsx, [...rest].tsx
(...) (丸括弧) グループ化を表す。URL には影響しないが、ルートをグループ化するのに使用される。 (auth), (tabs)

Expoのローカルサーバー経由ではなく、直接iPhone実機で動かす

まず、実機でExpoのアプリを動かす方法をまとめる。

実機で動かす方法

  • Expo Goを使う(推奨)
    • npx expo start
    • 事前にApp storeからExpo Goアプリをインストールして、ローカルのサーバー経由で起動する
  • 枠のようなものをインストールして、ローカルのサーバー経由で起動する
    • npx expo run:ios --device
    • やっていることはExpo Goと同じだが、別のアプリとして表示される
  • ExpoでprebuildしたコードをXcodeから入れる
  • EAS BuildしたコードをXcodeから入れる
  • EAS Buildしたコードを実際にApp Storeにリリースしてそこから入れる

基本的に実機に直接インストールしてテストするためには年間99ドルのApple Developer Programに登録した有料開発者アカウントを利用する必要がある。

以下の方法でやれば、無料アカウントでもインストールできるらしい。
https://zenn.dev/laddge/articles/9b4a8729669955
https://github.com/expo/expo/discussions/27489

「無料アカウントで、Expoのローカルサーバー経由ではなく、直接iPhone実機で動くこと」を条件で実機インストールを試みた手順についてまとめる。

prebuildについて

これはネイティブのコードを生成する機能である。用途としては、Expoでは使えない機能などを実装する際に、生成したネイティブのコードを直接編集するためにある。

npx expo prebuild

npx expo prebuildした際に、以下のエラーが出たが、cocoapodsをアップデートしたら解決した。
使用していたバージョンがVisionOS周りに対応していなかったらしい。

Invalid `hermes-engine.podspec` file: undefined method `visionos' for #<Pod::Specification name="hermes-engine/Pre-built">.

https://stackoverflow.com/questions/78874314/visionos-method-in-hermes-engines-fails-the-pod-install

sudo gem install cocoapods

npx expo prebuildしたのち、記事の通り、以下のコードを実行したところ、

記事に記載のコード
npx expo export:embed --entry-file='node_modules/expo/AppEntry.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'

「node_modules/expo/AppEntry.jsファイル内で../../Appモジュールを解決できない」というエラーが発生した。

エラーコード全文
expo-sample-app % npx expo export:embed --entry-file='node_modules/expo/AppEntry.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'
Starting Metro Bundler
iOS node_modules/expo/AppEntry.js ░░░░░░░░░░░░░░░░  0.0% (0/1)tailwindcss(i
done
iOS node_modules/expo/AppEntry.js ░░░░░░░░░░░░░░░░  0.0% (1/1)/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/expo/AppEntry.js:2:17: error: Unable to resolve module ../../App from /Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/expo/AppEntry.js: 
Error: Unable to resolve module ../../App from /Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/expo/AppEntry.js: 

None of these files exist:
  * App(.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx|.ios.mjs|.native.mjs|.mjs|.ios.js|.native.js|.js|.ios.jsx|.native.jsx|.jsx|.ios.json|.native.json|.json|.ios.cjs|.native.cjs|.cjs|.ios.scss|.native.scss|.scss|.ios.sass|.native.sass|.sass|.ios.css|.native.css|.css|.ios.css|.native.css|.css)
  * App/index(.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx|.ios.mjs|.native.mjs|.mjs|.ios.js|.native.js|.js|.ios.jsx|.native.jsx|.jsx|.ios.json|.native.json|.json|.ios.cjs|.native.cjs|.cjs|.ios.scss|.native.scss|.scss|.ios.sass|.native.sass|.sass|.ios.css|.native.css|.css|.ios.css|.native.css|.css)
  1 | import registerRootComponent from 'expo/build/launch/registerRootComponent';
  2 |
> 3 | import App from '../../App';
    |                  ^
  4 |
  5 | registerRootComponent(App);
  6 |
Error: Unable to resolve module ../../App from /Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/expo/AppEntry.js: 

None of these files exist:
  * App(.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx|.ios.mjs|.native.mjs|.mjs|.ios.js|.native.js|.js|.ios.jsx|.native.jsx|.jsx|.ios.json|.native.json|.json|.ios.cjs|.native.cjs|.cjs|.ios.scss|.native.scss|.scss|.ios.sass|.native.sass|.sass|.ios.css|.native.css|.css|.ios.css|.native.css|.css)
  * App/index(.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx|.ios.mjs|.native.mjs|.mjs|.ios.js|.native.js|.js|.ios.jsx|.native.jsx|.jsx|.ios.json|.native.json|.json|.ios.cjs|.native.cjs|.cjs|.ios.scss|.native.scss|.scss|.ios.sass|.native.sass|.sass|.ios.css|.native.css|.css|.ios.css|.native.css|.css)
  1 | import registerRootComponent from 'expo/build/launch/registerRootComponent';
  2 |
> 3 | import App from '../../App';
    |                  ^
  4 |
  5 | registerRootComponent(App);
  6 |
    at ModuleResolver.resolveDependency (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/node-haste/DependencyGraph/ModuleResolution.js:112:15)
    at DependencyGraph.resolveDependency (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/node-haste/DependencyGraph.js:235:43)
    at /Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/lib/transformHelpers.js:156:21
    at resolveDependencies (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/DeltaBundler/buildSubgraph.js:42:25)
    at visit (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/DeltaBundler/buildSubgraph.js:83:30)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Promise.all (index 0)
    at buildSubgraph (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/DeltaBundler/buildSubgraph.js:103:3)
    at Graph._buildDelta (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/DeltaBundler/Graph.js:157:22)
    at Graph.initialTraverseDependencies (/Users/ivm-2023-03/development/expo_pj/expo-sample-app/node_modules/metro/src/DeltaBundler/Graph.js:140:19)

先ほどのターミナルのコードをよく読むと、エントリポイントのパスを設定している。

記事に記載のコード(再掲)
npx expo export:embed --entry-file='node_modules/expo/AppEntry.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'

ここで、package.jsonを確認すると、mainに記載されているパスはexpo-router/entryである。

package.json
{
  "name": "expo-sample-app",
  "main": "expo-router/entry",
  "version": "1.0.0",
〜省略〜

そこで、パスを変更して実行する。

npx expo export:embed --entry-file='node_modules/expo-router/entry.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'
expo-sample-app % npx expo export:embed --entry-file='node_modules/expo-router/entry.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'
Starting Metro Bundler
done
iOS Bundled 15174ms node_modules/expo-router/entry.js (1248 modules)
Writing bundle output to: ./ios/main.jsbundle
Done writing bundle output

無事ビルドできた。
記憶が定かでないのだが、試行錯誤をしていた際にprebuildしたファイルをいきなりXcodeでビルドしようとしても、main.jsbundleが無いことでエラーを吐いていたと思う。

生成されたネイティブコードをXcodeで開く

次は生成された ios フォルダの中にある、.xcworkspace ファイルを見つけて、それをXcodeで開く。
(注:.xcodeproj ではなく、.xcworkspace)

bundleIdentifierを書き換える

端末にインストールする際に、"No profiles for 'com.anonymous.expo-sample-app' were found"のようなエラーが出た場合、"bundleIdentifier"を重複がない別の文字列に変更する。
本来はApple Developer Programの有料アカウントに登録しているドメイン名を設定するようだが、現在は無料アカウントなので一旦適当な文字列に変更する。
画像内の赤で塗りつぶしたところは個人情報のためマスクした。

https://zenn.dev/usamaru/articles/725b759d6a9561

Schemeのビルド設定を変更する

ここからが重要で、ビルドの設定をDebugからReleaseに変更する。

Product > Scheme > Edit Scheme > Run > Build Configuration > Release

自分で作成したコードの部分と一緒にExpoの開発用のコードがインストールされるため、Debugモードのままインストールすると、開発用画面が起動してしまう。(5敗)


iPhoneをmacに接続&デベロッパモードに変更する

設定 > プライバシーとセキュリティ > 最下部のセキュリティ列にの「デベロッパモード」をONにする。

その項目の表示がない人は、iPhoneをUSBケーブルで接続したら、一旦Xcode上の実行ボタンを押して、ダイアログを出す必要があるらしい。
https://qiita.com/ruemura3/items/968ca1f4e8f49001bca3

Xcode上で接続したiPhoneを選択してビルド

上部の実行先の項目を変更して、検証したい端末に開発したアプリをインストールする。
インストールが完了したら実機で動作確認をする。

最後に

Expo + React Native + gluestack-uiでの環境構築と、Xcodeを使った実機での動作確認を実施した。

Next.js + Tailwind CSS + Shadcn/ui環境とかなり近い環境で作成できたので、そこまで違和感なく操作できた。
今後はScrollなどのReact Native特有の操作ついて学んでいきたい。

Discussion