GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち

9つのうち1つは引いた距離の値が大きくなるほど実際に動く幅の値がだんだん狭くなり、1つはその距離の割合の値だけ回転の値も一緒に回り、もう1つは基準を超えた状態で指を離してはじめて確定した状態に変わります。引っ張って更新 スマホの9パターンです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つとも実際のサーバーには一度も届きません。それぞれがsetTimeoutだけでネットワークの往復をその場で再現しており、09の成功・失敗の切り替えもattemptCountという回数のカウンターによるもので、navigator.onLineのような実際の接続確認ではありません。代わりに9つとも同じ3種類の実際のポインターイベントに応答します。pointerdownで引っ張りが始まり、pointermoveが1フレームごとに生の距離の値を伝え、pointerupがそのまま確定させるか元に戻すかを決めます。wireReal()を通した実際の入力なしに自動再生だけで動く項目は9つの中に1つもありません。プルトゥリフレッシュという名前が指しているのは、まさにこの9つがpointerdownから拾い続けるその生の距離の値そのものです。

9つの解剖 — どの値がいつ変わるか

9つは、指を離した後にその値がどこへ向かうかで大きく分かれます。この違いが、9つを同じスピナーの使い回しではなく別々のパターンにしています。

01と04がいつまでも更新を起動しない理由

01と04はcommitも更新の開始も一度も呼びません。この2つは生の距離の値を表示の値へどう変換するかだけを扱っています。01のresist()はD_MAX * raw / (raw + K)という式で、rawが増えるほど伸びる幅の値が少しずつ小さい増分になります。04のdisplayedOf()はLINEAR(70px)まではほぼ生の距離の値どおりに動き、それを超えると1 - Math.exp(-excess / K)という指数関数の項に切り替わり、MAX_D(120)に近づきながらも決して届きません。どちらも指を離した後は同じdiff * 0.28・diff * 0.3のイージングで0へ戻るだけで、9つの中でこの2つだけが基準判定そのものを持っていません。

02と06だけ戻りのイージングを飛ばして指が離れた瞬間に0に戻る理由

02と06は、確定に届かなかった引っ張りに対してsettle()・goTo()のような戻りのループを一切使いません。02のonUp()はratioが1未満のまま指が離れた状態になるとapply(0)をその場で呼び、06のonUp()もraw = 0; lastTick = 0;をそのまま実行します。どちらも表示の値は次のフレームを待たずに0へ切り替わります。その代わりに確定した側の演出に手が込んでいます。02はspinFrame()という、引いた距離の値ではなく経過時間の値そのものから回転を作る処理に切り替わり、06はrawが新しいTICKの倍数を通過するたびにAMP * Math.exp(-t / TAU) * Math.sin(t * W)という減衰する跳ねを位置の値に重ねます。

03・05・07・08・09が同じ基準判定に5通りの方法でたどり着く理由

03・05・07・08・09は、指を離した距離の値を基準の値と比較して確定へ進む点は共通していますが、その比較の作り方は5つとも違います。03のsetCandidate()は基準を1つではなく2つ使い、ON(100)とOFF(70)の間にすき間を置くことで、100付近で値が揺れても候補の状態が細かく切り替わらないようにしています。05は自分の進行の割合の値をそのまま使い、raw >= REF(130)で指を離した状態になったcommit()が、引っ張りとは別に固定の320ミリ秒でチェック表示の線を描きます。07はイージングの代わりにaccel = -K * (pos - target) - C * velというばねの積分をフレームごとに計算し直すため、位置の値が一度目標を越えてから戻る動きが実際に生まれます。08と09はどちらも見慣れたdiff * 0.3のループで戻りますが、面白い計算は更新が始まった後にあります。08は各行の再表示をindex * STAGGER_MSだけ遅らせ、09は成否をattemptCount % 2という決まった手順で決めていて、ランダムな要素は一切入っていません。

パターンごとのコード

01弾性プル — 引いた距離の値が増えるほど抵抗が強まる更新トリガー

