Zenn
Closed41

React Native + Expo + Docker + Firebase

ピン留めされたアイテム
sunaosunao

コンポーネント新規

import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Feather } from "@expo/vector-icons";

interface NewComponentProps {
  title: string;
  onPress: () => void;
}

const NewComponent: React.FC<ResultDisplayButtonProps> = ({ title, onPress }) => {
  return <View style={[styles.container]}></View>;
};

const styles = StyleSheet.create({
  container: {},
});

export default NewComponent;
sunaosunao

node.jsの安定したバージョンを楽にインストールしたいのでnvmをインストールする。
nvmの最新バージョンはGithubページの右側のReleasesを見る
そのバージョンがちゃんとしてるかはReleasesの詳細コメントやIssuesでボージョンを検索して確認する。

ターミナルにて
nvmの最新をインストール
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvmを有効化する

[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

確認
nvm --version

sunaosunao

nvmを使ってnode.jsの安定版(長期サポート)をインストールする
node -v
現在はv19.3.0だった
nvm install --lts
node -v
安定版はv22.12.0だった

デフォルトバージョンの設定
Creating default alias: default -> lts/* (-> v22.12.0)
インストール時にこの文があるので基本的にこれがデフォルト設定されている。
確認は
nvm alias
のdefaultを見れば良い。
手動設定は
nvm alias default 'lts/*'
このコマンド。シングルクォートでlts/を囲わないとzsh でワイルドカード()が解釈されるので』zsh: no matches found: lts/*
このエラーが出る。

npmも確認
npm -v
最新にするなら
npm install -g npm@latest
現在は11.0.0

最後にnodeの確認
node -e "console.log('Node.js is OK')"

sunaosunao

Expo CLIのインストール
1. Expo CLIのインストール

Expoプロジェクトの作成

npx create-expo-app my-app
cd my-app
sunaosunao

Dcker

ルートにDockerfileを作成

# ベースイメージとしてNode.jsのバージョン22.12.0を指定
FROM node:22.12.0

# 作業ディレクトリを設定
WORKDIR /usr/src/app

# 必要なパッケージをグローバルインストール
RUN npm install -g expo-cli

# プロジェクト依存関係をキャッシュするために package.json と package-lock.json をコピー
COPY package*.json ./

# プロジェクトの依存関係をインストール
RUN npm install

# アプリケーションコードをコンテナ内にコピー
COPY . .

# Expoサーバーが利用するポートを公開
EXPOSE 19000 19001 19002

# デフォルトコマンドを指定(Expo開発サーバーを起動)
CMD ["npm", "start"]

ルートにdocker-compose.ymlを作成

version: "3.8"
services:
  expo:
    build:
      context: .
    ports:
      - "19000:19000" # Expoサーバー
      - "19001:19001" # Expoデバッグ用
      - "19002:19002" # Expo Metroバンドル
    volumes:
      - .:/app
    stdin_open: true
    tty: true

起動

docker compose up --build
Hidden comment
sunaosunao

Typescript対応

npm install --save-dev @types/react@18 @types/react-dom@18
sunaosunao

dockerは相性悪くて一度保留。
基本は

npx expo start
npx expo start --tarminal

これで開発する。

sunaosunao

Firebase
まずはコンソールにてwebで登録(バンドル IDの設定は慎重にしたいから)

npm install firebase

firebaseConfig.jsをルートに作って

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";

const firebaseConfig = {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
  projectId: "YOUR_PROJECT_ID",
  storageBucket: "YOUR_PROJECT_ID.appspot.com",
  messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
  appId: "YOUR_APP_ID"
};

// Initialize Firebase
const firebaseApp = initializeApp(firebaseConfig);
const analytics = getAnalytics(firebaseApp);

export default firebaseApp;
sunaosunao

Docker関連はこれでいく

Dockerfile

# ベースイメージとしてNode.jsのバージョン22.12.0を指定
FROM node:22.12.0

# 作業ディレクトリを設定
WORKDIR /usr/src/app

# プロジェクト依存関係をキャッシュするために package.json と package-lock.json をコピー
COPY package*.json ./

# プロジェクトの依存関係をインストール
RUN npm install

# アプリケーションコードをコンテナ内にコピー
COPY . .

# Expoサーバーが利用するポートを公開
EXPOSE 19000 19001 19002

# デフォルトコマンドを指定(Expo開発サーバーを起動)
CMD ["npm", "start"]

ローカルのpacage.jsonにExpoは入っているので、pacage.jsonをコピーしてnpm installすればコンテナにExpo導入はOK
pacage.jsonにExpoが入っていれば起動コマンドはnpm start でも自動で expo startにしてくれる。

docker-compose.yml

version: "3.8"
services:
  expo:
    build:
      context: .
    ports:
      - "8081:8081"
      - "19000:19000" # Expoサーバー
      - "19001:19001" # Expoデバッグ用
      - "19002:19002" # Expo Metroバンドル
    volumes:
      - .:/app
    env_file: .env
    stdin_open: true
    tty: true

ポイントは
portに "8081:8081"を設定する。
そうすればチームメンバーのローカルでみんなWEBは確認できる。

実機の確認パターンは2つ
ローカルで npx expo start してスマホでQRを読み込む
スマホとPCを同じWiFiでつなぎ
ターミナルで

ifconfig | grep "192"
        inet xx.xx.xx.xx netmask 0xffffff00 broadcast xx.xx.x

このinet 以下のIPアドレスを手動で作った.envファイルにコピペする
REACT_NATIVE_PACKAGER_HOSTNAME=xxx.xxx.xx.xxx
こんな感じ。場所はルート。
docker-compose.ymlの
env_file: .env
この表記に合わせる。

これで
docker compose up --build
して出てきた QRコードで実機確認できる。

sunaosunao

ヘッダーについて

  • スタックナビゲーション (Stack) を使用している場合、デフォルトでヘッダーに「バックボタン」が表示される
  • バックボタンは、スタック内の前の画面が存在する場合にのみ表示される

バックボタン非表示

<Stack.Screen
  name="details"
  options={{
    headerBackVisible: false, // バックボタンを非表示
  }}
/>
sunaosunao

モジュールインストールは

npx expo install

これを使う

sunaosunao

inputに文字列を入力してFirestoreに保存して、それを一覧で表示させる基本機能

index.tsx

import { useRouter } from "expo-router";
import React, { useState, useEffect } from "react";
import { View, Text, FlatList, Button, StyleSheet } from "react-native";
import { fetchHorses } from "@/utils/firestore";

interface Horse {
  id: string;
  name: string;
}

export default function HorseListScreen() {
  const [horses, setHorses] = useState<Horse[]>([]);
  const router = useRouter();

  useEffect(() => {
    const loadHorses = async () => {
      const data = await fetchHorses();
      setHorses(data); // name プロパティがあるため型エラー解消
    };
    loadHorses();
  }, []);

  return (
    <View style={styles.container}>
      <FlatList data={horses} keyExtractor={(item) => item.id} ListEmptyComponent={<Text>登録された馬がいません</Text>} renderItem={({ item }) => <Text style={styles.horseItem}>{item.name}</Text>} />
      <Button title="新規登録" onPress={() => router.push("/horses/register")} />
    </View>
  );
}
const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  horseItem: { fontSize: 18, marginVertical: 5 },
});

register.tsx

import { useRouter } from "expo-router";
import React, { useState } from "react";
import { View, TextInput, Button, Alert, StyleSheet } from "react-native";
import { addHorse } from "@/utils/firestore";

export default function HorseRegisterScreen() {
  const [horseName, setHorseName] = useState("");
  const router = useRouter();

  const handleRegister = async () => {
    if (!horseName.trim()) {
      Alert.alert("エラー", "馬の名前を入力してください");
      return;
    }

    await addHorse(horseName);
    Alert.alert("登録完了", `馬「${horseName}」を登録しました`);
    setHorseName("");
    router.back(); // 一覧ページに戻る
  };

  return (
    <View style={styles.container}>
      <TextInput placeholder="馬の名前" value={horseName} onChangeText={setHorseName} style={styles.input} />
      <Button title="登録" onPress={handleRegister} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  input: {
    borderBottomWidth: 1,
    fontSize: 18,
    marginBottom: 20,
    padding: 10,
  },
});

sunaosunao

コレクションにフィールドを追加するときに変更するコード

  1. utils/firestore.ts
    型、取得、更新、追加などの各モジュール ※ここがメインの変更
  2. app/horses/index.tsx
    表示する値の追加
  3. app/horses/edit/[id].tsx(馬編集)
    setの変数追加、項目の追加
sunaosunao

ページ遷移ボタンを追加

// モジュール追加
import { useRouter } from "expo-router";
import { Button } from "react-native";

// 初期化
const router = useRouter();

// passnameにpassを指定してページに遷移する
    <View style={styles.container}>
      <Button title="ボタン名" onPress={() => router.push("/passname")} />
    </View>

sunaosunao

下からニュッとでるActionSheetは簡単なボタンメニューには使える。
でもボタンをカスタ水したり横に並べたり、他のデータを読み込んで表示したりは難しいかも。

sunaosunao

Actionsheet

パッケージインストール

npm install @expo/react-native-action-sheet

ルートの一番上にラップする

import { ActionSheetProvider } from '@expo/react-native-action-sheet';

export default function AppContainer() {
  return (
    <ActionSheetProvider>
      <App />
    </ActionSheetProvider>
  );
}

基本のコード

import { useActionSheet } from '@expo/react-native-action-sheet';

export default Menu() {
  const { showActionSheetWithOptions } = useActionSheet();

  const onPress = () => {
    const options = ['Delete', 'Save', 'Cancel'];
    const destructiveButtonIndex = 0;
    const cancelButtonIndex = 2;

    showActionSheetWithOptions({
      options,
      cancelButtonIndex,
      destructiveButtonIndex
    }, (selectedIndex: number) => {
      switch (selectedIndex) {
        case 1:
          // Save
          break;

        case destructiveButtonIndex:
          // Delete
          break;

        case cancelButtonIndex:
          // Canceled
      }});
  }

  return (
    <Button title="Menu" onPress={onPress}/>
  )
};
sunaosunao

Modal

import React, { useState } from "react";
import { Modal } from "react-native";
  
  const [modalVisible, setModalVisible] = useState(false);

基本の形

      <Modal visible={modalVisible} animationType="slide" transparent>
        <View style={styles.modalContainer}>
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>編集</Text>
            <TextInput style={styles.input} value={editValue} onChangeText={setEditValue} keyboardType={editField === "age" ? "numeric" : "default"} />
            <View style={styles.modalActions}>
              <Button title="キャンセル" onPress={() => setModalVisible(false)} />
              <Button title="保存" onPress={handleSave} />
            </View>
          </View>
        </View>
      </Modal>

      const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  center: { flex: 1, justifyContent: "center", alignItems: "center" },

  modalContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "rgba(0,0,0,0.5)",
  },
  modalContent: {
    width: 300,
    padding: 20,
    backgroundColor: "white",
    borderRadius: 10,
  },
  modalTitle: { fontSize: 20, fontWeight: "bold", marginBottom: 10 },
  input: { borderWidth: 1, padding: 10, marginBottom: 10, width: "100%" },
  modalActions: { flexDirection: "row", justifyContent: "space-between" },
});
sunaosunao

horse / [id].tsx

// /horses/detail/[id].tsx
import React, { useEffect, useState } from "react";
import { View, Text, StyleSheet, ActivityIndicator, Button, Modal, TextInput, TouchableOpacity, Pressable } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { getHorseById } from "@/utils/firestore";
import { useRouter } from "expo-router";
import { ActionSheetProvider, useActionSheet } from "@expo/react-native-action-sheet";

interface Horse {
  id: string;
  name: string;
  gender: string;
  age: number;
  runningStyle: string;
  memo: string;
}

export default function HorseDetailScreen() {
  const { id } = useLocalSearchParams();
  const [horse, setHorse] = useState<Horse | null>(null);
  const [loading, setLoading] = useState(true);
  const router = useRouter();
  const { showActionSheetWithOptions } = useActionSheet();

  const [editField, setEditField] = useState<keyof Horse | null>(null);
  const [editValue, setEditValue] = useState<string>("");
  const [modalVisible, setModalVisible] = useState(false);

  useEffect(() => {
    const loadHorse = async () => {
      const data = await getHorseById(id as string);
      setHorse(data);
      setLoading(false);
    };
    loadHorse();
  }, [id]);

  //ここからアクションシート
  const handleEditPress = (field: keyof Horse) => {
    setEditField(field);
    setEditValue(horse?.[field]?.toString() || "");
    setModalVisible(true);
  };

  const handleSave = () => {
    if (horse && editField) {
      setHorse({ ...horse, [editField]: editValue });
      setModalVisible(false);
      // Firestoreに保存する処理をここに追加
    }
  };

  const openActionSheet = () => {
    console.log("アクションシート発火");
    const options = ["名前を編集", "性別を編集", "年齢を編集", "脚質を編集", "メモを編集", "キャンセル"];
    const fieldMapping: (keyof Horse)[] = ["name", "gender", "age", "runningStyle", "memo"];

    showActionSheetWithOptions(
      {
        options,
        cancelButtonIndex: options.length - 1,
      },
      (buttonIndex) => {
        if (buttonIndex !== undefined && buttonIndex < fieldMapping.length) {
          handleEditPress(fieldMapping[buttonIndex]);
        }
      }
    );
  };
  //ここまでアクションシート

  if (loading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (!horse) {
    return (
      <View style={styles.center}>
        <Text>馬が見つかりませんでした</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Button title="編集" onPress={openActionSheet} />
      {/* <TouchableOpacity style={styles.editButton} onPress={openActionSheet}>
        <Text style={styles.editButtonText}>編集</Text>
      </TouchableOpacity> */}

      <Text style={styles.name}>{horse.name}</Text>
      {horse.age !== undefined && <Text>年齢: {horse.age}</Text>}
      {horse.gender && <Text>性別: {horse.gender}</Text>}
      {horse.runningStyle && <Text>脚質: {horse.runningStyle}</Text>}
      {horse.memo && <Text>詳細: {horse.memo}</Text>}
      <Button title="過去レース登録" onPress={() => router.push("/horses/register")} />

      {/* 編集モーダル */}
      <Modal visible={modalVisible} animationType="slide" transparent>
        <View style={styles.modalContainer}>
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>編集</Text>
            <TextInput style={styles.input} value={editValue} onChangeText={setEditValue} keyboardType={editField === "age" ? "numeric" : "default"} />
            <View style={styles.modalActions}>
              <Button title="キャンセル" onPress={() => setModalVisible(false)} />
              <Button title="保存" onPress={handleSave} />
            </View>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  center: { flex: 1, justifyContent: "center", alignItems: "center" },
  name: { fontSize: 24, fontWeight: "bold", marginBottom: 10 },
  editButton: {
    position: "absolute",
    top: 20,
    right: 20,
    padding: 10,
    backgroundColor: "#007bff",
    borderRadius: 5,
  },
  editButtonText: { color: "white", fontSize: 16 },
  modalContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "rgba(0,0,0,0.5)",
  },
  modalContent: {
    width: 300,
    padding: 20,
    backgroundColor: "white",
    borderRadius: 10,
  },
  modalTitle: { fontSize: 20, fontWeight: "bold", marginBottom: 10 },
  input: { borderWidth: 1, padding: 10, marginBottom: 10, width: "100%" },
  modalActions: { flexDirection: "row", justifyContent: "space-between" },
});

