こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第40回です。
前回(第39回)では、iOS Safari や Android Chrome の難敵であるソフトウェアキーボード出現に対して、interactive-widget や VisualViewport API を駆使して「キーボード直上にピタッと吸い付く入力バー」を実現しました。
「これでスマホ対応は完璧だ!」と胸を撫で下ろしたのも束の間、実機でチャットをテストしていたメンバーから、またしても悲痛な叫びが上がりました。
「文字を打とうと入力欄をタップしたら、 上のヘッダーやトピックバーが画面の上に押し出されて消えちゃった! 」
「部屋名も見えないし、退室ボタンを押そうにもヘッダーが画面外に逃げて押せない!」
入力バーを救出したと思ったら、今度は 「ヘッダーが画面外へ逃亡する」 という新たなモグラ叩きが始まったのです。
本稿では、モバイルOSが裏で勝手に引き起こす「自動スクロールシフト」を力技でねじ伏せ、ヘッダー・メッセージログ・入力バーを美しく分離・固定した「鉄壁の4段レイアウト」の構築記録を解説します!
1. モバイルOSの親切心が引き起こす「スクロールシフトの罠」
なぜ、入力バーをキーボードに吸着させたのに、上部のヘッダーが画面外へ吹き飛んでしまうのでしょうか?
その原因は、iOS Safari や Android Chrome に組み込まれた 「OS側の自動スクロール(Scroll Shift)」 にあります。
私たちがいくら CSS で height: 100dvh や position: absolute を指定していても、ブラウザのウィンドウ自体(window.scrollY)が上にスクロールしてしまうため、画面上部にあったヘッダーが画面外(負の座標)へと押し出されてしまっていたのです。
2. 目指すべき「鉄壁の4段構造」
ネイティブアプリのチャット画面を見てみると、キーボードが開いても上部のヘッダーは1ピクセルも動きません。縮むのは 「中央のメッセージログ領域だけ」 です。
私たちが目指したのは、まさにこの挙動です。
┌──────────────────────────────────────────────────┐
│ ① アプリヘッダー(💬 つゔぁいちゃんねる P2Pチャット)│ ← 【上端・完全固定】
├──────────────────────────────────────────────────┤
│ ② トピックバー(#雑談部屋 | 👤 5人 | [✕ 退出]) │ ← 【上端・完全固定】
├──────────────────────────────────────────────────┤
│ │
│ ③ メッセージログ領域 │
│ Alice: こんにちは! │ ← 【可変伸縮・内部スクロール】
│ Bob: キーボード出ても崩れないね! │ (ここだけが縮む!)
│ │
├──────────────────────────────────────────────────┤
│ ④ 入力バー([名前バッジ] [メッセージ入力...] [送信])│ ← 【下端・キーボード吸着固定】
└──────────────────────────────────────────────────┘
▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
【 ソフトウェアキーボード領域 】
3. 実装の全貌:スクロールシフトの完全抹殺
この理想の4段構造を実現するため、私たちは 「CSSによる物理的ロック」 と 「JavaScriptによる強制座標補正」 を組み合わせました。
(1) CSS:アプリ外枠の position: fixed と flex-shrink: 0
まず、アプリ全体を position: fixed で画面の四隅にガチガチに固定し、ヘッダー要素が高さの縮小で潰れないよう flex-shrink: 0 を指定します。
/* 画面全体の勝手なスクロールを物理的に遮断 */
html, body {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden; /* 外側スクロールを禁止 */
}
/* P2Pチャット アプリコンテナ */
.p2p-standalone-app {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100dvh;
height: var(--p2p-viewport-height, 100dvh);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ① アプリヘッダー:縮小を絶対に許さない */
.p2p-standalone-header {
flex-shrink: 0;
height: 48px;
z-index: 10;
}
/* ② トピックバー:縮小を絶対に許さない */
.p2p-xchat-topic-bar {
flex-shrink: 0;
z-index: 9;
}
/* ③ メッセージログ:残りの高さをすべて引き受け、内部スクロール */
.p2p-xchat-main {
flex: 1;
min-height: 0; /* Flexboxの子要素でスクロールを正しく機能させる必須ハック */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* ④ 入力バー:縮小を絶対に許さない */
.p2p-xchat-input-row {
flex-shrink: 0;
}
[!TIP]
Flexboxの縦並び(flex-direction: column)の中でスクロール領域を作る場合、スクロールさせたい子要素にmin-height: 0;を指定しないと、中身のコンテンツ量に応じて親要素を突き破ってしまい、スクロールバーが出ないという落とし穴があります。
(2) JavaScript:window.scrollTo(0, 0) の徹底連打
OSがどんなに入力欄に引きずられて画面全体をスクロールさせようとしても、瞬時に (0, 0) に引き戻し、さらに VisualViewport.offsetTop のズレをアプリの top スタイルに同期させます。
/**
* 強化版:ビューポート&ヘッダー固定ハンドラ
*/
function setupViewportHandler() {
if (!window.visualViewport) return;
const app = document.querySelector('.p2p-standalone-app');
function lockHeaderAndFitViewport() {
const vv = window.visualViewport;
// 1. OSによる勝手なスクロールを原点 (0, 0) に強制リセット
if (window.scrollY !== 0 || window.scrollX !== 0) {
window.scrollTo(0, 0);
}
// 2. iOS Safari での視覚的オフセットを補正してヘッダーを画面最上部に固定
if (app) {
app.style.top = `${vv.offsetTop || 0}px`;
app.style.height = `${vv.height}px`;
}
// 3. CSS変数にも注入
document.documentElement.style.setProperty('--p2p-viewport-height', `${vv.height}px`);
}
// 各種イベントリスナーにバインド
window.visualViewport.addEventListener('resize', lockHeaderAndFitViewport);
window.visualViewport.addEventListener('scroll', lockHeaderAndFitViewport);
// テキスト入力欄のフォーカス時にも徹底的に (0, 0) を死守
const textarea = document.getElementById('p2p-message-input');
if (textarea) {
textarea.addEventListener('focus', () => {
setTimeout(lockHeaderAndFitViewport, 10);
setTimeout(lockHeaderAndFitViewport, 100);
});
}
lockHeaderAndFitViewport();
}
4. 自動テストと実機での動作確認
自動検証スクリプトおよび iPhone Safari 実機でのテストを行いました。
| 操作シナリオ | 修正前 | 修正後(本改修) |
|---|---|---|
| 入力欄をタップしてキーボード展開 | ヘッダーとトピックバーが画面外上部に消失 | ヘッダーは最上部にビタ止まり!全く動かない |
| キーボード展開中の部屋名確認 | 見えない | 常に最上部にクリアに表示 |
| キーボード展開中の「退出」ボタン操作 | 押せない | ワンタップでいつでも退出・設定可能 |
| メッセージログのスクロール | 画面全体がガタガタ揺れる | ヘッダーと入力欄の間でログだけが軽快にスクロール |
画面の上と下がガッチリと固定され、中央の会話ログだけがスムーズに伸縮する、まさにネイティブアプリそのものの極上UIが完成しました。
5. まとめと次回予告
モバイルWebにおける「キーボード追従」と「ヘッダー固定」という二大難問を、position: fixed、flex-shrink: 0、VisualViewport、そして scrollTo(0, 0) 強制ロックの合わせ技によって完全に征服しました。
こうしてモバイルでの操作性が極まったP2Pチャットですが、ユーザー数が増えるにつれて、シグナリングサーバー側に 「奇妙なアクセス」 が目立つようになりました。
「半日前に解散したはずの部屋URLにアクセスし続けて、画面が真っ白なまま固まっているユーザーがいる」
「URLのタイプミスで存在しない部屋に参加しようとして、無限ローディングに陥っている」
サーバーレスP2Pにおいて、 「存在しない部屋」や「すでに消滅した部屋」をどう判定し、どうユーザーに親切に案内するのか?
次回は、 【第041回】存在しない部屋へのアクセスを遮断せよ!P2Pチャット参加時のルーム存在検証とエラーハンドリング をお届けします。お楽しみに!
本番サービス : https://2ch.biz/