📱

【React / Tailwind】タッチスクリーンにおける最適化について(@media(hover:hover), onTouch)

2024/05/27に公開

はじめに

この間、開発中のWebアプリにレスポンシブデザインを抜かりなく実装しようと色々試行錯誤していた時に、iPadなどタッチ操作がメインのデバイスでは下記の現象が観測されました。

  1. hover状態が次のタッチ操作までは消えない
  2. onClickイベントの発火がワンテンポ遅れる

もちろんiPadだけではなく、Chromeの開発者モードでタッチスクリーンデバイスを選択すると同じような現象が観測できます。

「1」はかなり前から気づいていて、まぁhoverが残るくらい別にええやろと特に気にしなかったのですが、今回はちょっと背伸びをして、「2」も含めて徹底的に原因究明&対策することにしました。

目標

  • マウス・トラックパッド操作がメインのデバイスでは、hover、activeなどの擬似クラスを正しく適用させる
  • タッチ操作がメインのデバイスでは、hoverだけ適用させない
  • タップして、指が離れた瞬間にclickイベントに発火してほしい

hover状態が消えない問題は@media(hover:hover)で解決

色々調べた結果、どうやらhoverにもメディア特性が用意されているらしく、たとえば——

@media (hover: hover) {
  a:hover {
    background: yellow;
  }
}

と記述すれば、hoverはhover可能なデバイスでしか表示されなくなります。

これをTailwind CSSで実装するには、
tailwind.config.tsにプラグインを導入して、addVariantを使って通常のhoverを置き換える必要があります。

tailwind.config.ts
import plugin from 'tailwindcss/plugin'

const config = {

//...

    plugins: [
        plugin(function ({ addVariant }) {
            addVariant('hover', '@media(hover:hover){ &:hover }')
            addVariant('group-hover', '@media(hover:hover){ .group:hover & }')
        }),
    ],
}

ざっくり説明すると、
Tailwindで「hover:」や[group-hover:]と記述した擬似クラスは全部、

@media (hover: hover) {
  /* hoverの擬似クラス */
}

この@media (hover: hover) {}に包まれて生成されます。

例えば、

example.tsx
<button className="rounded-md bg-blue-700 px-5 py-3 text-white transition-all hover:scale-105 hover:bg-blue-700/90 active:scale-95">
    Button
</button>

と記述した場合、
下記のようなCSSが生成されます

@media(hover:hover) {
  .hover\:scale-105:hover {
    --tw-scale-x: 1.05;
    --tw-scale-y: 1.05;
    transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
  }

  .hover\:bg-blue-700\/90:hover {
    background-color: rgb(29 78 216 / 0.9);
  }
}

補足

tailwind.config.ts
    addVariant('hover', '@media(hover:hover){ &:hover }')
    addVariant('group-hover', '@media(hover:hover){ .group:hover & }')

とだけ記述した場合、
activeの擬似クラスで同じプロパティに指定があると、activeが適用されなくなる現象が観測されました。
原因はまだ究明できていませんが、おそらく何かがコンフリクトを起こしていると思われるので、一応解決策として、

tailwind.config.ts
     addVariant('hover', '@media(hover:hover){ &:hover }')
     addVariant('group-hover', '@media(hover:hover){ .group:hover & }')
+    addVariant('active', '&:active');

このように追加指定すれば、ちゃんとactiveも適用されるようになります。

TailwindのPlaygroundにコード例を載せておきましたので、興味のある方は実際に試してみてください。
https://play.tailwindcss.com/aNUc6W0jvs

onClickがワンテンポ遅れる問題はonTouchイベントで

そもそもなぜ遅延が発生するのか

GPT4の回答:

clickイベントの発火がワンテンポ遅れるように感じる主要な理由は、モバイルブラウザがタッチイベントとマウスイベントの両方をサポートしているためです。特に、モバイルブラウザはタッチ操作を受けた際、その操作が単なるタップ(clickに相当)なのか、それともダブルタップや長押し(長タッチ)、ピンチとズームなどの他のジェスチャーの開始なのかを識別する必要があります。

このため、ユーザーが画面をタップした瞬間にすぐにclickイベントを発火するのではなく、ブラウザは少しの間(通常は約300ミリ秒)イベントの発火を遅らせて、ユーザーが追加のタッチ(ダブルタップなど)を行うかどうかを確認します。この遅延により、ブラウザはダブルタップのような特別なジェスチャーを判断できますが、その副作用として単純なタップの反応が遅れることになります。

非常に説得力の高い回答でした。

解決策

とはいえ、ダブルクリックや長押しイベントを定義していない場合、この遅延はユーザー体験を損なうだけなので、対処が必要だと考えました。

自分なりの解決策は下記の通りです

button.tsx
const [isMoving, setIsMoving] = useState(false)

const handleTouchMove = () => {
    setIsMoving(true);
}

const handleTouchEnd = (event: React.TouchEvent<HTMLButtonElement>) => {
    if (!isMoving) {
        event.preventDefault()
        event.currentTarget.click()
    }
    setIsMoving(false);
}

handleTouchMovehandleTouchEnd、そしてisMovingを用意して、
コンポーネントに追加します。

button.tsx
// shadcn/uiのbuttonコンポーネント

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
    ({ className, animation = true, variant, size, asChild = false, ...props }, ref) => {
        const Comp = asChild ? Slot : "button"
        const [isMoving, setIsMoving] = useState(false)
    
        const handleTouchMove = () => {
            setIsMoving(true);
        }
    
        const handleTouchEnd = (event: React.TouchEvent<HTMLButtonElement>) => {
            if (!isMoving) {
                event.preventDefault()
                event.currentTarget.click()
            }
            setIsMoving(false);
        }

        return (
            <Comp
                className={cn(buttonVariants({ variant, size, className }), animation && "sm:hover:scale-105 sm:active:scale-95")}
                ref={ref}
                onTouchMove={handleTouchMove}
                onTouchEnd={handleTouchEnd}
                {...props}
            />
        )
    }
)

shadcn/uiを使用しています

handleTouchMoveは重要

最初はhandleTouchEndだけで遅延をなくそうとしていましたが、
誤操作(ただのスライドをタップと認識しちゃったりとか)が頻発するので、
仕方なくhandleTouchMoveを追加しました。

まとめ

@media(hover:hover)やonTouch関連のイベントをうまく駆使すれば、
Webアプリでもネイティブアプリに近いUXが得られることがわかりました。

上記の方法で実装して1週間経ちましたが、今のところ目立ったバグは起きていません。性能面での悪影響も特にないようです。
もし今後何かありましたここで報告します。

余談

iPadでマウスとマジックトラックパッドを使って検証した結果、
やはりiPadではどんな入力デバイスを使っていても、「hoverができないデバイス」として判定されてしまうようです。

PCのみならず、iPadまでマウスとタッチ操作両方に対応するようになった昨今、
すべての入力デバイスに完璧に対応しようとするなら、やはり@mediaだけでなく、hookなどを使った高度な判定が必要になってくるのかもしれません。

Discussion