こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトの記事には、表示を切り替えるボタンが付いています。文字の大きさ、書体、縦書きか横書きか、ふりがなの有無です。
読んでいる途中で押されることを想定しています。ところが、押すと読んでいた場所を見失いました。
なぜずれるか
これらの設定は <html> の属性を変えることで効きます。CSS がページ全体に当たるので、そのページにある全部の記事が再レイアウトされます。
文字を大きくすれば、各記事が縦に伸びます。押したボタンより上にある記事が伸びると、そのぶん下へ押し出されます。読んでいた場所が画面外へ出ます。
ふりがなの表示でも同じことが起きます。ルビが出ると行の高さが変わります。
厄介なのは、「その記事自体が画面に収まっている」ときでもずれることです。ページ全体の高さが変わるので、スクロール位置の意味が変わります。
押した記事の先頭へ合わせ直す
対処は、切り替えた後に、押したボタンがある記事の先頭へスクロールし直すことでした。
// トグルは <html data-kn-*> 経由でページ内の全記事を再レイアウトするため、
// バー(記事ヘッダ帯)より上の高さが変わりスクロール位置がズレる。
// 記事全体が切替前の画面内に収まっていてもページ全体の高さは変わるため、
// 再描画後は常に押した記事の Paper(scroll-margin-top 付き)の先頭へ合わせ直す。
// rAF は React のコミットと再レイアウト完了後にスクロールを確定するため。
const updateDisplayAndScrollToArticle = (update: () => void) => {
const article = navRef.current?.closest('[id^="art-"]');
if (!article) {
update();
return;
}
update();
requestAnimationFrame(() => {
article.scrollIntoView({ block: 'start' });
});
};
やっていることは 3 つです。
- 押されたボタンから、自分が属する記事の要素を探す
- 設定を更新する
- 次のフレームで、その記事の先頭へスクロールする
rAF が要る理由
設定を更新した直後にスクロールしても、意味がありません。その時点ではまだ再レイアウトが終わっていないので、古い座標へ移動します。
requestAnimationFrame を挟むと、React の更新が DOM に反映され、ブラウザがレイアウトを計算した後に実行されます。
固定の待ち時間(setTimeout(0) や 100ms)でも似たことはできますが、環境によって足りたり足りなかったりします。フレームの区切りに合わせるほうが確実でした。
記事が見つからないとき
ボタンは、記事の中だけでなくページの先頭にも置いています。トップページや月別のページでは、記事に属していません。
その場合は、スクロールの調整をせずに設定だけ更新します。
if (!article) {
update();
return;
}
同じ関数を両方の場面で使うので、対象が無いケースを最初に返しています。無理に何かへスクロールさせるより、何もしないほうが自然でした。
scroll-margin-top と組み合わせる
スクロール先の記事には、固定ヘッダに隠れないための余白が指定してあります。
scrollMarginTop: ANCHOR_SCROLL_MARGIN_TOP,
scrollIntoView はこの指定を尊重するので、ヘッダの下に潜りません。目次から飛んだときと同じ着地になります。
同じ「特定の要素へ移動する」でも、経路が 2 つ(目次からのリンクと、この処理)あります。着地の位置を決めるのは要素側の指定なので、どちらから来ても同じ位置に着きます。
移動する側ではなく、移動先の要素に着地位置を持たせると、経路が増えても揃います。
縦書きでは別の処理
縦書きの本文は、横方向にスクロールする独立したブロックです。設定を変えると、この中のスクロール位置もずれます。
こちらは別に、本文の先頭へ戻す処理を入れています。
const scrollToStart = () => { … };
scrollToStart();
const frame = requestAnimationFrame(scrollToStart);
2 回呼んでいるのは、即座に効かせつつ、レイアウト確定後にもう一度確定させるためです。1 回目で見た目が動き、2 回目で正確な位置になります。
読んでいた位置を保つ、という選択肢もありました。ただ、縦書きの列は組み直しで完全に変わるので、元の位置に対応する場所がありません。先頭に戻すほうが分かりやすいと判断しました。
どこまで戻すのが正しいか
「読んでいた場所」を厳密に復元するなら、表示中の段落や文字を覚えておいて、そこへ戻すことになります。
やりませんでした。記事 1 本がそれほど長くないので、先頭に戻せば十分に近い場所です。実装も単純で済みます。
厳密さと単純さの折り合いは、コンテンツの長さで決まります。長い記事を扱うサイトなら、別の判断になったと思います。
まとめ
- ページ全体に効く設定を変えると、そのページの全要素が再レイアウトされてスクロール位置がずれる
- 対象の記事が画面に収まっていても、ページ全体の高さが変わるのでずれる
- 操作したボタンが属する要素の先頭へ、切り替え後に合わせ直す
- スクロールは
requestAnimationFrameの中で行う。更新直後では古い座標になる - 着地位置は移動先の要素に持たせる(
scroll-margin-top)。経路が増えても揃う - 厳密な位置の復元より、先頭に戻すほうが単純で十分なことがある