Loading
BLOG 開発者ブログ

2022年12月3日

iOS Safari で常にスクロールバーを表示する

モバイル端末でウェブアプリケーションを表示する際、
ある要素が「スクロール可能領域であることをユーザーに認知させる」ことは
UX を損なわないために重要であり、注意する必要があります。

しかし、iOS 13 以降の Safari では、スクロールバーを拡張する CSS 疑似要素 ::-webkit-scrollbar が効かなくなり、
その CSS を適用するだけではスクロールバーを常時表示できなくなってしまっています。

そこで今回は、React (TypeScript) で作成したウェブアプリケーションにおいて、
追加の UI ライブラリを使用せずにスクロールバーを表示するために私が実際に実装したコード例をご紹介します。

目次

はじめに

この記事は アイソルート Advent Calendar 2022 3日目の記事です。

こんにちは。クラウドソリューション第一グループの namiki.t です。
昨日は watanabe.t さんの 「EC2でS3(Static Website Hosting)をプロキシしたい」でした。 ぜひこちらの記事もご覧ください。

冒頭にも記載した通り、iOS 13 以降においてスクロールバーを拡張する CSS 疑似要素 ::-webkit-scrollbar が効かなくなり、
単純な記載だけではスクロールバーを常時表示できなくなってしまっています(2022/12/03現在)。

これでは、例えば

  1. コード上は利用規約をスクロールありで表示しているが、iOS Safari ではスクロールバーが表示されていないので最後まで読まれない
  2. 利用規約を最後まで読まないと (最下部までスクロールしないと) 次へ進めないが、ユーザーにはそれがわからない
  3. ユーザーが離脱してしまう

といった問題が発生するおそれがあります。

今回は、そのような問題を防ぐための一つの方法として、 UI ライブラリを追加することなく、
シンプルなコードで iOS 13 以降でもスクロールバーを常に表示するコードをご紹介します。

前提条件

本記事で使用したライブラリのバージョンは以下の通りです。

パッケージ名 バージョン
react 18.2.0
react-dom 18.2.0
react-scripts 5.0.1
typescript 4.9.3

また、React (TypeScript) の環境構築手順や、コード上で使用している React Hooks などに関する解説は割愛し、
npm run start でローカルサーバを起動できる状態であることを前提とします。

スクロールバーの実装

実装は以下の通りです。ハイライトをかけている要点について下記で詳解します。

import { memo, UIEvent, useCallback, useState } from "react";

const SCROLL_AREA_WIDTH = 300;
const SCROLL_AREA_HEIGHT = 200;
const SCROLL_BAR_WIDTH = 8;
const SCROLL_BAR_HEIGHT = 30;

/**
 * 通常のスクロールエリア
 */
export const ScrollArea = memo(() => {
  return (
    <div
      style={{
        width: SCROLL_AREA_WIDTH,
        height: SCROLL_AREA_HEIGHT,
        overflowY: "scroll",
      }}
    >
      <div>
        {[...Array(100)].map(() => "通常のスクロールエリア")}
      </div>
    </div>
  );
});

/**
 * iOS 用スクロールエリア
 */
export const IOSScrollArea = memo(() => {
  /** スクロール位置 */
  const [scrollProgress, setScrollProgress] = useState<number>(0);

  /** バーの表示位置を算出し set する */
  const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
    /**
     * scrollTop: スクロール可動域上端からの距離
     * scrollHeight: 要素の中身の高さ
     * clientHeight: スクロール要素の高さ
     */
    const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
    setScrollProgress(scrollTop / (scrollHeight - clientHeight));
  }, []);

  return (
    <div
      style={{
        width: SCROLL_AREA_WIDTH,
        height: SCROLL_AREA_HEIGHT,
        display: "flex",
      }}
    >
      <div
        style={{
          width: SCROLL_AREA_WIDTH - SCROLL_BAR_WIDTH,
          height: SCROLL_AREA_HEIGHT,
          overflowY: "scroll",
        }}
        onScroll={(e) => {
          handleScroll(e);
        }}
      >
        {[...Array(100)].map(() => "iOS 用のスクロールエリア")}
      </div>
      <div
        style={{
          width: SCROLL_BAR_WIDTH,
          height: SCROLL_AREA_HEIGHT,
          background: "#EFEFEF",
        }}
      >
        <div
          style={{
            width: SCROLL_BAR_WIDTH,
            height: SCROLL_BAR_HEIGHT,
            marginTop:
              (SCROLL_AREA_HEIGHT - SCROLL_BAR_HEIGHT) * scrollProgress,
            background: "#BEBEBE",
            borderRadius: 8,
          }}
        ></div>
      </div>
    </div>
  );
});

L32 の scrollProgress には、最大スクロール量のうち
現在どの程度スクロールされているかを設定します。

現在どの程度スクロールされているかは、L42 の通り
スクロール可動域上端からの距離 / (要素の中身の高さ - スクロール要素の高さ(=最初から見えている高さ)) で算出できます。

それぞれを図に表すと以下の通りです。

L76 の marginTop には、上記で算出した scrollProgress
スクロールバーの可動域(=スクロール要素の高さ - スクロールバーの高さ) を掛け合わせたものを設定しています。

import { memo } from "react";

import { IOSScrollArea, ScrollArea } from "./scroll-area";

export const App = memo(() => {
  return (
    <>
      <ScrollArea />
      <IOSScrollArea />
    </>
  );
});

app.tsx で、iOS ではスクロールバーが見えないスクロールエリアと
iOS でスクロールバーを常に表示するスクロールエリアを並べます。

動作確認

iOS 15.6.1 を搭載した iPhone 13 mini の Safari で動作確認を行いました。

IOSScrollArea でスクロールバーを常時表示できていることが確認できました。

終わりに

今回は、iOS Safari でスクロールバーを常に表示するための実装をご紹介しました。
私が今回の問題に直面したとき、このような解決策にすぐにはたどり着けなかったので
本記事が多くの方の目に留まり、参考にしていただければ幸いです。

namiki.tのブログ