react-native-screens は、どのリストがコンテンツかの推測をやめてマーカーで指定する

UIKit も Material も、どのスクロールビューがコンテンツかを推測で決めている。React Native のビューフラット化はその推測を静かに壊す。Software Mansion はその答えを直接伝える ScrollViewMarker を追加し、Android ヘッダーのちらつきも直した。

日本語
コピー
白底上一组斜向排列的深蓝、蓝、青色圆角立牌,一只白色描黑边的手形光标按在中间的青色牌子上

Liquid Glass のタブバーがスクロール時に最小化されない?この記事はそんな人のためのものだ。react-native-screens が小さなコンポーネント ScrollViewMarker をリリースした。本記事では、それがなぜ存在し、何を解決するのかを説明する。Android における version 5 の新しい Stack ヘッダーにも触れる。私たちも実装中に同じ種類の問題にぶつかったので、同じ回り道をせずに済むはずだ。

ネイティブ UI はスクロールビューをどこに期待するか

ネイティブのナビゲーション UI はスクロールに反応する。iOS では大きなタイトルが折りたたまれ、コンテンツがナビゲーションバーの下までスクロールするとバーの見た目が変わる。iOS 26 からはタブバーも最小化できるようになり、iOS 27 ではこの挙動がナビゲーションバーにも広がった。Android では Material のアプリバーが浮き上がる。これらはすべて同じ一点にかかっている。その画面のコンテンツのスクロールビューがどれなのか、ということだ。

UIKit は「ビュー階層を分析する」ことでスクロールビューを選び出す。Apple のドキュメントの表現だ。Apple 以外に正確なルールを知る者はいないが、観察できるのは一点だけである。スクロールビューは最初の子孫パス上に存在しなければならず、サブビューの並べ替えが定番の解決策になる。Android の Material AppBarLayout は独自の方式で、CoordinatorLayout の直接の子ビューの中にスクロール可能なものがあるかを調べる。UIKit が提供しない部分については、react-native-screens が独自のヒューリスティクスを持っている。たとえば v4 の form sheet 実装では、ScrollView を現在の detent の高さに合わせる必要がある。その方法は、最初の子ビューを UIScrollView が現れるまで階層ごとにたどり、厳密な形を要求する。最初の子ビューが省略可能なヘッダー、2 番目がスクロールビューで、それ以外は許されない。

ヒューリスティクスはさまざまだが、共通する前提が一つある。リストが階層の中で最初に来るものだという前提だ。これは画面レイアウトを制約するだけでなく、React Native ではビューのフラット化によって簡単に破られてしまう。

ビューのフラット化が前提を壊す場所

RN アプリでは、この前提は JavaScript の実装からは見えない形で破られる。リストだけを包む View は、中身が flex: 1 だけなら React Native によってフラット化されることがあり、その場合リストは確かに最初のネイティブ子ビューになり、すべてうまくいく。同じ View に backgroundColor を加えると、それはネイティブビューとしてインスタンス化される。リストは最初ではなくなり、タブバーは黙って最小化しなくなる。私たちは #3954 でまさにこの報告を受け取った。

失敗は静かだ。エラーは出ず、ただ効かなくなる。そしてビュー階層を調べ始めることになる。それまでは、ScrollView を最初の子ノードとして残すか、ラッパーに collapsable={false} を付けてフラット化を無効にするか、より高度な ScrollView の検索ロジックを含むパッチを当てることを勧めていた。どれも問題は解決するが、厳密なビュー階層を強制し、開発者が気にする必要のない知識を要求する。

Apple には既製の解決策がある。UIKit は setContentScrollView(_:for:) を提供しており、ドキュメントの原文はこうだ。「ビュー階層が複雑な場合、ビューコントローラは監視に適したスクロールビューを選択できないことがあります」。React Native にもこれに相当するものが必要だ。

スクロールビューを明示的にマークする

よくあるレイアウトを見てみよう。リストの上に固定メッセージバーがあり、全体が背景を描画する View の中にある。これまでネイティブ側はヒューリスティクスだけを頼りに、そのリストを自分で見つけるしかなかった。画面はたいてい単一のリストから始まり、その場合は何の問題もなく見つかる。そして画面は複雑になっていく。リストの上に固定メッセージバーが増え、ラッパーに背景が付き、途中のどこかのコンポーネントがフラット化されたりされなくなったりする。ヒューリスティクスはその変化の一部には耐えられるが、保証はなく、ルールが破綻しても教えてはくれない。

