0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【第040回】キーボードが出てもヘッダーが逃げない!固定ヘッダーと可変スクロール領域の完全分離

0
Posted at

こんにちは!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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?