結論
- SDK 54以降なら
expo-router/unstable-native-tabsで、追加パッケージなしにiOS 26のLiquid Glassタブバーになる - App Storeでできる「タブバーを横スワイプしてタブ移動」は、これでは実現できなかった
-
useSafeAreaInsets()のbottomは 0 のまま。タブバーの高さは自分で定数を持つ必要がある。そして 49pt ではない
検証環境は以下のとおりです。unstable な機能なので、バージョンによって挙動が変わる可能性があります。
| パッケージ | バージョン |
|---|---|
| expo | 54.0.36 |
| expo-router | 6.0.24 |
| react-native-screens | 4.16.0 |
| react-native | 0.81.5 |
実機はiPhone(ホームボタン機、iOS 26)です。
きっかけ
個人開発しているiOSアプリで、タブを横移動できるようにしたいと思いました。ホーム / 一覧 / 集計 / 設定の4本があり、端から端まで指を伸ばすのが地味に遠かったからです。
App Storeアプリを開いてみると、下のタブバーを横にスワイプするだけでタブが切り替わりました。これと同じことをしたい、というのが出発点です。
結論から言うと、見た目は再現できましたが、スワイプは再現できませんでした。
検討した3案
expo-routerの標準の Tabs(実体は @react-navigation/bottom-tabs)は、スワイプでのタブ移動に対応していません。そこで3つの案を比べました。
1. @react-navigation/material-top-tabs を tabBarPosition: 'bottom' で使う
withLayoutContext でexpo-routerに組み込む方法です。指に追従する本物のスワイプが手に入ります。ただし react-native-pager-view というネイティブ依存が増えることと、このナビゲータがヘッダーを持たないため、ヘッダーを表示している画面では作り直しが発生します。
そして何より、見た目がiOS標準から離れる方向に進みます。今回やりたかったこととは逆でした。
2. 各画面にPanResponderを巻いて、横フリックで router.navigate する
ネイティブ依存ゼロで実装できます。ただし指に追従するアニメーションは出ず、「フリックしたらパッと切り替わる」だけになります。
3. expo-router/unstable-native-tabs(NativeTabs)
タブバーの描画をOSに委譲します。iOS 26ならLiquid Glass、それ以前のバージョンでは従来のタブバーに自動的に落ちます。追加パッケージは不要です。
見た目を最優先したかったので3を選びました。スワイプについては「ネイティブのタブバーなら標準で付いてくるかもしれない」という期待もありました。この期待は外れます。
移行の実際
変更したのは app/(tabs)/_layout.tsx の1ファイルだけです。19行追加・7行削除の計26行でした。
変更前:
import { Tabs } from 'expo-router';
export default function TabsLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: 'ホーム' }} />
<Tabs.Screen name="list" options={{ title: '一覧' }} />
<Tabs.Screen name="summary" options={{ title: '集計' }} />
<Tabs.Screen name="settings" options={{ title: '設定' }} />
</Tabs>
);
}
変更後:
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabsLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon sf={{ default: 'house', selected: 'house.fill' }} />
<Label>ホーム</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="list">
<Icon sf={{ default: 'list.bullet', selected: 'list.bullet' }} />
<Label>一覧</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="summary">
<Icon sf={{ default: 'chart.bar', selected: 'chart.bar.fill' }} />
<Label>集計</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf={{ default: 'gearshape', selected: 'gearshape.fill' }} />
<Label>設定</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
記法は Trigger の子に置く形
NativeTabs.Trigger に options で渡す形ではなく、Icon / Label / Badge を子要素として置く形です。
NativeTabTriggerProps.options のJSDoc(node_modules/expo-router/build/native-tabs/NativeBottomTabs/types.d.ts)にも、raw optionsではなくこれらのコンポーネントを子として使うように書かれています。
なお Icon / Label / Badge の定義自体は別ファイルの build/native-tabs/common/elements.d.ts にあります。sf propの型を確認したいときはこちらを見てください。types.d.ts 側の NativeTabOptions.icon(raw options用)にも sf がありますが、そちらは SFSymbol の単純型で、default / selected の構造を持ちません。別物なので注意してください。
また Icon と Label は NativeTabs.Trigger のstatic propertyではなく、独立してexportされています。importを忘れないようにしてください。
アイコンはSF Symbolの名前を文字列で渡すだけ
sf propの型は SFSymbol | { default?: SFSymbol; selected: SFSymbol } です。選択時に塗りつぶしバリアントへ切り替えたい場合はオブジェクト形式を使います。
iOS単独のアプリであれば、これだけで済みます。@expo/vector-icons などの追加依存は要りませんでした。
なお list.bullet には .fill バリアントが存在しないため、一覧タブだけは選択時も同じシンボルにしています。実機で見た限り、Liquid Glassのハイライトと色で選択状態は十分に分かるので、気になりませんでした。
副産物:tabBarIcon 未指定の三角形が消えた
移行前、4つのタブすべてに同じ三角形のアイコンが表示されていました。アイコン名の解決に失敗しているのだと思い込んでいましたが、原因は単純で tabBarIcon を一度も指定していなかったからです。
@react-navigation/bottom-tabs は tabBarIcon が未指定のとき、@react-navigation/elements の MissingIcon にフォールバックします(BottomTabBar.tsx)。MissingIcon は下向き三角形をTextで描画するプレースホルダです。名前解決の失敗ではなく、指定そのものが存在しなかったということでした。
ハマったこと
1. ヘッダーが消える
NativeTabsはSwiftUIの TabView をラップしたタブバーを提供するだけで、画面上部のヘッダーは提供しません。移行するとネイティブヘッダーが消えます。
これは移行前に把握しておく必要があります。維持したい場合は各タブ配下にStackをネストして headerShown: true を指定することになります。
自分の場合は、4画面ともScrollViewの先頭で見出しのTextを自前で描画していました。つまりネイティブヘッダーと自作見出しが二重に出ていた状態です。移行によって二重表示が解消され、結果的に得をしました。既存のコードを見直すきっかけになるかもしれません。
2. FABがタブバーに埋もれる
Liquid Glassのタブバーは画面下端から浮いていて、コンテンツはその背後まで広がります。右下に置いていたFAB(position: 'absolute' の bottom: 16)が、タブバーの下に潜り込みました。
react-native-safe-area-context の useSafeAreaInsets で対応します。このとき SafeAreaProvider がツリーの上位に存在しないと useSafeAreaInsets は動作しないので、ルートレイアウトに置いてください。
最終的には、こういうフックにまとめて4画面から使っています。
const insets = useSafeAreaInsets();
const fabBottom = insets.bottom + NATIVE_TAB_BAR_HEIGHT + spacing.lg;
3. タブバーの高さは 49pt ではない
ここが今回いちばん時間を使ったところです。
まず「iOSのタブバーは49pt」という慣例値で組みました。従来型のUITabBarで長く使われてきた値です。しかし実機で見ると、FABがタブバーに重なったままでした。
Liquid Glassのタブバーは浮いていて上下にマージンを持つため、実質的な高さが49ptより大きいのだと考えられます。最終的に72ptに変更したところ、FABとタブバーの間に適切な余白が生まれました。
なお @react-navigation/bottom-tabs にある useBottomTabBarHeight に相当するAPIは、NativeTabs側には見当たりませんでした。build/native-tabs 配下の型定義を検索しても、タブバー高さを取得できるexportは存在しませんでした。実測して定数を持つしかなさそうです。
4. insets.bottom は 0 のまま
高さの調整をする前に、ひとつ確認しておきたいことがありました。ネイティブのタブバーを使う場合、その高さがセーフエリアのbottom insetに反映される可能性があります。もし反映されているなら、そこにさらに定数を足すと余白が二重に入ってしまいます。
一時的にこういうTextを画面に置いて実測しました。
const insets = useSafeAreaInsets();
// ...
<Text>{`top:${insets.top} bottom:${insets.bottom}`}</Text>
結果は top: 20 / bottom: 0 でした。ホームボタン機なので bottom が0なのは想定どおりですが、重要なのはタブバーの高さが含まれていないということです。つまり自分で定数を足す設計で正しいことが確認できました。
副次的な効果として、この足し算はFace ID機でもそのまま成立するはずです。浮動タブバーはホームインジケータの上に浮くので、insets.bottom(34) + 72 で自動的に正しい位置に来ます。
ただしここはFace ID機の実機を持っていないため未確認です。同じ加算式で成立すると考えていますが、確認できたのはホームボタン機のみである点は明記しておきます。
その上で、insets.bottom > 0 のときだけ計算を変える、といった条件分岐は書かずに済みました。検証できない分岐を推測で書かなくてよかった、というのが実測してみての一番の収穫です。
できなかったこと:タブバーの横スワイプ
本題です。移行後に実機で試しましたが、タブバーを横にスワイプしてもタブは移動しませんでした。
App Storeアプリでその操作ができることは確認しています。ただ、それがiOS 26の標準タブバーに備わった挙動なのか、Apple側の独自実装なのかは、調べた範囲では確認できませんでした。少なくとも expo-router/unstable-native-tabs をそのまま使った状態では効きません。
タブバーではなく画面全体を横スワイプしたい場合は、前述の「PanResponderで横フリックを拾って router.navigate する」方法になります。指に追従するアニメーションは諦めることになります。
指追従のスワイプが要件なら、見た目を捨てて material-top-tabs を選ぶことになります。「Liquid Glassの見た目」と「ページの横スワイプ」は、現状では両立しません。ここは移行前に決めておいたほうがよいポイントです。
再ビルドは不要
expo-router/unstable-native-tabs は expo-router に同梱されていて、node_modules/expo-router/unstable-native-tabs.js として実在します(中身は build/native-tabs を再exportしているだけです)。NativeTabsの実体はすでにインストール済みの react-native-screens 側にあるため、新しいパッケージのインストールは発生しません。
つまり package.json は無変更で、ネイティブバイナリも変わりません。既存のdevelopment buildにJSを流し込むだけで確認できました。EAS Buildの無料枠を気にしている場合は、いきなり再ビルドせず、まず手元のdev buildで試すことをおすすめします。
まとめ
- 見た目のアップグレードとしては、変更26行に対して効果が非常に大きい
- ヘッダーが消えることと、セーフエリアを自分で面倒みる必要があることは事前に把握しておく
- タブバーの高さは実測する。慣例値の49ptは通用しない
- タブバーの横スワイプは付いてこない。これが目的なら別の方法を検討する
alpha段階の機能なのでAPIは変わる可能性があります。実装する際は、記事のコードをそのまま信じず、手元の型定義を確認してから書くことをおすすめします。自分もそれで記法を確定させました。