指で引いた距離の値が大きくなるほど、実際にずれる距離の値は徐々に小さい増分になり、指を離した状態になるとその距離の値は再び0に戻ります。モバイルウェブフィードの更新や、リスト最上部の引っ張り領域に向いています。

弾性プル — 引いた距離の値が増えるほど抵抗が強まる更新トリガー
新しいタブで開く

01 · 弾性プル — 引いた距離の値が増えるほど抵抗が強まる更新トリガー — 計測 累積面積 15.385% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

D_MAXKresist

数字が語ること

23フレームのうち01は14フレームで動き、累積面積は15.39%、平均強度は30.6です。ゴースト・ドット・リストの3つが同じresist(raw)から得たdisplayedの値を同時に読んでいるため、引っ張りが伸びるほど3つとも一緒に増分が小さくなっていく様子が見えます。

  // 計算層: 引いた生の距離(raw)を表示用の距離(displayed)へ変換する抵抗曲線。
  // displayed = D_MAX * raw / (raw + K) — rawが増えるほど伸びが小さい増分になる
  // 飽和曲線(ミカエリス・メンテン式)。
  var D_MAX = 56, K = 80, RAW_MAX = 200;

  function resist(raw) { return D_MAX * raw / (raw + K); }

  function apply(raw) {
    var displayed = resist(raw);
    var ratio = displayed / D_MAX;
    ghost.style.transform = 'translateY(' + Math.min(raw, D_MAX + 24).toFixed(1) + 'px)';
    ghost.style.opacity = String(Math.min(1, raw / 30));
    dot.style.transform = 'translateY(' + displayed.toFixed(1) + 'px) scale(' + (0.6 + 0.4 * ratio).toFixed(2) + ')';
    dot.style.opacity = String(ratio);
    list.style.transform = 'translateY(' + displayed.toFixed(1) + 'px)';
    hint.textContent = raw > 4 ? ('引っぱり ' + Math.round(raw) + 'px · ずれ ' + Math.round(displayed) + 'px') : '下に引っぱってみましょう';
  }

02回転連動 — 引いた距離の割合の値だけ回転の値も一緒に回る表示

引いた距離の値を基準距離で割った割合の値だけ表示の回転の値も一緒に回り、割合の値が1になった状態になると回転が続く状態に変わります。ニュースフィード上部の表示や、受信箱の更新に使われる形です。

回転連動 — 引いた距離の割合の値だけ回転の値も一緒に回る表示
新しいタブで開く

02 · 回転連動 — 引いた距離の割合の値だけ回転の値も一緒に回る表示 — 計測 累積面積 14.129% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWEEPRADIUSspinFrame

数字が語ること

23フレームのうち02は20フレームで動き、累積面積は14.13%、平均強度は125.9で9つの中で最も高い値です。ほぼ毎フレームplace()がノブを270度の弧に沿って大きく動かしているため、小さな位置ずれではなく大きな回転そのものが強度を押し上げています。

  function place(angleDeg, pullPx) {
    var rad = (angleDeg - 90) * Math.PI / 180;
    var x = Math.cos(rad) * RADIUS, y = Math.sin(rad) * RADIUS;
    knob.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
    list.style.transform = 'translateY(' + pullPx.toFixed(1) + 'px)';
  }

  var state = 'idle'; // idle -> dragging -> spinning -> idle
  var raw = 0, ratio = 0, spinAngle = 0, spinStart = 0, spinRaf = null;

  function fromRatio() { return ratio * SWEEP; }

  function spinFrame(ts) {
    if (!spinStart) { spinStart = ts; }
    var el = ts - spinStart;
    spinAngle = (el * SPIN_SPEED) % 360;
    place(spinAngle, 24);
    if (el < 900) {
      spinRaf = requestAnimationFrame(spinFrame);
    } else {
      state = 'idle'; ratio = 0; raw = 0; spinStart = 0;
      hint.textContent = '引っぱって更新';
      place(0, 0);
    }
  }

03基準確定 — 引いた距離の値が基準を超えると確定状態に変わるトリガー

