// 動きと手ざわりの共通部品。 // Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの: // - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、 // 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる // - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗 // - Sheet: 下から出るシート(つまみを下へ払うと閉じる) // - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる) // - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー // CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける (function () { 'use strict'; const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); const reducedMotion = () => motionQuery.matches; const clamp = (v, min, max) => Math.min(max, Math.max(min, v)); // ---------- ばね ---------- const running = new Set(); let frame = 0; let lastTime = 0; function tick(now) { // タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める const dt = clamp((now - lastTime) / 1000, 0, 0.05); lastTime = now; for (const s of [...running]) s.step(dt); frame = running.size ? requestAnimationFrame(tick) : 0; } class Spring { // opts: { damping, response, precision(止まったとみなす差), onUpdate(value) } constructor(value = 0, opts = {}) { this.value = value; this.target = value; this.velocity = 0; this.precision = opts.precision || 0.01; this.onUpdate = opts.onUpdate || null; this.onRest = null; this.configure(opts.damping ?? 1, opts.response ?? 0.4); } // 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response // damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む configure(damping, response) { this.damping = damping; this.response = response; this.stiffness = ((2 * Math.PI) / response) ** 2; this.friction = (4 * Math.PI * damping) / response; } get animating() { return running.has(this); } // 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。 // 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き) to(target, opts = {}) { if (opts.damping != null || opts.response != null) { this.configure(opts.damping ?? this.damping, opts.response ?? this.response); } if (opts.velocity != null) this.velocity = opts.velocity; this.target = target; this.onRest = opts.onRest || null; if (reducedMotion() && !opts.allowReduced) { this.set(target); this.rest(); return this; } running.add(this); if (!frame) { lastTime = performance.now(); frame = requestAnimationFrame(tick); } return this; } // すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ) set(value) { running.delete(this); this.value = this.target = value; this.velocity = 0; if (this.onUpdate) this.onUpdate(value); return this; } // 画面上のいまの位置で止める(動いている最中につかんだとき) stop() { running.delete(this); this.target = this.value; this.velocity = 0; this.onRest = null; return this; } step(dt) { // 硬いばねでも発散しないよう 1/240 秒刻みで積分する const n = Math.max(1, Math.ceil(dt * 240)); const h = dt / n; for (let i = 0; i < n; i++) { const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity; this.velocity += force * h; this.value += this.velocity * h; } const settled = Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10; if (settled) { this.value = this.target; this.velocity = 0; running.delete(this); } if (this.onUpdate) this.onUpdate(this.value); if (settled) this.rest(); } rest() { const fn = this.onRest; this.onRest = null; if (fn) fn(); } } // ---------- 指の動き ---------- // 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる class VelocityTracker { constructor() { this.samples = []; } reset() { this.samples.length = 0; } add(x, y, t = performance.now()) { this.samples.push({ x, y, t }); while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift(); } velocity() { const s = this.samples; if (s.length < 2) return { x: 0, y: 0 }; const a = s[0]; const b = s[s.length - 1]; const dt = (b.t - a.t) / 1000; return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 }; } } // 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる function project(velocity, decelerationRate = 0.998) { return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate); } // 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない) function rubberband(overshoot, dimension, constant = 0.55) { return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot)); } const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])'; function trapFocus(box, e) { const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null); if (!list.length) return; const first = list[0]; const last = list[list.length - 1]; if (!box.contains(document.activeElement)) { e.preventDefault(); first.focus(); } else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } // ---------- シート ---------- // root は背景(.sheet-backdrop)、中に .sheet。 // [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を // 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。 // opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed } function Sheet(root, opts = {}) { const sheet = root.querySelector('.sheet'); const dismissible = opts.dismissible !== false; const y = new Spring(0, { precision: 0.5 }); const tracker = new VelocityTracker(); let height = 0; let isOpen = false; let prevFocus = null; let drag = null; let downOnBackdrop = false; const measure = () => { height = sheet.offsetHeight + 24; // 影まで画面の外へ出す }; y.onUpdate = (v) => { sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : ''; root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1'); }; function onKey(e) { if (e.key === 'Escape' && dismissible) { e.preventDefault(); close(); } else if (e.key === 'Tab') { trapFocus(sheet, e); } } function open() { if (isOpen) return; isOpen = true; if (root.hidden) { root.hidden = false; measure(); y.set(height); } y.to(0, { damping: 1, response: 0.38 }); prevFocus = document.activeElement; document.addEventListener('keydown', onKey); const target = opts.focus ? sheet.querySelector(opts.focus) : null; (target || sheet).focus({ preventScroll: true }); } function close(velocity) { if (!isOpen) return; isOpen = false; document.removeEventListener('keydown', onKey); measure(); y.to(height, { damping: 1, response: 0.3, velocity: typeof velocity === 'number' ? velocity : undefined, onRest: () => { if (isOpen) return; root.hidden = true; if (opts.onClosed) opts.onClosed(); }, }); if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true }); if (opts.onClose) opts.onClose(); } root.addEventListener('pointerdown', (e) => { downOnBackdrop = e.target === root; }); root.addEventListener('click', (e) => { if (dismissible && e.target === root && downOnBackdrop) close(); }); sheet.addEventListener('pointerdown', (e) => { if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return; if (!e.target.closest('[data-sheet-drag]')) return; drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false }; tracker.reset(); tracker.add(e.clientX, e.clientY, e.timeStamp); }); sheet.addEventListener('pointermove', (e) => { if (!drag || e.pointerId !== drag.id) return; tracker.add(e.clientX, e.clientY, e.timeStamp); if (!drag.active) { const dx = e.clientX - drag.x; const dy = e.clientY - drag.y; if (Math.hypot(dx, dy) < 8) return; if (Math.abs(dx) > Math.abs(dy)) { drag = null; return; } // ここで初めてつかむ。動いている最中なら画面上のいまの位置から drag.active = true; drag.y = e.clientY; y.stop(); drag.origin = y.value; measure(); sheet.setPointerCapture(e.pointerId); sheet.classList.add('dragging'); if (!isOpen) { isOpen = true; document.addEventListener('keydown', onKey); } } const raw = drag.origin + (e.clientY - drag.y); y.set(raw < 0 ? -rubberband(-raw, height) : raw); }); const end = (e) => { if (!drag || e.pointerId !== drag.id) return; const wasActive = drag.active; drag = null; sheet.classList.remove('dragging'); if (!wasActive) return; tracker.add(e.clientX, e.clientY, e.timeStamp); const v = tracker.velocity().y; if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v); // 払った勢いがあるので少しだけ弾ませて戻す else y.to(0, { damping: 0.8, response: 0.3, velocity: v }); }; sheet.addEventListener('pointerup', end); sheet.addEventListener('pointercancel', end); return { open, close, get isOpen() { return isOpen; }, }; } // ---------- セグメント ---------- // container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。 // 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。 // 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ function Segmented(container) { const thumb = document.createElement('span'); thumb.className = 'segmented-thumb'; thumb.setAttribute('aria-hidden', 'true'); container.prepend(thumb); container.classList.add('has-thumb'); const x = new Spring(0, { precision: 0.1 }); const w = new Spring(0, { precision: 0.1 }); const press = new Spring(1, { precision: 0.001 }); const tracker = new VelocityTracker(); let placed = false; let drag = null; let suppressClick = false; const paint = () => { thumb.style.width = `${w.value.toFixed(2)}px`; thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`; }; x.onUpdate = w.onUpdate = press.onUpdate = paint; const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden); const isSelected = (el) => el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active'); const center = (el) => el.offsetLeft + el.offsetWidth / 2; function sync(animate = true) { if (drag && drag.active) return; const el = items().find(isSelected); thumb.hidden = !el; if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる) if (animate && placed) { if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 }); if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 }); } else { x.set(el.offsetLeft); w.set(el.offsetWidth); } placed = true; } function select(el) { if (isSelected(el)) return; if (el.matches('label')) { const input = el.querySelector('input'); input.checked = true; input.dispatchEvent(new Event('change', { bubbles: true })); } else { el.click(); } } container.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' && e.button !== 0) return; const el = items().find((i) => i.contains(e.target)); if (!el || !isSelected(el)) return; drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false }; tracker.reset(); tracker.add(e.clientX, 0, e.timeStamp); press.to(0.95, { damping: 1, response: 0.2 }); }); container.addEventListener('pointermove', (e) => { if (!drag || e.pointerId !== drag.id) return; tracker.add(e.clientX, 0, e.timeStamp); if (!drag.active) { if (Math.abs(e.clientX - drag.x) < 6) return; drag.active = true; drag.x = e.clientX; x.stop(); drag.origin = x.value; container.setPointerCapture(e.pointerId); } const list = items(); const last = list[list.length - 1]; const min = list[0].offsetLeft; const max = last.offsetLeft + last.offsetWidth - w.value; let raw = drag.origin + (e.clientX - drag.x); if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth); else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth); x.set(raw); }); const end = (e) => { if (!drag || e.pointerId !== drag.id) return; const wasActive = drag.active; drag = null; press.to(1, { damping: 1, response: 0.25 }); if (!wasActive) return; suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); tracker.add(e.clientX, 0, e.timeStamp); const v = tracker.velocity().x; const aim = x.value + w.value / 2 + project(v, 0.99); const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a)); x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v }); w.to(best.offsetWidth, { damping: 1, response: 0.3 }); select(best); }; container.addEventListener('pointerup', end); container.addEventListener('pointercancel', end); // ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように container.addEventListener( 'click', (e) => { if (!suppressClick) return; e.preventDefault(); e.stopPropagation(); }, true ); container.addEventListener('change', () => sync()); // ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる container.addEventListener('click', () => requestAnimationFrame(() => sync())); if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container); sync(false); return { update: sync }; } // ---------- 出し入れ ---------- // 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。 // from: { scale, y }。視差効果を減らす設定では透明度だけ変える function presence(el, show, from = {}) { let p = el._presence; if (!p) { p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 }); el.hidden = !show; } const scale = from.scale ?? 0.96; const dy = from.y ?? 8; p.onUpdate = (v) => { el.style.opacity = v >= 1 ? '' : v.toFixed(3); el.style.transform = v >= 1 || reducedMotion() ? '' : `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`; }; if (show) { if (el.hidden) { el.hidden = false; p.set(0); } if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true }); } else if (!el.hidden && p.target !== 0) { p.to(0, { damping: 1, response: 0.22, allowReduced: true, onRest: () => { el.hidden = true; }, }); } } // 数字をばねで変える(ポット・スタックなど) function countTo(el, value, format) { const show = (v) => { el.textContent = format(Math.round(v)); }; let s = el._count; if (!s) { s = el._count = new Spring(value, { precision: 0.5 }); s.onUpdate = show; show(value); return; } s.onUpdate = show; if (s.target !== value) s.to(value, { damping: 1, response: 0.5 }); } // 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる function shake(el) { if (reducedMotion()) return; let s = el._shake; if (!s) { s = el._shake = new Spring(0, { precision: 0.05 }); s.onUpdate = (v) => { el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : ''; }; } s.to(0, { damping: 0.22, response: 0.26, velocity: -320 }); } // 振動(対応している端末だけ。画面に触れる前は振動できない) function haptic(pattern = 10) { try { const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive; if (navigator.vibrate && touched) navigator.vibrate(pattern); } catch { /* 振動できない環境 */ } } // ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、 // 大きな見出しが隠れたらバーの中に小さな見出しを出す function navBar(nav, largeTitle) { if (!nav) return; const update = () => nav.classList.toggle('scrolled', window.scrollY > 2); update(); window.addEventListener('scroll', update, { passive: true }); const inline = nav.querySelector('.nav-title'); if (!largeTitle || !inline) return; const mirror = () => { inline.textContent = largeTitle.textContent; }; mirror(); new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true }); if ('IntersectionObserver' in window) { new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), { rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`, }).observe(largeTitle); } } window.Fluid = { Spring, VelocityTracker, project, rubberband, clamp, reducedMotion, Sheet, Segmented, presence, countTo, shake, haptic, navBar, }; })();