0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

横にめくりたいだけなのに、縦まで止めないで――素のJavaScriptでスワイプとスクロールを分ける

0
Posted at

本を読む画面に、横スワイプでページをめくる機能を付けるとします。

指が触れた。ページをめくる準備。指が動いた。スクロールを止める。準備万端です。

ただし、読者は続きを下にスクロールしたかっただけかもしれません。

本屋に入ると案内係が付き、棚を眺めようとするたびに腕をつかんで「次の棚ですね!」と連れていく。そんな接客を、JavaScriptで実装してしまうわけです。親切のつもりで、自由行動を廃止しています。

横スワイプの実装で大事なのは、横を検出することと、横ではない操作に介入しないことをセットにすることでした。

横を担当する前に、縦の担当を決める

個人開発のAfter 6 Streetには、本を開いて読む画面があります。紙面には縦のパンとピンチズームを許可し、横スワイプの成立をJavaScriptで判断します。

.bk-paper {
  touch-action: pan-y pinch-zoom;
}

touch-actionは、ブラウザーにどの操作を任せるかを、ジェスチャーの開始前に伝えます。だから、指が動いてからCSSを書き換えても、その操作には間に合いません。スクロールを担う要素までの祖先の指定も関わります。

StreetのコードはTouch Eventsで座標を受け取り、横と判断したときだけ、取り消せるtouchmoveをpreventDefault()します。そのために、移動と終了のリスナーはpassive: falseです。

Pointer Eventsで組む場合、パンやズームの許可・抑制はtouch-actionで宣言します。ブラウザーがパンなどを引き取るとpointercancelが届き、アプリ側はそこで処理を中止できます。

指が触れただけでは、注文を受けない

リンクを押すつもりの人に、ページめくりを売り込む必要はありません。文章を選んでいる人にも、まず選び終えてもらいます。

touchstartでは、指が一本で、めくれる画面にいて、文字を選択していないときだけ記録を始めます。リンクやボタン、入力欄から始まった操作も除外します。closest()を使うので、リンクの内側の文字やアイコンを触っても同じ扱いです。対象のセレクターは、画面に置く操作部品に合わせます。

記録するのは、指のidentifier、開始座標、時刻、表示中の画面を表すキーです。Streetは表示モード・コラム・ページ位置などをキーに含めます。指を置いたまま画面が切り替わったら、古い操作はそこで打ち切ります。

横かどうかと、めくるかどうかは別々に決める

touchmoveで開始位置からの横・縦の移動量を比べます。どちらかが12 CSS pxを超えたら、横が縦の1.5倍より大きいときだけ横に確定。ほかは縦として、その軸を最後まで持ちます。一度縦と決めたら、そのあと指が右へ大きく曲がっても、案内係は追いかけません。

横に確定すると、取り消せる移動イベントを止めます。一方、実際にめくるのはtouchendです。横に13px動いてすぐ離せば、移動イベントを止めたのにページは変わりません。早めに横を引き受けるための判定と、短い動きで誤ってめくらないための判定は役目が違います。

終了時には、同じ画面で文字選択がなく、指が残っていないことを確認します。離れた指はtouchesから消えるので、changedTouchesで開始時と同じidentifierを探します。移動中に縦と決めていなければ、その指の移動が48 CSS px以上、横が縦の1.5倍より大きく、800ms以内で成立します。左へ動けば次、右へ動けば前をページ移動側に伝えます。

touchstartからclickまでのコード

公開版のsrc/swipe.jsからattach()を抜き出し、改行とインデントを整えました。context()はめくれる画面の間はキーを返し、対象外ではnullを返します。onSwipe()は、すでにあるページ移動処理につなぎます。

