[crop系列] 4 drag實作

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,會搭配上比較多的計算,希望下篇能夠完結!

參考