Expo + React Native + gluestack-uiでTODOアプリを作る
今後ネイティブアプリを作成する可能性が出てきた。
この記事では下調べのために自身で簡単なアプリを作成してみる。
また、無料Apple Developer Programアカウントでの実機動作確認も行った。他にはあまり言及している記事がなかったため、同様の状況で困っている人にとっては有益な内容だと考えている。
Expo
- iOSとAndroidに両対応している
- カメラなどの端末のネイティブ機能を使うためのSDKが利用でき、効率的に開発できる
- QRコードを読み込むだけで実機で動作確認ができる
gluestack-ui
- Next.jsでいうShadcn/uiのようなもの
- Tailwind CSS (=NativeWind)が利用できる
- 利用することで、ロジック+スタイルが提供されており、短期間で開発できる
UIコンポーネントライブラリの選定にあったってはこの記事が参考になった。
私はスタイリングにTailwind CSSを利用したかったので、有名どころのTAMAGUIは除外された。
学習コストの観点で、Next.jsとExpoに両対応している点が魅力的に感じたため、gluestack-uiを選んだ。
Expoのプロジェクト作成
以下のドキュメントにしたがってプロジェクトを作成する。
動作確認
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
エラー文章で調べると、下記の記事がヒットして解決した。
単純にXcode内にiOSをインストールし忘れていたようなので、インストールする。
再度npx expo start
からiOS simulatorを起動したかったのだが、同様のエラーが出た。
そこで一旦Xcodeを開いて、Xcode -> open developer tool -> Simulatorを押して、手動で立ち上げてから再度ターミナルからコマンドを打つと起動できた。
その後は手動でiOS simulatorを立ち上げなくても、ターミナルから起動できている。
gluestack-uiとNativewindのインストール
下記のページにしたがって、CLIでインストールする。nativewindも同時にインストールされる。
テスト用の画面を作成する
まずは画面中央にHello Worldを表示してみる。
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が指定したものと合っていない。
以下のコードで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が使えるようになった。
表示を確認してみる。
コードを書く
コード全体
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コンポーネントで作成した。
必要な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に登録した有料開発者アカウントを利用する必要がある。
以下の方法でやれば、無料アカウントでもインストールできるらしい。
「無料アカウントで、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">.
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
である。
{
"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の有料アカウントに登録しているドメイン名を設定するようだが、現在は無料アカウントなので一旦適当な文字列に変更する。
画像内の赤で塗りつぶしたところは個人情報のためマスクした。
Schemeのビルド設定を変更する
ここからが重要で、ビルドの設定をDebugからReleaseに変更する。
Product > Scheme > Edit Scheme > Run > Build Configuration > Release
自分で作成したコードの部分と一緒にExpoの開発用のコードがインストールされるため、Debugモードのままインストールすると、開発用画面が起動してしまう。(5敗)
iPhoneをmacに接続&デベロッパモードに変更する
設定 > プライバシーとセキュリティ > 最下部のセキュリティ列にの「デベロッパモード」をONにする。
その項目の表示がない人は、iPhoneをUSBケーブルで接続したら、一旦Xcode上の実行ボタンを押して、ダイアログを出す必要があるらしい。
Xcode上で接続したiPhoneを選択してビルド
上部の実行先の項目を変更して、検証したい端末に開発したアプリをインストールする。
インストールが完了したら実機で動作確認をする。
最後に
Expo + React Native + gluestack-uiでの環境構築と、Xcodeを使った実機での動作確認を実施した。
Next.js + Tailwind CSS + Shadcn/ui環境とかなり近い環境で作成できたので、そこまで違和感なく操作できた。
今後はScrollなどのReact Native特有の操作ついて学んでいきたい。
Discussion