集酒: Expo のまま iOS ネイティブの体感に寄せる

作成: 2026-09-12 JST(あおい)。対象: business/shushu/shushu-app(Expo SDK 54 / React Native 0.81.5 / src 64 ファイル)。 きっかけは shika の問い「参考にしているアプリ(おそらく Swift 製)の iOS ネイティブな UI が洗練されている。Expo で挙動をネイティブに寄せたり通知許可を出したりできるか」。 工数は見積りで、実測ではない。実機・シミュレータでの確認はしていない(この日の検証は型チェック・lint・iOS 向け JS バンドルの成功まで)。

1. 結論

  1. 作り替える必要は無い。 今のコードは最初から iOS を出力対象にしている(app.config.js に ios.bundleIdentifier、eas.json に ios.simulator、ローカルには 2026-09-10 生成の Xcode プロジェクト)
  2. 「ネイティブに寄せる」の中身は、ネイティブ風を自作することではなく、iOS のネイティブ部品を使わせること。 今のコードは逆をやっていた(ネイティブヘッダーを自作 View で置き換えていた)。そこを戻すのが一番効いた
  3. 通知は出せる。 ローカル通知はサーバー不要。プッシュは Apple Developer アカウント + APNs キー + development build が要る(Expo Go では不可)
  4. Swift での作り直しは 15〜25 人日。この日やった範囲(下表)は 実働 1 日以内で、体感の差の大半はこちらで埋まる

2. 何が「ネイティブっぽくなさ」を作っていたか

原因どこ失っていたもの
ネイティブヘッダーを自作 View に差し替えていたnavigation/appHeaderOptions.tsx の header: () => <AppHeader />ラージタイトルの縮み / スクロール下のすりガラス / 戻るジェスチャー中のタイトル追従 / セーフエリアの自動確保(Android の edge-to-edge 潜り込みもこれが原因)
タブアイコンが絵文字navigation/MainTabs.tsx の 🏠📚👤OS と字面が揃わない。ネイティブでないことが一番わかりやすく出る場所
アプリ独自の欧文フォントを全面に使用theme/typography.ts(Noto Sans JP)見出しの字面が OS と違い、「Web っぽさ」が出る
触覚フィードバックが 1 箇所も無い全体「気持ちよさ」の層。参考アプリが丁寧なのは大体ここ
画像がプレースホルダ・キャッシュ無し図鑑グリッド・タイムライン・アバタースクロール時のちらつき

3. この日やったこと

やったこと中身
ヘッダーをネイティブに戻したappHeaderOptions が native-stack のオプションを返すだけになった。詳細画面は不透明ヘッダー、一覧のトップ画面(図鑑・マイページ)は headerLargeTitleEnabled + headerTransparent + headerBlurEffect: 'systemChromeMaterialDark'。自作の AppHeader(バー本体)は削除し、ヘッダー右に置くボタンだけ残した
タブ画面をスタックで包んだHomeStack / CollectionStack を追加。これまでホームと図鑑はタブ直下の素の画面で、ヘッダーを自前で描いていた。図鑑は画面側の「📚 日本酒図鑑」の見出しと手書きの paddingTop: insets.top を削除(ラージタイトルと二重になるため)
タブアイコンを SF Symbols にcomponents/ui/TabIcon.tsx。iOS は expo-symbols(house / books.vertical / person、選択時は .fill)、Android は @expo/vector-icons の Material Icons、それ以外は絵文字に落とす。※ SDK 54 の expo-symbols は iOS 専用 API(name がプラットフォーム別オブジェクトを取れるのは新しい SDK)なので分岐して書いた
触覚フィードバックutils/haptics.ts(expo-haptics)。★評価のタップ = Light、フィード切替 = Selection、投稿成功 = Success、失敗 = Error。Web では何もしない。呼び出しは await しない
画像を expo-image に図鑑カード・投稿グリッド・タイムライン・アバター。contentFit="cover" + transition={150} でフェードイン、キャッシュも入る
iOS だけシステムフォントtheme/typography.ts。iOS は fontFamily を指定せず(= SF Pro / ヒラギノ)、太さを fontWeight で出す。Android は Noto Sans JP のまま。USE_SYSTEM_FONT_ON_IOS = false の 1 行で元に戻せる
ナビゲーションの作法ルートのスタックに fullScreenGestureEnabled(画面のどこからでもスワイプで戻れる)。新規投稿はモーダル表示のままだが、左上に「キャンセル」を置いた(下スワイプしか閉じ方が無い状態を解消)
投稿 FAB を X と同じ作法にアイコンを ✏️ から 「+」(SF Symbols の plus)へ。タップはメニューを挟まず即・新規投稿(これまでは開いて選ぶ 2 タップだった)。長押しすると横に「下書き」ボタンが出る(RN の Animated でスプリング)。長押しに触覚、下書きへ進むときに選択の触覚。暗幕はモーダルではないので薄い(0.12)。アイコンの分岐は components/ui/AppSymbol.tsx に切り出し、TabIcon もこれを使う
死んだコードを削除navigation/PostStack.tsx(どこからも参照されていない重複実装)と、その型定義