引いた距離の値が決まった基準の値を超えた状態で指を離した状態になると確定した状態に変わって更新が始まり、基準に届かないまま指を離した状態になると距離の値は0に戻ります。メールボックスの更新やSNSタイムラインの更新に向いた形です。

基準確定 — 引いた距離の値が基準を超えると確定状態に変わるトリガー
新しいタブで開く

03 · 基準確定 — 引いた距離の値が基準を超えると確定状態に変わるトリガー — 計測 累積面積 13.559% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ONOFFsetCandidate

数字が語ること

23フレームのうち03は21フレームで動き、累積面積は13.56%、平均強度は35.0です。円盤の大きさと位置はどちらもMath.min(1, raw / ON)を毎フレーム読み続けているため、確定候補のフラグが切り替わる前から見た目の変化が止まりません。

  // 計算層: 引っ張りが確定候補になるかどうかは、rawを1回比較するだけでは決めず、
  // 2つの基準の間でヒステリシスを取って決める。
  // ON(100px)を超えるとcandidateがtrueになり、OFF(70px)を下回ってはじめてfalseに戻るため、
  // 境界付近で状態が細かく揺れない。
  var ON = 100, OFF = 70, MAX = 160;
  var candidate = false, state = 'idle'; // idle -> dragging -> committing -> idle

  function setCandidate(raw) {
    if (!candidate && raw >= ON) { candidate = true; }
    else if (candidate && raw < OFF) { candidate = false; }
  }

  function apply(raw) {
    setCandidate(raw);
    var pull = Math.min(raw, MAX) * 0.3;
    disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, raw / ON)).toFixed(2) + ')';
    disc.classList.toggle('prtc__disc--ready', candidate);
    list.style.transform = 'translateY(' + pull.toFixed(1) + 'px)';
    hint.textContent = candidate ? '離すと更新' : (raw > 4 ? 'もっと引っぱってください (' + Math.round(raw) + '/' + ON + 'px)' : '下に引っぱってください');
  }

04ゴムバンド限界 — 最大距離の値を超えると伸びる幅が急に減るプル

引いた距離の値が決まった最大値に近づくほど、同じ力でも伸びる幅の値は急に小さくなり、指を離した状態になると距離の値は元の位置へ跳ね戻ります。短いリストの更新や、過度な引っ張りを防ぐ領域に向いています。

ゴムバンド限界 — 最大距離の値を超えると伸びる幅が急に減るプル
新しいタブで開く

04 · ゴムバンド限界 — 最大距離の値を超えると伸びる幅が急に減るプル — 計測 累積面積 9.799% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LINEARMAX_DdisplayedOf

数字が語ること

23フレームのうち04は15フレームで動き、累積面積は9.80%、平均強度は50.2です。マーカーとリストはどちらもdisplayedOf(raw)を読んでいますが、リストの動きの方が上限に近づく前から目に見えて鈍り始め、マーカーだけが上限へじわじわ近づき続けます。

  // 計算層: rawはLINEAR(70px)まではほぼ1対1で追従し、それを超えると
  // 指数減衰曲線(1-e^-x/K)でMAX_D(120px)へ近づくため、上限に近いほど
  // 同じ力を加えても伸びる幅が急に小さくなる。
  var LINEAR = 70, MAX_D = 120, K = 42, RAW_MAX = 260;
  var TRACK_H = 92, SCALE = TRACK_H / 150;

  function displayedOf(raw) {
    if (raw <= LINEAR) { return raw * 0.9; }
    var base = LINEAR * 0.9;
    var excess = raw - LINEAR;
    return base + (MAX_D - base) * (1 - Math.exp(-excess / K));
  }

  function apply(raw) {
    var displayed = displayedOf(raw);
    marker.style.transform = 'translateY(' + (-(displayed * SCALE)).toFixed(1) + 'px)';
    list.style.transform = 'translateY(' + (displayed * 0.55).toFixed(1) + 'px)';
    var pctOfCap = Math.round((displayed / MAX_D) * 100);
    hint.textContent = raw > 4 ? ('ずれ ' + Math.round(displayed) + 'px · 限界の ' + pctOfCap + '%') : '下に引っぱってください';
  }

05アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示

