0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Native & Expoでアプリを作成してみた【メモアプリ】

Last updated at Posted at 2025-08-15

プロジェクト作成

新規プロジェクトの作成

npx create-expo-app --template blank-typescript@sdk-52 react-native-memo

プロジェクトディレクトリに移動

cd react-native-memo

Expo サーバーを起動

npx expo start

コードスタイルツール設定

1.プロジェクトルートで右クリックし、ファイル名を入力する

$ touch .prettierrc.js

2.設定内容を記載する

.prettierrc.js
module.exports = {
  // 文末のカンマ設定
  //  none: カンマをつけない(デフォルト)
  //  es5:  一部にカンマをつける
  //  all:  全てにカンマをつける
  trailingComma: 'es5',
  // 折り返す行の長さを指定する。
  printWidth: 150,
  // インデントレベルごとのスペースの数を指定する。
  tabWidth: 2,
  // スペースではなくタブでインデントを行うか指定する。
  useTabs: false,
  // 行末にセミコロンを付与するか指定する。
  semi: true,
  // 文字列の囲みをダブルクォーテーションではなく、シングルクォーテーションにするか指定する。
  singleQuote: true,
  // オブジェクトのプロパティを引用符で囲むか指定する。
  // "as-needed" - 必要な場合にのみ、引用符で囲む。
  // "consistent" - オブジェクト内の少なくとも 1 つのプロパティに引用符が必要な場合は、すべてのプロパティを引用符で囲む。
  // "preserve"- オブジェクトのプロパティでの入力の引用符の使用を尊重する。
  quoteProps: 'as-needed',
  // 複数要素の末尾の後ろにカンマを付与するかどうか指定する。
  // "all"- 可能な限り末尾のカンマを付与する。
  // "es5"- ES5 で有効な末尾のカンマ (オブジェクト、配列など)。TypeScript の型パラメーターの末尾にカンマは付与しない。
  // "none"- 末尾にカンマを付与しない。
  trailingComma: 'none',
  // オブジェクト内の要素と括弧の間にスペースを出力するか指定する。
  bracketSpacing: true,
  // アロー関数のパラメーターをカッコで囲むか指定する。
  // "always" - 常に括弧を含める。例:(x) => x
  // "avoid" - 可能な場合は括弧を省略する。例:x => x
  arrowParens: 'avoid',
  // プラグマを含むファイルのみにフォーマットを行うか指定する。
  requirePragma: false,
  // プラグマを挿入するか指定する。
  insertPragma: false,
  // 改行コードを指定する。
  // "lf"– 改行のみ ( \\\\n)、Linux と macOS、および git リポジトリ内で一般的。
  // "crlf"- キャリッジ リターン + ライン フィード文字 ( \\\\r\\\\n)、Windows で一般的。
  // "cr"- 復帰文字のみ ( \\\\r)、非常にまれに使用される。
  // "auto"- 既存の行末を維持する。 (1つのファイル内の混合値は、最初の行の後に何が使用されているかを確認することで正規化される)
  endOfLine: 'auto',
  // 組み込み言語のフォーマットを有効にするか指定する。
  // "auto"– Prettier が自動的に識別できる場合は、埋め込みコードをフォーマットする。
  // "off"- 埋め込みコードを自動的にフォーマットしない。
  embeddedLanguageFormatting: 'auto',
};

3.プロジェクトルートで右クリックし、ファイル名を入力する

$ touch .editorconfig 

4.設定内容を記載する

.editorconfig
root = true

[*]
# インデントスタイル (tab / space)
indent_style = space

# インデントサイズ
indent_size = 2

# 改行コード(lf/cr/crlf)
end_of_line = lf

# 文字コード
charset = utf-8

# 文末スペースのトリミング (true: 削除する / false: 削除しない)
trim_trailing_whitespace = true
[*.md] # Markdownファイルはトリミングしない
trim_trailing_whitespace = false

# 最終行の改行 (true: 改行を入れる / false: 改行を入れない)
insert_final_newline = true

Expo Router

1.パッケージをインストールします。

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

2.package.json を修正し、エントリーポイントを指定する

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

3.app.json を修正し、scheme を設定する

app.json
{
  "scheme": "react-native-memo"
}

4.プロジェクトルートで右クリックし、ファイル名を入力する

$ touch babel.config.js 

5.設定内容を記載する

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

6.キャッシュをクリアしてプロジェクトを起動する

$ npx expo start --clear

7.App.tsx と index.tsを削除する

画面作成

画面構成の作成

1.app/index.tsxを作成する(起動画面)

app/index.tsx
import { StyleSheet, Text, View } from 'react-native';

export default function InitialScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>アプリ起動中・・</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

2.app/_layout.tsxを作成する

app/_layout.tsx
import { Stack } from 'expo-router';

export default function Layout() {
  return (
    <Stack screenOptions={{ headerTintColor: '#0000000', headerStyle: { backgroundColor: '#F9F9F9' } }}>
      <Stack.Screen name="index" options={{ headerShown: false }} />
      {/*ホーム*/}
      <Stack.Screen name="home/index" options={{ headerTitle: 'ホーム' }} />
      {/*ラベル*/}
      <Stack.Screen name="labels" options={{ headerShown: false, presentation: 'fullScreenModal' }} />
      {/*メモ*/}
      <Stack.Screen name="memos/index" options={{ headerTitle: 'メモ' }} />
      <Stack.Screen name="memos/create" options={{ headerTitle: '' }} />
      <Stack.Screen name="memos/[id]" options={{ headerTitle: '' }} />
    </Stack>
  );
}

画面遷移の実装1

1.app/home/index.tsxを作成する(ホーム画面)

app/home/index.tsx
//ホーム画面
import { StyleSheet, Text, View, Button } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { router } from 'expo-router';

