iPhone 16 Pro Maxで完璧に見えたUIが、iPhone SE3で操作不能になる
Rorkでアプリを作っていて、プレビューでは美しく仕上がったのに実機テストで崩壊する——この経験がある開発者は多いはずです。特に多いのが、大画面のiPhoneで確認して満足した後、小さな画面のデバイスやAndroidで開いた瞬間にボタンが画面外に飛び出す、テキストが重なる、スクロールできないといった症状です。
Rorkが生成するReact Nativeのコードは、基本的にFlexboxベースのレイアウトシステムを使っています。Webブラウザ上のCSSに似ているようで微妙に違うため、Web開発経験者ほど落とし穴にはまりやすい。ここでは私がRorkアプリの開発で実際に遭遇したレイアウト崩れのパターンと、それぞれの修正方法をコード付きで解説します。
SafeAreaViewの罠:ノッチとDynamic Islandに潜む3つの落とし穴
Rorkが生成するコードで最も多いレイアウト問題は、SafeArea周りです。iPhoneのノッチやDynamic Island、Androidのステータスバーとの干渉で、コンテンツが隠れたりパディングが二重にかかったりします。
落とし穴1:SafeAreaViewの二重ラップ
Rorkが生成したコードをカスタマイズする過程で、SafeAreaViewが入れ子になるケースがあります。親コンポーネントと子コンポーネントの両方がSafeAreaViewを使うと、余白が倍になります。
// ❌ 二重SafeAreaView — 余白が2倍になる
const App = () => (
<SafeAreaView style={{ flex: 1 }}>
<NavigationContainer>
<Stack.Screen component={HomeScreen} />
</NavigationContainer>
</SafeAreaView>
);
const HomeScreen = () => (
<SafeAreaView style={{ flex: 1 }}> {/* ここで余白がさらに追加される */}
<Text>コンテンツ</Text>
</SafeAreaView>
);修正方法は、SafeAreaViewを最上位の1箇所だけに限定し、子コンポーネントでは通常のViewを使うことです。
// ✅ SafeAreaViewは最上位のみ
const App = () => (
<SafeAreaView style={{ flex: 1 }}>
<NavigationContainer>
<Stack.Screen component={HomeScreen} />
</NavigationContainer>
</SafeAreaView>
);
const HomeScreen = () => (
<View style={{ flex: 1 }}> {/* 通常のViewを使う */}
<Text>コンテンツ</Text>
</View>
);落とし穴2:Androidのステータスバー問題
React NativeのSafeAreaViewは、iOSではノッチやホームインジケーターに対応するが、Androidではステータスバーの領域を考慮しない。Rorkで生成したアプリをAndroidで見ると、コンテンツがステータスバーの裏に潜り込む。
// ✅ クロスプラットフォーム対応のSafeArea処理
import { Platform, StatusBar, StyleSheet, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
// react-native-safe-area-contextを使う方法(推奨)
const Screen = ({ children }) => (
<SafeAreaView style={styles.container}>
{children}
</SafeAreaView>
);
// もしくはPlatform判定でAndroidのみ手動パディング
const ScreenManual = ({ children }) => (
<View style={[
styles.container,
Platform.OS === 'android' && {
paddingTop: StatusBar.currentHeight || 24
}
]}>
{children}
</View>
);
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' }
});ポイントは、React Native本体のSafeAreaViewではなく、react-native-safe-area-contextライブラリのものを使うこと。後者はiOSとAndroidの両方で安全領域を正しく計算します。Rorkのプロジェクトには通常すでにインストールされているが、インポート元が違う点に注意しよう。
落とし穴3:Dynamic IslandデバイスでのSafeArea上端の値
iPhone 14 Pro以降のDynamic Island搭載機では、SafeAreaの上端インセットが従来のノッチ機より大きい(59pt vs 47pt)。固定値でパディングを設定していると、Dynamic Islandモデルでコンテンツが被る。
// ❌ 固定値は特定デバイスでしか機能しない
const Header = () => (
<View style={{ paddingTop: 47 }}>
<Text>ヘッダー</Text>
</View>
);
// ✅ useSafeAreaInsetsで動的に取得
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const Header = () => {
const insets = useSafeAreaInsets();
return (
<View style={{ paddingTop: insets.top }}>
<Text>ヘッダー</Text>
</View>
);
};Flexboxの「flex: 1」が効かない——親のheight指定を忘れていないか
Rorkが生成するレイアウトコードはflex: 1を多用します。Flexboxは親要素から子要素へと空間を分配する仕組みなので、親のサイズが確定していないと子のflex: 1は効果がない。
典型的な症状は「ScrollViewの中身が見えない」です。
// ❌ ScrollViewの親にflex: 1がない → 高さ0になる
const Screen = () => (
<View> {/* heightもflexもない */}
<ScrollView>
<View style={{ height: 2000 }}>
<Text>長いコンテンツ</Text>
</View>
</ScrollView>
</View>
);
// ✅ 親チェーン全体にflex: 1を貫通させる
const Screen = () => (
<View style={{ flex: 1 }}> {/* これが必須 */}
<ScrollView style={{ flex: 1 }}>
<View style={{ height: 2000 }}>
<Text>長いコンテンツ</Text>
</View>
</ScrollView>
</View>
);このルールは「flex: 1のチェーン」と覚えるとよい。ルート(SafeAreaView)から対象のコンポーネントまで、途中の全てのViewにflex: 1が設定されていなければならありません。1箇所でも切れると、そこから下は高さが0として扱われます。
デバッグのコツは、問題のある画面でReact Native Debuggerの「Inspect Element」を開き、各Viewの計算後のheightを確認することです。height: 0のViewがあれば、そこがチェーンの切断点になっています。
固定px指定で壊れる——画面サイズ対応の実践パターン
Rorkでアプリを作ると、AIがwidth: 375のような固定値を生成することがあります。開発中のプレビューでは完璧に見えるが、画面幅が異なるデバイスでは当然崩れます。
パーセンテージの代わりにFlexを使う
React NativeではCSSのwidth: 50%も使えるが、より柔軟なのはFlexboxの比率指定です。
// ❌ 固定幅 — iPhone SE3で画面外にはみ出す
const Card = () => (
<View style={{ width: 350, padding: 16 }}>
<Text>カード内容</Text>
</View>
);
// ✅ Flex比率 + 最大幅で制御
const Card = () => (
<View style={{
flex: 1,
maxWidth: 500,
padding: 16,
marginHorizontal: 16
}}>
<Text>カード内容</Text>
</View>
);Dimensionsで画面サイズを取得する
2カラムレイアウトや画像のアスペクト比維持には、画面の実サイズが必要な場面があります。
import { Dimensions, useWindowDimensions } from 'react-native';
// ❌ Dimensions.get は初回レンダリング時の値を返す(回転に追従しない)
const { width } = Dimensions.get('window');
// ✅ useWindowDimensions は画面回転・分割表示に追従する
const GridItem = () => {
const { width } = useWindowDimensions();
const numColumns = width > 768 ? 3 : 2;
const itemWidth = (width - 48) / numColumns; // 48 = 余白合計
return (
<View style={{ width: itemWidth, height: itemWidth, margin: 8 }}>
<Text>アイテム</Text>
</View>
);
};Dimensions.get('window')はアプリ起動時のスナップショットしか返さありません。iPadの画面分割やAndroidのマルチウィンドウで表示領域が変わっても更新されません。useWindowDimensionsフックを使えば、サイズ変更のたびに再レンダリングが走り、常に正しい値が得られます。
ScrollViewとFlatListの落とし穴——「スクロールできない」を解決する
ScrollView内にFlatListを入れてはいけない
Rorkが複雑な画面を生成すると、ScrollViewの中にFlatListが配置されることがあります。この組み合わせはスクロール競合を起こし、パフォーマンス警告が出るだけでなく、FlatListの仮想化(画面外の要素を破棄する最適化)が無効になります。
// ❌ ScrollView内のFlatList — 仮想化が無効になり全データをレンダリングする
const Screen = () => (
<ScrollView>
<Text style={styles.header}>ヘッダー</Text>
<FlatList
data={items} // 1000件あると1000件全てレンダリングされる
renderItem={renderItem}
/>
</ScrollView>
);
// ✅ FlatListのListHeaderComponentを使う
const Screen = () => (
<FlatList
data={items}
renderItem={renderItem}
ListHeaderComponent={
<Text style={styles.header}>ヘッダー</Text>
}
ListFooterComponent={
<View style={{ height: 40 }} /> // 下部の余白
}
/>
);FlatListのListHeaderComponentとListFooterComponentを活用すれば、ヘッダーやフッターをリストと一緒にスクロールさせつつ、仮想化の恩恵も受けられます。
contentContainerStyleを忘れていないか
ScrollViewのスタイリングでよくある間違いが、styleとcontentContainerStyleの混同です。
// ❌ style に padding を指定 → スクロールバーの位置がずれる
<ScrollView style={{ padding: 16 }}>
<Text>内容</Text>
</ScrollView>
// ✅ contentContainerStyle でコンテンツ領域にパディング
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Text>内容</Text>
</ScrollView>styleはScrollViewの外枠(固定領域)に適用され、contentContainerStyleはスクロールするコンテンツ全体に適用されます。パディングや中央揃えはcontentContainerStyleに書くのが正解です。
テキストが切れる・重なる——フォントサイズと行間の対処法
日本語テキストはアルファベットに比べて文字あたりの面積が大きく、行間も多めに取らないと読みづらい。Rorkが英語ベースで生成したフォント設定では、日本語が窮屈に見えることが多いです。
// ❌ 英語ベースの行間 — 日本語テキストが詰まって見える
const styles = StyleSheet.create({
body: {
fontSize: 14,
lineHeight: 18, // 行間が狭すぎる
}
});
// ✅ 日本語に最適化した行間
const styles = StyleSheet.create({
body: {
fontSize: 14,
lineHeight: 24, // fontSize × 1.7前後が日本語に適する
letterSpacing: 0.3,
}
});また、numberOfLinesを設定せずに長いテキストを表示すると、コンテナからはみ出す。切り捨て表示にするなら明示的に指定します。
// ✅ テキスト切り捨ての正しい実装
<Text
numberOfLines={2}
ellipsizeMode="tail"
style={{ fontSize: 14, lineHeight: 22 }}
>
長い説明テキストがここに入ります。2行を超える部分は省略記号で表示されます。
</Text>実践:ブレークポイント対応のレスポンシブレイアウトを組む
最後に、Rorkアプリで複数画面サイズに対応するレスポンシブレイアウトの実装例を示す。カスタムフックを1つ作れば、画面全体で一貫したブレークポイント判定ができます。
// hooks/useResponsive.ts
import { useWindowDimensions } from 'react-native';
type Breakpoint = 'compact' | 'medium' | 'expanded';
export const useResponsive = () => {
const { width, height } = useWindowDimensions();
const breakpoint: Breakpoint =
width >= 840 ? 'expanded' : // iPad・タブレット
width >= 600 ? 'medium' : // 大画面スマホ・横向き
'compact'; // 通常のスマホ
const isLandscape = width > height;
return { width, height, breakpoint, isLandscape };
};
// 使用例:ProductListScreen.tsx
import { useResponsive } from '../hooks/useResponsive';
const ProductListScreen = () => {
const { breakpoint, width } = useResponsive();
const numColumns = breakpoint === 'expanded' ? 4
: breakpoint === 'medium' ? 3
: 2;
const itemWidth = (width - (numColumns + 1) * 12) / numColumns;
return (
<FlatList
data={products}
numColumns={numColumns}
key={numColumns} // numColumns変更時に再マウントするために必須
renderItem={({ item }) => (
<View style={{ width: itemWidth, margin: 6 }}>
<ProductCard product={item} />
</View>
)}
/>
);
};FlatListのnumColumnsを動的に変える場合、keyプロパティにカラム数を渡すのがポイントです。これがないとReact Nativeはカラム数の変更を検知できず、レイアウトが壊れます。公式ドキュメントにも記載されているが、実装時に見落としやすい。
このフックを使えば、「compactではシングルカラム、expandedではサイドバー付き」のような分岐もシンプルに書ける。Rorkが生成した画面に後からレスポンシブ対応を足す場合でも、UIロジックをuseResponsiveに集約することで、各画面のコードを最小限の変更で対応できます。
レスポンシブ対応でお困りの方はRork UXデザインパターン完全ガイドも参考になります。iPadレイアウトに特化した話はRorkでiPad対応アプリを作るガイドで詳しく扱っています。また、スタイリング全般についてはNativeWind(Tailwind CSS)スタイリングガイドが実践的です。