30 天精通 RxJS (11): 真實示例 - 完整拖拽應用

有次不當心進到了優酷,發現優酷有個不錯的功能,能大大的提高用戶體驗,就讓咱們一塊兒來實現這個效果吧!javascript

同樣建議你們能夠直接看影片css

在第 08 篇的時候,咱們已經成功作出簡易的拖拽效果,今天要來作一個完整的應用,並且是實務上有機會遇到但很差處理的需求,那就是優酷的影片效果!html

若是尚未用過優酷的讀者能夠先前往這裏試用。java

當咱們在優酷看影片時往下滾動畫面,影片會變成一個小視窗在右下角,這個視窗還可以拖拽移動位置。這個功能可讓使用者一邊看留言同時又能看影片,且不影響其餘的資訊顯示,真的是很不錯的 feature。瀏覽器

優酷影片拖拽功能
優酷影片拖拽功能

就讓咱們一塊兒來實現這個功能,同時補完拖拽所須要注意的細節吧!dom

需求分析

首先咱們會有一個影片在最上方,本來是位置是靜態(static)的,卷軸滾動到低於影片高度後,影片改成相對於視窗的絕對位置(fixed),往回滾會再變回本來的狀態。當影片爲 fixed 時,滑鼠移至影片上方(hover)會有遮罩(masker)與鼠標變化(cursor),能夠拖拽移動(drag),且移動範圍不超過可視區間!ide

上面能夠拆分紅如下幾個步驟優化

  • 準備 static 樣式與 fixed 樣式
  • HTML 要有一個固定位置的錨點(anchor)
  • 當滾動超過錨點,則影片變成 fixed
  • 當往回滾動過錨點上方,則影片變回 static
  • 影片 fixed 時,要可以拖拽
  • 拖拽範圍限制在當前可視區間

基本的 HTML 跟 CSS 筆者已經幫你們完成,你們能夠直接到下面的連接接着實現:動畫