export default function HomeScreen() {
  const handleAllMemoPress = () => {
    router.push({ pathname: '/memos' });
  };

  const handleLabelPress = (labelId: number) => {
    const params = { labelId: labelId };
    router.push({ pathname: '/memos', params: params });
  };

  const handleAddLabelPress = () => {
    router.push({ pathname: '/labels/create' });
  };

  const handleEditLabelPress = (labelId: number) => {
    router.push({ pathname: `/labels/${labelId}` });
  };

  return (
    <View style={styles.container}>
      <Button title="ラベル追加" onPress={handleAddLabelPress} />

      <Button title="全てのメモ" onPress={handleAllMemoPress} />

      <View style={{ flexDirection: 'row', alignItems: 'center' }}>
        <Button title="ラベル1" onPress={() => handleLabelPress(1)} />
        <MaterialIcons name="edit" size={24} color={'gray'} onPress={() => handleEditLabelPress(1)} />
      </View>

      <View style={{ flexDirection: 'row', alignItems: 'center' }}>
        <Button title="ラベル2" onPress={() => handleLabelPress(2)} />
        <MaterialIcons name="edit" size={24} color={'gray'} onPress={() => handleEditLabelPress(2)} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

2.app/index.tsxを修正する(起動画面)

app/index.tsx
import { router, Router } from 'expo-router';
import { useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';

export default function InitialScreen() {
  useEffect(() => {
    const timer = setTimeout(() => {
      router.replace('/home');
    }, 2000);
    return () => clearTimeout(timer);
  }, []);
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>アプリ起動中・・</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

3.app/labels/create.tsxを作成する(ラベル画面)

app/labels/create.tsx
//ラベル画面作成
import { router } from 'expo-router';
import { StyleSheet, Text, View, Button } from 'react-native';

export default function LabelCreateScreen() {
  const handleCreatePress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>ラベル作成</Text>
      <Button title="作成" onPress={handleCreatePress} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

4.app/labels/[id].tsxを作成する(ラベル修正画面)

app/labels/[id].tsx
//ラベル修正画面
import { router, useLocalSearchParams } from 'expo-router';
import { StyleSheet, Text, View, Button } from 'react-native';

export default function LabelEditScreen() {
  const { id } = useLocalSearchParams;

  const handleEditPress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>ラベル修正: {id}</Text>
      <Button title="修正" onPress={handleEditPress} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

5.app/labels/_layout.tsxを作成する(ラベル修正画面)

app/labels/_layout.tsx
import { Stack } from 'expo-router';
import { Text, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';

export default function Layout() {
  return (
    <Stack screenOptions={{ headerShown: true }}>
      <Stack.Screen
        name="create"
        options={{
          headerTitle: 'ラベル作成',
          headerLeft: () => (
            <TouchableOpacity onPress={() => router.dismiss()}>
              <Text>閉じる</Text>
            </TouchableOpacity>
          )
        }}
      />
      <Stack.Screen
        name="[id]"
        options={{
          headerTitle: 'ラベル修正',
          headerLeft: () => (
            <TouchableOpacity onPress={() => router.dismiss()}>
              <Text>閉じる</Text>
            </TouchableOpacity>
          )
        }}
      />
    </Stack>
  );
}

6.app/memos/create.tsxを作成する(メモ作成画面)

app/memos/create.tsx
// メモ作成画面
import { router } from 'expo-router';
import { StyleSheet, Text, View, Button } from 'react-native';

export default function MemoCreateScreen() {
  const handleCreatePress = () => {
    router.back();
  };
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>メモ作成</Text>
      <Button title="作成" onPress={handleCreatePress} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

7.app/memos/[id].tsxを作成する(メモ修正画面)

app/memos/[id].tsx
//メモ修正画面
import { router, useLocalSearchParams } from 'expo-router';
import { StyleSheet, Text, View, Button } from 'react-native';

export default function MemoEditScreen() {
  const { id } = useLocalSearchParams;

  const handleSavePress = () => {
    router.back();
  };
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>メモ修正: {id}</Text>
      <Button title="保存" onPress={handleSavePress} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

8.app/memos/index.tsxを作成する(メモ一覧画面)

app/memos/index.tsx
//メモ修正画面
import { router, useLocalSearchParams, usePathname } from 'expo-router';
import { Button, StyleSheet, Text, View } from 'react-native';

// アプリ起動時の画面

export default function MemoListScreen() {
  const { labelId } = useLocalSearchParams;

  const handleCreatePress = () => {
    router.push({ pathname: '/memos/create' });
  };

  const handleMemoPress = (memoId: String) => {
    router.push({ pathname: `/memos/${memoId}` });
  };

  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>{labelId ? `ラベルID: ${labelId}` : '全てのメモ'}</Text>
      <Button title="メモ作成" onPress={handleCreatePress} />
      <Button title="メモ1" onPress={() => handleMemoPress('ABCD')} />
      <Button title="メモ2" onPress={() => handleMemoPress('EFGH')} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

ナビゲーションバーの設定

1.app/home/index.tsxを修正する(ホーム画面)

app/home/index.tsx
//ホーム画面
import { MaterialIcons } from '@expo/vector-icons';
import { router, useNavigation } from 'expo-router';
import { Button, StyleSheet, View } from 'react-native';
import { useEffect } from 'react';

export default function HomeScreen() {
  const navigation = useNavigation();

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <MaterialIcons name="new-label" size={24} color="black" onPress={handleAddLabelPress} />;
      }
    });
  }, []);

  const handleAllMemoPress = () => {
    router.push({ pathname: '/memos' });
  };

  const handleLabelPress = (labelId: number) => {
    const params = { labelId: labelId };
    router.push({ pathname: '/memos', params: params });
  };

  const handleAddLabelPress = () => {
    router.push({ pathname: '/labels/create' });
  };

  const handleEditLabelPress = (labelId: number) => {
    router.push({ pathname: `/labels/${labelId}` });
  };

  return (
    <View style={styles.container}>
      <Button title="全てのメモ" onPress={handleAllMemoPress} />

      <View style={{ flexDirection: 'row', alignItems: 'center' }}>
        <Button title="ラベル1" onPress={() => handleLabelPress(1)} />
        <MaterialIcons name="edit" size={24} color={'gray'} onPress={() => handleEditLabelPress(1)} />
      </View>

      <View style={{ flexDirection: 'row', alignItems: 'center' }}>
        <Button title="ラベル2" onPress={() => handleLabelPress(2)} />
        <MaterialIcons name="edit" size={24} color={'gray'} onPress={() => handleEditLabelPress(2)} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

2.app/memos/index.tsxを修正する(メモ一覧画面)

app/memos/index.tsx
//メモ修正画面
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { Button, StyleSheet, Text, View } from 'react-native';
import { useEffect } from 'react';
import { Feather } from '@expo/vector-icons';

// アプリ起動時の画面

export default function MemoListScreen() {
  const navigation = useNavigation();
  const { labelId } = useLocalSearchParams();

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Feather name="edit" size={24} color="black" onPress={handleCreatePress} />;
      }
    });
  }, []);

  const handleCreatePress = () => {
    router.push({ pathname: '/memos/create' });
  };

  const handleMemoPress = (memoId: String) => {
    router.push({ pathname: `/memos/${memoId}` });
  };

  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>{labelId ? `ラベルID: ${labelId}` : '全てのメモ'}</Text>
      <Button title="メモ1" onPress={() => handleMemoPress('ABCD')} />
      <Button title="メモ2" onPress={() => handleMemoPress('EFGH')} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

3.app/memos/create.tsxを修正する(メモ作成画面)

app/memos/create.tsx
// メモ作成画面
import { router, useNavigation } from 'expo-router';
import { Button, StyleSheet, Text, View } from 'react-native';
import { useEffect } from 'react';

export default function MemoCreateScreen() {
  const navigation = useNavigation();

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Button title="作成" onPress={handleCreatePress} />;
      }
    });
  }, []);

  const handleCreatePress = () => {
    router.back();
  };
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>メモ作成</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

4.app/memos/[id].tsxを修正する(メモ修正画面)

app/memos/[id].tsx
//メモ修正画面
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { Button, StyleSheet, Text, View } from 'react-native';
import { useEffect } from 'react';