引いた距離の値が大きくなるほど円形リングの進行の値が円周に沿って満ちていき、進行の値が満杯の状態になるとリングの内側にチェック表示の線の値が描かれて更新完了を示します。タスクリストの更新やチャットルームの最新メッセージ読み込みに向いた形です。

アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示
新しいタブで開く

05 · アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示 — 計測 累積面積 1.133% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RcheckLenringOf

数字が語ること

23フレームのうち05は13フレームで動き、累積面積は1.13%と9つの中で最も低い一方、平均強度は133.0と最も高い値です。書き換わるのはリングの細い線とチェック表示だけですが、その少ない画素ほど変化が急なので平均強度が押し上げられます。

  // 計算層: 引いた距離(raw)をREFで割った0〜1の進行の値を、リングの円周(C)へ
  // そのまま掛けてstrokeDashoffsetへ渡す。CSSトランジションではなく、
  // 毎フレーム角度を計算し直している。
  var REF = 130, MAX = 150;
  var R = 22, C = 2 * Math.PI * R;
  bar.style.strokeDasharray = C.toFixed(2);
  var checkLen = check.getTotalLength();
  check.style.strokeDasharray = checkLen.toFixed(2);
  check.style.strokeDashoffset = checkLen.toFixed(2);

  function ringOf(progress) { return C * (1 - progress); }

  function apply(raw) {
    var progress = Math.max(0, Math.min(1, raw / REF));
    bar.style.strokeDashoffset = ringOf(progress).toFixed(2);
    var pct = Math.round(progress * 100);
    hint.textContent = progress >= 1 ? '離すと更新' : (raw > 4 ? ('引っぱり進行 ' + pct + '%') : '下に引っぱってください');
  }

06ラチェット目盛り — 引いた距離の値が目盛りの値を通過するたびに位置が短く跳ねる表示

引いた距離の値が一定間隔の目盛りの値を通過するたびに表示の位置の値が短く跳ねてから落ち着き、通過した目盛りの数の値が増えるほどその跳ねが繰り返されます。確実な確認が必要な更新や、強いフィードバックが必要なリストに向いています。

ラチェット目盛り — 引いた距離の値が目盛りの値を通過するたびに位置が短く跳ねる表示
新しいタブで開く

06 · ラチェット目盛り — 引いた距離の値が目盛りの値を通過するたびに位置が短く跳ねる表示 — 計測 累積面積 8.078% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TICKAMPtriggerBurst

数字が語ること

23フレームのうち06は22フレームで動き、9つの中で最も高い動作フレーム数です。累積面積は8.08%、平均強度は74.2です。frame()はドラッグ中でなくても毎アニメーションフレーム走り続けるため、目盛りを通過する瞬間以外でもマーカーがほとんど止まりません。

  function triggerBurst(now) { burstActive = true; burstStart = now; }

  function frame(now) {
    var clamped = Math.min(raw, MAX_TICK);
    var tickIndex = Math.floor(clamped / TICK);
    if (tickIndex > lastTick) { triggerBurst(now); }
    lastTick = tickIndex;

    var basePos = (clamped / MAX_TICK) * TRACK_H;
    var burst = 0;
    if (burstActive) {
      var t = (now - burstStart) / 1000;
      burst = AMP * Math.exp(-t / TAU) * Math.sin(t * W);
      if (t > 0.6) { burstActive = false; }
    }
    marker.style.transform = 'translateY(' + (-(basePos) + burst).toFixed(1) + 'px)';
    list.style.transform = 'translateY(' + (clamped * 0.35).toFixed(1) + 'px)';
    hint.textContent = raw > 4 ? ('目盛り ' + Math.min(tickIndex, N) + '/' + N) : '下に引っぱってください';
    requestAnimationFrame(frame);
  }

07スプリング着地 — 指を離した後、位置の値が目標を少し越えてから元に戻る表示

指を離した状態になると表示の位置の値は固定位置を少し越えた値まで動き、時間が経つにつれその値が収まって固定位置の値に落ち着いた状態になります。上部固定の更新表示や、カード型フィードの更新に向いた形です。