シミュレータで実際に確認した(2026-09-12 18:12〜18:25)

dev client を作り直して iPhone 17 Pro(iOS 26.2)で起動し、画面を確認した。

ここで 30 分溶かした事故: シミュレータが 2 台起動していて、xcrun simctl ... booted が新ビルドを入れた端末とは別の端末(古いビルドが入ったまま)を選んでいた。 JS は開発サーバから最新が届くのでヘッダーもフィード切替も新しく見えるのに、ネイティブだけ古い、という食い違いになる。 症状は「タブアイコンが赤い Uni の箱」、リロードすると Cannot find native module 'ExpoImage'。端末は名前ではなく UDID で名指しする。

ほかに詰まった点: npx expo prebuild は pod install を走らせない。手で流すと SDWebImageWebPCoder の衝突(expo-image が ~> 0.14.6 要求 / ロックは 0.15.0)が出るので pod update SDWebImageWebPCoder が要る。

静的な確認は npx tsc --noEmit・npm run lint・npx expo export --platform ios(4.84 MB のバンドル生成に成功)。実機での手触りは引き続き未確認。

4. 通知(まだ入れていない)

公式ドキュメントで確認した条件。

種類要るもの集酒での使いどころ
ローカル通知・予約通知サーバー不要。scheduleNotificationAsync()「今日の晩酌を記録しませんか」
プッシュ通知Apple Developer アカウント + APNs キー + development build(Expo Go 不可)「◇ 〇〇 が図鑑に追加されました。あなたの記録が図鑑に入りました」(自由記述の昇格通知)、乾杯された、フォローされた
許可の要求requestPermissionsAsync({ ios: { allowAlert, allowBadge, allowSound } })。状態は NOT_DETERMINED / DENIED / AUTHORIZED / PROVISIONAL / EPHEMERAL—

作法として、OS のダイアログをいきなり出さない。 一度「許可しない」を押されると設定アプリからしか戻せないので、先に自前の画面で「何を通知するか」を見せてから出す。 昇格通知は DB のトリガー(link_free_text_posts)が起点になるので、構成は Supabase トリガー → Edge Function → Expo Push API になる。見積り 0.5〜1 日(Apple 登録を除く)。

4.5 ヘッダーの高さを 3 画面で揃えた(実測つき)

「ヘッダーの縦幅が大きすぎる。図鑑とマイページも規格を揃えて、タイトルの位置とサイズをホームに合わせて」という指摘を受けての変更。 X の iOS アプリそのものは計測できていない(App Store のアプリはシミュレータに入れられず、手元に実機のスクリーンショットも無い)。 そこで、X が従っている iOS の標準値に合わせた。

根拠にした値出典
標準のナビゲーションバー: 44pt/ラージタイトル時: 96ptApple Developer Forums のナビゲーションバー実装の議論(CGRect(x:0, y:20, width:…, height:96))
最小のタップ領域: 44×44ptApple HIG(Layout)

やったこと

実測(iPhone 17 Pro / iOS 26.2 のスクリーンショットを画素単位で測った)

画面ヘッダー全体内訳タイトルの ink
ホーム(変更前)165.3ptセーフエリア 62.2 + ナビバー 53.8 + タブ行 59.277.0〜91.3pt
ホーム(変更後)160.0pt62.2 + 53.8 + タブ行 44.077.0〜91.3pt
図鑑(変更後)116.0pt62.2 + 53.8(タブ行なし)77.0〜91.3pt

タイトルの ink は図鑑もホームも 77.0〜91.3pt(中心 84.2pt・高さ 14.7pt)で完全に一致。位置もサイズも揃った。 なお iOS 26 のナビゲーションバーは 53.8pt で描画されている(標準の 44pt に約 10pt 分の余白が付く)。ここはネイティブの描画なので、こちらからは詰められない。 さらに詰めるなら「ホームのタイトル行そのものを無くし、タブを直接ステータスバーの下に置く」形になる(X のロゴ行に相当するものを持たない選択)。

5. 残っている候補(やっていないこと)

候補なぜ今日やらなかったか見積り
タブバー自体をネイティブ化(react-native-bottom-tabs = UITabBarController)サードパーティで、ナビゲーションの土台を差し替える変更になる。dev client の再ビルドも要る0.5 日
react-native-reanimated + gesture-handler を入れて動きを付ける未導入。入れる価値は高いが、当てる場所を決めてからのほうがよい1〜2 日
ユーザー検索をネイティブ検索バー(headerSearchBarOptions)に画面側の入力の作りを組み替える必要がある2〜4h
react-native-keyboard-controllerKeyboardAvoidingView で今は動いている。置き換えは実機で比べてから2〜3h
通知(上記)Apple Developer 登録が前提0.5〜1 日
Dynamic Type(文字サイズ設定への追従)固定サイズ前提のレイアウトが多く、崩れる箇所の確認が要る1 日

6. Expo では届きにくい領域

7. 未確認(断定していないこと)

8. 関連