[crop系列] 4 drag實作

35 mins.
  1. 1. mouse
    1. 1.1. event參數
    2. 1.2. 非rx實作
      1. 1.2.1. 事件組合
      2. 1.2.2. 計算位移
    3. 1.3. event參數
    4. 1.4. 實作
    5. 1.5. 重構
  2. 2. 結合touch、mouse
  3. 3. 物件只能在區域內
  4. 4. 參考

crop系列之第n篇,這篇要來說怎麼用rxjs來實作drag並且要支援手機的touch行為

如果還沒看過系列其他篇的麻煩這邊請

[crop系列] 1 scale和position的糾葛[crop系列] 2 canvas drawimage應用[crop系列] 3 圖片mask

所有的範例都會是以react為主,angular版本改天再出

mouse

Drag的操作行為分為desktop和mobile兩種,在desktop上面大家都很清楚就是用mouse,但是在mobile上面則用touch,基本上兩個的概念是差不多,只是差在事件上面的不同,那就讓我們先用mouse的event來做

event參數

首先要先了解drag的行為模式,點在一個物件上(down),然後移動(move),最後放開(up),那物件要根據移動的位移量來跟著移動,所以要計算位移量的話就會是跟x, y軸有關係,因此我們先來看看MDN有哪些參數可以使用

  • clientX、clientY
    Returns the coordinate of the mouse pointer, relative to the current window, when the mouse event was triggered
  • movementX、movementY
    Returns the coordinate of the mouse pointer relative to the position of the last mousemove event
  • offsetX、offsetY
    Returns the coordinate of the mouse pointer relative to the position of the edge of the target element
  • pageX、pageY
    Returns the coordinate of the mouse pointer, relative to the document, when the mouse event was triggered
  • screenX、screenY
    Returns the coordinate of the mouse pointer, relative to the screen, when an event was triggered

上面這些講的很漏漏等,還是用一張圖來呈現比較明確

mouse event

圖片來源

看完圖片有沒有比較清楚知道每一個的用途,那最好用的應該會是movement這個參數,但這個不支援部分瀏覽器,所以我們要改用其他的參數,而其中client這個會比offset還要好,等一下我們來實作看看兩個的差異

非rx實作

那我們先來用傳統的addEventListener來實作看看,首先我們用一個component叫做dragable,裡面只有一個div作為可以被拖拉的element,那我們第一步就是註冊各個事件

當然不要忘記在componentWillUnMount取消註冊

1
2
3
4
5
componentDidMount () {
this.dragNode.addEventListener('mousedown', this.mouseDown);
this.dragNode.addEventListener('mouseup', this.mouseUp);
this.dragNode.addEventListener('mousemove', this.mouseMove);
}
  • mouseDown
    透過全域變數來記當下的座標以及開始拖拉
1
2
3
4
5
6
7
8
9
mouseDown = event => {
this.setState({
isDrag: true,
lastPoint: {
left: event.clientX,
top: event.clientY,
},
});
}
  • mouseUp
    取消拖拉狀態
1
2
3
4
5
mouseUp = event => {
this.setState({
isDrag: false,
});
}
  • mouseMove
    將目前所在位置 減掉 前一次移動的值 再加上 目前element所在位置,最後將計算的結果寫入到element
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mouseMove = event => {
const {
isDrag,
top,
left,
lastPoint,
} = this.state;
if(!isDrag){
return;
}

const newLeft = event.offsetX - lastPoint.left + left;
const newTop = event.offsetY - lastPoint.top + top;
this.setState({
left: newLeft,
top: newTop,
lastPoint: {
left: event.offsetX,
top: event.offsetY,
}
});
}

寫完以上的程式實際操作一下,發現會有點卡卡的,尤其是滑鼠會跑超過drag的框,但又希望dragable的物件能跟著走,因此在監聽的地方要改一下,mouseupmousemove要改成document的全域監聽

1
2
3
4
5
componentDidMount () {
this.dragNode.addEventListener('mousedown', this.mouseDown);
document.addEventListener('mouseup', this.mouseUp);
document.addEventListener('mousemove', this.mouseMove);
}