// before: hope the heuristic finds it
<View style={styles.screen}>
  <PinnedMessage />
  <FlatList data={chats} renderItem={renderChat} />
</View>

ScrollViewMarker はこの運任せを宣言に置き換える。スクロールビューを包めば、画面はどれがコンテンツかを把握する。

// after: point which scroll view is the content
<View style={styles.screen}>
  <PinnedMessage />
  <ScrollViewMarker style={{ flex: 1 }}>
    <FlatList data={chats} renderItem={renderChat} />
  </ScrollViewMarker>
</View>

このマークは、そのスクロールビューを最も近い Stack または Tabs の画面に関連付ける。ネストしたコンテナはそれを階層に沿って伝えるので、タブ内のスタックでも同じように機能する。以降の挙動はプラットフォームによって異なる。

iOS では、UIKit がマークしたリストを監視するようになる。ナビゲーションバーとタブバーはコンテンツの到達に応じて見た目を切り替え、iOS 26+ ではタブバーが最小化もする。このマークは scrollEdgeEffects も運ぶので、リストのある場所で直接、辺ごとにエッジのぼかしスタイルを選べる。ネストしたコンテナでは内側の設定が優先される。

iOS の同じチャットリスト:タブバーはマーク前は展開したまま、マーク後はスクロール時に円形に折りたたまれる
同じ iOS 画面にマークを追加する前後の比較。リストをスクロールすると、タブバーが現在アクティブなタブのアイコンだけを含む円形に縮小し、上部に戻ると再び展開する。

最初は何のマークも付いていなかった。リストはいつもどおりスクロールするのに、タブバーは完全に展開されたままで、理由を教えてくれるものは何もない。リストは first-descendant のパス上になく、固定のメッセージバーがそこにいる。だから UIKit はリストをコンテンツスクロールビューとして選ばない。

そこに ScrollViewMarker を足す。他は何も変えない。もう一度スクロールすると、タブバーは現在のタブのアイコンが入った円に縮む。リストが先頭に戻るまで縮んだままで、戻ると再び展開する。これが期待していたネイティブの最小化動作だ。マークは、どこを見ればいいかを教えているだけだった。

Android では、small Stack のヘッダーは持ち上がるべきときに持ち上がり、そのまま上がったままでいる。liftOnScroll で、デフォルトで有効になっている。下へスクロールして隠れたヘッダーは、戻ってきたとき縮んだ状態で現れ、急に飛び出すことはない。

Android では、小さい Material 3 ヘッダーがリストのスクロールに合わせて退避し、上方向にスクロールするとスライドして戻る。マーカーを付ければ戻る途中で白く点滅しない
同じ Android 画面で、ヘッダーはコンテンツと一緒にビューポートから外れ、上にスクロールすると滑り戻る。マークがないと戻る途中で白くちらつき、マークがあると戻ったときには青い。

Android では、同じ画面を version 5 の Stack に入れ、さらに small の Material 3 ヘッダーを組み合わせている。ヘッダーはコンテンツと一緒にスクロールして消え、上方向にスクロールするとすぐに戻ってくる。

リストが先頭にあるとき、ヘッダーは白であるべきだ。コンテンツがその下までスクロールすると、即座に青くなる。中間の状態はない。バグが出るのはヘッダーが戻ってくる瞬間だ。マーカーを付けない場合、下方向にスクロールするとヘッダーが消え、ステータスバーの後ろには青い帯だけが残る。上方向にスクロールすると、ヘッダーが戻ってくる。そのとき背景に注目してほしい。戻ってきた瞬間は青で、数フレームだけ白に落ち、また青にアニメーションで戻る。Material がコンテンツがスクロール済みかどうかを再判定していて、しかも判定を誤っている。あなたのリストが見つからないからだ。次のスクロールイベントで、またヘッダーが持ち上がる。理由は下の Android の節で説明する。

マーカーを付けると、ヘッダーは戻ってくるたびに青で、青のままになる。リストが先頭に戻ったときだけ白くなる。これが正しい静止状態だ。

マーカーに期待すること

スクロール可能なコンポーネントを直接包む。マーカーが受け取る子は 1 つだけだ。FlatList でも SectionList でもよい。ScrollView が両者のルートだからだ。マーカーは screen ごとに 1 つだけ使う。複数付けた場合は最後にマウントされたものが有効になる。マーカーはレイアウト上はただの view なので、リストがもともと持っていた flex: 1 はマーカーに渡す。