スプリング着地 — 指を離した後、位置の値が目標を少し越えてから元に戻る表示
新しいタブで開く

07 · スプリング着地 — 指を離した後、位置の値が目標を少し越えてから元に戻る表示 — 計測 累積面積 7.859% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

KCaccel

数字が語ること

23フレームのうち07は17フレームで動き、累積面積は7.86%、平均強度は76.8です。この越えて戻る動きそのものが、他のパターンならとっくに止まっている段階でも数フレーム余分にマーカーを動かし続けています。

  var last = null;
  function frame(now) {
    if (last === null) { last = now; }
    var dt = Math.min(0.032, (now - last) / 1000);
    last = now;
    if (integrating) {
      var accel = -K * (pos - target) - C * vel;
      vel += accel * dt;
      pos += vel * dt;
      if (Math.abs(pos - target) < 0.5 && Math.abs(vel) < 5) {
        pos = target; vel = 0; integrating = false;
        if (pendingCommit) { pendingCommit = false; commit(); }
      }
    }
    render();
    requestAnimationFrame(frame);
  }

9つの中で他のパターンは毎フレーム作り直すdiff * factorだけで戻りますが、07だけはaccel = -K * (pos - target) - C * velという速度そのものを積分しています。chrome 引っ張って更新の挙動を自分のサイトで再現したい場合も、ここに並ぶ9つと同じpointerdown・pointermove・pointerupの3つのイベントで十分です。

08行順次表示 — 更新が終わると一覧の各行が順番の値に沿って現れる効果

更新が終わった状態になると一覧の各行は自分の順番の値の分だけ遅れて位置の値と不透明度の値が元に戻り、順番の値が大きい行ほど後から現れます。新着記事一覧の更新や、通知一覧の更新に使われる形です。

行順次表示 — 更新が終わると一覧の各行が順番の値に沿って現れる効果
新しいタブで開く

08 · 行順次表示 — 更新が終わると一覧の各行が順番の値に沿って現れる効果 — 計測 累積面積 8.638% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGGER_MSLATENCY_MSrefreshCycle

数字が語ること

23フレームのうち08は15フレームで動き、累積面積は8.64%、平均強度は46.2です。測定データを見ると、9つの中で唯一自動再生だけで動くフレーム(10フレーム)を含んでいます。行の再表示は更新の一連の手順が終わった後にしか起きないためです。

  function refreshCycle() {
    disc.classList.add('prsg__disc--spin');
    hint.textContent = '更新中';
    timers.push(setTimeout(function () {
      for (var i = 0; i < rows.length; i++) { rows[i].classList.remove('prsg__row--in'); }
      // 強制リフロー — クラスを外した状態をブラウザに実際反映させてから付け直す。
      void list.offsetHeight;
      for (var j = 0; j < rows.length; j++) {
        (function (idx) {
          timers.push(setTimeout(function () { rows[idx].classList.add('prsg__row--in'); }, idx * STAGGER_MS));
        })(j);
      }
      var totalDelay = (rows.length - 1) * STAGGER_MS + 260;
      timers.push(setTimeout(function () {
        disc.classList.remove('prsg__disc--spin', 'prsg__disc--ready');
        hint.textContent = '下に引っぱってください';
        raw = 0; apply(0);
      }, totalDelay));
    }, LATENCY_MS));
  }

09失敗振動 — 更新が失敗した状態になると表示が左右に短く揺れる効果

更新が失敗した状態になると表示の左右位置の値が短い時間の間に何度か行き来してから0に戻り、再び引いた状態になると最初の手順がやり直されます。ネットワーク不安定状態の案内や、再試行が必要な更新に向いた形です。

失敗振動 — 更新が失敗した状態になると表示が左右に短く揺れる効果
新しいタブで開く

09 · 失敗振動 — 更新が失敗した状態になると表示が左右に短く揺れる効果 — 計測 累積面積 2.943% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

attemptCountFREQshaking

数字が語ること