sunaosunao

下からカスタマイズできるアクションシート(react-native-modal)

 npx expo install react-native-modal
import React, { useEffect, useState } from "react";
import { View, Text, StyleSheet, ActivityIndicator, Button, Modal, TextInput, TouchableOpacity, Pressable, FlatList } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { getHorseById, updateHorse } from "@/utils/firestore";
import { useRouter } from "expo-router";
import NativeModal from "react-native-modal"; // 追加

interface Horse {
  id: string;
  name: string;
  gender: string;
  age: number;
  runningStyle: string;
  memo: string;
}

export default function HorseDetailScreen() {
  const { id } = useLocalSearchParams();
  const [horse, setHorse] = useState<Horse | null>(null);
  const [loading, setLoading] = useState(true);
  const router = useRouter();
  const [isActionSheetVisible, setActionSheetVisible] = useState(false);
  const [isEditModalVisible, setEditModalVisible] = useState(false);
  const [editField, setEditField] = useState<keyof Horse | null>(null);
  const [editValue, setEditValue] = useState<string>("");

  // 下からモーダルの開閉
  const toggleActionSheet = () => {
    setActionSheetVisible(!isActionSheetVisible);
  };

  const handleEditPress = (field: keyof Horse) => {
    setEditField(field);
    setEditValue(horse?.[field]?.toString() || "");
    setEditModalVisible(true);
  };

  const handleSave = async () => {
    if (horse && editField) {
      const updatedHorse = { ...horse, [editField]: editValue };

      // UIの即時更新
      setHorse(updatedHorse);
      setEditModalVisible(false);

      try {
        // Firestore に保存
        await updateHorse(horse.id, updatedHorse);
        console.log("データを更新しました:", updatedHorse);
      } catch (error) {
        console.error("データ更新エラー:", error);
      }
    }
  };

  useEffect(() => {
    const loadHorse = async () => {
      const data = await getHorseById(id as string);
      setHorse(data);
      setLoading(false);
    };
    loadHorse();
  }, [id]);

  if (loading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (!horse) {
    return (
      <View style={styles.center}>
        <Text>馬が見つかりませんでした</Text>
      </View>
    );
  }

  const editOptions: { key: keyof Horse; label: string }[] = [
    { key: "name", label: "名前" },
    { key: "gender", label: "性別" },
    { key: "age", label: "年齢" },
    { key: "runningStyle", label: "脚質" },
    { key: "memo", label: "メモ" },
  ];

  return (
    <View style={styles.container}>
      {/* 編集メニューを開くボタン */}
      <Button title="編集" onPress={toggleActionSheet} />
      {/* <TouchableOpacity style={styles.editButton} onPress={toggleActionSheet}>
        <Text style={styles.editButtonText}>編集</Text>
      </TouchableOpacity> */}

      {/* 馬情報表示 */}
      <Text style={styles.name}>{horse.name}</Text>
      {horse.age !== undefined && <Text>年齢: {horse.age}</Text>}
      {horse.gender && <Text>性別: {horse.gender}</Text>}
      {horse.runningStyle && <Text>脚質: {horse.runningStyle}</Text>}
      {horse.memo && <Text>詳細: {horse.memo}</Text>}

      {/* 過去レース登録 */}
      <Button title="過去レース登録" onPress={() => router.push("/horses/register")} />

      {/* カスタムアクションシート */}
      <NativeModal isVisible={isActionSheetVisible} onBackdropPress={toggleActionSheet} style={styles.modalContainer}>
        <View style={styles.modalContent}>
          <Text style={styles.modalTitle}>編集メニュー</Text>

          {/* ボタンを横並び(折り返し対応) */}
          <View style={styles.buttonContainer}>
            {editOptions.map((option) => (
              <TouchableOpacity
                key={option.key}
                style={styles.modalButton}
                onPress={() => {
                  handleEditPress(option.key);
                  toggleActionSheet();
                }}
              >
                <Text style={styles.modalButtonText}>{option.label}</Text>
              </TouchableOpacity>
            ))}
          </View>

          {/* キャンセルボタン */}
          <TouchableOpacity style={styles.cancelButton} onPress={toggleActionSheet}>
            <Text style={styles.cancelButtonText}>キャンセル</Text>
          </TouchableOpacity>
        </View>
      </NativeModal>

      {/* 編集用モーダル */}
      <NativeModal isVisible={isEditModalVisible} onBackdropPress={() => setEditModalVisible(false)} style={styles.editModalContainer}>
        <View style={styles.editModalContent}>
          <Text style={styles.modalTitle}>{editField ? `${editOptions.find((o) => o.key === editField)?.label} を編集` : ""}</Text>
          <TextInput style={styles.input} value={editValue} onChangeText={setEditValue} keyboardType={editField === "age" ? "numeric" : "default"} />
          <View style={styles.modalActions}>
            <Button title="キャンセル" onPress={() => setEditModalVisible(false)} />
            <Button title="保存" onPress={handleSave} />
          </View>
        </View>
      </NativeModal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  center: { flex: 1, justifyContent: "center", alignItems: "center" },
  name: { fontSize: 24, fontWeight: "bold", marginBottom: 10 },

  // モーダルのスタイル
  modalContainer: {
    justifyContent: "flex-end",
    margin: 0,
  },
  modalContent: {
    backgroundColor: "white",
    padding: 20,
    borderTopLeftRadius: 15,
    borderTopRightRadius: 15,
    alignItems: "center",
  },
  editModalContainer: {
    justifyContent: "center",
    alignItems: "center",
    margin: 0, // 余白をなくして、中央配置を可能にする
  },
  editModalContent: {
    backgroundColor: "white",
    padding: 20,
    borderRadius: 10, // 🔹 角を丸くする
    width: "80%", // 🔹 幅を調整(適宜変更可能)
    alignItems: "center",
  },
  modalTitle: {
    fontSize: 20,
    fontWeight: "bold",
    marginBottom: 10,
  },

  // ボタンのコンテナ(横並び・折り返し対応)
  buttonContainer: {
    flexDirection: "row",
    flexWrap: "wrap",
    justifyContent: "center",
    gap: 10,
  },

  // 各ボタンのスタイル(小さいサイズ)
  modalButton: {
    paddingVertical: 8,
    paddingHorizontal: 15,
    borderRadius: 8,
    backgroundColor: "#007bff",
    margin: 5,
  },
  modalButtonText: {
    fontSize: 16,
    color: "white",
  },

  // キャンセルボタン
  cancelButton: {
    marginTop: 10,
    width: "100%",
    padding: 15,
    backgroundColor: "#f8f8f8",
    alignItems: "center",
    borderRadius: 10,
  },
  cancelButtonText: {
    fontSize: 18,
    color: "#333",
  },

  // 入力用
  input: {
    width: "100%",
    padding: 10,
    borderWidth: 1,
    borderRadius: 5,
    marginBottom: 10,
  },
  modalActions: {
    flexDirection: "row",
    justifyContent: "space-between",
  },
});

sunaosunao
  • 型を継承して拡張
  • 該当する馬idの馬情報をFirestoreから取得
  • 該当するレースidのレビュー情報を全部取得
  • レースレビュー情報から馬idで一致するレースレビュー情報をfindで絞って事前に取得していた馬id情報をマージいして新たなオブジェクトにしてuseStateで保存
sunaosunao
<View style={styles.resultProgramNumber}>
    <Text>{item.order || "-"}</Text>
    <Text>{item.margin ? `(${item.margin})` : ""}</Text>
</View>
<View style={styles.resultProgramNumber}>
    <Text>{item.order || "-"}</Text>
    {item.margin ? <Text>({item.margin})</Text> : null}{" "}
 </View

ネイティブだと<Text>の高さが残ってレンダリングされるのでnullで存在すら消滅させる。

{item.margin && <Text>({item.margin})</Text>}

これも同じか。

sunaosunao

ネイティブ開発はheightに敏感になった方が良い

sunaosunao

iosはButtonがデフォルトだと青文字テキストボタンになる

sunaosunao

FirestoreにはaddではなくdocやsetDocを使って最初からIDを含めるバージョンで登録しておくと、ID取り出しやすいかも

sunaosunao

VSCodeのキーバインドに
shift + cmd + v で選択範囲をViewで囲むショートカット作ったらかなり便利

sunaosunao
export const getRaceWinner = async (raceId: string): Promise<string> => {
  try {
    const raceDoc = await firestore().collection("races").doc(raceId).get();
    if (raceDoc.exists) {
      return raceDoc.data()?.winnerHorseName || "不明";
    }
  } catch (error) {
    console.error("1着馬の取得エラー:", error);
  }
  return "不明";
};

firestoreファイルで書き直すと

export const getRaceWinner = async (raceId: string): Promise<string> => {
  try {
    const raceDocRef = doc(db, "races", raceId);
    const raceDocSnap = await getDoc(raceDocRef);

    if (raceDocSnap.exists()) {
      return raceDocSnap.data()?.winnerHorseName || "不明";
    }
  } catch (error) {
    console.error("1着馬の取得エラー:", error);
  }
  return "不明";
};
sunaosunao

条件分岐でstyle変更

  const getOrderColor = (frame: string | number | undefined) => {
    const oderColors: { [key: string]: string } = {
      "1": "yellow",
      "2": "blue",
      "3": "pink",
    };
    return order ? orderColors[order.toString()] || "white" : "white"; // デフォルト色を gray に設定
  };
<View style={[styles.orderContainer, { backgroundColor: getOrderColor(item.order) }]} />
</View>
sunaosunao
<ScrollView contentContainerStyle={styles.scrollContainer} keyboardShouldPersistTaps="handled">

このhandledは入力フォーム時はない方が具合がいい気がする。下に決定ボタンがある場合scrollViewにしても隠れる。paddingBottomで下に余裕作れば問題ないか?

sunaosunao

備忘録

  • Tabs.Screen増やしてやっぱ消した時、実際のファイル残ってるとコード消してもタブに残る
  • タップしてトグルで詳細の基本まとめておく
  • NativeModal 下からモーダルと中央モーダル
  • モーダルからモーダルはSTEP UI方式がベスプラ
  • Button Select Group
  • Button Select Tags
  • 情報更新のインラインメッセージ(自作スナックバー)
  • render Field Input / データ編集時にFieldごとに入力フォームを変える
  • インクリメンタリサーチ
  • タブで画面表示切り替え & renderList
  • horseコレクションからrace_horsesコレクション取得してそこから1着(order)のhorseId検索して馬名を表示する
  • 空の時と特定の条件(同名のデータなど)でボタンを押せなくするバリデーション
  • タップしてモーダルで編集して即時更新する印機能
  • 入ってくるデータによって色や表示を変形させる
  • 脚質更新 / HorseWithRaceDetailsという型のrunningStyleで表示された脚質をタップして、horseIdを抜き取りそこからFirestoreクエリでHorse型として馬情報を取得して、一度Horse型steteにセットしてそのstateでhorsesコレクションの脚質を更新。その後、画面のデータを即時更新するにはHorseWithRaceDetails型で取得しているregisteredHorsesの中のhorseIdがマッチした馬のrunningStyleを更新する処理を書く。けっこう大変だった。
  • アニメーション削除ボタン
  • null許容やundefind対策の書き方パターン
sunaosunao

値がない時は表示したくない

 {item.fieldName ? <Text>{item.fieldName}</Text> : null}

値がbooleanの場合(trueの場合のみレンダリング)

 {item.fieldName &&  <Text>{item.fieldName}</Text>}

値がない時は別の表示をしておきたい場合

<Text>着順: {item.fieldNameNest?.fieldName || "未登録"}</Text>
sunaosunao

ページごとに型が違うのにユーザービリティ考えると違う型のページに遷移させたい。
複合した型を作って、firestoreからデータ取得してローカルStateはmargeしてこねくり回す。
そうするとmargeの時に、新しく取得したデータのIDと複合した型に入ってるIDが重複するエラーが起こった。

mapのkeyをIDにしてnew Mapで重複IDを上書きする処理

      const mergedData: RaceWithRaceDetails[] = Array.from(
        new Map(
          races.map((race) => [
            race.id, // キーとして `id` を使用
            {
              ...race,
              horseRaceDetails: details.find((detail) => detail.raceId === race.id) || {}, 
            },
          ])
        ).values()
      );
const uniqueRacesMap = new Map(races.map((race) => [race.id, race]));
console.log(uniqueRacesMap);
Map(2) {
  'race_1' => { id: 'race_1', name: 'レースA(重複)' },
  'race_2' => { id: 'race_2', name: 'レースB' }
}
const uniqueRaces = Array.from(new Map(races.map((race) => [race.id, race])).values());
console.log(uniqueRaces);
[
  { id: "race_1", name: "レースA(重複)" },
  { id: "race_2", name: "レースB" }
]

sunaosunao

ボタンを押してモーダルでInputフォームを出現させて保存を押すと指定し型の指定Fieldを更新するコンポーネント。
普通に作れば簡単だが、やりたいことの条件が多くてややこしかった。
条件は
使い回すコンポーネントにしたかった
setState(型)で更新する場合と、引数にvalue? :stringを渡して使う場合と二刀流にしたかった
ボタン自体がコンポーネントなのでコンポーネントからコンポーネントに渡す処理が必要だった

完成系

import React, { useState, forwardRef, useImperativeHandle } from "react";
import { Modal, View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native";
import type { HorseWithRaceDetails } from "@/types"; 

export type ReviewInputModalRef = {
  open: () => void;
};

type Props = {
  fieldName: keyof HorseWithRaceDetails;
  initialValue?: string;
  onChangeValue: (value: string) => void; // ここで外のstateにセット
  onSave: (value: string) => void;
};

const ReviewInputModal = forwardRef<ReviewInputModalRef, Props>(({ fieldName, initialValue = "", onChangeValue, onSave }, ref) => {
  const [modalVisible, setModalVisible] = useState(false);
  const [text, setText] = useState(initialValue);

  useImperativeHandle(ref, () => ({
    open: () => setModalVisible(true),
  }));

  const handleSave = () => {
    onChangeValue(text); // まず親のstateに値を渡す
    onSave(text); // ← 最新のtextを直接渡す
    setModalVisible(false);
  };

  return (
    <Modal visible={modalVisible} animationType="slide" transparent>
      <View style={styles.modalOverlay}>
        <View style={styles.modalContainer}>
          <Text style={styles.title}>レビュー入力</Text>
          <TextInput multiline value={text} onChangeText={setText} placeholder="ここに入力..." style={styles.input} />
          <View style={styles.footer}>
            <TouchableOpacity onPress={() => setModalVisible(false)} style={styles.cancelButton}>
              <Text style={styles.cancelText}>キャンセル</Text>
            </TouchableOpacity>
            <TouchableOpacity onPress={handleSave} style={styles.saveButton}>
              <Text style={styles.saveText}>保存</Text>
            </TouchableOpacity>
          </View>
        </View>
      </View>
    </Modal>
  );
});

export default ReviewInputModal;

親コンポーネントで呼び出し

  const reviewModalRef = useRef<ReviewInputModalRef>(null);

  // フィールド名セット ここはFieldで増やさなきゃいけない
  const openReviewModal = (fieldName: keyof HorseWithRaceDetails, currentValue: string, horse: HorseWithRaceDetails) => {
    setDiagnosisHorseEditField(fieldName);
    setDiagnosisHorseEditValue(currentValue);
    setSelectedDiagnosisHorse(horse);
    reviewModalRef.current?.open();
  };


                  <DiagnosisHeader
                    width="100%"
                    title="レースの評価"
                    buttonLabel="評価"
                    onPress={() => openHorseDiagnosisActionSheet(item)}
                    onPressReview={() => openReviewModal("diagnosisResultReview", selectedDiagnosisHorse?.diagnosisResultReview ?? "", item)}
                  />
                  <ReviewInputModal
                    ref={reviewModalRef}
                    fieldName="diagnosisResultReview"
                    initialValue={diagnosisHorseEditValue}
                    onChangeValue={(value) => setDiagnosisHorseEditValue(value)}
                    onSave={(value) => handleSaveDiagnosisHorseField(value)} // ← valueを明示的に渡す
                  />

sunaosunao

だいぶ改良した。
初期値の反映と画面の即時更新。モーダル内でuseEffect使ってstate変更して表示させた。ボタン押した瞬間に親コンポーネントでuseState変更してから引数で渡してたら間に合わなかったので。

sunaosunao

後からuser機能作るときの弊害。
普通に考えてuserはすべてのコレクションの親になるわけだから⋯⋯
firestore関連のフックを全部直すことになる、なった。
userId引数にしてコレクションのパスにすべてusers userIdって増えるだけなんだが、漏れなくすべてのフックを変更しなきゃいけないのが地味にしんどかった。

sunaosunao

ChatGPTを頼ってもまったく解決できなくて自力で解決したエラー2選。

ReactNativeだけどlocalhostでデバックしたかったからWEBもつかう。で、そのWEBですべての画面がフリーズした。ボタン類がすべて押せなくなる。フォーカスすらしない。実家は普通に動く。かなりコメントアウトして、ほぼ初期状態にまで戻したのに直らなかった。5時間戦った。もうこの頃にはGPTは高いテンションですでに試した案を何度も繰り返すことしかできないポンコツに成り下がった。
結果、パソコンの再起動で直った。
user機能を後から追加して大改修した後なら出来事だから尚更その可能性を忘れていた。

もう一つは、ヘッダーの戻るボタンをブラウザ更新してから押すとエラーになる問題。
よくあるのはCanGoを使った条件式でreplace(/)とかやればうまくいく。

しかしexpo routerやnavigationを行ったり来たり何をやってもエラー。他のページだと最初から使ってたハンドラー関数で普通に動くことを発見した。
じっくり見比べてみると、useEffectの配置が違っていた。なぜかuseFocusEffectの上にuseEffectがあるとダメだった。ルーティングとは関係ないuseEffectなのに。

このスクラップは11時間前にクローズされました
ログインするとコメントできます