這樣改完再跑一次,發現還是跑的不順,這時候我們改用client來取代offset,再跑一次會發現整個順暢了,非常完美!完整的範例如下

## rx版本實作

那接著我們就要來使用rx的方法實作同樣的功能,首先一樣先定義事件

1
2
3
4
5
componentDidMount(){
const down$ = fromEvent(this.dragNode, 'mousedown');
const up$ = fromEvent(document, 'mouseup');
const move$ = fromEvent(document, 'mousemove');
}

事件組合

接著要來開始把這些事件組合,首先我們是透過mousedown作為起點,然後mousemove接著再mouseup的時候結束

1
2
3
4
5
6
7
8
9
10
componentDidMount(){
const down$ = fromEvent(this.dragNode, 'mousedown');
const up$ = fromEvent(document, 'mouseup');
const move$ = fromEvent(document, 'mousemove');
down$.pipe(
mergeMap(down => move$.pipe(
)),
takeUntil(up$),
);
}

這時候將up$的事件當做結束,但是這樣會有一個問題,因為takeUntil是有值進來的時候會觸發complete,因此只要mouseup過一次以後這個流程就結束了

但期望的應該是每次mousedown的時候都要能夠被觸發,因此我們應該要將takeUntil(up$)放在move$的身上,讓流程只斷移動,而不會影響到down$

1
2
3
4
5
6
7
8
9
10
componentDidMount(){
const down$ = fromEvent(this.dragNode, 'mousedown');
const up$ = fromEvent(document, 'mouseup');
const move$ = fromEvent(document, 'mousemove');
down$.pipe(
mergeMap(down => move$.pipe(
takeUntil(up$),
)),
);
}

計算位移

事件的流程搞定以後就可以開始的來針對位移量做計算,概念會跟前面有點類似,但又會有點不同,直接來說說不同的點,不是每次的偏移量,而是這次位置和初始位置的偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
down$.pipe(
mergeMap(down => move$.pipe(
takeUntil(up$),
map(move => ({
deltaX: down.clientX - move.clientX,
deltaY: down.clientY - move.clientY,
})),
)),
).subscribe(point => {
const {
left,
top,
} = this.state;

this.setState({
left: point.deltaX + left,
top: point.deltaY + top,
});
});

很開心的寫完執行,發現怎麼跑的飛快,原因是delta的量是重新計算後跟一開始的偏移量,但我們卻拿最新的位置做計算,那當然會跑的飛快,這邊要微調一下,從down$的時候要就拿到state的資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
down$.pipe(
mergeMap(down => of(this.state).pipe(
map(state => ({
down,
state,
})),
)),
mergeMap(({down, state}) => move$.pipe(
takeUntil(up$),
map(move => ({
deltaX: move.clientX - down.clientX,
deltaY: move.clientY - down.clientY,
})),
map(delta => ({
left: delta.deltaX + state.left,
top: delta.deltaY + state.top,
})),
))
).subscribe(point=>{
this.setState(point);
});

在執行看看,跟前面的效果是一樣,完美!完整範例如下

# touch

做完滑鼠的接著當然就是要處理mobile上面的行為,跟mouse的差不多主要都是由touch的事件做處理,一樣先來了解MDN的參數內容

event參數

  • touches
  • targetTouches
  • changedTouches

在mobile有多點觸控的功能,所以會是一個陣列,所以在寫的時候必須要注意一下目前的觸控數有多少,那在上面的兩個屬性中,有的參數其實跟mouse一樣

  • screen
  • client
  • page

實作

了解完參數以後就可以開始動工啦,這邊我就不用傳統的寫法而是直接採用rx

1
2
3
4
5
6
7
8
9
10
11
componentDidMount(){
const start$ = fromEvent(this.dragNode, 'touchstart');
const end$ = fromEvent(document, 'touchend');
const move$ = fromEvent(document, 'touchmove');
start$.pipe(
filter(event => event.touches.length === 1),
mergeMap(start => move$.pipe(
takeUntil(end$),
)),
);
}

這邊有兩個地方要注意一下

  1. 要記得filter個數,不然會發生跟預期不一樣的行為
  2. 這邊的邏輯好像跟上面mouse的差不多