function attach(element, {
  context,
  onSwipe,
  selected = () => !window.getSelection()?.isCollapsed,
  now = () => Date.now()
}) {
  let gesture = null, suppressClickUntil = 0;
  const controls = 'button,a,input,select,textarea,[contenteditable="true"]';

  element.addEventListener('touchstart', event => {
    gesture = null;
    suppressClickUntil = 0;
    const key = context();
    if (
      event.touches.length !== 1 ||
      !key ||
      event.target.closest(controls) ||
      selected()
    ) return;

    const t = event.touches[0];
    gesture = {
      id: t.identifier, x: t.clientX, y: t.clientY,
      time: now(), key, axis: null
    };
  }, { passive: true });

  element.addEventListener('touchmove', event => {
    if (!gesture) return;
    if (event.touches.length !== 1 || context() !== gesture.key || selected()) {
      gesture = null;
      return;
    }

    const t = event.touches[0],
      dx = Math.abs(t.clientX - gesture.x),
      dy = Math.abs(t.clientY - gesture.y);

    if (!gesture.axis && Math.max(dx, dy) > 12)
      gesture.axis = dx > dy * 1.5 ? 'x' : 'y';
    if (gesture.axis === 'x' && event.cancelable) event.preventDefault();
  }, { passive: false });

  element.addEventListener('touchend', event => {
    const g = gesture;
    gesture = null;
    if (
      !g ||
      event.touches.length ||
      context() !== g.key ||
      selected() ||
      now() - g.time > 800
    ) return;

    const t = [...event.changedTouches].find(t => t.identifier === g.id);
    if (!t) return;
    const dx = t.clientX - g.x, dy = t.clientY - g.y;
    if (
      g.axis === 'y' ||
      Math.abs(dx) < 48 ||
      Math.abs(dx) <= Math.abs(dy) * 1.5
    ) return;

    if (event.cancelable) event.preventDefault();
    suppressClickUntil = now() + 400;
    onSwipe(dx < 0 ? 1 : -1);
  }, { passive: false });

  element.addEventListener('touchcancel', () => {
    gesture = null;
  }, { passive: true });

  element.addEventListener('click', event => {
    if (event.detail !== 0 && now() < suppressClickUntil) {
      event.preventDefault();
      event.stopPropagation();
    }
  }, true);
}

成立直後のクリックにも担当があります。本のコンテナでキャプチャ時に拾い、成立から400ms未満でdetailが0以外なら、クリックの既定動作と子要素への伝播を止めます。detail === 0を通すのは、キーボードでの起動などを巻き込みにくくするためです。detailはクリックの回数に関係する値で、入力元を厳密に判定する値ではありません。新しいtouchstartが来れば、前の抑制時間は解除します。

境界は、少しだけ意地悪に確認する

与えた操作 ページ移動 確認できたこと
左へ60px 次へ1回 移動中と終了時にイベントを取り消す
縦へ60px なし 移動中も終了時もイベントを取り消さない
横へ47px/48px なし/1回 成立する距離は48pxから
横60px・縦40px/横61px・縦40px なし/1回 1.5倍ちょうどは横扱いしない
最初に縦へ進み、そのあと横へ大きく動く なし 一度決めた縦の軸を保つ
800ms/801msで横へ60px 1回/なし 800msまでは成立する
成立後399ms/400msのクリック(detail=1) ― 取り消す/取り消さない

表は合成イベントで判定を追った結果です。iPhoneでのタッチスクロール、ピンチ、画面端の「戻る」との競合は実機で未確認です。

小さな本で試す

Streetの本文は紙面に収まる長さに分けて表示し、スクロールできる目次はページが変わると先頭に戻します。下のサンプルでは、同じattach()を縦にスクロールできる紙面に付け、ページが変わるとpaper.scrollTop = 0で先頭へ戻しています。

前・次ボタンもスワイプと同じturn()を呼ぶので、タッチ操作が使えない環境でもページを送れます。HTMLを保存して開けば試せます。

