iOS で click イベントがわけのわからない動作をする件について

  • 180
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

iPhone や iPad などでも、 JavaScript の click イベントは基本的に有効です。
しかし、これらのデバイスで click イベントを拾おうとすると、うまくいかないことがあります。
このあたり、結構わけのわからない仕様になっているので、順を追って説明します。

なお、以下の説明では jQuery 1.9以上の使用を前提としています。

どういうときにうまくいかないか

とりあえず、以下のHTMLを前提とします。

...
<body>
  <p id="child1">...</p>
  <p id="child2">...</p>
</body>
...

このHTMLに対して、以下のように click イベントを登録してみます。
この場合、#child1 をタップすると click イベントが発生し、関数が実行されます。

$('#child1').on('click', function () { ... });

これは問題ないです。

(ちなみに、もしかすると、bind() とか click() とか delegate() とか使う人もいるかもしれませんが、書き方は違うにせよ動作的にはだいたい同じです。ここでは on() に統一するので、on() の使い方がよくわからない方は、リファレンスをご参照ください。)

では次に、ドキュメント中の全 p 要素に対してイベントを登録したい場合、どう書きましょうか。
たとえば、以下のように書くこともあると思います。

$('p').on('click', function () { ... });

しかしこれだと、後から動的に挿入された p 要素をクリックしても click イベントが発生しません。これはデバイスに関係なく、当然の仕様で、上記はあくまでイベント登録時に存在する要素のみを対象として、イベントを登録する書き方だからです。

後から動的挿入される要素にも click イベントを登録するには、以下のように書きます。

$('body').on('click', 'p', function () { ... });

もしくは

$(document).on('click', 'p', function () { ... });

といった感じで書くことがあります。イベントバブリングを使ったやり方ですね。

こういう書き方をすると、ルート要素(上の例でいうところの 'body'document のこと)の子孫要素で click イベントが発生したときに、その子孫要素がセレクタ(上の例でいうところの 'p')に一致するかどうかを判定し、一致すればイベントを発動させる、という仕組みになります。

したがって、イベント登録後にルート要素以下に動的に要素が挿入されても、その要素をイベント発動の対象とすることができるわけです。

なお、'body' と書いても document と書いても同じ挙動になるはずなので、どちらを使うかは好みです(もしかすると 'body' のほうが速かったりはするかもしれません)。

(ちなみにこれと同じことをやるための jQuery 関数 live() というものがかつてありましたが、イベント登録はすべて on() に統合されたので、廃止されました)

しかし、ここで問題が起こります。

最後の2つの例のように 'body' や document をルート要素としてイベントを登録すると、iOS では動かないんです。

(ちなみに Safari だけの不具合かのように書いているサイトもありますが、 Chrome でも動かないようです。また、上記以外の場合でも click イベントが発動しないこともあるようですが、以下と同様の対策で解決することがあります)

どう対策すればいいか

有効な対策がいくつかあるので、紹介します。個人的オススメ順です。

ルート要素として body や document を指定しない

'body'document にイベントを登録してしまうとうまくいきませんが、body の中にある要素にイベントを登録する場合は、うまく動きます。

<body>
  <div class="container">
    <p id="child1">...</p>
    <p id="child2">...</p>
  </div>
</body>

この場合は、以下で動きます。

$('.container').on('click', 'p', function () { ... });

処理速度的にも、できるだけ範囲の狭い子要素にイベントを登録したほうが良いので、こういった対応ができるのであれば、それで一石二鳥です。

該当要素に対して cursor: pointer というスタイルを適用する

一番手軽かつ万能なのは、この方法かもしれません。

なんと、CSSを追記すると、JavaScript が動くようになるという、不思議な現象が起こるんです。

さきほどの

<body>
  <p id="child1">...</p>
  <p id="child2">...</p>
</body>
$(document).on('click', 'p', function () { ... });

p {
  cursor: pointer;
}

というCSSを追加するだけでOKです。

これで動くようになります。

該当要素を a 要素に変更する

要素の種類を変更して構わないのであれば、これでも動くようになります。

touchstart または touchend を使う

タッチパネルデバイス用に touchstarttouchend というイベントがあるので、こちらを使えば問題なくイベントが発生します。
しかしPCと共通のページの場合は、click と両方書くのも面倒ですし、click とは発動のタイミングや条件が微妙に異なるので、注意が必要です。

$(document).on('click touchstart', 'p', function () { ... });

該当要素に適当な click イベントを登録しておく

HTMLを以下のように書き換えても、動きます。
あまり綺麗ではないので、特にこの方法を採用するメリットは思い浮かびませんが、これで動くようになる、というのは興味深いです。

<body>
  <p id="child1" onclick="">...</p>
  <p id="child2" onclick="">...</p>
</body>

なぜこのような仕様になっているのか

上記の解決法は、日本語サイトでも解説しているところはいくつかあるようなんですが、なぜこんな仕様になっているのかについて触れている日本語サイトは見つかりませんでした。

特に、CSSで cursor: pointer を指定することで JavaScript の動作が変わるというのは、かなり不思議なことのように思いませんか?

実は、この仕様については、ひとつもっともらしい説があるんです。

iOS は、ご存知の通りタッチパネルによる操作を前提としています。

PC の場合、マウスカーソルでシビアに当たり判定を行いますが、指で操作するタッチパネルの場合、ユーザーはどうしてもマウスカーソルほど厳密な位置で、タップをすることはできません。

液晶との間にあるガラスの厚みによって見る角度次第で若干押しているつもりの位置が変わりますし、指自体に太さがあるので正確に「どのドットを押した」という判定をすることは現実的ではありません。

そこで、Apple は、「物理的にどこが押されたのか」ではなく、「ユーザーはどこをタップしたいのか」を判定する独自のアルゴリズムを作りました。

そして、そのアルゴリズムを実現するためには「このページは、どの部分がタップによる操作対象なのか」という情報が必要です。

その判定をするために、a 要素だとか、click イベントが登録されている要素だとか、cursor: pointer が適用されている要素だとかを抽出します。これらは普通、クリックされることが想定されて用意されている部品でしょうから、タップ対象と考えられるわけです。

そしてそれ以外の p とか span とかで、直接 click イベントが登録されているわけでもない要素は、クリックされることを想定されていないっぽいので、タップ対象として想定しないで良いだろう、というわけです。

ただ、さすがに bodyclick イベントが登録されているからといって、それをタップ対象と認めてしまえば、結局全部タップ対象ということになって、あまり意味がないことになってしまいます。

そこで、'body'document はタップ対象としては認識しないことになっているのかもしれません。

上記の理屈で、タップ対象として認識されなくなった要素は、いくらタップしてもイベントが発生しない、ということが起こるのだと考えられます。

これはに Apple が公式に説明している内容ではありませんし、細かいところで説明不足感があるかもしれませんが、それなりに説得力があると思います。いかがでしょうか?

というわけでみなさん、iOS での click イベントにはくれぐれもご注意ください。