React Native × Notion API
はじめに
スマホアプリを作るにあたり、最近熱いと話題のReact Nativeを勉強するためにNotion APIを用いて、CRUD処理を行う簡単なメモリアプリを作成しました。
React Nativeは、元々パフォーマンス面で問題があると言われており、そこが技術選定のボトルネックになっていましたが、以下のアーキテクチャを一新したことにより、ネックとされていたパフォーマンスが改善されたそうです。
React Nativeがアツい理由は以下の記事によくまとめられておりましたので、こちらをご参照ください。
アプリの構成について
CRUD処理を行うメモアプリを作成する場合、以下のような設計/実装パターンがあると思われます。
-
スマホのみにデータを保持するアプリとして実装する
1つ目はサーバとの通信は行わずにクライアントだけで完結するアプリになります。
この場合、サーバ、DBの構築やバックエンドの実装が不要になりますが、データがその端末のみでしか参照できないというデメリットがあります。
特定の1つの端末からのみ、データを参照・操作するユースケースだけ想定されるアプリの場合はこの方法で十分ですが、複数端末からデータを参照・操作したいなどのユースケースが想定される場合はこの実装方法は適していません。 -
サーバと通信を行いバックエンド側でデータを保持するように実装する
2つ目はAPIサーバ、DBサーバの構築とバックエンドの機能を実装し、スマホアプリ(クライアント側)と通信を行いデータのやり取りを行うアプリになります。
この場合は、データをバックエンド側に保持するため、複数端末からデータを参照・操作したい場合や他システムと連携する場合(メモ内容をLINEに転送する、メモ内容をChatGPTに要約させる)などのユースケースが想定される場合に適しています。
デメリットとしては、サーバの構築、運用とバックエンドを実装するコストがかかるということが挙げられます。また、不特定多数のユーザが複数端末から操作するため、ユーザを識別する必要があり、認証機能の実装も必要になります。
近年ではクライアントで完結するアプリより、サーバサイドと通信を行うスマホアプリが多いと思われます。今後そういったアプリを作成するための勉強として、今回は以下の2のサーバと通信を行うメモアプリを作成します。
サーバと通信を行うスマホアプリを実現する方法について
サーバと通信を行うアプリケーションを実現する場合は、サーバサイドのインフラ面と実装面で以下のパターンが想定されます。
-
インフラを自身で構築し、バックエンドの機能(API)を実装する
API用のサーバ、DBサーバ、ネットワーク、ロードバランサー等をオンプレ/クラウドどちらでもいいので構築し、その上に実装したバックエンドのアプリケーション(API)を動かすことで実現するものになります。
この場合はインフラ構築・運用、バックエンドの実装すべてにコスト(時間・労力・資金)がかかります。 -
FaaS、BaaS(MBaaS)などクラウドサービスを利用する
Function as a Servic(FaaS)はサーバレスで、特定の処理を関数(Function)単位で実行できるものになります。
有名なものでAWSのLambdaやGCPのCloud Functionsなどがあります。
AWSで構成する場合は、Amazon API GatewayとLambda、S3の構成で簡単なAPIを構築することができます。FaaSはサーバレスなので、サーバの運用保守を行う必要がないですし、バックエンドの実装面でも処理したい関数を書くだけになるので、容易に実装が可能になります。
Backend as a Servic(BaaS)はクラウド上で認証機能、CI/CD機能、バックエンド側のアプリ機能、データベース機能などバックエンド機能に関わるサービスの総称です。FaaSもBaaSの一部として含まれます。
有名なものでAWSのAmplifyやGCPのFirebaseなどがあります。BaaSを使用することでバックエンド側の開発、運用保守に関してはBaaSが全て担ってくれるのでフロントエンド側の開発に注力することができます。
上記のサービスを使用するデメリットとしてはクラウドサービスなので、お金がかかるという点と使用するクラウドサービスの知識が必要になることです。大抵のクラウドサービスに無料枠があるため勉強目的の使用程度であれば無料枠の範囲で収まる可能性が高いです。
-
既存の公開されているAPIを使用する
色々なサービスでAPIが公開されており、中には無料で使用することができるものもあります。
無料で使用できるものとして、郵便番号を調べることができる郵便番号検索APIやホットペッパーが提供しているレストラン検索APIなどがあります。
用途に合ったAPIを選定することで、クライアント側としてはAPIを呼び出すだけで実現したい機能を組み込むことができます。
デメリットとして、提供されている形でしか使用できないため、提供されているAPIを呼び出すだけでは求める機能を完全には実現できない可能性があります。
また、機能変更や機能削除、サービス停止等API側の仕様変更の影響を受けてしまう可能性があります。
ただ、APIを使用することで独自の機能にAPIを組み合わせたりすることで、一からすべて独自で実装するより開発コストが抑えることができるなどそういったケースでもAPIを使用することのメリットは大きいです。
今回は勉強のためにReact Nativeのコードを書くことに集中したく、インフラ構築やクラウドサービスの勉強、バックエンドの実装をしたくなかったので、既存の公開されているAPIを使用することにしました。
既存の公開されているAPIの中で、メモアプリのバックエンドの機能を満たすAPIを探したところ、NotionのAPIが適してした。
Notionはメモ、ドキュメント作成、プロジェクト管理など色々な用途で使用できるツールであり、Notion APIはNotionの各機能を他システムと連携するためにNotionが提供してくれているAPIになります。
そのため今回はReact Nativeでスマホアプリのクライアント側のみを実装し、バックエンドの処理はNotion APIに任せるようにしました。
作成したアプリについて
今回作成したのは、Notionのデータベースにデータを保存するメモアプリを作成しました。
こうすることでアプリで作成したメモをNotion上でも参照することができ、Notionで作成したメモをアプリ側で表示させることができます。
本アプリ
Notion(NotionDB)
機能としては、以下のものを実装しました。
- メモの新規作成
- メモ内容の作成・更新
- メモの削除
- 作成したメモの一覧表示
- メモの詳細を表示
画面としては以下の2画面を作成しました。
ホーム画面
この画面はアプリ起動時に初めに表示され、Notionデータベースに保存されているメモの全量を取得し、一覧表示しています。
メモを新規作成する場合は画面下部のテキストボックスからメモタイトルを入力することで作成することができます。また、メモタイトルの更新とメモの削除は各アイコンを選択することで行うことができます。
メモ編集画面
この画面はホーム画面から該当するメモを選択されることで遷移され、メモの詳細内容の表示及び内容の追加・更新・削除を行うことができます。
また、React Nativeのためandroid、ios両方のアプリを作成することができますが今回はandroidアプリとしてビルドすることを考えて実装しております。基本的に同じコードでandroid、ios両方にビルドすることができますがネイティブ部分やレイアウト部分で多少の差異がある可能性があります。
開発環境構築について
今回、react naviteの開発を行うにあたり、expoというReact Native アプリを開発やビルドするための便利機能を集めたプラットフォームを使用します。
本節ではスマホアプリ開発に必要なプロジェクト、ライブラリ等のセットアップを行います。
開発環境はWindows11にて行いますので、MacOS、Linuxで開発する方は自身の環境に合わせて、読みかえてください。
Volta(Node.js)
はじめにNodeのセットアップから始めます。
Node.jsのバージョン管理にはvoltaを使用します。
以下の公式サイトからOSに合わせて、voltaをインストールしてください。
今回は以下のgithubページからWindowsのインストーラをダウンロードし、voltaをインストールします。
cpuアーキテクチャに合わせてインストーラを選択してください。
私はx86_64のcpuを使用しているので、volta-2.0.1-windows-x86_64.msiをダウンロードします。
ダウンロードしたmsiファイルをダブルクリックし実行します。
以下の画面が出た場合は実行をクリックし、セットアップを進めます。
特に設定することもないので、Nextボタンをクリックし続け、最後にInstallボタンをクリックしインストールを開始します。
以下の画面が出てきたらセットアップ完了なので、Finishを選択し、セットアップを終了します。
コマンドプロンプト(Power Shell)を起動し、以下のコマンドを実行。バージョンが表示されればインストールおよびパスが通っていることが確認できます。
volta --version
node.jsをインストールします。nodeのバージョンを指定することができますが、今回はバージョンを指定せずにLTS版をインストールします。
今回はバージョンv20.18.0のnodeとv10.8.2のnpmがインストールされました。
# インストール
volta install node
# 確認
node -v
Node.js、npm のバージョンをプロジェクトで固定します。
volta pin node@20.18.0
volta pin npm@10.8.2
package.jsonに以下のものが追加されていれば固定することができています。
"volta": {
"node": "20.18.0",
"npm": "10.8.2"
}
Expoのセットアップ
はじめに以下のサイトからExpoのアカウントを作成します。
次に以下のコマンドでプロジェクトを作成します。【project-name】は任意のプロジェクト名に置き換えて実行してください。
--templateを付与することで、テンプレートを選択することができます。今回はblank-typescriptを選択しました。
npx create-expo-app@latest【project-name】--template
以下のコマンドで必要なモジュールをインストールします。
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar expo-linear-gradient lucide-react-native
expoではExpo Goという機能が提供されており、簡単に実機で開発したコードを動かすことができます。
Android、iphoneそれぞれアプリストアからExpoGoのアプリをインストールします。
以下のコマンドで試しにコードを実行して、スマホで表示させてみましょう。
# プロジェクトへ移動
cd 【project-name】
# 起動
yarn start
実行後、QRコードが表示されるので、ExpoGoアプリを起動させてスキャンさせることで、開発コードがビルドされて、スマホで操作可能になります。基本的にホットリロードが有効になっているので、起動中にコードの変更を検知すると自動的に変更がアプリに反映されます。
パスエイリアス
モジュールやコンポーネントをimportするときに、相対パスで記述するのではなくパスエイリアスにて記載したいときはtsconfig.json
のpathsの部分を変更します。
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@*": [
"./*"
],
"tailwind.config": [
"./tailwind.config.js"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.jsx"
]
}
Notion API準備
Notion APIを呼び出すための設定、API_KEYの取得及び使用するNotionデータベースの作成について説明します。
Notionのアカウント及びワークスペースは作成していることを前提に進めます。
未作成の場合は以下のページからアカウントを作成してください。Googleアカウントから簡単にアカウントを作成することができます。
Notion データベースの作成
APIから呼び出すデータベースを自身のワークスペースに作成します。
Notionの詳しい操作は公式サイトかNotionの使い方について記載されているWEB記事等を参照してください。
ここで以下のようにAPIから呼び出す項目をデータベースに作成してください。
色々、項目を作成しましたが、実装したアプリで使用しているのはtitleというページのタイトル名を保持している項目のみになります。
Notionのデータベースは各レコードをページとして保持しており、この項目はデータベースにデータを挿入する際に入力するものになります。(入力しなくてもページ自体は作成できる)
一般的なRDBのレコードがNotion Databaseではページ、カラムがブロックとして構成されているイメージです。
項目を作成する注意点としては、コードから項目を指定するために項目名をアルファベットで定義してください。
日本語用のNotionを使用している場合、項目を作成する時にデフォルトで日本語の項目が入ってしまいます。
Integrationの作成&API_KEYの取得
はじめに以下のページから自身のIntegrationを作成します。
IntegrationではAPIからどのNotionデータベースを呼び出すか、どのレベルの権限を付与するかなどを設定します。
新しいインテグレーションから新規作成します。
以下の画面で任意のインテグレーション名と関連ワークスペース、種類を選択し保存ボタンをクリックします。
関連ワークスペースではアクセスしたいデータベースがあるワークスペースを選択してください。
種類は内部(Internal)と外部(Public)があります。不特定多数のユーザが該当するデータベースを参照する必要がある場合は外部(Public)を選択する必要がありますが、今回は私のみしかアクセスすることがないので、内部(Internal)を選択します。
外部(Public)のインテグレーションを使ったアプリ開発について書かれている記事がありましたので、その用途で開発する場合は以下の記事をご覧ください。
以下の画面でNotionデータベースに対する権限を設定します。
以下の画像ようにコンテンツ&コメントの取得・更新・挿入の権限を付与するためにチェックボックスに✅を入れます。
設定後、保存ボタンをクリックし設定を保存します。
同画面上部にAPI_KEYが非表示でありますので、表示させてコピーしておきます。
Integrationとデータベースを接続
上記で作成したNotionデータベースとIntegrationを接続させて、APIを使用できる状態にします。
データベースを作成したワークスペースの右上の三点リーダから接続を選択します。
先ほど作成したIntegration名をテキストボックスに入力し、表示された該当のIntegrationを選択します。
アクティブな接続として該当のIntegrationが選択されていれば接続完了しています。
CLIからAPIを実際に呼び出してみます。
以下のコマンドを叩きます。<データベースID>と<API_KEY>は自身のものに置き換えてください。
curl -X POST "https://api.notion.com/v1/databases/<データベースID>/query" -H "Authorization: Bearer <API_KEY>" -H "Notion-Version: 2022-06-28" -H "Content-Type: application/json"
<データベースID>は該当のNotionデータベースを表示しているurlに以下のように記載されています。
WEBブラウザから確認してください。
https://www.notion.so/<データベースID>?v=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
この呼び出したAPIはデータベースの全レコードの情報を取得するものになります。
上記のコマンドでデータが正常に返却されれば成功です。
JS用SDKインストール
Notion APIはjavascriptから呼び出すためにAPIクライアントをSDKとして提供されています。
今回はそちらを使用するので、以下のコマンドでインストールします。
インストールが完了すれば準備完了です!
npx expo install @notionhq/client
アプリの実装について
ここではアプリの実装内容についての説明を記載します。
本アプリの全コードは以下のgithubリポジトリ上で公開しております。
以下、アプリのルーティング、UIについてと画面ごとの実装されている機能について記載します。
API呼び出し部分の処理に関してはカスタムフックとして、実装しております。
ルーティングについて
画面遷移のルーティングにはexpo-routerを使用します。
元々デファクトスタンダードとして使われていたReact Navigationを拡張して作成されたライブラリでファイルベースルーティングによるルーティングが可能です。
ファイルベースルーティングはディレクトリ構造がそのままパスとなり、ルーティングを実現します。
ディレクトリ構造
本アプリのディレクトリ構成は以下になります。appディレクトリをアプリのルートディレクトリとして作成し、ホーム画面のファイルを配置します。また、画面ごとにディレクトリを作成し、その中に各画面に関係するファイルを配置します。
/
│── app/
│ │── +not-found.tsx
│ │── index.tsx #ホーム画面
│ │── _layout.tsx
│ │
│ └─ edit/ #編集画面
│ └─[id].tsx
│── componets/ui #UIコンポーネント
│ └─ xxxx
│ └─index.tsx
│── hooks #カスタムフック
└─ xxxx.ts
起動時の画面をホーム画面に設定するために以下のようにpackage.json
を変更します。
{
"name": "notionMemo",
"license": "0BSD",
"version": "1.0.0",
+ "main": "expo-router/entry",
- "main": "expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "DARK_MODE=media expo start --android",
"ios": "DARK_MODE=media expo start --ios",
"web": "DARK_MODE=media expo start --web"
},
...
Stack Navigationを利用し_layout.tsx
にナビゲーションの設定を実装します。
Stackコンポーネントを利用することで、画面へ移動したり、移動した画面から戻ったりする処理を実現することができます。
Stackは以下のように画面遷移する際に前の画面をスタックしておき、戻る際は現在の画面を破棄し、スタックしておいていた画面を表示させます。
以下のように_layout.tsx
にStackコンポーネントを配置します。
今回はホーム画面と編集画面の2画面分設定します。デフォルトであればファイル名がヘッダータイトルとして表示されますが、optionsのtitleに任意のヘッダータイトルを設定することができます。
ここでは、ホーム画面は"Home"、編集画面は"Edit"とヘッダータイトルが表示されるように表示しています。
import React from "react";
import { Stack } from "expo-router";
import "@/global.css";
import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider";
import { LinearGradient } from "expo-linear-gradient"
export default function Layout() {
return (
<GluestackUIProvider>
<Stack
screenOptions={{
headerBackground:() => {
return (
<LinearGradient
className="w-full h-full"
colors={["#8637CF", "#0F55A1"]}
start={[0, 1]}
end={[1, 0]}
/>
);
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
<Stack.Screen
name="index"
options={{
title: 'Home',
}}
/>
<Stack.Screen
name="edit/[id]"
options={{
title: 'Edit',
}}
/>
</Stack>
</GluestackUIProvider>
);
}
本アプリではヘッダー部分の背景色に2色のグラデーションで表示しており、LinearGradientコンポーネントを使用することで2色のグラデーションを表現することができます。
また、後述しますがUIライブラリとして、gluestack-uiを使用しているため、GluestackUIProviderコンポーネントを_layout.tsx
に配置しています。
UIコンポーネントについて
本アプリはUIライブラリとして、gluestack-uiを使用してUI部分を実装しております。
以下のコマンドを実行することで、gluestack-uiを使うために必要な設定が行われます。
npx gluestack-ui init
gluestack-uiでは使用したいUIコンポーネントを適宜インストールすることで使用することができます。
Buttonコンポーネントをインストールしたい場合は以下のコマンドを実行することでインストールすることができ、インストール完了後はcomponents/ui配下にbuttonコンポーネントが生成されています。
npx gluestack-ui add button
ホーム画面
ここではホーム画面で実装されている機能について説明します。
ホーム画面は、index.tsx
にレイアウトを定義します。ホーム画面にはメモ一覧取得機能、メモ作成・削除機能、メモタイトル編集機能を実装します。
index.tsxの全量は以下のgithubページからご覧ください。
メモ一覧取得機能
メモは以下のようなNotionDBで保持しています。メモ一覧取得処理ではAPI経由でこの表(NotionDB)の全レコードを取得し、画面に表示するように実装します。
画面には取得した情報からメモ名(title)を表示します。
以下が実装部分になります。(一部省略)
カスタムフックであるuseNotionDBFetchを呼び出し、APIからNotionDBを取得して、そのデータをFlatListコンポーネントを使ってリスト表示しています。
API取得時にエラーが発生した場合は、エラー画面が出力され、データ取得中はローディング画面が表示されます。また、NotionDB上ではタイトルがないレコードを作成することが可能ですが、そのレコードは不正なデータとして画面表示しないようにしています。
// ソート条件
const sorts = useMemo(
() => [
{
property: "createdAt",
direction: "descending" as "descending" | "ascending",
},
],
[]
);
// Notion Databaseからデータを取得
const { data, loading, error, refetch } = useNotionDBFetch({sorts});
// エラーが発生した場合の表示
if (error || add_error || delete_error || update_error) {
const err = error || add_error;
return (
<View style={styles.center}>
<Text style={{ color: 'red' }}>Error: {err?.message}</Text>
</View>
);
}
// ローディング中の表示
if (loading || add_loading || delete_loading || update_loading) {
return(
<View style={styles.center}>
<ActivityIndicator size="large" color="#007BFF" />
</View>
)
}
// リストアイテムの描画
const renderItem = ({ item }: {item: PageObjectResponse} ) => {
const title = item.properties?.title.type === "title" ? item.properties.title.title[0]?.plain_text : "";
return (
<View>
<LinearGradient
style={styles.container}
colors={["#8637CF", "#0F55A1"]}
start={[1, 0]}
end={[0, 1]}>
<Link href={`/edit/${item.id}`}>
<Text style={styles.titleText} >{title}</Text>
</Link>
<View style={styles.row}>
<Ionicons
name="pencil"
size={24}
color="#fff"
style={styles.icon}
onPress={() => {
setShowUpdateModal(true)
setUpdateId(item.id)
}}
/>
<Ionicons
name="trash"
size={24}
color="#fff"
style={styles.icon}
onPress={() => {
setShowDLModal(true)
setDeleteId(item.id)
}}
/>
</View>
</LinearGradient>
</View>
);
};
// ページ一覧
const pages = (data?.results || []) as PageObjectResponse[];
// 空のページは除外
const filteredPages = pages.filter(
(item) =>
item.properties?.title.type === 'title' &&
item.properties.title.title[0]?.plain_text
);
return (
<View style={styles.screen}>
<FlatList
style={styles.list}
data={filteredPages} // データソース
renderItem={renderItem}// 各アイテムの描画
keyExtractor={(item) => item.id}
/>
// ※省略
</View>
);
useNotionDBFetchの実装は以下のgithubをご覧ください。
useNotionDBFetchはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
このアプリは自身の学習用で他者に公開、提供しないことを前提としています。そのためAPIキーをアプリに環境変数として組み込んでいます。
.env
にAPIキーとNotionDBのIDを環境変数として定義します。こうすることでビルド時に環境変数として保持してくれて、アプリケーションから呼び出すことができます。
EXPO_PUBLIC_NOTION_API_KEY=ntn_4340dsdxxxxxxxxxxxxxxxxxxxxxxxxxx
EXPO_PUBLIC_NOTION_DATABASE_ID=18edc211dsa4f8024b103ed4xxxxxxxxxx
今回実装したカスタムフックでは以下のように環境変数からAPIキーとNotionDBのIDを取得し、APIを呼び出す時に使用しています。
const databaseId = process.env.EXPO_PUBLIC_NOTION_DATABASE_ID;
const apiKey = process.env.EXPO_PUBLIC_NOTION_API_KEY;
メモ作成
本アプリではホーム画面下部のテキストエリアにメモのタイトルを入力することでメモをNotionDBに作成することができます。
①画面下部のテキストエリアを選択。
②追加するメモタイトルを入力して、✅をタップする。
③追加されて、画面に表示される
以下のようにNotion側でもメモが追加されていることが確認できます。
ここではカスタムフックuseNotionDBAddを使用して、テキストエリアから入力されたメモタイトルをAPI経由でNotionDBにtitle
として追加しています。NotionDBではtitleが追加されるとそれに合わせてページが自動作成されます。メモの詳細内容に関してはそのページを使用します。
以下が実装部分になります。(一部省略)
はじめにuseNotionDBAddからメモタイトルからページを作成する関数addPageを取得します。
入力エリアでテキストが入力されたら、メモタイトルを保持するステートに保持し、✅部分のエリアがタップされたらaddPageを発火させ、ステートで保持しているメモタイトルをAPIに送信しています。
// Notion Databaseにページを追加するカスタムフック
const { addPage, loading: add_loading, error: add_error } = useNotionDBAdd();
// ※省略
// 追加するページのタイトル
const [pageTitle, setPageTitle] = useState('');
// ※省略
return (
<View style={styles.screen}>
// ※省略
<Input
className="w-11/12 bg-white text-2xl"
variant="outline"
size="xl"
style={styles.input}>
<InputField
onChangeText={(text) => setPageTitle(text)}
placeholder="Enter Memo Title here..." type='text' className='mr-4'/>
<InputSlot className="pr-3"
onPress={() => {
if (pageTitle.trim() !== '') {
addPage(pageTitle)?.then(() => {
refetch();
setPageTitle('');
});
}
}}>
<InputIcon as={CheckIcon} className='text-sky-600'/>
</InputSlot>
</Input>
// ※省略
</View>
);
useNotionDBAddの実装は以下のgithubをご覧ください。
useNotionDBAddはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
削除機能
一覧表示されているメモのゴミ箱アイコンをタップするとそのメモをNotionDBから削除することができます。メモに詳細内容を追加している場合はその詳細内容もまとめて削除されます。
①削除したいメモのゴミ箱アイコンを選択。今回は一番上のメモを削除します。
②確認画面が表示されるので、Deleteボタンをタップする。
③削除されて、今保存されているメモが画面に表示される
以下のようにNotion側でもメモが削除されていることが確認できます。
以下が実装部分になります。
はじめにuseNotionDeleteからNotion Databaseからページを削除する処理を行うdeleteBlock関数を取得します。
// Notion Databaseからページを削除するカスタムフック
const { deleteBlock, loading: delete_loading, error: delete_error } = useNotionDelete();
削除機能では一覧表示されているメモのゴミ箱アイコンをタップすると削除用の確認画面(モーダル)が表示され、確認画面にてDeleteボタンをタップすると削除が行われる仕組みになっています。以下の実装では、その削除用の確認画面を表示するかどうかの状態をステートで保持し、アイコンがタップされると状態が変更され削除用の確認画面を表示されるようにしております。
また同時にどのメモを削除するかという情報としてIDもステートで保持するようにし、削除処理時にdeleteBlock関数に引数として渡します。
// 削除用モーダルの表示
const [showDLModal, setShowDLModal] = useState(false);
// 削除対象のID
const [deleteId, setDeleteId] = useState('');
// ※省略
// リストアイテムの描画
const renderItem = ({ item }: {item: PageObjectResponse} ) => {
const title = item.properties?.title.type === "title" ? item.properties.title.title[0]?.plain_text : "";
return (
<View>
<LinearGradient
style={styles.container}
colors={["#8637CF", "#0F55A1"]}
start={[1, 0]}
end={[0, 1]}>
// ※省略
<View style={styles.row}>
<Ionicons
name="pencil"
size={24}
color="#fff"
style={styles.icon}
onPress={() => {
setShowUpdateModal(true)
setUpdateId(item.id)
}}
/>
// ※省略
</View>
</LinearGradient>
</View>
);
};
削除用の確認画面はgluestack-uiライブラリのModalコンポーネントを用いて実装しています。
{/* 削除用モーダル */}
<Modal
isOpen={showDLModal}
onClose={() => {
setShowDLModal(false)
}}>
<ModalBackdrop />
<ModalContent className="max-w-[305px] items-center">
<ModalHeader>
<Box className="w-[56px] h-[56px] rounded-full bg-background-error items-center justify-center">
<Icon as={TrashIcon} className="stroke-error-600" size="xl" />
</Box>
</ModalHeader>
<ModalBody className="mt-0 mb-4">
<Heading size="md" className="text-typography-950 mb-2 text-center">
Delete memo
</Heading>
<Text style={{ fontSize: 14 }} className="text-typography-500 text-center">
Are you sure you want to delete this memo? This action cannot be
undone.
</Text>
</ModalBody>
<ModalFooter className="w-full">
<Button
variant="outline"
action="secondary"
size="sm"
onPress={() => {
setShowDLModal(false)
}}
className="flex-grow"
>
<ButtonText>Cancel</ButtonText>
</Button>
<Button
onPress={() => {
setShowDLModal(false);
deleteBlock(deleteId)?.then(() => {
refetch();
setDeleteId('');
});
}}
size="sm"
className="flex-grow">
<ButtonText>Delete</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
useNotionDeleteの実装は以下のgithubをご覧ください。
useNotionDeleteはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
メモタイトル編集機能
メモタイトル編集機能は作成したメモのタイトル名を変更することができます。
①タイトルを変更したいメモのペンアイコンを選択。今回は一番上のメモのタイトルを変更します。
②タイトル変更用モーダルが表示され、下部にあるテキストエリアに変更後のタイトル名を入力し、Submitボタンをタップ。
③メモのタイトルが変更されて、今保存されているメモが画面に表示される。
以下のようにNotion側でもメモのタイトルが変更されていることが確認できます。
以下が実装部分になります。(一部省略)
はじめにuseNotionPageTitleUpdateからNotion Databaseのページタイトルを更新するupdatePageTitle関数を取得します。
const { updatePageTitle, loading: update_loading, error: update_error } = useNotionPageTitleUpdate();
一覧表示されているメモのペンアイコンをタップするとタイトル変更用モーダルが表示され、タイトル変更用モーダルのテキストエリアで入力したテキストでメモタイトルを更新する仕組みになっています。以下の実装では、そのタイトル変更用モーダルを表示するかどうかの状態をステートで保持し、アイコンがタップされると状態が変更されタイトル変更用モーダルを表示されるようにしております。また同時にどのメモを更新するかという情報としてIDもステートで保持し、更新処理時にupdatePageTitle関数に引数として渡します。
// 更新するページのタイトル
const [updateTitle, setUpdateTitle] = useState('');
// 更新用モーダルの表示
const [showUpdateModal, setShowUpdateModal] = useState(false);
// 更新対象のID
const [updateId, setUpdateId] = useState('');
// ※省略
// リストアイテムの描画
const renderItem = ({ item }: {item: PageObjectResponse} ) => {
const title = item.properties?.title.type === "title" ? item.properties.title.title[0]?.plain_text : "";
return (
<View>
<LinearGradient
style={styles.container}
colors={["#8637CF", "#0F55A1"]}
start={[1, 0]}
end={[0, 1]}>
// ※省略
<View style={styles.row}>
<Ionicons
name="pencil"
size={24}
color="#fff"
style={styles.icon}
onPress={() => {
setShowUpdateModal(true)
setUpdateId(item.id)
}}
/>
// ※省略
</View>
</LinearGradient>
</View>
);
};
タイトル変更用モーダルはgluestack-uiライブラリのModalコンポーネントを用いて実装しています。
{/* 更新用モーダル */}
<Modal
isOpen={showUpdateModal}
onClose={() => {
setShowUpdateModal(false)
}}
>
<ModalBackdrop />
<ModalContent>
<ModalHeader className="flex-col items-start gap-0.5">
<Heading>Change the title?</Heading>
<Text style={{ fontSize: 14 }} className="text-typography-500 text-center">
Please enter the title to be changed
</Text>
</ModalHeader>
<ModalBody className="mb-4">
<Input>
<InputField
placeholder="Enter Text here..."
onChangeText={(text) => setUpdateTitle(text)}
/>
</Input>
</ModalBody>
<ModalFooter className="flex-col items-start">
<Button
onPress={() => {
updatePageTitle(updateId, updateTitle)?.then(() => {
refetch();
setUpdateTitle('');
setUpdateId('');
});
setShowUpdateModal(false)
}}
isDisabled={!updateTitle}
className="w-full"
>
<ButtonText>Submit</ButtonText>
</Button>
<Button
variant="link"
size="sm"
onPress={() => {
setShowUpdateModal(false)
}}
className="gap-1"
>
<ButtonIcon as={ArrowLeftIcon} />
<ButtonText>Back to home</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
useNotionPageTitleUpdateの実装は以下のgithubをご覧ください。
useNotionPageTitleUpdateはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
編集画面
ここでは編集画面で実装されている機能について説明します。
編集画面は、edit/[id].tsx
にレイアウトを定義します。編集画面にはメモ詳細表示機能、メモ内容編集機能を実装します。edit/[id].tsxのようにファイルを作成することで、画面ごとのパラメータ(id)を渡すことができ、特定のメモ詳細を表示することができます。
編集画面ではメモ内容をブロック単位で管理します。私はよくLINEでメモを取るのでチャット画面のようなUIにしました。
edit/[id].tsxの全量は以下のgithubページからご覧ください。
ホーム画面からは一覧表示しているアイテム部分に以下のようにLinkコンポーネントを入れることで各IDごとの編集画面へ画面遷移するように実装しています。
<Link href={`/edit/${item.id}`}>
<Text style={styles.titleText} >{title}</Text>
</Link>
メモ詳細表示機能
一覧表示されているメモをタップすると編集画面(詳細画面)へ遷移し、Notion Databaseからそのメモの詳細内容を表示します。
①詳細内容を表示したいメモをタップ。今回は一番上のメモを選択します。
②編集画面(詳細画面)へ遷移され、詳細内容が一覧表示される。
以下のようにNotion側でもメモの詳細内容を確認できます。
以下が実装部分になります。
メモ詳細表示機能ではメモタイトルを取得するuseNotionPageTitleFetchとメモ詳細内容を取得するuseNotionPageFetchの2つのカスタムフックを使用します。
はじめにuseLocalSearchParamsよりメモID(PageId)を取得します。編集画面(詳細画面)ではこのIDに紐づくデータを表示、更新等の処理を行います。
// URLパラメータからIDを取得
const { id } = useLocalSearchParams();
const PageId = Array.isArray(id) ? id[0] : id;
この画面では遷移時にuseNotionPageTitleFetchを呼び出し、APIからメモID(PageId)に紐づくタイトル名を取得して、画面ヘッダー部分に表示させるようにしています。
また、useNotionPageFetchを呼び出し、APIからメモID(PageId)に紐づくメモの詳細内容を取得して、そのデータをFlatListコンポーネントを使ってリスト表示しています。
API取得時にエラーが発生した場合は、エラー画面が出力され、データ取得中はローディング画面が表示されます。
// Notionページのタイトルを取得
const { data: pageData, loading: pageLoading, error: pageError } = useNotionPageTitleFetch(PageId);
// Notionページのコンテンツを取得
const { data, loading, error, refetch } = useNotionPageFetch({ blockId: PageId });
// ヘッダー部分のタイトル
const title = pageData?.properties?.title.type === "title" ? pageData?.properties.title.title[0]?.plain_text : "Edit";
// ※省略
// エラーが発生した場合の表示
if (error || add_error || pageError || delete_error || update_error) {
const err = error || add_error;
return (
<View style={styles.center}>
<Text style={{ color: 'red' }}>Error: {err?.message}</Text>
</View>
);
}
// ローディング中の表示
if (loading || add_loading || pageLoading || delete_loading || update_loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007BFF" />
</View>
);
}
const renderItem = ({ item }: {item: BlockObjectResponse} ) => {
const content = item.type === "paragraph" ? item.paragraph.rich_text[0]?.plain_text : "";
return (
<View>
<Box style={styles.box}>
<LinearGradient
style={styles.container}
colors={["#8637CF", "#0F55A1"]}
start={[1, 0]}
end={[0, 1]}
>
<Avatar size="md" className="bg-indigo-300 border-2 border-indigo-600 mr-2">
<Icon as={User} size="xl" className="text-indigo-900"/>
</Avatar>
<Text
className='flex-1'
style={styles.item}
onPress={() => {
setTargetId(item.id);
setShowUpdateModal(true);
setUpContets(content);
}}
>
{content}
</Text>
</LinearGradient>
</Box>
</View>
);
};
const content = (data?.results || []) as BlockObjectResponse[];
return (
<View style={styles.screen}>
{/* ヘッダー部分 */}
<Stack.Screen
name="edit/[id]"
options={{
title: title,
}}
/>
<FlatList
style={styles.list}
data={content} // データソース
renderItem={renderItem}// 各アイテムの描画
keyExtractor={(item) => item.id}
/>
// ※省略
</View>
);
useNotionPageTitleFetchとuseNotionPageFetchの実装は以下のgithubをご覧ください。
useNotionPageTitleFetchとuseNotionPageFetchはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
メモ内容追加機能
編集画面(詳細画面)下部にあるテキストエリアにメモ詳細内容を入力し、送信ボタンをタップすることで、Notion Databaseにメモ詳細内容を追加することができます。
①編集画面(詳細画面)下部にあるテキストエリアからメモ詳細内容を入力。
②入力後、送信ボタンをタップ。
③メモ詳細内容が追加されて、今保存されているメモ詳細内容が画面に表示される
以下のようにNotion側でもメモ詳細内容が追加されていることが確認できます。
以下が実装部分になります。
はじめにuseNotionContentAddからNotion Databaseへブロックを追加する処理を行うaddContent関数を取得します。また、メモ詳細内容をステートとして保持します。
画面下部にある入力エリアでテキストが入力されたら、メモ詳細内容を保持するステートに保持し、送信ボタンがタップされたらaddContentを発火させ、ステートで保持しているメモ詳細内容をAPIに送信しています。
// Notionページにコンテンツを追加
const { addContent, loading: add_loading, error: add_error } = useNotionContentAdd();
// コンテンツ内容
const [contents, setContents] = useState('');
// ※省略
return (
<View style={styles.screen}>
// ※省略
{/* テキストエリア */}
<HStack
space="sm" reversed={false}
className="w-11/12"
style={styles.input}>
<Textarea
className="w-11/12 h-auto bg-white"
size="xl"
>
<TextareaInput
onChangeText={(text) => setContents(text)}
placeholder="Enter Content here..." type='text' className=''/>
</Textarea>
<Center>
<Feather name="play" size={24} color="blue"
onPress={
() => {
if (contents.trim() !== '') {
addContent(PageId, contents)?.then(() => {
refetch();
setContents('');
});
}
}}
/>
</Center>
</HStack>
// ※省略
</View>
);
useNotionContentAddの実装は以下のgithubをご覧ください。
useNotionContentAddはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
メモ内容編集機能
一覧表示されているメモ詳細内容をタップするとそのメモ詳細内容を更新・削除することができます。
更新処理
①更新したいメモ詳細内容を選択。今回は一番上のメモ詳細内容を更新します。
②メモ内容編集モーダルが表示されるので、テキストエリアに更新後のメモ詳細内容を入力し、Updateボタンをタップ。
③更新されて、今保存されているメモ詳細内容が画面に表示される。
以下のようにNotion側でもメモ詳細内容が更新されていることが確認できます。
削除処理
①削除したいメモ詳細内容を選択。今回は一番上のメモ詳細内容を削除します。
②メモ内容編集モーダルが表示されるので、Deleteボタンをタップする。
③更に削除確認画面が表示されるので、Deleteボタンをタップする。
④削除されて、今保存されているメモ詳細内容が画面に表示される。
以下のようにNotion側でもメモ詳細内容が削除されていることが確認できます。
以下が実装部分になります。
メモ詳細内容の削除機能はuseNotionDeleteを使用し、更新処理にはuseNotionContentUpdateというカスタムフックを使用します。useNotionDeleteはホーム画面でメモを削除する際にも使用したものと一緒のカスタムフックを使用します。
はじめにuseNotionDeleteからNotion Databaseからブロックを削除する処理を行うdeleteBlock関数を、useNotionContentUpdateからNotion Databaseのブロックを更新する処理を行うupdateBlock関数取得します。
// Notionページのコンテンツを削除
const { deleteBlock, loading: delete_loading, error: delete_error } = useNotionDelete();
// Notionページのコンテンツを更新
const { updateBlock, loading: update_loading, error: update_error } = useNotionContentUpdate();
■更新処理について
一覧表示されているメモ詳細内容をタップするとメモ内容編集用モーダルが表示され、内容を更新する場合はメモ内容編集用モーダルのテキストエリアで入力したテキストでメモ詳細内容を更新する仕組みになっています。以下の実装では、そのメモ内容編集用モーダル(更新用モーダル)を表示するかどうかの状態をステートで保持し、アイテムがタップされると状態が変更されメモ内容編集用モーダルを表示されるようにしています。
更新処理時にステートで保持しているメモ内容編集用モーダルのテキストエリアで入力したテキストと
どのメモ詳細内容を更新するかという情報として対象IDをupdateBlock関数に引数として渡しています。
上記の二つのステートは、アイテムがタップされた際にそのアイテムの表示テキストとIDがステートに設定されます。
// 更新用コンテンツ内容
const [upcontents, setUpContets] = useState('');
// 更新用モーダルの表示
const [showUpdateModal, setShowUpdateModal] = useState(false);
// 更新&削除対象のID
const [targetId, setTargetId] = useState('');
// ※省略
const renderItem = ({ item }: {item: BlockObjectResponse} ) => {
// ※省略
return (
<View>
<Box style={styles.box}>
<LinearGradient
style={styles.container}
colors={["#8637CF", "#0F55A1"]}
start={[1, 0]}
end={[0, 1]}
>
// ※省略
<Text
className='flex-1'
style={styles.item}
onPress={() => {
setTargetId(item.id);
setShowUpdateModal(true);
setUpContets(content);
}}
>
{content}
</Text>
</LinearGradient>
</Box>
</View>
);
};
メモ内容編集用モーダルはgluestack-uiライブラリのModalコンポーネントを用いて実装しています。
Updateボタンをタップされるとイベントが発火し、updateBlock関数が実装されメモ詳細更新処理が行われます。
また、Deleteボタンがタップされるとイベントが発火し、setShowDLModalによって削除確認画面表示状態を保持するステートが変更され、削除確認画面が表示されます。
削除確認画面表示状態を保持するステートについての詳細は後述します。
{/* メインモーダル */}
<Modal
isOpen={showUpdateModal}
onClose={() => {
setShowUpdateModal(false)
}}
>
<ModalBackdrop />
<ModalContent className="max-w-[375px]">
<Button
variant="link"
size="sm"
onPress={() => {
setShowUpdateModal(false)
}}
className="gap-1 self-start"
>
<ButtonIcon as={ArrowLeftIcon} />
<ButtonText>Back</ButtonText>
</Button>
<Textarea
className="h-[185px] w-full rounded">
<TextareaInput
onChangeText={(text) => setUpContets(text)}
type='text' className='align-top'
defaultValue={upcontents}
/>
</Textarea>
<ModalBody className="mb-5" contentContainerClassName="">
<Heading size="md" className="text-typography-950 text-center">
Delete or update content!
</Heading>
<Text style={{ fontSize: 14 }} className="text-typography-500 text-center">
Click the bottom right button to update, or the bottom left button to delete.
</Text>
</ModalBody>
<ModalFooter className="w-full">
<Button
variant="outline"
action="primary"
size="sm"
onPress={() => {
setShowDLModal(true)
}}
className="flex-grow"
>
<ButtonText>Delete</ButtonText>
</Button>
<Button
onPress={() => {
updateBlock(targetId, upcontents)?.then(() => {
refetch();
setUpContets('');
setTargetId('');
});
setShowUpdateModal(false)
}}
size="sm"
className="flex-grow"
>
<ButtonText>Update</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
■削除処理について
メモ内容編集用モーダルにあるDeleteボタンがタップされると削除確認画面(削除用モーダル)が表示され、そこで更にDeleteボタンがタップされるとメモ詳細内容の削除処理が行われます。
以下の実装では、その削除確認画面を表示するかどうかの状態をステートで保持するようにしています。
また、どのメモ詳細内容を削除するかという情報として対象IDもステートで保持しており、
削除処理時にdeleteBlock関数に引数として渡しています。
// 削除用モーダルの表示
const [showDLModal, setShowDLModal] = useState(false);
// 更新&削除対象のID
const [targetId, setTargetId] = useState('');
削除確認画面はgluestack-uiライブラリのModalコンポーネントを用いて実装しています。
{/* 削除用モーダル */}
<Modal
isOpen={showDLModal}
onClose={() => {
setShowDLModal(false)
}}>
<ModalBackdrop />
<ModalContent className="max-w-[305px] items-center">
<ModalHeader>
<Box className="w-[56px] h-[56px] rounded-full bg-background-error items-center justify-center">
<Icon as={TrashIcon} className="stroke-error-600" size="xl" />
</Box>
</ModalHeader>
<ModalBody className="mt-0 mb-4">
<Heading size="md" className="text-typography-950 mb-2 text-center">
Delete memo
</Heading>
<Text style={{ fontSize: 14 }} className="text-typography-500 text-center">
Are you sure you want to delete this memo? This action cannot be
undone.
</Text>
</ModalBody>
<ModalFooter className="w-full">
<Button
variant="outline"
action="secondary"
size="sm"
onPress={() => {
setShowDLModal(false)
}}
className="flex-grow"
>
<ButtonText>Cancel</ButtonText>
</Button>
<Button
onPress={() => {
deleteBlock(targetId)?.then(() => {
refetch();
setTargetId('');
});
setShowDLModal(false);
setShowUpdateModal(false);
}}
size="sm"
className="flex-grow">
<ButtonText>Delete</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
useNotionContentUpdateの実装は以下のgithubをご覧ください。
useNotionContentUpdateはNotionAPIの公式ドキュメントに記載されているAPI呼び出しをhooksとして実装したものになります。
ビルドについて
expoを使って開発を行う場合はEAS Buildを使ってアプリケーションをビルドすることが多いと思われます。EAS Buildはローカルでもクラウドサービスでもビルドすることが可能ですが、ローカル環境を整えるのが面倒なので、今回はExpoが提供しているクラウドサービスを使用します。
ビルド準備
まずはCLIツールをインストールします。
ターミナルで以下のコマンドを実行してください
npm install -g eas-cli
次にexpoにログインします。
ターミナルで以下のコマンドを実行してください
eas login
以下のようにEmailまたは、Expoのアカウント名とパスワードを聞かれるので、入力し認証します。
アカウント名とパスワードは開発環境セットアップ時にExpoに登録したものを使用してください
Log in to EAS with email or username (exit and run eas login --help to see other login options)
√ Email or username xxxxxxxxxx
√ Password ... *******************************
Logged in
ビルドに必要な設定ファイルを生成します。以下のコマンドを実行してください
EASにプロジェクトを自動で作っていい?等いくつか質問がくるので、プロジェクトに合った形で回答してください
eas build:configure
上記の処理が正常に完了するとeas.jsonという設定ファイルが生成されます。
以下のように用途に応じたビルド設定ができます。
私は今回はプレビュー用(検証用)でビルドすることだけしか考えてなかったので、プレビュー用のビルド設定を変更しております。
変更内容としてはandroidの出力形式をapkに指定したのと、環境変数として保持していたNotionのAPIKey等の情報をビルド時に含むように設定しました。
また、内部配布可能にするための設定も一応いれていますが、今回の用途では配布する想定はないので不要だったかもしれません。
リリース用としてビルドしてもよかったのですが、アプリの署名などが必要で面倒だと思ったのと、今回の私の用途ではプレビュー用でビルドするので十分だったので、プレビュー用でビルドしました。
{
"cli": {
"version": ">= 14.5.0",
"appVersionSource": "remote"
},
// ビルド設定
"build": {
// 開発用のビルド設定
"development": {
"developmentClient": true,
"distribution": "internal"
},
// プレビュー用のビルド設定
"preview": {
// 内部配布可能
"distribution": "internal",
"android": {
"buildType": "apk"
},
"env": {
"EXPO_PUBLIC_NOTION_API_KEY": "自身のNotionのAPIKEYを記載",
"EXPO_PUBLIC_NOTION_DATABASE_ID": "自身のNotionDatabaseのIDを記載"
}
},
// リリース用のビルド設定
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
これで準備完了です!ただ何か問題がないか以下のコマンドを実行し確認しておきましょう
npx expo-doctor
ビルド&アプリ確認
それではビルドしましょう!
以下のコマンドを実行することでクラウド上でビルドが開始されます。
以下のコマンドはプラットフォームをアンドロイドでプレビュー用のビルド設定のみ適応してビルドするようにいます。--platform
オプションでビルドするプラットフォームを、--profile
で適用するビルド設定を指定できます。
eas build --platform android --profile preview
AndroidとiOSを両方ビルドしたい場合以下のコマンドを実行します。
eas build --platform all
ビルドが完了したらExpoの画面よりビルドが正常できているか確認して、成果物(apk)をインストールします。今回作成したアプリのproject配下のbuildsから各ビルド状況が参照できます。
終わりに
今回はとりあえず動くものを目指して、コードを書いたので、機能ごとにコードを分割したり、コンポーネント化することが出来なかったので今後の開発ではそこを意識して作成していきたいと思います。
参考
Discussion