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 。

相关文章
相关标签/搜索