サードパーティ製のリストでも、マーカーの子がそのスクロール view であれば使える。LegendList のルートノードは ScrollView としてレンダリングされるので、FlatList の要領で包む。FlashList は自身の ScrollView の外側に View をかぶせる。この場合は renderScrollComponent を通して、マーカーとスクロール view をまとめて渡す。

4.x ではどうするか

4.x では scrollEdgeEffects を screen に設定し、「最初の子孫」ヒューリスティックに頼っている。互換性のためにそのまま残してあり、4.x では今も collapsable={false} が正直な答えだ。ScrollViewMarker は version 5 の機能である。

仕組み

ここまでで、使うのに必要なものは揃った。興味があれば、以下では内部で何が起きているかを説明する。

マーカーはネイティブのコンポーネントで、スクロール可能な子を 1 つだけ持つ。window にマウントされると、包んでいるスクロール view を取り出し、view 階層をたどって最も近い Stack screen または Tabs screen を見つけ、そこにスクロール view を登録する。以降、その screen は自分のコンテンツのスクロール view を把握している。ヒューリスティックは残っているが、最後のフォールバックとしてだけだ。screen はまずマーカーが登録したものを取り、次にネストしたコンテナが現在の screen に対して報告したものを取り、最後にようやく推測を始める。

この中間のステップがあるからこそ、ネストが成立する。スタックを載せている tab はスタックに問い合わせ、スタックは最上位の screen に問い合わせ、最上位の screen はマーカーを付けたスクロール可能なコンポーネントで答える。コンテナはマウント時に親 screen へ登録するので、この連鎖は自動的につながる。

iOS

登録の最後のステップで、すべてのエッジに対して setContentScrollView(_:for:) を呼ぶ。これにより UIKit はリストを探し回らずに直接観察できる。tab を再タップしたときのトップへのスクロールも同じネストの連鎖を通り、同じやり方でスクロールビューを解決する。

このマーカーには iOS 26+ でもう 1 つ用途がある。各スクロールビューのエッジには UIScrollEdgeEffect があり、コンテンツがバーの下で徐々に消えていくぼかし効果に使われる。以前は scrollEdgeEffects を screen に設定し、ヒューリスティックな検索で見つかった view に適用していた。つまり、何も起きずに効かなくなることがありえた。ScrollViewMarker に変えると、マーカーが包んでいるスクロールビューに作用するので、外れることがなくなる。

Android

version 5 の Stack ヘッダーは Material 3 の AppBarLayout で、small ヘッダーはコンテンツがその下までスクロールすると持ち上がる。つまり背景に色が付く。これが liftOnScroll で、デフォルトで有効だ。Material は持ち上げるかどうかを、対象の view に canScrollVertically(-1) を問い合わせて決める。この対象は明示的に指定できる。指定しない場合、Material は現在ネストスクロールしている view を使うが、レイアウトのたびに、そして app bar のオフセットが変わるたびに、findFirstScrollingChild から状態を再計算する。後者は CoordinatorLayout の直接の子だけを見る。私たちの Stack の実装では、この検索が何も見つけられない。CoordinatorLayout のコンテンツ子は 1 つだけで、それが screen を包むためのただの FrameLayout であり、あなたのリストはそのさらに下にある。だから view をどう組み立てても、Material はコンテンツがスクロールしていないと判断する。

マーカーが提供するのは、まさにこの明示的な対象だ。ScrollViewMarker がスクロール view を Stack screen に登録すると、ヘッダーはそれを setLiftOnScrollTargetView に渡し、以降の再計算はすべてあなたのリストに問い合わせる。ちらつきが解消するのはこうしてだ。再表示されたヘッダーが折りたたまれた状態で戻るべきかも、同じスクロール view が答える。Tabs のトップへのスクロールも同様にこれを使う。

試してみる

ScrollViewMarker は react-native-screens 5.0.0-alpha で利用できる。どう組み立てられているか知りたければ、これらの PR を見てほしい。コンポーネント本体、Tabs の iOS と Android への統合、そして liftOnScroll だ。サンプルアプリにはいくつかのシナリオもあり、動かせば各統合の効果を確認できる。フィードバックを待っている。楽しんでほしい!

出典: Software Mansion← ホームへ戻る