2023-11-20
iPod 转盘手势
0%
XhrUnsent
iPod 最伟大的设计即是它提供的 ClickWheel 转盘交互,它是 iPod 的核心竞争力。
在近年智能手机流行后, iPod 就逐渐淡出大众了, 但是它所代表的产品精神 / 体验让我着迷,因此我决定在屏幕上也实现一个 ClickWheel 交互手势。

# 极坐标系

很显然,屏幕上的鼠标/手指操作是 ClickWheel 的超集, 也就是说 touch 和 mouse 事件组完全能模拟出转盘手势,为了后续方便处理,首先我们需要抹平两个事件组的差异 —— 需要数抹平
MouseEvent
TouchEvent
的差异并从中取出点击的位置坐标:
00// get-event-point.ts
01
02/** 二维坐标系中的点 */
03export interface Point2D {
04  x: number;
05  y: number;
06}
07
08export type FingerEvent = TouchEvent | MouseEvent;
09
10/**
11 * 抹平 touch 和 mouse 事件
12 * 统一返回事件发生的 Point2D 坐标点
13 */
14export function getEventPoint(e: FingerEvent): Point2D {
15  e.preventDefault();
16  if (e instanceof TouchEvent) {
17    return {
18      x: e.touches[0].clientX,
19      y: e.touches[0].clientY,
20    }
21  }
22
23  return {
24    x: e.clientX,
25    y: e.clientY,
26  };
27}
28
29/**
30 * 从 touch 或者 mouse 事件中解析出 Point2D 出来
31 * 这个 Point2D 是以 div 的中心点为原点的
32 */
33export function getDivPoint(div: HTMLElement, ep: Point2D) {
34  const rect = div.getBoundingClientRect();
35  // 算出相对位置
36  const offsetX = ep.x - rect.x;
37  const offsetY = ep.y - rect.y;
38
39  // 需要以 div 的中心点为原点, 这样才方便调用 toPolarPoint
40  const x = offsetX - (rect.width / 2);
41  // 为什么是负数呢, 因为浏览器坐标系左上角是原点
42  const y = - (offsetY - (rect.height / 2));
43
44  return { x, y }
45}
考虑到转盘手势核心参数是
转动角度
, 此概念在二维坐标系中不能直接描述, 需要取出 x y 进行计算得到,为了实现的简洁和外部使用方便,最好使用
极坐标系
来实现和构造 ClickWheel 手势。
极坐标系如下左图所示, 从 P1 转动到 P2, 如下左图所示,其中
(∠A, r1)
代表 P1 点相对于圆心 x 轴的角度, r1 代表离圆心的距离。
0%
XhrUnsent
0%
XhrUnsent
上右图也表明了,由于圆的周期性质,从 P1 到 P2 可以有无数种方式,比如转三圈后再转一个锐角这样,或者逆时针转一个钝角,这样的周期性质还会导致反三角函数求不出大于 180 度的角,需要手动去加一个半圆
下图描述了从 P0 转到 P1 再到 P2 的过程, 由于周期性的原因,用反三角函数计算 P2 的极坐标角度的时候
∠1+∠2
的计算结果跟
∠1
一样 (其实算出来的结果就是 ∠3),因此需要在代码里额外判断当
y<0
的时候计算 ∠3 的补角。
0%
XhrUnsent
从上面的讨论中,可以得到极坐标系的类型定义
PolarPoint
以及转换:
00// polar-point.ts
01import { Point2D } from './get-event-point';
02
03/** 极坐标系中的点 */
04export interface PolarPoint {
05  angle: number;
06  radius: number;
07}
08
09/** 将二维坐标 Point2D 转为以原点 (0,0) 为中心的极坐标系 */
10export function toPolarPoint(p: Point2D) {
11  const { x, y } = p;
12
13  // 三角函数计算构造极坐标点
14  const radius = Math.sqrt(x * x + y * y);
15  const pp: PolarPoint = {
16    angle: (Math.acos(x / radius) * 180 / Math.PI),
17    radius,
18  }
19
20  // 圆的周期性质会导致在第三第四象限计算的角度始终在 0,180 度内
21  // 这种情况应该计算补角
22  if (y < 0) pp.angle = 360 - pp.angle
23
24  // 保证顺时针方向为正
25  pp.angle = 360 - pp.angle;
26  return pp;
27}

# 状态定义