export default function MemoEditScreen() {
  const navigation = useNavigation();
  const { id } = useLocalSearchParams();

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Button title="保存" onPress={handleSavePress} />;
      }
    });
  }, []);

  const handleSavePress = () => {
    router.back();
  };
  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>メモ修正: {id}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

UI作成

React Native Elementsのインストール

npm install @rneui/themed@4.0.0-rc.8 @rneui/base@4.0.0-rc.7 --save-exact

ラベルリストの作成

1.src/components/LabelListitem.tsxを作成する

src/components/LabelListitem.tsx
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Foundation } from '@expo/vector-icons';
import { ListItem } from '@rneui/themed';
import { StyleSheet, View } from 'react-native';

type LabelListItemProps = {
  color: string;
  name: string;
  onPress: () => void;
  onEditPress: () => void;
};

const LabelListItem: React.FC<LabelListItemProps> = props => {
  const { color, name, onPress, onEditPress } = props;

  return (
    <View style={styles.container}>
      <ListItem bottomDivider style={styles.ListItem} onPress={onPress}>
        <MaterialCommunityIcons name="label" size={26} color={color} style={styles.labelIcon} />
        <ListItem.Content>
          <ListItem.Title style={styles.title}>{name}</ListItem.Title>
        </ListItem.Content>
        <Foundation name="pencil" size={26} color={'#818181'} style={styles.editIcon} onPress={onEditPress} />
      </ListItem>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row'
  },
  labelIcon: {
    marginRight: 10
  },
  ListItem: {
    flex: 1
  },
  title: {
    fontSize: 18
  },
  editIcon: {
    marginRight: 12
  }
});

export { LabelListItem };

2.app/home/index.tsxを修正する(ホーム画面)

app/home/index.tsx
//ホーム画面
import { MaterialIcons } from '@expo/vector-icons';
import { router, useNavigation } from 'expo-router';
import { useEffect } from 'react';
import { ScrollView, StyleSheet, View, Text } from 'react-native';
import { LabelListItem } from '../../src/components/LabelListitem';
import { ListItem } from '@rneui/themed';

const LABEL_DATA = [
  { id: 1, name: 'プログラミング', color: 'blue' },
  { id: 2, name: 'パスワード', color: 'green' },
  { id: 3, name: '料理', color: 'orange' }
];

export default function HomeScreen() {
  const navigation = useNavigation();

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <MaterialIcons name="new-label" size={24} color="black" onPress={handleAddLabelPress} />;
      }
    });
  }, []);

  const handleAllMemoPress = () => {
    router.push({ pathname: '/memos' });
  };

  const handleLabelPress = (labelId: number) => {
    const params = { labelId: labelId };
    router.push({ pathname: '/memos', params: params });
  };

  const handleAddLabelPress = () => {
    router.push({ pathname: '/labels/create' });
  };

  const handleEditLabelPress = (labelId: number) => {
    router.push({ pathname: `/labels/${labelId}` });
  };

  return (
    <View style={styles.container}>
      <ScrollView contentContainerStyle={{ paddingVertical: 40 }}>
        <ListItem bottomDivider onPress={handleAllMemoPress}>
          <ListItem.Content>
            <ListItem.Title>全てのメモ</ListItem.Title>
          </ListItem.Content>
          <ListItem.Chevron />
        </ListItem>

        <Text style={styles.sectionText}>ラベル</Text>

        {LABEL_DATA.map(item => (
          <LabelListItem
            key={item.id}
            color={item.color}
            name={item.name}
            onPress={() => handleLabelPress(item.id)}
            onEditPress={() => handleEditLabelPress(item.id)}
          />
        ))}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'EFEFF4'
  },
  sectionText: {
    marginTop: 30,
    marginBottom: 10,
    marginLeft: 14,
    fontSize: 14,
    color: '707070'
  }
});

3.src/components/MemoListitem.tsxを作成する

src/components/MemoListitem.tsx
import { ListItem, Button } from '@rneui/themed';
import { StyleSheet } from 'react-native';
import { LabelTag } from './LabelTag';

type MemoListItemProps = {
  name: string;
  content: string;
  onPress: () => void;
  onLongPress?: () => void;
  onDeletePress?: () => void;
  label?: { color: string; name: string };
};

const MemoListItem: React.FC<MemoListItemProps> = props => {
  const { content, name, onPress, onLongPress, onDeletePress, label } = props;

  return (
    <ListItem.Swipeable
      onPress={onPress}
      onLongPress={() => onLongPress?.()}
      rightContent={reset => (
        <Button
          title="削除"
          onPress={() => {
            if (onDeletePress) {
              onDeletePress();
            }
            reset();
          }}
          icon={{ name: 'delete', color: 'white' }}
          buttonStyle={{ minHeight: '100%', backgroundColor: 'red' }}
        />
      )}
    >
      <ListItem.Content>
        <ListItem.Title style={styles.title}>{name}</ListItem.Title>
        <ListItem.Subtitle style={styles.subTitle} numberOfLines={4}>
          {content}
        </ListItem.Subtitle>
        {label ? <LabelTag color={label.color} name={label.name} /> : <></>}
      </ListItem.Content>
      <ListItem.Chevron />
    </ListItem.Swipeable>
  );
};
const styles = StyleSheet.create({
  title: {
    color: '#4A5054',
    fontWeight: 'bold',
    fontSize: 20
  },
  subTitle: {
    color: '#95A2AC',
    fontSize: 15,
    padding: 4,
    maxHeight: 100
  }
});

export { MemoListItem };

4.app/memos/index.tsxを修正する(メモ一覧画面)

app/memos/index.tsx
//メモ修正画面
import { Feather } from '@expo/vector-icons';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useEffect } from 'react';
import { Button, StyleSheet, Text, View, FlatList } from 'react-native';
import { MemoListItem } from '../../src/components/MemoListitem';
import { LabelTag } from '../../src/components/LabelTag';

const MEMO_DATA = [
  { id: 'ABCD', name: 'useStateについて', content: 'blue', label: { color: 'blue', name: 'プログラミング' } },
  { id: 'EFGH', name: 'アカウント', content: 'メールアドレス' },
  { id: 'IJKL', name: 'オムライス', content: '' }
];

// アプリ起動時の画面
export default function MemoListScreen() {
  const navigation = useNavigation();
  const { labelId } = useLocalSearchParams();

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Feather name="edit" size={24} color="black" onPress={handleCreatePress} />;
      }
    });
  }, []);

  const handleCreatePress = () => {
    router.push({ pathname: '/memos/create' });
  };

  const handleMemoPress = (memoId: String) => {
    router.push({ pathname: `/memos/${memoId}` });
  };

  const handleMemoLongPress = (memoId: String) => {
    console.log('メモが長押しされました', memoId);
  };

  const handleMemoDeletePress = (memoId: String) => {
    console.log('メモが削除されました', memoId);
  };

  return (
    <View style={styles.container}>
      <FlatList
        ListHeaderComponent={
          labelId ? (
            <View style={{ margin: 10 }}>
              <LabelTag color="blue" name={`ラベルID: ${labelId}`} />
            </View>
          ) : (
            <></>
          )
        }
        contentContainerStyle={{ paddingBottom: 100 }}
        data={MEMO_DATA}
        renderItem={({ item }) => (
          <MemoListItem
            name={item.name}
            content={item.content}
            onPress={() => handleMemoPress(item.id)}
            onLongPress={() => handleMemoLongPress(item.id)}
            onDeletePress={() => handleMemoDeletePress(item.id)}
            label={item.label}
          />
        )}
        keyExtractor={item => item.id}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

5.src/components/LabelTag.tsxを作成する

src/components/LabelTag.tsx
import { Text, View } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';

type LabelTagProps = {
  color: string;
  name: string;
};

const LabelTag: React.FC<LabelTagProps> = ({ color, name }) => {
  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', marginVertical: 2 }}>
      <MaterialCommunityIcons name="label" size={24} color={color} />
      <Text style={{ marginLeft: 5 }}>{name}</Text>
    </View>
  );
};

