Programming Field

[Web] スマホ版Safariでリンクやボタンの反応エリアがわずかに広いのに :active が反応しない

HTML・CSSで要素をクリック・タップしたときに、その反応を表現するために「:active」疑似クラスを使います。スマホ(iOS)版Safariでは「タップできるエリアが実際の要素に対してわずかに広がっている」のですが、広がった部分をタップすると「:active」疑似クラスに指定したスタイルが適用されず、にもかかわらずクリックやタップによる処理が行われる、という現象が起きる場合があります。ここではその対策を紹介しています。

デモ

以下にタップできるボタン(四角形)があります。このボタンの境界線ぎりぎりをタップしたとき、枠外になるか枠内になるか試してみてください。:active に背景色変化が入っているので、ちゃんとした枠内であればボタンの背景色が変化します。

[ボタン位置: xxx]

[ここにタップ結果が表示されます]

詳細

「:active」とiOS版Safari

「:active」疑似クラスは、HTMLの要素をクリック・タップしたことによる「アクティブ化」によって有効になるクラスで、

.foo:active {
    background-color: #ff0;
}

と書いた場合、例えば「.foo」のクラスが付いた要素をタップするとタップしている間背景色が変化します。これは主にボタン(button 要素 / input 要素)やリンク(a 要素)などに用いられ、ユーザー操作に対するリアクションとしてUX面で有用な方法です。

通常であれば、該当の要素内をクリック/タップしている間「:active」のスタイルが有効になるはずです。

ここで、一部?のiPhone×Safariにおいては、タップ可能な要素のタップ可能エリアをわずかに広げる処理が入っているのか、要素のエリアのわずかに外側をタップするとその要素をタップした扱いになります。一方、わずかに広がった部分をタップすると「:active」のスタイルは適用されない、という現象が発生します。

  • このとき、click イベントにおける座標(clientX/clientYなど)は、エリア外をタップしているにもかかわらずエリア内の座標に丸められます

「:active」が有効にならないのでタップした反応がないにもかかわらず、実際には click イベントが発行されリンクであればページ遷移等が行われるので、UXの観点で微妙な状態になってしまいます。

※ もともとiOS版Safariでは、「touchstart」イベントを設定しておかないと「:active」が有効にならない、という問題もありますが、ここで記載の問題は「touchstart」イベントを設定している状態で発生する問題です。「touchstart」設定に関する参考: iOS で active 状態をサポートする - サイトにタッチを追加する  |  Articles  |  web.dev

エリア外のタップをエリア内タップに捻じ曲げるのを阻止する方法

「エリア外をタップしているのにエリア内がタップされる」をハンドルするために、素直に考えれば該当エリア(ボタン/リンク等)の click イベントをハンドルしてエリア外であれば処理をブロックする、が考えられます。

しかし、実際にこれをやろうとすると前述の通り「座標(clientX/clientYなど)はエリア外をタップしているにもかかわらずエリア内の座標に丸められる」という状況が確認できると思います。

  • 該当エリアではなく親要素で(capture: true 等を使って)イベントをハンドルすればよいと考えるかもしれませんが、座標の丸め込みはイベント発生時に既に起こっているので、親要素でハンドルしても得られる座標はエリア内になってしまいます。

そのため、一見すると対策がなさそうに見えますが、ここで使えるのがタッチイベント、特に「touchend」イベントです。「touchend」イベントの時点ではまだ座標が丸め込まれていないので、親要素で「touchend」をハンドルして座標がボタンやリンクの外であれば処理を止める、という対応をすると、エリア外では click イベントや既定の動作の発生を阻止して「タップによる反応が無いのに動作が処理される」のを防止することができます。

以下がその例です。

container.addEventListener("touchend", function (e) {
    // 複数点タップではもともとクリック等の動作が起きないので最初の点のみを見る
    var point = e.changedTouches[0];
    if (!point) { return; }
    // 位置から要素を探す
    var element = document.elementFromPoint(point.clientX, point.clientY);
    // 要素が見つからないか、コンテナーの外である場合は何もしない
    if (!element || !container.contains(element)) {
        return;
    }
    // 見つかった要素から親要素を探索してクリック可能要素を探す
    // クリック可能要素が見つかった場合、それはタッチした位置がその要素内にあることを示す
    while (foundElement && foundElement !== container) {
        if ('tagName' in foundElement) {
            var t = foundElement.tagName.toLowerCase();
            // ※ ここの条件式はUI実装等によって変わります。
            if (t === 'button' || t === 'input' || t === 'a' || t === 'select' || t === 'textarea') {
                return;
            }
        }
        foundElement = foundElement.parentElement;
    }
    // 見つからなかった場合は既定の処理を止める
    e.preventDefault();
    e.stopPropagation();
    // 必要であれば見つかった要素に対する click イベントを手動で発行する
    // (既定の処理に任せると問題が起きるので、必要な場合は手動で行う必要がある)
    element.dispatchEvent(new MouseEvent('click', { clientX: point.clientX, clientY: point.clientY, screenX: point.screenX, screenX: point.screenY }));
}, false);

※ そもそもiOS版Safariがタップ可能領域を広くとっているのは、「要素が小さいとタップできない」のを防止するために行っていると考えられます。モバイルを考慮する場合、基本的にタップ可能なボタンは一定以上の大きさを持つべきとされています。

改良版デモ

上記のコード例を加えたデモです。

[ボタン位置: xxx]

[ここにタップ結果が表示されます]

まとめ

  • 「:active」疑似クラスではクリックやタップの反応を表現することができます
  • しかし一部のiOS版Safariでは、当該要素のわずかに外側をタップすると、「:active」が適用されないのにタップによる処理が実行されることがあります
  • それを回避するには、親要素で touchend イベントをハンドルして「外側であれば既定処理を実行しない」を行う必要があります