先设计类型,由上面的分析决定使用极坐标系来描述 ClickWheel 手势,基于 ADT 的思想进行设计可得:
00// clickwheel-state.tsx
01import { PolarPoint } from './polar-point';
02
03export type ClickWheelState =
04  // 未激活
05  | null
06  // 滑动中
07  | {
08    type: 'dragging';
09    /** 起始点 */
10    start: PolarPoint;
11    /** 结束点 */
12    current: PolarPoint;
13  }
为了方便调试, 还需要有一个打印
ClickWheelState
的组件:
00import React from 'react';
01import { ClickWheelState } from './clickwheel-state';
02
03export function RenderClickWheelState(
04  props: { state: ClickWheelState }
05) {
06  const { state } = props;
07  const dangle = state
08    ? state.current.angle - state.start.angle
09    : 0;
10  
11  const t1 = `移动角度: ${dangle}`;
12  const t2 = `当前选中: ${renderSelect(dangle)}`;
13
14  return (
15    <div>
16      <div>{t1}</div>
17      <div style={{ fontSize: '2rem' }}>{t2}</div>
18    </div>
19  )
20}
21
22const DICT = `ABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890`
23function renderSelect(angle: number) {
24  const n = Math.floor(angle / 30);
25  const idx = n % DICT.length;
26  if (idx < 0) return DICT[DICT.length + idx];
27  return DICT[idx]
28}
在上述
RenderClickWheelState
中, 当状态变化, current 移动角度增大后, 对于渲染会顺序循环选择
DICT
中的元素来展示

# 完整实现 & Demo

移动角度: 0
当前选中: A
完整实现如下, 注意其中换圈的逻辑 (不过我感觉有更好的办法, 目前还不够简洁)
000// use-clickwheel.tsx
001import React from 'react';
002import { toPolarPoint } from './polar-point';
003import { ClickWheelState } from './clickwheel-state';
004import {
005  getEventPoint,
006  getDivPoint,
007  FingerEvent,
008} from './get-event-point';
009
010/**
011 * 为 elemRef 处理 clickWheel 相关事件状态
012 * @param elemRef 比如一个正方形 div ref
013 * @param onSubmit 当 touchend/mouseup 结束事件的时候传出最后一次状态
014 * @returns 
015 */
016export function useClickWheel(
017  elemRef: React.RefObject<HTMLElement>,
018  onSubmit: (state: ClickWheelState) => void
019) {
020  const [state, setState] = React.useState<ClickWheelState>(null);
021
022  React.useEffect(() => {
023    const elem = elemRef.current;
024    if (!elem) return;
025
026    // 记录当前转了多少圈
027    let circle = 0;
028
029    const onStart = (e: FingerEvent) => {
030      const ep = getEventPoint(e);
031      const p = getDivPoint(elem, ep);
032      const pp = toPolarPoint(p);
033
034      // 重置状态
035      circle = 0;
036      setState({
037        type: 'dragging',
038        start: pp,
039        current: pp,
040      });
041    }
042
043    const onMove = (e: FingerEvent) => {
044      const ep = getEventPoint(e);
045      const p = getDivPoint(elem, ep);
046      const pp = toPolarPoint(p);
047
048      setState(prev => {
049        if (!prev) return null;
050
051        // 圈数多的时候 prev.current.angle 将会大于 360
052        // 需要划定在 [0,360) 之间
053        let prevCurrentAngle = prev.current.angle % 360;
054        if (prevCurrentAngle < 0) prevCurrentAngle += 360;
055
056        // 经过极坐标系 0 度的时候会突变, 此时需要记录圈数
057        let dangle = pp.angle - prevCurrentAngle;
058        let dcircle = 0;
059
060        // 说明是顺时针过 0 点
061        if (dangle <= -330) {
062          dcircle = 1;
063          console.log('+1'); // 方便调试
064        }
065
066        // 说明是逆时针过 0 点
067        if (dangle >= 330) {
068          dcircle = -1;
069          console.log('-1'); // 方便调试
070        }
071
072        const nextCircle = circle + dcircle;
073        circle = nextCircle;
074        // 最新值写入, 下次 onMove 会用
075        pp.angle = nextCircle * 360 + pp.angle;
076
077        return {
078          type: 'dragging',
079          start: prev.start,
080          current: pp,
081        }
082      })
083    }
084
085    const onEnd = (e: FingerEvent) => {
086      setState((prev) => {
087        // 将最后一次状态提交出去
088        onSubmit(prev);
089        return null;
090      });
091    }
092
093    // 需要禁用 passive
094    const options = { passive: false }
095    elem.addEventListener('touchstart', onStart, options);
096    elem.addEventListener('touchmove', onMove, options);
097    elem.addEventListener('touchend', onEnd, options);
098    elem.addEventListener('touchcancel', onEnd, options);
099
100    // mouse 事件组
101    elem.addEventListener('mousedown', onStart, options);
102    elem.addEventListener('mousemove', onMove, options);
103    elem.addEventListener('mouseup', onEnd, options);
104    elem.addEventListener('mouseleave', onEnd, options);
105
106    // 取消副作用
107    return () => {
108      elem.removeEventListener('mousedown', onStart);
109      elem.removeEventListener('mousemove', onMove);
110      elem.removeEventListener('mouseup', onEnd);
111      elem.removeEventListener('touchstart', onStart);
112      elem.removeEventListener('touchmove', onMove);
113      elem.removeEventListener('touchend', onEnd);
114    }
115  }, [elemRef.current]);
116
117  return {
118    state,
119  }
120}

# 结尾

至此实现了一个基本的
ClickWheel
转盘架子, 就完成度来说还差一点, 还缺一些能力, 比如提供
onClick
防误触
等等,不过这些倒不是最核心的就是了。