export { LabelTag };

gluestack-ui v1

必要なパッケージをインストールする

npm install @gluestack-ui/themed@1.1.62 @gluestack-style/react@1.0.57 react-native-svg@13.4.0 --save-exact --legacy-peer-deps

設定ファイルをインストールする

npm install @gluestack-ui/config@1.1.20 --save-exact --legacy-peer-deps

app/_layout.tsx ファイルにプロバイダーを追加する

app/_layout.tsx
import { config } from '@gluestack-ui/config';
import { GluestackUIProvider } from '@gluestack-ui/themed';

export default function Layout() {
  return (
    <GluestackUIProvider config={config}>
      <Stack>
        ・・・(省略)・・・
      </Stack>
    </GluestackUIProvider>
  );
}

メモ入力フォームの作成

1.src/components/MemoInputForm.tsxを作成する

src/components/MemoInputForm.tsx
import { Input, InputField, Textarea, TextareaInput } from '@gluestack-ui/themed';
import { View } from 'react-native';
import { Button, InputAccessoryView, Keyboard, Platform } from 'react-native';

type MemoInputFormProps = {
  title: string;
  content: string;
  onTitleChange: (text: string) => void;
  onContentChange: (text: string) => void;
};

const inputAccessoryViewID = 'INPUT_ACCESSORY_VIEW_ID';

const MemoInputForm: React.FC<MemoInputFormProps> = props => {
  const { content, title, onTitleChange, onContentChange } = props;

  return (
    <View style={{ flex: 1, paddingBottom: 100 }}>
      <Textarea borderWidth={0} minWidth={'$full'} minHeight={'$full'}>
        <Input borderWidth={0} minWidth={'$full'} marginTop={'$4'} marginBottom={'$1'} paddingHorizontal={'$1'}>
          <InputField defaultValue={title} onChangeText={onTitleChange} fontSize={'$2xl'} fontWeight={'$bold'} placeholder="タイトル" />
        </Input>
        <TextareaInput
          scrollEnabled={true}
          paddingHorizontal={'$5'}
          defaultValue={content}
          onChangeText={onContentChange}
          fontSize={'$md'}
          placeholder="メモを入力してください"
          inputAccessoryViewID={inputAccessoryViewID}
        />
      </Textarea>
      {Platform.OS === 'ios' && (
        <InputAccessoryView backgroundColor={'#F1F1F1'}>
          <View style={{ alignItems: 'flex-end' }}>
            <Button title="閉じる" onPress={() => Keyboard.dismiss()} />
          </View>
        </InputAccessoryView>
      )}
    </View>
  );
};

export { MemoInputForm };

2.app/memos/create.tsxを修正する(メモ作成画面)

app/memos/create.tsx
// メモ作成画面
import { router, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { MemoInputForm } from '../../src/components/MemoInputForm';
import { KeyboardAvoidingView } from '@gluestack-ui/themed';

export default function MemoCreateScreen() {
  const navigation = useNavigation();

  const [title, setTitle] = useState<string>('');
  const [content, setContent] = useState<string>('');

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Button title="作成" onPress={handleCreatePress} />;
      }
    });
  }, []);

  const handleCreatePress = () => {
    router.back();
  };
  return (
    <KeyboardAvoidingView style={styles.container} behavior="padding" keyboardVerticalOffset={100}>
      <MemoInputForm title={title} content={content} onTitleChange={setTitle} onContentChange={setContent} />
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ffffff'
  }
});

3.app/memos/[id].tsxを修正する(メモ修正画面)

app/memos/[id].tsx
//メモ修正画面
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { MemoInputForm } from '../../src/components/MemoInputForm';
import { KeyboardAvoidingView } from '@gluestack-ui/themed';

export default function MemoEditScreen() {
  const navigation = useNavigation();
  const { id } = useLocalSearchParams();

  const [title, setTitle] = useState<string>('');
  const [content, setContent] = useState<string>('');

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Button title="保存" onPress={handleSavePress} />;
      }
    });
  }, []);

  const handleSavePress = () => {
    router.back();
  };
  return (
    <KeyboardAvoidingView style={styles.container} behavior="padding" keyboardVerticalOffset={100}>
      <MemoInputForm title={title} content={content} onTitleChange={setTitle} onContentChange={setContent} />
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ffffff'
  }
});

ラベル設定モーダルの作成

1.src/components/LabelListModal.tsxを作成する

src/components/LabelListModal.tsx
import { Modal, ModalBackdrop, ModalContent, ModalHeader, ModalBody, Heading, ModalCloseButton, Icon, CloseIcon } from '@gluestack-ui/themed';
import { TouchableOpacity, Text } from 'react-native';
import { LabelTag } from './LabelTag';
import { MaterialCommunityIcons } from '@expo/vector-icons';

type LabelListModalProps = {
  visible: boolean;
  title: string;
  data: { id: number; name: string; color: string }[];
  onPress: (labelId?: number) => void;
  onClose: () => void;
};

const LabelListModal: React.FC<LabelListModalProps> = props => {
  const { visible, title, data, onPress, onClose } = props;
  return (
    <Modal isOpen={visible} onClose={onClose}>
      <ModalBackdrop />
      <ModalContent width={'85%'} backgroundColor="#ffffff">
        <ModalHeader>
          <Heading size="lg">{title}</Heading>
          <ModalCloseButton>
            <Icon size="lg" as={CloseIcon} />
          </ModalCloseButton>
        </ModalHeader>
        <ModalBody>
          <TouchableOpacity style={{ flexDirection: 'row', alignItems: 'center', marginVertical: 5 }} onPress={() => onPress(undefined)}>
            <MaterialCommunityIcons name="label" size={26} color={'gray'} />
            <Text style={{ marginLeft: 5 }}>ラベル削除</Text>
          </TouchableOpacity>
          {data.map(label => (
            <TouchableOpacity key={label.id} style={{ marginVertical: 2 }} onPress={() => onPress(label.id)}>
              <LabelTag color={label.color} name={label.name} />
            </TouchableOpacity>
          ))}
        </ModalBody>
      </ModalContent>
    </Modal>
  );
};
export { LabelListModal };