23フレームのうち09は17フレームで動き、累積面積は2.94%、平均強度は55.5です。揺れそのものは短く、スマホ枠自体の左右のずれに閉じているため、動きは鋭いのに累積面積は9つの中でも小さめの部類に収まっています。

  var attemptCount = 0, committing = false, commitTimer = null;
  function attempt() {
    attemptCount += 1;
    var fail = attemptCount % 2 === 1;
    committing = true;
    if (fail) {
      hint.textContent = 'オフライン - 再試行';
      disc.classList.add('prof__disc--fail');
      shaking = true; shakeStart = performance.now();
      commitTimer = setTimeout(function () {
        disc.classList.remove('prof__disc--fail', 'prof__disc--ready');
        committing = false;
        raw = 0; apply(0);
        hint.textContent = '下に引っぱってください';
      }, 640);

値が形を変える場所 — 距離・割合・基準の境界

3つの定数は、パターンの動き自体を変えずに感触だけを変えます。入れ替えても同じ引っ張りは同じ終着点に着きますが、そこまでの道筋が変わるだけです。

03のONが確定のしやすさだけを変え、円盤の見た目自体は変えない理由

setCandidate()はrawをONとだけ比較しますが、円盤の大きさと位置はMath.min(1, raw / ON)という同じ式でONの値そのものを読み続けています。100pxから150pxへ引き上げても円盤の育ち方は変わらず、変わるのは指を離す前にどれだけ引く必要があるかだけです。150pxにすると軽いスクロールの反動で誤って更新が始まる確率が減る代わりに、はっきりした引っ張りが必要になります。

07のKが着地の締まり具合だけを変え、動く距離自体は変えない理由

accel = -K * (pos - target) - C * velのKはばねの硬さだけを決め、指を離した位置そのものはKの値と関係ありません。170では数フレームで素早く目標に収まってわずかに跳ね返るだけですが、80まで下げると同じ距離を戻るのに時間がかかり、目標を越える幅もはっきり大きくなります。

08のSTAGGER_MSが表示の間隔だけを変え、現れる行の数自体は変えない理由

どの行もidx * STAGGER_MSという同じ式で自分の遅延を受け取り、変わるのは行と行の間隔だけです。90ミリ秒ではほぼ一斉に現れてひとまとまりに見えますが、180ミリ秒まで広げると1行ずつ順番に現れる様子がはっきり目で追えるようになります。

壊れる場所 — 素早い引っ張り・短い引っ張り・途中で離す

更新の最中にもう一度引っ張るとどうなるか

03・05・06・07・08・09はonDown()の先頭でcommitting(または同等のstate)を確認し、更新が進んでいる間は新しい引っ張りをそのまま無視します。01と04には確定状態そのものがないため、更新中の再度の引っ張りも、その時点のrawから同じ抵抗曲線・オーバースクロール曲線をそのまま続けるだけです。02はその中間で、state === 'spinning'の間だけ新しいドラッグを止めるため、その状態に切り替わる直前のわずかな間なら確定前のノブへの再度の引っ張りが割り込めます。

基準に届く前に短く引っ張って離すとどうなるか

01・03・04・05・08・09は、短い引っ張りでも同じdiff * factorのループで数フレームかけて0へ戻るため、軽く引いてすぐ離しても値がふわっと戻る様子が見えます。02と06はその逆で、apply(0)とraw = 0; lastTick = 0;が指を離した瞬間にそのまま実行されるため、確定に届かなかった引っ張りは戻る間もなくその場で消えます。

離す前に最大値を超えて引っ張るとどうなるか

9つともonMove()の中でrawをRAW_MAXや同等のMAXで必ずクランプしているため、表示の枠を大きく超えて引っ張り続けてもresist()・displayedOf()や各種の割合計算に際限のない値が渡ることはありません。03のヒステリシスはもう一段細かく、OFF(70)がON(100)より低い位置にあるため、1回のpointermoveで両方の基準を一気に飛び越える速い引っ張りでもそのフレームでcandidateはtrueに切り替わり、境界をゆっくり通過する場合と同じように確定します。

無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで・【コピペOK】ローディング 画面 デザイン CSSだけ 9選・スワイプ削除リストUI 9選 — ページネーションまでとこの9つが違うのは、どの値を計算のもとにしているかです。無限スクロールの9つは、残りスクロール距離・追加された高さの値・表示中のインデックス範囲という、絶えず変わり続けるスクロール位置から値を導いていて、引っ張りのように指を離して判定する場面が一度もありません。ローディング画面の9つはポインターの値そのものに触れず、固定のCSSキーフレームの周期だけで動き続けます。スワイプ削除の9つはすでにプル・トゥ・リフレッシュの1項目を、スワイプ削除やページネーションと並ぶ広い9パターンの中の1つとして含んでいますが、こちらは9つすべてがこの1つの操作に特化していて、抵抗曲線とヒステリシス基準とばねの積分を互いに比べられます。この9つのソースファイルはすべて、パスワードbffdpzhjで解除した先にあり、擬似的なネットワーク遅延を実際のfetchに置き換えるだけで本物の更新処理に組み込めます。

3つのバリエーションとアクセシビリティ

9つのうち実際に配布用のバリエーションにする価値がある定数は3つだけです。他の6つに調整できる数値がないわけではなく、ON・K・STAGGER_MSの3つが、値をひとつ変えるだけで許容度・重さ・間隔の感触が最もはっきり変わる場所だからです。

バリエーション 変わるもの トレードオフ
03のONを100pxから150pxへ 確定候補になるまでに必要な引っ張りの距離 100pxでは軽い引っ張りだけで確定候補になり指を離した瞬間に更新が始まるが、150pxではより長くはっきり引っ張る必要があり、素早い更新の代わりに誤操作が減る
07のKを170から80へ 指を離した後の着地の締まり具合 170ではばねが硬く数フレームで素早く収まるが、80まで下げると同じ引っ張りでもゆっくり戻ってはっきりした行き過ぎが見える
08のSTAGGER_MSを90msから180msへ 各行の再表示がどれだけ際立つか 90msでは行がほぼ一塊で現れるが、180msまで広げると1行ずつ順番に現れる様子がはっきり見える

9つともwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesを自動再生プレビューの開始前にだけ確認します。このガードが止めるのはそのsetIntervalによるデモ動作だけで、wireReal()のpointerdown・pointermove・pointerupを通した実際の引っ張りは、この設定に関わらず抵抗曲線・回転・基準判定・ばねの着地をいつも最後まで再生します。9つともaria-labelやaria-hiddenはマークアップのどこにも置かれておらず、hintというテキスト要素の中身が「下に引っぱってください」「離すと更新」「更新中」と書き換わるだけで、aria-liveのようなライブリージョンは置かれていないため、スクリーンリーダーはこの状態変化を自動では読み上げません。実際のサービスでは更新ボタンなどキーボードの代替経路と読み上げ用の通知を別途用意する必要があります。

FAQ

これらのプル・トゥ・リフレッシュにアニメーションライブラリを使っているか

いいえ。9つともrequestAnimationFrame・setTimeout・CSSのtransformだけで動いており外部依存は一切ありません。HTML・SCSS・JSをそのまま貼り付けるだけで同じ動きがローカルでも再現できます。

9つのうち実際にサーバーへ通信しているものはあるか

ありません。どの引っ張りもsetTimeoutによるローカルな再現で、09の失敗状態もattemptCount % 2という決まった手順の切り替えであり、navigator.onLineのような実際の接続確認ではありません。fetchはこの9つのどこにも登場しません。

引っ張って更新 無効にする設定と9つのデモの関係

ブラウザ側の引っ張って更新 無効の設定は、ブラウザ自体が持つネイティブな引っ張り更新ジェスチャーを止めるものですが、この9つはpointerdown・pointermove・pointerupを自前で受け取るカスタム実装なので、その設定の有無に関わらずそのまま動きます。逆にOSの弱いモーション設定は自動再生プレビューのsetIntervalだけを止め、実際の引っ張りには影響しません。

Sources: MDN — Pointer events · MDN — transform-function: translate() · MDN — Window.requestAnimationFrame · MDN — prefers-reduced-motion

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。