Rork で生成したアプリを少し触っていくと、ある瞬間に「標準の Modal ではどうしても物足りない」場面が出てきます。地図の下からスッと現れる詳細パネル、商品一覧の上に重ねるフィルター、入力フォームを途中までだけ表示するシート — どれも単純な全画面モーダルでは表現できません。
私自身、自分のアプリに「2段階で開くシート」を入れたかったのですが、Rork が出してくれた Modal ベースのコードでは慣性スクロールが効かず、Android で背景が真っ黒に光る問題に半日悩みました。結論としては @gorhom/bottom-sheet を後から差し込むのが一番素直で、Rork の出力をほとんど壊さずに導入できます。ここではその手順とつまずきやすいポイントをまとめておきます。
なぜ Rork が出す Modal だけでは足りなくなるのか
Rork は React Native 標準の Modal を使った実装をよく出してきます。確認ダイアログや軽い入力パネル程度なら十分機能しますが、次のような要件が重なると一気に苦しくなります。
- シートの「半開き」「全開き」など複数のスナップポイントを切り替えたい
- 中身に長いリストがあり、シート内で慣性スクロールさせたい
- ドラッグの途中で戻したり、しきい値で自動的に開閉させたい
- iOS 風の角丸・グレースケールの背景フェードを揃えたい
Modal でこれらを再現しようとすると、自前で PanResponder を書き、リスト内のスクロールと外側のドラッグを切り分け、Animated.Value を慎重に扱う必要があります。書けないことはないのですが、保守を考えると専用ライブラリに任せたほうが現実的です。
@gorhom/bottom-sheet を Rork プロジェクトに追加する
Rork は内部的に Expo + React Native のセットアップを使っています。@gorhom/bottom-sheet は Reanimated と Gesture Handler を前提にしているため、まずこの2つが入っているかを確認してから本体を追加します。
# Rork プロジェクトのルートで実行
npx expo install react-native-reanimated react-native-gesture-handler
npm install @gorhom/bottom-sheetReanimated は Babel プラグインの登録が必須です。babel.config.js にすでに設定が入っていることが多いですが、もし無ければ末尾に追記します。プラグインの順番が変わるとビルドが通らなくなることがあるので、必ず plugins 配列の最後に置いてください。
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
// ...他のプラグイン...
'react-native-reanimated/plugin', // ← 必ず最後
],
};
};Gesture Handler はアプリのエントリポイントで GestureHandlerRootView がルートを覆っている必要があります。Rork が出した _layout.tsx の最外側に1行足すだけで済むことがほとんどです。
// app/_layout.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Slot } from 'expo-router';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Slot />
</GestureHandlerRootView>
);
}ここまでで、Rork の生成コードはほとんど触っていません。後付けで Bottom Sheet を入れたい場合の最小限の準備はこれで完了です。
最小構成のボトムシートを動かす
まずは「半分まで開く / 全画面まで開く」の2スナップポイントだけを持つシートを作ります。useRef で参照を取り、ボタンから expand() / close() を呼べる形にしておくと、後から AI 生成された画面に組み込みやすくなります。
import React, { useCallback, useMemo, useRef } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
export default function PlaceDetailScreen() {
const sheetRef = useRef<BottomSheet>(null);
// 端末の高さに対するパーセンテージで指定できる
const snapPoints = useMemo(() => ['25%', '60%', '90%'], []);
const openSheet = useCallback(() => {
sheetRef.current?.snapToIndex(1); // 60% の位置で開く
}, []);
return (
<View style={styles.container}>
<Button title="詳細を見る" onPress={openSheet} />
<BottomSheet
ref={sheetRef}
index={-1} // 初期は閉じておく
snapPoints={snapPoints}
enablePanDownToClose
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.indicator}
>
<BottomSheetView style={styles.sheetBody}>
<Text style={styles.title}>カフェ・ヒロカワ</Text>
<Text>営業時間: 10:00 - 19:00</Text>
<Text>東京都渋谷区...</Text>
</BottomSheetView>
</BottomSheet>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
sheetBg: { backgroundColor: '#ffffff' },
indicator: { backgroundColor: '#cccccc', width: 48 },
sheetBody: { padding: 20 },
title: { fontSize: 20, fontWeight: '600', marginBottom: 8 },
});期待する挙動は次の通りです。ボタンを押すとシートが下から60%の位置までスッと現れ、上にドラッグすれば90%、下にドラッグすれば25%もしくは閉じる、の3段階で止まります。enablePanDownToClose を入れておかないと下方向にドラッグして消すことができないので忘れないでください。
スナップポイントとジェスチャの設計で意識すること
スナップポイントは「ピクセル」ではなく「比率」で書くのが基本です。iPhone SE と iPad で見た目を揃えるためには、画面サイズに連動した比率指定のほうが扱いやすいからです。一方で、コンテンツの中身が決まっている場合は 'CONTENT_HEIGHT' を渡して中身の高さに合わせるという選択肢もあります(v4 以降)。
スナップポイントを増やしすぎると、ユーザーがどこで止めればいいか分からなくなります。私は「閉じる + 半開き + 全開き」の3つで止めることが多く、4つ以上はほぼ使いません。地図系アプリで Apple Maps のような挙動を再現したいときだけ、最下段に「ハンドルだけ見える」位置(10%程度)を追加します。
ドラッグ操作を細かく制御したい場合は onChange でインデックスを監視し、特定の位置から先に行ったらフォーカスを当てる、といった実装も可能です。ただし、ジェスチャの結果に副作用を被せると Reanimated のワーレットスレッドで実行されるため、UI スレッドへ戻す処理を runOnJS で囲む必要があります。この辺りの注意点は Rork × Reanimated と Gesture Handler で作る高度なアニメーション にまとめています。
入力フォームを乗せるときのキーボード対応
Bottom Sheet にテキスト入力を置くと、必ず一度はキーボードに隠される事故が起きます。@gorhom/bottom-sheet は専用の BottomSheetTextInput を提供しており、これを使うとフォーカス時に自動でシートを上にずらしてくれます。
import BottomSheet, {
BottomSheetView,
BottomSheetTextInput,
} from '@gorhom/bottom-sheet';
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={['40%', '90%']}
keyboardBehavior="interactive" // iOS で自然に追従
keyboardBlurBehavior="restore" // 閉じる時に元の位置へ
android_keyboardInputMode="adjustResize"
>
<BottomSheetView style={{ padding: 16 }}>
<BottomSheetTextInput
placeholder="メモを入力..."
style={{
height: 44,
paddingHorizontal: 12,
backgroundColor: '#f4f4f4',
borderRadius: 8,
}}
/>
</BottomSheetView>
</BottomSheet>keyboardBehavior は iOS 限定のオプションで、'interactive' にしておくとキーボードと連動してシートが滑らかに動きます。Android 側は android_keyboardInputMode="adjustResize" を指定し、加えて app.json の softwareKeyboardLayoutMode を "resize" に変更してください。ここを "pan" のままにしているとシート全体が押し上げられて見た目が崩れます。
複数の入力欄があるフォームでは、フォーカスの切り替え時に若干の遅延が起きることがあります。これは BottomSheetScrollView を内側に使うことで解消できることが多く、リスト上にフォームを配置するパターンでも自然なスクロールになります。
iOS と Android で挙動が分かれるポイント
実機で触ると、思ったよりも OS 間の差が目立ちます。私が踏みやすかった3点だけ書いておきます。
- 背景の暗転: iOS は半透明の黒が綺麗にフェードしますが、Android は端末によって
BackdropComponentを渡さないと真っ黒になることがあります。@gorhom/bottom-sheetにはBottomSheetBackdropが付属しているので、明示的に渡すのが安全です。 - ハンドルのドラッグ可能領域: Android は標準だとハンドル部分の判定が小さく、指が滑ります。
handleHeightを 28〜32 程度まで広げると操作感が自然になります。 - 戻るボタンとの連動: Android では物理戻るボタンでシートだけを閉じたいことがあります。
useFocusEffectでBackHandlerを登録し、sheetRef.current?.close()を呼んでtrueを返すと意図通りに動きます。
これらは「Rork が生成したデフォルトコード」では考慮されないことがほとんどなので、Bottom Sheet を入れた直後にレビュー実機(できれば中価格帯の Android)で必ず確認することをおすすめします。実機特有のクラッシュや表示崩れを早期に拾いたい場合は、Rork で作ったアプリが実機でクラッシュする時の切り分け方 も併せて読んでみてください。
なお、シート内のローディング表示は標準のスピナーよりもスケルトンを使うほうが体感が良くなります。実装パターンは Rork でスケルトンスクリーンを実装してロード待ちを快適にする にまとめてあるので、組み合わせて使うと完成度が上がります。
まずは小さく組み込んでみる
Rork が出してくれる Modal で十分な場面では、無理に Bottom Sheet を導入する必要はありません。ただ「半開き」や「上から重ねるリスト」を作りたいと思った瞬間に、自前実装と既製ライブラリで悩む時間が一気に増えます。そのときの選択肢として @gorhom/bottom-sheet をひとつ持っておくと、Rork の生成コードと無理なく共存できる、というのが3アプリほど触ってみた感想です。
最初の一歩としては、すでにある画面のうち1つだけシート化してみるのが取り組みやすいと思います。設定ボタンを押すと下から出てくるカテゴリ選択、地図の下に出てくる場所詳細など、効果が分かりやすい画面から差し替えてみてください。