2.app/memos/index.tsxを修正する(メモ一覧画面)

app/memos/index.tsx
//メモ修正画面
import { Feather } from '@expo/vector-icons';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { LabelTag } from '../../src/components/LabelTag';
import { MemoListItem } from '../../src/components/MemoListitem';
import { LabelListModal } from '../../src/components/LabelListModal';

const MEMO_DATA = [
  { id: 'ABCD', name: 'useStateについて', content: 'blue', label: { color: 'blue', name: 'プログラミング' } },
  { id: 'EFGH', name: 'アカウント', content: 'メールアドレス' },
  { id: 'IJKL', name: 'オムライス', content: '' }
];

const LABEL_DATA = [
  { id: 1, name: 'プログラミング', color: 'blue' },
  { id: 2, name: 'パスワード', color: 'green' },
  { id: 3, name: '料理', color: 'orange' }
];

// アプリ起動時の画面
export default function MemoListScreen() {
  const navigation = useNavigation();
  const { labelId } = useLocalSearchParams();

  const [isLabelListModalVisible, setIsLabelListModalVisible] = useState(false);

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Feather name="edit" size={24} color="black" onPress={handleCreatePress} />;
      }
    });
  }, []);

  const handleCreatePress = () => {
    router.push({ pathname: '/memos/create' });
  };

  const handleMemoPress = (memoId: String) => {
    router.push({ pathname: `/memos/${memoId}` });
  };

  const handleMemoLongPress = (memoId: String) => {
    console.log('メモが長押しされました', memoId);
    setIsLabelListModalVisible(true);
  };

  const handleMemoDeletePress = (memoId: String) => {
    console.log('メモが削除されました', memoId);
  };

  const handleLabelPress = (labelId?: number) => {
    console.log('ラベルが選択されました', labelId);
    setIsLabelListModalVisible(false);
  };

  const handleLabelListModalClose = () => {
    setIsLabelListModalVisible(false);
  };

  return (
    <View style={styles.container}>
      <FlatList
        ListHeaderComponent={
          labelId ? (
            <View style={{ margin: 10 }}>
              <LabelTag color="blue" name={`ラベルID: ${labelId}`} />
            </View>
          ) : (
            <></>
          )
        }
        contentContainerStyle={{ paddingBottom: 100 }}
        data={MEMO_DATA}
        renderItem={({ item }) => (
          <MemoListItem
            name={item.name}
            content={item.content}
            onPress={() => handleMemoPress(item.id)}
            onLongPress={() => handleMemoLongPress(item.id)}
            onDeletePress={() => handleMemoDeletePress(item.id)}
            label={item.label}
          />
        )}
        keyExtractor={item => item.id}
      />
      <LabelListModal
        visible={isLabelListModalVisible}
        title="ラベル設定"
        data={LABEL_DATA}
        onPress={handleLabelPress}
        onClose={handleLabelListModalClose}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

ラベル名入力フィールドの作成

1.app/labels/create.tsxを修正する(ラベル作成画面)

app/labels/create.tsx
//ラベル画面作成
import { router } from 'expo-router';
import { StyleSheet, Text, View, Button } from 'react-native';
import { Input, InputField } from '@gluestack-ui/themed';
import { useState } from 'react';

export default function LabelCreateScreen() {
  const [labelName, setLabelName] = useState<String>('');

  const handleCreatePress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <Input variant="underlined" size="md" backgroundColor="$white" borderBlockColor="$warmGray400">
        <InputField padding={'$2'} placeholder="ラベル名" onChangeText={setLabelName} />
      </Input>
      <Button title="作成" onPress={handleCreatePress} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

2.app/labels/[id].tsxを修正する(ラベル修正画面)

app/labels/[id].tsx
//ラベル修正画面
import { router, useLocalSearchParams } from 'expo-router';
import { Button, StyleSheet, Text, View } from 'react-native';
import { Input, InputField } from '@gluestack-ui/themed';
import { useState } from 'react';

export default function LabelEditScreen() {
  const { id } = useLocalSearchParams();

  const [labelName, setLabelName] = useState<String>('');

  const handleEditPress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <Input variant="underlined" size="md" backgroundColor="$white" borderBlockColor="$warmGray400">
        <InputField padding={'$2'} placeholder="ラベル名" onChangeText={setLabelName} />
      </Input>
      <Button title="修正" onPress={handleEditPress} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

ラベルカラーピッカーの作成

1.src/components/ColorPicker.tsxを作成する

src/components/ColorPicker.tsx
import React from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';

/**
 * カラーピッカーのプロパティ
 */
type ColorPickerProps = {
  onPress: (color: string) => void;
  defaultColor?: string; // デフォルト選択するカラーコード
};

// 色定義
const COLOR_CONFIG = [
  { id: 0, code: '#EAEDED' },
  { id: 1, code: '#AEB6BF' },
  { id: 2, code: '#273746' },
  { id: 3, code: '#F1948A' },
  { id: 4, code: '#B03A2E' },
  { id: 5, code: '#C39BD3' },
  { id: 6, code: '#8E44AD' },
  { id: 7, code: '#7FB3D5' },
  { id: 8, code: '#2E86C1' },
  { id: 9, code: '#2471A3' },
  { id: 10, code: '#76D7C4' },
  { id: 11, code: '#2ECC71' },
  { id: 12, code: '#117A65' },
  { id: 13, code: '#F8C471' },
  { id: 14, code: '#E67E22' },
  { id: 15, code: '#9A7D0A' }
];

const ColorPicker: React.FC<ColorPickerProps> = props => {
  const { onPress, defaultColor } = props;

  const [colorId, setColorId] = React.useState<number | undefined>(); // 選択された色のID

  React.useEffect(() => {
    //  defaultColor が指定されている場合は該当する色を選択する
    if (defaultColor) {
      const defaultColorConfig = COLOR_CONFIG.find(color => color.code === defaultColor);
      if (defaultColorConfig) {
        setColorId(defaultColorConfig.id);
        onPress(defaultColorConfig.code);
      }
    }
  }, [defaultColor, onPress]);

  /**
   * カラーボックスを押した時の処理
   * @param id カラーID
   */
  const handleColorBoxPress = (id: number) => {
    const color = COLOR_CONFIG[id].code;

    setColorId(id);
    onPress(color);
  };

  return (
    <View style={styles.container}>
      {COLOR_CONFIG.map((color, index) => (
        <TouchableOpacity onPress={() => handleColorBoxPress(index)} activeOpacity={1} key={index}>
          <View style={[styles.colorBox, colorId === color.id ? styles.selectedColorBox : {}, { backgroundColor: color.code }]}></View>
        </TouchableOpacity>
      ))}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    justifyContent: 'center',
    alignContent: 'flex-start',
    flexWrap: 'wrap'
  },
  colorBox: {
    width: 32,
    height: 32,
    margin: 6,
    alignItems: 'center',
    justifyContent: 'center',
    borderWidth: 1,
    borderColor: 'black'
  },
  selectedColorBox: {
    borderWidth: 4,
    borderColor: 'red'
  }
});

export { ColorPicker };

2.app/labels/create.tsxを修正する(ラベル作成画面)

app/labels/create.tsx
//ラベル画面作成
import { Input, InputField, VStack } from '@gluestack-ui/themed';
import { router } from 'expo-router';
import { useState } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { ColorPicker } from '../../src/components/ColorPicker';

export default function LabelCreateScreen() {
  const [labelName, setLabelName] = useState<String>('');
  const [color, setColor] = useState<String | undefined>(undefined);

  const handleColorPress = (color: string) => {
    setColor(color);
  };

  const handleCreatePress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <VStack space="lg">
        <Input variant="underlined" size="md" backgroundColor="$white" borderBlockColor="$warmGray400">
          <InputField padding={'$2'} placeholder="ラベル名" onChangeText={setLabelName} />
        </Input>
        <ColorPicker onPress={handleColorPress} />
        <Button title="作成" onPress={handleCreatePress} />
      </VStack>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

3.app/labels/[id].tsxを修正する(ラベル修正画面)

app/labels/[id].tsx
//ラベル修正画面
import { Input, InputField, VStack } from '@gluestack-ui/themed';
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { ColorPicker } from '../../src/components/ColorPicker';

export default function LabelEditScreen() {
  const { id } = useLocalSearchParams();

  const [labelName, setLabelName] = useState<String>('');
  const [color, setColor] = useState<String | undefined>(undefined);

  const handleColorPress = (color: string) => {
    setColor(color);
  };

  const handleEditPress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <VStack space="lg">
        <Input variant="underlined" size="md" backgroundColor="$white" borderBlockColor="$warmGray400">
          <InputField padding={'$2'} placeholder="ラベル名" onChangeText={setLabelName} />
        </Input>
        <ColorPicker onPress={handleColorPress} />
        <Button title="修正" onPress={handleEditPress} />
      </VStack>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

ラベルアクションボタンの作成

1.app/labels/create.tsxを修正する(ラベル作成画面)

app/labels/create.tsx
//ラベル画面作成
import { Input, InputField, VStack } from '@gluestack-ui/themed';
import { router } from 'expo-router';
import { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { ColorPicker } from '../../src/components/ColorPicker';
import { Button, ButtonText } from '@gluestack-ui/themed';

export default function LabelCreateScreen() {
  const [labelName, setLabelName] = useState<String>('');
  const [color, setColor] = useState<String | undefined>(undefined);

  const handleColorPress = (color: string) => {
    setColor(color);
  };

  const handleCreatePress = () => {
    router.dismiss();
  };
  return (
    <View style={styles.container}>
      <VStack space="lg">
        <Input variant="underlined" size="md" backgroundColor="$white" borderBlockColor="$warmGray400">
          <InputField padding={'$2'} placeholder="ラベル名" onChangeText={setLabelName} />
        </Input>
        <ColorPicker onPress={handleColorPress} />

        <Button size="md" action="primary" marginHorizontal={'$4'} onPress={handleCreatePress}>
          <ButtonText>作成</ButtonText>
        </Button>
      </VStack>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

2.app/labels/[id].tsxを修正する(ラベル修正画面)

app/labels/[id].tsx
//ラベル修正画面
import { Input, InputField, VStack } from '@gluestack-ui/themed';
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { ColorPicker } from '../../src/components/ColorPicker';
import { Button, ButtonText } from '@gluestack-ui/themed';

export default function LabelEditScreen() {
  const { id } = useLocalSearchParams();

  const [labelName, setLabelName] = useState<String>('');
  const [color, setColor] = useState<String | undefined>(undefined);

  const handleColorPress = (color: string) => {
    setColor(color);
  };

  const handleEditPress = () => {
    router.dismiss();
  };

  const handleDeletePress = () => {
    router.dismiss();
  };

  return (
    <View style={styles.container}>
      <VStack space="lg">
        <Input variant="underlined" size="md" backgroundColor="$white" borderBlockColor="$warmGray400">
          <InputField padding={'$2'} placeholder="ラベル名" onChangeText={setLabelName} />
        </Input>
        <ColorPicker onPress={handleColorPress} />

        <VStack space="md">
          <Button size="md" action="primary" marginHorizontal={'$4'} onPress={handleEditPress}>
            <ButtonText>修正</ButtonText>
          </Button>

          <Button size="md" action="negative" marginHorizontal={'$4'} onPress={handleDeletePress}>
            <ButtonText>削除</ButtonText>
          </Button>
        </VStack>
      </VStack>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

インジケーターの作成

1.src/componemts/Indicator.tsxを作成する

src/componemts/Indicator.tsx
import React from 'react';
import { ActivityIndicator, Modal, StyleSheet, Text, View } from 'react-native';

/**
 * インジケーターのプロパティ
 */
type IndicatorProps = {
  visible: boolean; // インジケーターの表示状態
  text?: string; // テキスト
};

/**
 * インジケーター
 * @param props プロパティ
 * @returns インジケーター
 */
const Indicator: React.FC<IndicatorProps> = props => {
  const { visible, text } = props;

  return (
    <Modal
      visible={visible}
      transparent={true}
      animationType="none"
      onRequestClose={() => {}} // Android の戻るボタンで閉じないようにする
    >
      <View style={styles.overlay}>
        <View style={styles.container}>
          <ActivityIndicator style={styles.indicator} size="large" />
          {text && <Text style={styles.text}>{text}</Text>}
        </View>
      </View>
    </Modal>
  );
};

const styles = StyleSheet.create({
  overlay: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
    // backgroundColor: 'rgba(0, 0, 0, 0.3)'
  },
  container: {
    alignItems: 'center',
    justifyContent: 'center'
  },
  indicator: {
    padding: 10
  },
  text: {
    fontSize: 17,
    textAlign: 'center',
    color: '#ffffff'
  }
});

export { Indicator };

Recoilのインストール

1.パッケージをインストールする

npm install recoil@0.7.7 --save-exact

2.app/_layout.tsx に RecoilRoot を追加する

app/_layout.tsx
import { RecoilRoot } from 'recoil';

export default function Layout() {
  return (
    <RecoilRoot>
      <GluestackUIProvider config={config}>
        <Stack>
          ・・・(省略)・・・
        </Stack>
      </GluestackUIProvider>
    </RecoilRoot>
  );
}

ラベル情報の保持

1.src/recoils/selectedLabelIdState.tsを作成する

src/recoils/selectedLabelIdState.ts
import { atom } from 'recoil';

const selectedLabelIdState = atom<number | undefined>({
  key: 'selectedLabelIdState',
  default: undefined
});

export { selectedLabelIdState };

2.app/home/index.tsxを修正する

app/home/index.tsx
//ホーム画面
import { MaterialIcons } from '@expo/vector-icons';
import { ListItem } from '@rneui/themed';
import { router, useNavigation } from 'expo-router';
import { useEffect } from 'react';
import { ScrollView, StyleSheet, View, Text } from 'react-native';
import { LabelListItem } from '../../src/components/LabelListitem';
import { useRecoilState } from 'recoil';
import { selectedLabelIdState } from '../../src/recoils/selectedLabelIdState';
import { LABEL_DATA } from '../../src/dummy_data/labelData';
export default function HomeScreen() {
  const navigation = useNavigation();

  const [selectedLabelId, setSelectedLabelId] = useRecoilState(selectedLabelIdState);

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <MaterialIcons name="new-label" size={24} color="black" onPress={handleAddLabelPress} />;
      }
    });
  }, []);

  const handleAllMemoPress = () => {
    setSelectedLabelId(undefined);
    router.push({ pathname: '/memos' });
  };

  const handleLabelPress = (labelId: number) => {
    setSelectedLabelId(labelId);
    router.push({ pathname: '/memos' });
  };

  const handleAddLabelPress = () => {
    router.push({ pathname: '/labels/create' });
  };

  const handleEditLabelPress = (labelId: number) => {
    router.push({ pathname: `/labels/${labelId}` });
  };

  return (
    <View style={styles.container}>
      <ScrollView contentContainerStyle={{ paddingVertical: 40 }}>
        <ListItem bottomDivider onPress={handleAllMemoPress}>
          <ListItem.Content>
            <ListItem.Title>全てのメモ</ListItem.Title>
          </ListItem.Content>
          <ListItem.Chevron />
        </ListItem>

        <Text style={styles.sectionText}>ラベル</Text>

        {LABEL_DATA.map(item => (
          <LabelListItem
            key={item.id}
            color={item.color}
            name={item.name}
            onPress={() => handleLabelPress(item.id)}
            onEditPress={() => handleEditLabelPress(item.id)}
          />
        ))}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'EFEFF4'
  },
  sectionText: {
    marginTop: 30,
    marginBottom: 10,
    marginLeft: 14,
    fontSize: 14,
    color: '707070'
  }
});

3.app/memos/create.tsxを修正する

app/memos/create.tsx
// メモ作成画面
import { KeyboardAvoidingView } from '@gluestack-ui/themed';
import { router, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { MemoInputForm } from '../../src/components/MemoInputForm';
import { useRecoilState } from 'recoil';
import { selectedLabelIdState } from '../../src/recoils/selectedLabelIdState';

export default function MemoCreateScreen() {
  const navigation = useNavigation();

  const [selectedLabelId, setSelectedLabelId] = useRecoilState(selectedLabelIdState);

  const [title, setTitle] = useState<string>('');
  const [content, setContent] = useState<string>('');

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Button title="作成" onPress={handleCreatePress} />;
      }
    });
  }, []);

  const handleCreatePress = () => {
    router.back();
  };
  return (
    <KeyboardAvoidingView style={styles.container} behavior="padding" keyboardVerticalOffset={100}>
      <MemoInputForm title={title} content={content} onTitleChange={setTitle} onContentChange={setContent} />
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ffffff'
  }
});