保存して開けるHTML一式
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>横だけめくる本</title>
  <style>
    body {
      max-width: 42rem;
      margin: 1.5rem auto;
      padding: 0 1rem;
      color: #293b36;
      background: #f6f2e8;
      font: 16px/1.8 system-ui, sans-serif;
    }
    button { font: inherit; padding: .4rem 1rem; }
    a { color: #1f6755; }
    #paper {
      touch-action: pan-y pinch-zoom;
      block-size: clamp(14rem, 50vh, 24rem);
      overflow: auto;
      padding: .75rem 1.25rem;
      border: 1px solid #c6cabb;
      border-radius: .75rem;
      background: #fffdf7;
    }
    :focus-visible { outline: 2px solid #1f6755; outline-offset: 2px; }
  </style>
</head>
<body>
  <h1>横だけめくる本</h1>
  <p>紙面の中を縦にスクロールできます。タッチ端末で横にめくるか、前・次ボタンを押してみてください。</p>
  <button id="prev" type="button">前へ</button>
  <button id="next" type="button">次へ</button>
  <p id="status" aria-live="polite"></p>
  <article id="paper" tabindex="0" aria-labelledby="heading">
    <h2 id="heading"></h2>
    <p><a href="#note">紙面の注記へ</a></p>
    <div id="prose"></div>
    <p id="note" tabindex="-1">この本はサンプルです。めくると、次の紙面の先頭に戻ります。</p>
  </article>

  <script>
function attach(element, {
  context,
  onSwipe,
  selected = () => !window.getSelection()?.isCollapsed,
  now = () => Date.now()
}) {
  let gesture = null, suppressClickUntil = 0;
  const controls = 'button,a,input,select,textarea,[contenteditable="true"]';

  element.addEventListener('touchstart', event => {
    gesture = null;
    suppressClickUntil = 0;
    const key = context();
    if (
      event.touches.length !== 1 ||
      !key ||
      event.target.closest(controls) ||
      selected()
    ) return;

    const t = event.touches[0];
    gesture = {
      id: t.identifier, x: t.clientX, y: t.clientY,
      time: now(), key, axis: null
    };
  }, { passive: true });

  element.addEventListener('touchmove', event => {
    if (!gesture) return;
    if (event.touches.length !== 1 || context() !== gesture.key || selected()) {
      gesture = null;
      return;
    }

    const t = event.touches[0],
      dx = Math.abs(t.clientX - gesture.x),
      dy = Math.abs(t.clientY - gesture.y);

    if (!gesture.axis && Math.max(dx, dy) > 12)
      gesture.axis = dx > dy * 1.5 ? 'x' : 'y';
    if (gesture.axis === 'x' && event.cancelable) event.preventDefault();
  }, { passive: false });

  element.addEventListener('touchend', event => {
    const g = gesture;
    gesture = null;
    if (
      !g ||
      event.touches.length ||
      context() !== g.key ||
      selected() ||
      now() - g.time > 800
    ) return;

    const t = [...event.changedTouches].find(t => t.identifier === g.id);
    if (!t) return;
    const dx = t.clientX - g.x, dy = t.clientY - g.y;
    if (
      g.axis === 'y' ||
      Math.abs(dx) < 48 ||
      Math.abs(dx) <= Math.abs(dy) * 1.5
    ) return;

    if (event.cancelable) event.preventDefault();
    suppressClickUntil = now() + 400;
    onSwipe(dx < 0 ? 1 : -1);
  }, { passive: false });

  element.addEventListener('touchcancel', () => {
    gesture = null;
  }, { passive: true });

  element.addEventListener('click', event => {
    if (event.detail !== 0 && now() < suppressClickUntil) {
      event.preventDefault();
      event.stopPropagation();
    }
  }, true);
}

const pages = [
  ['一ページ目 親切な案内係', '棚を眺めるたびに案内係が駆けてくる。そんな本屋だと、立ち止まるのにも勇気がいります。'],
  ['二ページ目 上下左右の担当', '縦に読み進めるのはブラウザーの仕事。横にめくるときだけJavaScriptの出番です。'],
  ['三ページ目 用があるときだけ', 'リンクを押す人もいれば、文章を選ぶ人もいます。とりあえず見守るのも接客です。']
];
const paper = document.querySelector('#paper');
const prev = document.querySelector('#prev');
const next = document.querySelector('#next');
let index = 0;

function render() {
  const [heading, text] = pages[index];
  document.querySelector('#heading').textContent = heading;
  const paragraphs = Array.from({ length: 9 }, () => {
    const p = document.createElement('p');
    p.textContent = text;
    return p;
  });
  document.querySelector('#prose').replaceChildren(...paragraphs);
  document.querySelector('#status').textContent = (index + 1) + ' / ' + pages.length;
  prev.disabled = index === 0;
  next.disabled = index === pages.length - 1;
}

function turn(direction) {
  const target = index + direction;
  if (target < 0 || target >= pages.length) return;
  index = target;
  render();
  paper.scrollTop = 0;
}

attach(paper, { context: () => String(index), onSwipe: turn });
prev.addEventListener('click', () => turn(-1));
next.addEventListener('click', () => turn(1));
render();
  </script>
</body>
</html>

案内係の求人票

横に動かす人だけを見ていると、画面はだんだん横着になります。縦に読みたい人も、リンクを押したい人も、ボタンで進みたい人もいます。

この仕様を、案内係の求人票にするとします。

業務内容は「その人には付いていかない」「今は腕をつかまない」「もう注文は終わっている」。

「気の利く案内係募集」と書いたはずなのに、いちばん大事な応募条件は、こうなりました。

お客さんを、放っておける方。


参考資料と関連回

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?