30 天精通 RxJS (11): 实务范例 - 完整拖拉应用

在第 08 篇的时候,我们已经成功做出简易的拖拉效果,今天要来做一个完整的应用,而且是实务上有机会遇到但不好处理的需求,那就是优酷的影片效果!

如果还没有用过优酷的读者可以先前往这裡试用。

当我们在优酷看影片时往下滚动画面,影片会变成一个小视窗在右下角,这个视窗还能够拖拉移动位置。这个功能可以让使用者一边看留言同时又能看影片,且不影响其他的资讯显示,真的是很不错的 feature。

image.png

就让我们一起来实作这个功能,同时补完拖拉所需要注意的细节吧!

需求分析

首先我们会有一个影片在最上方,原本是位置是静态(static)的,卷轴滚动到低于影片高度后,影片改为相对于视窗的绝对位置(fixed),往回滚会再变回原本的状态。当影片为 fixed 时,滑鼠移至影片上方(hover)会有遮罩(masker)与鼠标变化(cursor),可以拖拉移动(drag),且移动范围不超过可视区间!

上面可以拆分成以下几个步骤

  • 准备 static 样式与 fixed 样式
  • HTML 要有一个固定位置的锚点(anchor)
  • 当滚动超过锚点,则影片变成 fixed
  • 当往回滚动过锚点上方,则影片变回 static
  • 影片 fixed 时,要能够拖拉
  • 拖拉范围限制在当前可视区间

基本的 HTML 跟 CSS 笔者已经帮大家完成,大家可以直接到下面的连结接著实作:

先让我们看一下 HTML,首先在 HTML 裡有一个 div(#anchor),这个 div(#anchor) 就是待会要做锚点用的,它内部有一个 div(#video),则是滚动后要改变成 fixed 的元件。

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 物件的宽高以及上下左右离萤幕可视区间上(左)的距离,如下图

image.png

当我们可视范围区间滚过 #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 就是元件相对于可视区间的距离,也就是他一开始没动的位置!

image.png

接著让我们解决第二个问题,拖拉会超出可视范围。这个问题其实只要给最大最小值就行了,因为需求的关系,这裡我们的元件是相对可视居间的绝对位置(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 行的程式码,完成了一个还算複杂的功能。更重要的是我们还保持了整支程式的可读性,让我们之后维护更加的轻鬆。

今天的练习就到这边结束了,不知道读者有没有收穫呢? 如果有任何问题欢迎在下方留言给我!

如果你喜欢本篇文章请帮我按个 like 跟 星星。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,245评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,749评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,960评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,575评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,668评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,670评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,664评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,422评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,864评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,178评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,340评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,015评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,646评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,265评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,494评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,261评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,206评论 2 352

推荐阅读更多精彩内容