4.src/dummy_data/labelData.tsを作成する

src/dummy_data/labelData.ts
import { type Label } from '../types/label';

const LABEL_DATA: Label[] = [
  { id: 1, name: 'プログラミング', color: 'blue' },
  { id: 2, name: 'パスワード', color: 'green' },
  { id: 3, name: '料理', color: 'orange' }
];
export { LABEL_DATA };

5.src/dummy_data/memoData.tsを作成する

src/dummy_data/memoData.ts
import { type Memo } from '../types/memo';

const MEMO_DATA: Memo[] = [
  { id: 'ABCD', title: 'useStateについて', content: 'blue', labelId: 1 },
  { id: 'EFGH', title: 'アカウント', content: 'メールアドレス', labelId: 2 },
  { id: 'IJKL', title: 'オムライス', content: '', labelId: 3 }
];
export { MEMO_DATA };

6.src/types/label.tsを作成する

src/types/label.ts
type Label = {
  id: number;
  name: string;
  color: string;
};
export type { Label };

7.src/types/memo.tsを作成する

src/types/memo.ts
type Memo = {
  id: string;
  title: string;
  content: string;
  labelId: number | undefined;
};
export type { Memo };

8.app/memos/index.tsxを修正する(メモ一覧画面)

app/memos/index.tsx
//メモ修正画面
import { Feather } from '@expo/vector-icons';
import { router, useNavigation } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { LabelListModal } from '../../src/components/LabelListModal';
import { LabelTag } from '../../src/components/LabelTag';
import { MemoListItem } from '../../src/components/MemoListitem';
import { type Label } from '../../src/types/label';
import { type Memo } from '../../src/types/memo';