先讓咱們看一下 HTML,首先在 HTML 裏有一個 div(#anchor),這個 div(#anchor) 就是待會要作錨點用的,它內部有一個 div(#video),則是滾動後要改變成 fixed 的元件。ui

CSS 的部分咱們只須要知道滾動到下方後,要把 div(#video) 加上 video-fixed 這個 class。

接着咱們就開始實現滾動的效果切換 class 的效果吧!

第一步,取得會用到的 DOM

由於先作滾動切換 class,因此這裏用到的 DOM 只有 #video, #anchor。

const video = document.getElementById('video');
const anchor = document.getElementById('anchor');複製代碼

第二步,創建會用到的 observable

這裏作滾動效果,因此只須要監聽滾動事件。

const scroll = Rx.Observable.fromEvent(document, 'scroll');複製代碼

第三步,撰寫程式邏輯

這裏咱們要取得了 scroll 事件的 observable,當滾過 #anchor 最底部時,就改變 #video 的 class。

首先咱們會須要滾動事件發生時,去判斷是否滾過 #anchor 最底部,因此把本來的滾動事件變成是否滾過最底部的 true or false。

scroll.map(e => anchor.getBoundingClientRect().bottom < 0)複製代碼

這裏咱們用到了 getBoundingClientRect 這個瀏覽器原生的 API,他能夠取得 DOM 事件的寬高以及上下左右離螢幕可視區間上(左)的距離,以下圖

當咱們可視範圍區間滾過 #anchor 底部時, anchor.getBoundingClientRect().bottom 就會變成負值,此時咱們就改變 #video 的 class。

scroll
.map(e => anchor.getBoundingClientRect().bottom < 0)
.subscribe(bool => {
    if(bool) {
        video.classList.add('video-fixed');
    } else {
        video.classList.remove('video-fixed');
    }
})複製代碼

到這裏咱們就已經完成滾動變動樣式的效果了!

所有的 JS 代碼,以下

const video = document.getElementById('video');
const anchor = document.getElementById('anchor');

const scroll = Rx.Observable.fromEvent(document, 'scroll');

scroll
.map(e => anchor.getBoundingClientRect().bottom < 0)
.subscribe(bool => {
    if(bool) {
        video.classList.add('video-fixed');
    } else {
        video.classList.remove('video-fixed');
    }
})複製代碼

固然這段還能在用 debounce/throttle 或 requestAnimationFrame 作優化,這個部分咱們往後的文章會在說起。

接下來咱們就能夠接着作拖拽的行爲了。

第一步,取得會用到的 DOM

這裏咱們會用到的 DOM 跟前面是同樣的(#video),因此不用多作什麼。

第二步,創建會用到的 observable

這裏跟上次同樣,咱們會用到 mousedown, mouseup, mousemove 三個事件。

const mouseDown = Rx.Observable.fromEvent(video, 'mousedown')
const mouseUp = Rx.Observable.fromEvent(document, 'mouseup')
const mouseMove = Rx.Observable.fromEvent(document, 'mousemove')複製代碼

第三步,撰寫程式邏輯

跟上次是差很少的,首先咱們會點擊 #video 元件,點擊(mousedown)後要變成移動事件(mousemove),而移動事件會在滑鼠放開(mouseup)時結束(takeUntil)

mouseDown
.map(e => mouseMove.takeUntil(mouseUp))
.concatAll()複製代碼

由於把 mouseDown observable 發送出來的事件換成了 mouseMove observable,因此變成了 observable(mouseDown) 送出 observable(mouseMove)。所以最後用 concatAll 把後面送出的元素變成 mouse move 的事件。

這段若是不清楚的能夠回去看一下 08 篇的講解

但這裏會有一個問題,就是咱們的這段拖拽事件其實只能作用到 video-fixed 的時候,因此咱們要加上 filter

mouseDown
.filter(e => video.classList.contains('video-fixed'))
.map(e => mouseMove.takeUntil(mouseUp))
.concatAll()複製代碼

這裏咱們用 filter 若是當下 #video 沒有 video-dragable class 的話,事件就不會送出。

再來咱們就能跟上次同樣,把 mousemove 事件變成 { x, y } 的事件,並訂閱來改變 #video 元件

mouseDown
    .filter(e => video.classList.contains('video-fixed'))
    .map(e => mouseMove.takeUntil(mouseUp))
    .concatAll()
    .map(m => {
        return {
            x: m.clientX,
            y: m.clientY
        }
    })
    .subscribe(pos => {
        video.style.top = pos.y + 'px';
        video.style.left = pos.x + 'px';
    })複製代碼

到這裏咱們基本上已經完成了全部功能,其步驟跟 08 篇的方法是同樣的,若是不熟悉的人能夠回頭看一下!

但這裏有兩個大問題咱們尚未解決

  1. 第一次拉動的時候會閃一下,不像優酷那麼順
  2. 拖拽會跑出當前可視區間,跑上出去後就抓不回來了

讓咱們一個一個解決,首先第一個問題是由於咱們的拖拽直接給元件滑鼠的位置(clientX, clientY),而非給滑鼠相對移動的距離!

因此要解決這個問題很簡單,咱們只要把點擊目標的左上角看成 (0,0),並以此改變元件的樣式,就不會有閃動的問題。

這個要怎麼作呢? 很簡單,咱們在昨天講了一個 operator 叫作 withLatestFrom,咱們能夠用它來把 mousedown 與 mousemove 兩個 Event 的值同時傳入 callback。

mouseDown
    .filter(e => video.classList.contains('video-fixed'))
    .map(e => mouseMove.takeUntil(mouseUp))
    .concatAll()
    .withLatestFrom(mouseDown, (move, down) => {
        return {
            x: move.clientX - down.offsetX,
            y: move.clientY - down.offsetY
        }
    })
    .subscribe(pos => {
        video.style.top = pos.y + 'px';
        video.style.left = pos.x + 'px';
    })複製代碼

當咱們可以同時獲得 mousemove 跟 mousedown 的事件,接着就只要把 滑鼠相對可視區間的距離(client) 減掉點按下去時 滑鼠相對元件邊界的距離(offset) 就好了。這時拖拽就不會先閃動一下囉!

你們只要想一下,其實 client - offset 就是元件相對於可視區間的距離,也就是他一開始沒動的位置!

offset&client
offset&client

接着讓咱們解決第二個問題,拖拽會超出可視範圍。這個問題其實只要給最大最小值就好了,由於需求的關係,這裏咱們的元件是相對可視居間的絕對位置(fixed),也就是說

  • top 最小是 0
  • left 最小是 0
  • top 最大是可視高度扣掉元件自己高度
  • left 最大是可視寬度扣掉元件自己寬度

這裏咱們先宣告一個 function 來處理這件事

const validValue = (value, max, min) => {
    return Math.min(Math.max(value, min), max)
}複製代碼

第一個參數給本來要給的位置值,後面給最大跟最小,若是今天大於最大值咱們就取最大值,若是今天小於最小值則取最小值。

再來咱們就能夠直接把這個問題解掉了

mouseDown
    .filter(e => video.classList.contains('video-fixed'))
    .map(e => mouseMove.takeUntil(mouseUp))
    .concatAll()
    .withLatestFrom(mouseDown, (move, down) => {
        return {
            x: validValue(move.clientX - down.offsetX, window.innerWidth - 320, 0),
            y: validValue(move.clientY - down.offsetY, window.innerHeight - 180, 0)
        }
    })
    .subscribe(pos => {
        video.style.top = pos.y + 'px';
        video.style.left = pos.x + 'px';
    })複製代碼

這裏我偷懶了一下,直接寫死元件的寬高(320, 180),實際上應該用 getBoundingClientRect 計算是比較好的。

如今咱們就完成整個應用囉!

這裏有最後完成的結果。

今日結語

咱們簡單地用了不到 35 行的代碼,完成了一個還算複雜的功能。更重要的是咱們還保持了整支程式的可讀性,讓咱們以後維護更加的輕鬆。

今天的練習就到這邊結束了,不知道讀者有沒有收穫呢? 若是有任何問題歡迎在下方留言給我!

若是你喜歡本篇文章請幫我按個 star 。

相關文章
相關標籤/搜索