重構

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
componentDidMount(){
const start$ = fromEvent(this.dragNode, 'touchstart').pipe(this.parseTouchEvent);
const end$ = fromEvent(document, 'touchend');
const move$ = fromEvent(document, 'touchmove').pipe(this.parseTouchEvent);
start$.pipe(
filter(event => event.touches.length === 1),
mergeMap(start => move$.pipe(
takeUntil(end$),
)),
);
}

parseTouchEvent = obs => obs.pipe(
tap(event => event.preventDefault()),
filter(event => event.touches.length === 1),
map(event => ({
x: event.touches[0].clientX,
y: event.touches[0].clientY,
})),
);

因為已經過濾掉只有一個觸控點的情況下會往後,因此直接拿陣列的第一個物件中client資料來使用,這樣在後面的資料都統一用{x, y}的物件做處理

記得要加上*preventDefault*,但千萬記得不要加在end上面,會死的不明不白…

結合touch、mouse

前面有看到兩個的相似之處,而且在touch的時候我們也針對資料做了處理,接著就是要把這兩段的程式做結合,主要要進行串接的有兩個地方

  • 資料的處理
    將mouse的事件也加上前置處理

    1
    2
    3
    4
    5
    6
    7
    parseMouseEvent = obs => obs.pipe(
    tap(event => event.preventDefault()),
    map(event => ({
    x: event.clientX,
    y: event.clientY,
    })),
    );
  • 事件對事件的串接
    先把move加上end/up的結束處理,再來就是兩個相同類型的事件做merge

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const start$ = fromEvent(this.dragNode, 'touchstart').pipe(this.parseTouchEvent);
    const end$ = fromEvent(document, 'touchend');
    const touchMove$ = fromEvent(document, 'touchmove').pipe(
    this.parseTouchEvent,
    takeUntil(end$),
    );

    const down$ = fromEvent(this.dragNode, 'mousedown').pipe(
    this.parseMouseEvent,
    merge(start$),
    );
    const up$ = fromEvent(document, 'mouseup');
    const move$ = fromEvent(document, 'mousemove').pipe(
    this.parseMouseEvent,
    takeUntil(up$),
    merge(touchMove$),
    );

最後就是針對新的物件把邏輯做個微調

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
down$.pipe(
takeUntil(this.destory$),
mergeMap(down => of(this.state).pipe(
map(state => ({
down,
state,
})),
)),
mergeMap(({down, state}) => move$.pipe(
map(move => ({
deltaX: move.x - down.x,
deltaY: move.y - down.y,
})),
map(delta => ({
left: delta.deltaX + state.left,
top: delta.deltaY + state.top,
})),
))
)

物件只能在區域內

最後來點進階的,要把dragable的物件限制在一個區域內,也就是左上、左下、右上、右下

但是要判斷四個資料寫起來也很煩,漏漏等,因此換個角度思考看看,如過只判斷一邊在一個範圍值之內是不是就可以,例如左邊只能在0100,上面只能在0200

首先一定要取出外框以及dragable的width、height,接著就要把外框的width、height扣掉dragable的width、height,這樣就能知道物件不超出的最大值是多少

1
2
3
const parent = this.dragNode.parent;
const maxLeft = parent.offsetWidth - this.dragNode.offsetWidth;
const maxTop = parent.offsetHeight = this.dragNode.offsetHeight;

接著就是要來判斷最大最小值,然後這邊用個公式目前值最小值較大者,然後再和最大值較小者

1
validValue = (value, min, max) => Math.min(Math.max(value, min), max)

最後直接來看一下完整的程式

# 結論

先說一下,原本打算這篇就要給他全部結束,但後來發現很難,因為太多程式碼要交代

Rx真的是我們的好朋友,尤其是在這種情況下使用,你可以發現很多邏輯都可以被抽出去,而且可以合併來合併去,我們就只要關注真正的邏輯面就好(要算算看我總共用了哪些operator嗎?其實答案就在程式的最上面,有沒有覺得使用的類型不多)

下一篇就會是更進階的zoom,會搭配上比較多的計算,希望下篇能夠完結!

參考