トラックパッドの慣性に、2回目のスワイプを飲まれていた

カードのスクロールが「効いたり効かなかったり」する原因は、慣性スクロールの尾が次のスワイプを飲み込んでいたことだった。感度を上げずに直した方法。

#UI#スクロール#ジェスチャ

このサイトのトップでは、画面の左にある説明カードの上でスクロールすると、次のエリアへカードを1枚ずつ送れます。カメラもそれに合わせて、惑星の上の次の建物へ飛んでいきます。

8月の初め、これを使っていて気になっていたことを、そのまま頼みました。

カードの上にカーソルを合わせているときのスクロールの感度が悪いです。カードが切り替えられたり、できなかったりするので、適切に切り替えられるようにしてください。ですが、次のカードが出現していないのに、その次のカードに切り替えられるようなことは防いでください。

このお願いには、2つの要望が入っていて、しかも逆を向いています。「もっと反応してほしい」と「先へ行きすぎないでほしい」。スクロールの感度(しきい値)を上げ下げする1つのつまみでは、両方は満たせません。そこで、この2つを別々の不具合として切り分けるところから始めました。

「効いたり効かなかったり」の正体

カードの送り方は、「1回のスクロールの動作(ジェスチャ)で1枚」という決まりで作っていました。スクロールの量が80ピクセルを越えた瞬間に1枚送って、そのジェスチャが終わるまでロックする。ジェスチャの終わりは、「スクロールのイベントが220ミリ秒止まったら」で判定していました。

問題は、Mac のトラックパッドの慣性スクロールでした。トラックパッドで勢いよくスワイプして指を離すと、画面はしばらく惰性でスクロールし続けます。このとき、ブラウザには1〜2秒のあいだ、だんだん小さくなるスクロールのイベントが届き続けます。

この「惰性の尾」がまだ続いているうちに、2回目のスワイプをしたとします。2回目のイベントは、尾との間隔が220ミリ秒より短いので、「前のジェスチャの続き」と見なされてしまう。2回目のスワイプが、丸ごと捨てられていたのです。

尾が切れたあとにスワイプすれば、ちゃんと効きます。つまり、効くか効かないかの分かれ目は「前のスワイプの惰性に重なったかどうか」でした。使っている側からは、これは完全にランダムに見えます。「効いたり効かなかったり」の正体は、これでした。

惰性と、指が戻ってきたことを見分ける

直し方のヒントは、イベントの大きさの変わり方にありました。

  • 慣性の尾は、単調に小さくなっていく
  • 指でスワイプしているあいだは、イベントが大きくなっていく

そこで、「静止したらジェスチャ終了」に加えて、2つ目の終了条件を足しました。このジェスチャで一番大きかったイベントの半分を下回ったあとに、直前のイベントの2.5倍以上に跳ね上がったら、それは惰性ではなく、新しく指が戻ってきたしるしなので、新しいジェスチャとして扱う。

この2つの条件は、片方だけでは意味がありません。スワイプを始めた直後は、イベントが 2、5、13、34、88、240 と急に大きくなっていきます。ここで「2.5倍に跳ね上がった」だけを条件にすると、スワイプ自身の立ち上がりを「新しいジェスチャ」と誤解してしまいます。でも立ち上がりの最中は、直前のイベントがいつも「今までで一番大きい」ので、「半分を下回った」という条件が成り立ちません。だから誤爆しない。2つで1つの仕掛けです。

途中で、「これより小さいイベントでは判定しない」という下限の値も一度入れました。慣性の尾の末端では、値が1や2のように小さくなって、2.5倍が誤差でも成り立ってしまうかもしれない、と考えたからです。でも、それで実際に困る場面を、テストとして書くことができませんでした。尾の末端で間違って判定しても、残りのスクロール量はわずかで、80ピクセルには届かないからです。壊れたときに何が起きるかをテストで書けない値は、根拠のない値なので、外しました。

「先へ行きすぎない」は、待つことで守る

もう1つの要望、「次のカードが出ていないのに、その次へ行かない」は、感度ではなく、待つことで守りました。

カードを送る操作は、実は「カードを変える」命令ではなく、「カメラに次の建物へ向かってほしい」というお願いです。カードが実際に切り替わるのは、カメラが動いて、次の建物が一番近くなったあと。ここを待たずに次のお願いを受け付けると、まだ画面から消えかけている古いカードを基準に「次」を計算してしまい、同じカードを指してスクロールが空振りしたり、逆に出そろっていないカードを飛び越したりします。

そこで、お願いを出したら、カードが実際に切り替わったことを確認するまで、次のお願いを受け付けないようにしました。待ち時間も目で決めずに計算しています。カメラが境目を越えるまでが約0.13秒、新しいカードが画面にほぼ収まるまでが約0.16秒。合わせて最短で約0.3秒、つまり毎秒3枚が上限です。

待っているあいだに弾かれたスクロールは、ためておかずに捨てます。ためておくと、待ちが解けた瞬間に、もう誰も触っていない惰性の尾が次の1枚を送ってしまうからです。本当に次を見たいなら、次のスワイプで必ず通ります。

感度のつまみは、回さなかった

途中で、しきい値を80から50に下げる案も出ました。軽く2本指でなでるだけでも、確実に1枚送れるようにする案です。でも僕は、80のままにすることを選びました。

取りこぼしの本当の原因が、慣性の尾の誤判定だと切り分けられていたからです。もし最初に感度のつまみを回していたら、誤って送られるカードが増えただけで、本当の原因は残ったままだったはずです。

ついでに見つかったのが、マウスホイールの違いです。Chrome はマウスのホイール1ノッチを「100ピクセル」として送ってくるのに、Firefox は同じ1ノッチを「3行」として送ってきます。行のまま80と比べると、1枚送るのに27ノッチ回す必要があり、感度が悪いどころか、実質的に動かない状態でした。行やページ単位で届いた値を、ピクセルに換算してから比べるようにしました。

同じジェスチャ列で、直る前と後を比べる

直したことの確認は、実際の画面に、スクロールのイベントを流し込んで行いました。

  • 1ノッチだけ回す → 1枚
  • 勢いよくスワイプして、惰性の尾が50回続く → 1枚
  • 惰性の尾の最中に、2回目のスワイプを始める → 2枚

3つ目が、今回直したかった場面です。念のため、直した部分だけを元に戻して、まったく同じイベント列を流し直しました。すると、結果は1枚になりました。症状と修正が、同じ現物の上で1対1に対応していることを確かめられたので、ここで完了にしています。

スクロールのような「手触り」の不具合は、報告する側も「なんとなく効きが悪い」としか言えないことが多いと思います。でも、その「なんとなく」の中に、「惰性に重なったかどうか」という、ちゃんとした分かれ目が隠れていました。

このノートに出てくるプロダクト

ほかのノート