import { useRecoilValue } from 'recoil';
import { selectedLabelIdState } from '../../src/recoils/selectedLabelIdState';

import { LABEL_DATA } from '../../src/dummy_data/labelData';
import { MEMO_DATA } from '../../src/dummy_data/memoData';

// アプリ起動時の画面
export default function MemoListScreen() {
  const navigation = useNavigation();

  const selectedLabelId = useRecoilValue(selectedLabelIdState);
  const [labels, setLabels] = useState<Label[]>([]);
  const [memos, setMemos] = useState<Memo[]>([]);
  const selectedLabel = labels.find(label => label.id === selectedLabelId);

  const [isLabelListModalVisible, setIsLabelListModalVisible] = useState(false);

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Feather name="edit" size={24} color="black" onPress={handleCreatePress} />;
      }
    });
  }, []);

  useEffect(() => {
    const labels = LABEL_DATA;
    setLabels(labels);
    const filteredMemos = selectedLabelId ? MEMO_DATA.filter(memo => memo.labelId === selectedLabelId) : MEMO_DATA;
    setMemos(filteredMemos);
  }, []);

  const handleCreatePress = () => {
    router.push({ pathname: '/memos/create' });
  };

  const handleMemoPress = (memoId: String) => {
    router.push({ pathname: `/memos/${memoId}` });
  };

  const handleMemoLongPress = (memoId: String) => {
    console.log('メモが長押しされました', memoId);
    setIsLabelListModalVisible(true);
  };

  const handleMemoDeletePress = (memoId: String) => {
    console.log('メモが削除されました', memoId);
  };

  const handleLabelPress = (labelId?: number) => {
    console.log('ラベルが選択されました', labelId);
    setIsLabelListModalVisible(false);
  };

  const handleLabelListModalClose = () => {
    setIsLabelListModalVisible(false);
  };

  return (
    <View style={styles.container}>
      <FlatList
        ListHeaderComponent={
          selectedLabel ? (
            <View style={{ margin: 10 }}>
              <LabelTag color={selectedLabel.color} name={selectedLabel.name} />
            </View>
          ) : (
            <></>
          )
        }
        contentContainerStyle={{ paddingBottom: 100 }}
        data={memos}
        renderItem={({ item }) => (
          <MemoListItem
            name={item.title}
            content={item.content}
            onPress={() => handleMemoPress(item.id)}
            onLongPress={() => handleMemoLongPress(item.id)}
            onDeletePress={() => handleMemoDeletePress(item.id)}
            label={selectedLabelId ? undefined : labels?.find(label => label.id === item.labelId)}
          />
        )}
        keyExtractor={item => item.id}
      />
      <LabelListModal
        visible={isLabelListModalVisible}
        title="ラベル設定"
        data={labels}
        onPress={handleLabelPress}
        onClose={handleLabelListModalClose}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4'
  }
});

