用 React 做出好用的 Switch 元件

daqianmen發表於2021-09-09

關於作者

周林,github,陸金所前端程式設計師,專注 Hybrid APP 效能優化和新技術探索。歡迎任何形式的提問和討論。

前言

HTML5 將 WEB 開發者的戰場從傳統的 PC 端帶到了移動端。然而移動端互動的核心在於手勢和滑動,如果只是將 PC 端的點選體驗簡單地移植到移動端,勢必讓移動端體驗變得了無生趣。以某 APP 收銀臺的支付密碼輸入框為例,裡面的 Switch 元件只能通過點選改變狀態,和原生控制元件的體驗有著非常大的差距,不符合移動端的互動習慣。接下來,我們來嘗試做出一個支援手指滑動操作的Switch 元件,提升使用者體驗。

jd.png

手勢檢測

手勢互動的關鍵在於一套手勢事件監測系統,用於檢測move, tap, double tap, long tap, swipe, pinch, rotate等手勢行為。安卓和 IOS 都提供一套完善的手勢系統供原生 APP 呼叫,遺憾的是,HTML5 還沒有相應的 API,需要 HTML5 工程師自己實現。出於簡化,我們的 Switch 元件只支援 move 事件,因此,本章也只實現 move 事件的檢測。其他事件的檢測我們將在下一篇博文 «HTML5 手勢檢測原理和實現» 中詳細介紹。

我們對move事件的要求非常簡單,就是每當手指在 DOM 內移動時,就把手指劃過的相對距離告知監聽器。

move.png

假設手指從 (X1,Y1) 點滑到 (X2,Y2) 點,那麼手指在兩點間滑動的X軸相對距離就是 X2 - X1 ,Y軸相對距離 Y2 - Y1。所以,只要我們能夠獲取手指的座標位置,就能算出手指每次移動的相對距離,然後把ΔX和ΔY告知 move 事件的監聽函式。 所以,move事件的監聽器一般是這樣(注意ES6語法):

_onMove (event) {
  let {
    deltaX,  //手指在X軸上的位移
    deltaY   //手指在Y軸上的位移
  } = event;
  ...
}

無論多麼複雜的手勢系統,他們都會基於四個最基礎的觸控事件:

  1. touchstart
  2. touchmove
  3. touchend
  4. touchcancel

通過他們可以獲取手指觸控點的座標資訊,進而算出手指移動的相對距離。

touch.png

根據上面的圖解,先來實現 touch 事件監聽函式:

_onTouchStart(e) {
  let point = e.touches ? e.touches[0] : e;
  this.startX= point.pageX;
  this.startY = point.pageY;
}

_onTouchStart 函式非常簡單,就是記錄下初始觸控點的座標,儲存在startX startY 變數中。

_onTouchMove(e) {
  let point = e.touches ? e.touches[0] :e;
  let deltaX = point.pageX - this.startX;
  let deltaY = point.pageY - this.startY;
  this._emitEvent('onMove',{
    deltaX,
    deltaY
  });
  this.startX = point.pageX;
  this.startY = point.pageY;
  e.preventDefault();
}

_onTouchMove 函式邏輯也比較清楚,通過 touch 的觸控點 point 和 startX, startY 得到手指的相對位移 deltaX, deltaY, 然後發出 onMove 事件,告知監聽器有 move 事件發生,並攜帶deltaX, deltaY 資訊。最後,用現在的觸控點座標去更新 startX, startY

_onTouchEnd(e) {
  this.startX = 0;
  this.startY = 0;
}
_onTouchCancel(e) {
  this._onTouchEnd();
}

既然我們要用 React 實現元件,那就把 move 事件轉化成 React 程式碼:

render() {
  return React.cloneElement(React.Children.only(this.props.children), {
    onTouchStart: this._onTouchStart.bind(this),
    onTouchMove: this._onTouchMove.bind(this),
    onTouchCancel: this._onTouchCancel.bind(this),
    onTouchEnd: this._onTouchEnd.bind(this)
  });
}

一定注意我們用了 React.Children.only 限制只有一個子級,思考一下為什麼。完整的程式碼請參考這裡,我們只給出大致結構:

export default class Gestures extends Component {
  constructor(props) {}
  _emitEvent(eventType,e) {}
  _onTouchStart(e) {}
  _onTouchMove(e) {}
  _onTouchCancel(e){}
  _onTouchEnd(e){}
  render(){}
}
Gestures.propTypes = {
  onMove: PropTypes.func
};

Switch 元件實現

Switch 元件的 DOM 結構並不複雜,由最外的 wrapper 層包裹裡層的 toggler。

switch.png

有一點要注意,toggler 需要設定為 absolute 定位。因為這樣,就可以將手指在 wrapper X軸上的相對滑動距離 deltaX 轉化為 toggler 的 tranlate 的 x 值。

render() {
  return (
   <div ref="wrapper" className="wrapper">
      <div ref="toggler" className="toggler"></div>
   </div>
  );
}

那 move 事件應該加在 wrapper 上面還是 toggler 上面呢?經驗之談,在固定不動的元素上檢測手勢事件,這會為你減少很多bug。 我們在 wrapper 上監聽手指的 move 事件,將 move 事件發出的 deltaX 做累加,就是 toggler 的 translate 的 x 值。即:

translateX = deltaX0 + deltaX1 + … + deltaXn

有了這個公式,就可以用 React 來實現了。首先修改render函式

render() {
  let {translateX} = this.state;
  let toggleStyle = {
      transform: `translate(${translateX}px,0px) translateZ(0)`,
      WebkitTransform: `translate(${translateX}px,0px) translateZ(0)`
   }
 return (
  <Gestures onMove={this.onMove}>
        <div className="wrapper ref="wrapper" >
         <div className="toggler"
            ref="togger" style={toggleStyle}></div>
         </div>
  </Gestures>);
}

在 Gestures 中,用 this.onMove 去監聽 move 事件。在 onMove 函式中,需要累加 deltaX 作為 toggler 的位移。

onMove(e) {
    this.translateX += deltaX;
   if(this.translateX >= this.xBoundary) this.translateX = this.xBoundary;
   this.translateX = this.translateX <=1 ? 0 : this.translateX;
   this.setState({
     translateX: this.translateX
   });
 }

注意this.xBoundary,toggler 不能無限制的移動,必須限制在 wrapper 的範圍內,這個範圍的下限是0,上限是 wrapper 的寬度減去 toggler 的寬度。

componentDidMount() {
   this.xBoundary = ReactDOM.findDOMNode(this.refs.wrapper).clientWidth - ReactDOM.findDOMNode(this.refs.togger).offsetWidth;
   this.toggerDOM = ReactDOM.findDOMNode(this.refs.togger);
   this.toggerDOM.translateX = 0;
  }

switch_with_bug.gif

好了,這樣 Switch 元件的 V1 版本就完成了,點選這裡線上檢視你的大作吧。

然而還有兩個明顯的問題。

  1. 現在只要手指進入 wrapper 的範圍,就可以滑動 toggler 了。而我們的需求是隻有當手指進入 toggler 才能滑動。
  2. 當手指抬起時,toggler 就立即停止移動了。而我們的需求是當手指抬起時,toggler 需要自動滑到開始或者結束的位置。

也就是說,還需要監聽手指在 toggler 上面的 touchstart 和 touchend 事件。當 touchstart 發生時,需要開啟 toggler 移動的開關,當 touchend 發生時,需要根據情況讓 toggler 滑到開始或結束的位置。

邏輯還是很清楚,下面來修改程式碼吧: 首先為 toggler 加上 touch 監聽函式

render() {
  ...
    <div className="toggler"  
            onTouchStart={this.onToggerTouchStart}
            onTouchCancel={this.onToggerTouchCancel}
            onTouchEnd={this.onToggerTouchCancel}
            ref="togger" style={toggleStyle}>
   </div>
  ...
}

在 onToggerTouchStart 函式中,開啟滑動開關(movingEnable) , 同時取消 toggler 位移動畫。

onToggerTouchStart(e) {
    this.movingEnable = true;
    this.enableTransition(false);
  }

在 onToggerTouchCancel 函式中,關閉滑動開關,同時為 toggler 新增一個位移動畫。還根據 toggler 此時的位移量(translateX),將 toggler 調整為回到初始位置(0) 或者回到最大位置(xBoundary)。

onToggerTouchCancel(e) {
    this.movingEnable = false;
    this.enableTransition(true);
    if(this.translateX < this.xBoundary /2) {
      this.translateX = 0;
    }else {
      this.translateX = this.xBoundary;
    }
    this.setState({
      translateX: this.translateX,
    });
  }

switch.gif

這樣,我們的 Switch元件就大功告成了,在這裡線上體驗。 完整程式碼請參考 Github

相關文章