9.app/memos/[id].tsxを修正する

app/memos/[id].tsx
//メモ修正画面
import { KeyboardAvoidingView } from '@gluestack-ui/themed';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { Button, StyleSheet } from 'react-native';
import { MemoInputForm } from '../../src/components/MemoInputForm';

import { MEMO_DATA } from '../../src/dummy_data/memoData';

export default function MemoEditScreen() {
  const navigation = useNavigation();
  const { id } = useLocalSearchParams();

  const [title, setTitle] = useState<string>('');
  const [content, setContent] = useState<string>('');

  useEffect(() => {
    navigation.setOptions({
      headerRight: () => {
        return <Button title="保存" onPress={handleSavePress} />;
      }
    });
  }, []);

  useEffect(() => {
    const memo = MEMO_DATA.find(memo => memo.id === id);
    if (memo) {
      setTitle(memo.title);
      setContent(memo.content);
    }
  }, [id]);

  const handleSavePress = () => {
    router.back();
  };
  return (
    <KeyboardAvoidingView style={styles.container} behavior="padding" keyboardVerticalOffset={100}>
      <MemoInputForm title={title} content={content} onTitleChange={setTitle} onContentChange={setContent} />
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ffffff'
  }
});

データベースサービスの作成

ReactNativeで使用される代表的なローカルデータベース

データベース 特徴 保存領域 Expoで使用 
AsyncStorage Key,Valueで
データを扱う
端末ストレージ 可能
SQLite RDB,SQLを使用 SQLiteDB 可能
Realm NoSQL、
処理が高速
RealmDB 不可

1.Expo SQLiteパッケージをインストールします。

npx expo install expo-sqlite

2.Expo FileSystemパッケージをインストールします。

npx expo install expo-file-system

3.src/database/dbService.tsを作成する

src/database/dbService.ts
import * as SQLite from 'expo-sqlite';
import * as FileSystem from 'expo-file-system';

type SqlArg = {
  sql: string;
  params?: (string | number)[];
};

const DB_NAME = 'MemoApp.db';
const getDbFilePath = () => {
  const path = FileSystem.documentDirectory + 'SQLite' + '/' + DB_NAME;
  return path;
};

const excute = async (...sqlArg: SqlArg[]): Promise<void> => {
  const db = await SQLite.openDatabaseAsync(DB_NAME);

  await db.withExclusiveTransactionAsync(async () => {
    for (const arg of sqlArg) {
      const { sql, params } = arg;

      try {
        await db.runAsync(sql, ...(params || []));
      } catch (error) {
        console.error('SQLの実行に失敗しました', error);
        throw error;
      }
    }
  });
};

const fetch = async <T>(sqlArg: SqlArg): Promise<T[]> => {
  const db = await SQLite.openDatabaseAsync(DB_NAME);
  const { sql, params } = sqlArg;

  try {
    const allRows = await db.getAllAsync<T>(sql, ...(params || []));
    return allRows;
  } catch (error) {
    console.error('SQLの実行に失敗しました', error);
    throw error;
  }
};

export { excute, fetch, getDbFilePath };

## 起動時にテーブルを作成
1.src/database/schemas/labelSchemas.tsを作成する

src/database/schemas/labelSchemas.ts
type LabelSchema = {
  id: number;
  name: string;
  color: string;
  created_At: string;
  updated_At: string;
};
export type { LabelSchema };

2.src/database/schemas/memoSchemas.tsを作成する

src/database/schemas/memoSchemas.ts
type MemoSchema = {
  id: string;
  title: string;
  content: string | null;
  label_id: number | null;
  created_At: string;
  updated_At: string;
};
export type { MemoSchema };

3.src/database/queries/labelQueries.tsを作成する

src/database/queries/labelQueries.ts
const CreateTableLabels = `
CREATE TABLE IF NOT EXISTS labels
  (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    color TEXT NOT NULL,
    created_at TEXT DEFAULT(DATETIME('now','localtime')),
    updated_at TEXT DEFAULT(DATETIME('now','localtime'))
  );
`;

const LabelQueries = Object.freeze({
  CREATE_TABLE: CreateTableLabels
});

export { LabelQueries };

4.src/database/queries/memoQueries.tsを作成する

src/database/queries/memoQueries.ts
const CreateTableMemos = `
CREATE TABLE IF NOT EXISTS memos
  (
    id TEXT,
    label_id  INTEGER,
    title TEXT NOT NULL,
    content TEXT,
    created_at TEXT DEFAULT(DATETIME('now','localtime')),
    updated_at TEXT DEFAULT(DATETIME('now','localtime'))
    PRIMARY KEY(id),
    FOREIGN KEY(label_id) REFERENCES labels(id),
  );
`;

const MemolQueries = Object.freeze({
  CREATE_TABLE: CreateTableMemos
});

export { MemolQueries };

5.src/database/services/labelServices.tsを作成する

src/database/services/labelServices.ts
const CreateTableMemos = `
CREATE TABLE IF NOT EXISTS memos
  (
    id TEXT,
    label_id  INTEGER,
    title TEXT NOT NULL,
    content TEXT,
    created_at TEXT DEFAULT(DATETIME('now','localtime')),
    updated_at TEXT DEFAULT(DATETIME('now','localtime'))
    PRIMARY KEY(id),
    FOREIGN KEY(label_id) REFERENCES labels(id),
  );
`;

const MemolQueries = Object.freeze({
  CREATE_TABLE: CreateTableMemos
});

export { MemolQueries };

6.src/database/services/memoServices.tsを作成する

src/database/services/memoSrvices.ts
import { excute as execute } from '../database/dbService';
import { MemolQueries } from '../database/queries/memoQueries';

const createTable = async () => {
  await execute({ sql: MemolQueries.CREATE_TABLE });
};

export { createTable };

7.app/index.tsxを修正する

app/index.tsx
import { router } from 'expo-router';
import { useEffect } from 'react';
import { StyleSheet, Text, View, Alert } from 'react-native';
import * as LabelService from '../src/services/labelServices';
import * as MemoService from '../src/services/memoServices';

export default function InitialScreen() {
  useEffect(() => {
    initApp();
  }, []);

  const initApp = async () => {
    try {
      await LabelService.createTable();
      await MemoService.createTable();
      router.replace('/home');
    } catch (error) {
      console.log('アプリの起動に失敗しました', error);
      Alert.alert('エラー', 'アプリの起動に失敗しました', [{ text: '再起動', onPress: initApp }]);
    }
  };

  initApp();

  return (
    <View style={styles.container}>
      <Text style={styles.tittle}>アプリ起動中・・</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#EFEFF4',
    justifyContent: 'center'
  },
  tittle: {
    fontSize: 20,
    fontWeight: 'bold'
  }
});

参考サイト

【2025年 最新版】React Native & TypeScript でメモ帳アプリを開発しながら学ぶ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?