可拖动修改宽度侧弹的实现

在项目中遇到这样一个需求:

点击按钮,从页面右侧出现一个侧弹,要求这个侧弹在不同的位置有不同的表现,一种是覆盖在原内容上,另一种是把侧弹左边的内容(也是一个侧弹)推开,并且出现的侧弹需要支持拉左边界修改宽度。

侧弹的整体构造

首先先来写这个侧弹的整体布局,因为侧弹是和按钮一起的,所以可以直接把侧弹和按钮写进一个组件里。

初步布局

css要实现容器大小的更改,是通过resize属性设置的,我们要修改宽度,设置resize: horizontal;​即可。

侧弹展示需要动画,在样式中写两个keyframes​即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { defineComponent, ref, onUnmounted } from 'vue';
import './index.scss';
import { Button } from 'bkui-vue';

export default defineComponent({
name: 'MySlider',
setup() {
const isShowHelper = ref(false);
const animationClass = ref('');
const toggleHelper = () => {
if (isShowHelper.value) {
animationClass.value = 'slide-out';
setTimeout(() => {
isShowHelper.value = false;
}, 250);
} else {
isShowHelper.value = true;
animationClass.value = 'slide-in';
setTimeout(() => {
}, 250);
}
};

onUnmounted(() => {
isShowHelper.value = false;
});

return () => (
<>
<Button onClick={toggleHelper}>展示侧弹</Button>
{isShowHelper.value && (
<div class={['bv-slider', animationClass.value]}></div>
)}
</>
);
},
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@keyframes slide-in {
from {
transform: translateX(400px);
}

to {
transform: translateX(0);
}
}

@keyframes slide-out {
from {
transform: translateX(0);
}

to {
transform: translateX(100%);
}
}

.bv-slider {
height: 100%;
right: 0;
top: 0;
z-index: 99999999;
overflow: auto;
box-shadow: -2px 0 6px #dcdee5;
max-width: 100%;
position: fixed;
width: 400px;
height: 100%;
resize: horizontal;
overflow: scroll;
min-width: 400px;
background-color: #fff;

&.slide-in {
animation: slide-in .25s forwards;
}

&.slide-out {
animation: slide-out .25s forwards;
}
}

效果:

::-webkit-resizer​伪元素

可以看到侧弹的用于改变宽度的那一个小块,位于侧弹的右下角,在Chrome中,这是个叫::-webkit-resizer​的伪元素,这是MDN对这个伪元素的定义:

  • ::-webkit-resizer​ — the draggable resizing handle that appears at the bottom corner of some elements.

这个伪元素位于元素的底端,它的宽高是由滚动条的宽高来决定的,我们要实现拖动一个边就可以改变宽度,因此需要把滚动条的高度拉满:

1
2
3
4
&::-webkit-scrollbar {
width: 20px;
height: 100vh;
}

高度拉满后,现在拉动div的右边可以改变宽度了,我们需要实现的是拉动左边,这个可以通过设置direction: rtl;​来实现。

布局重构

现在又有问题了,浏览器默认的::-webkit-resizer​很丑!我们需要自定义一个用来拉动的东西。

但是,给这个伪元素或者滚动条设置opacity​/visibility​等属性是不生效的,而且又不能设置display:none;​,这会导致滚动条不显示,失去拉动的功能。能够实现隐藏这个伪元素,并保持拉动功能的方法只有把整个div的透明度改为0。

因此,我们需要改变侧弹的布局:在用于改变宽度的div外面再包一层div即可,将改变宽度的div设置透明度为0。

我们还可以自定义一下用于拉动的元素,让布局好看一点,并加上内容。由于内层的div用于撑开外层,所以内容的div需要使用绝对定位。

1
2
3
4
5
<div class={['bv-slider', animationClass.value]}>
<div class="bv-slider-resizer"></div> // 用于拉动修改宽度的元素
<div class="bv-slider-resizer-indicator">.....</div> // 那五个点
<div class="bv-slider-content">content</div> // 内容
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
.bv-slider {
height: 100%;
right: 0;
top: 0;
z-index: 99999999;
overflow: auto;
box-shadow: -2px 0 6px #dcdee5;
max-width: 100%;
position: fixed;
background-color: #fff;

&.slide-in {
animation: slide-in .25s forwards;
}

&.slide-out {
animation: slide-out .25s forwards;
}

&-resizer {
width: 400px;
height: 100%;
resize: horizontal;
overflow: scroll; // 使滚动条常驻
min-width: 400px;
transform: scale(-1); // 因为是拉左边,把div翻转过来
opacity: 0; // 为了隐藏不好看的resizer伪元素,设置一整个div透明度为0

&-indicator {
color: #C4C6CC;
top: 50%;
right: auto;
pointer-events: none;
writing-mode: vertical-lr;
z-index: 1000;
position: absolute;
}

&::-webkit-scrollbar {
width: 20px;
height: 100vh; // 让滚动条铺满视口
}
}

&-content {
inset: 0 0 0 4px;
padding: 14px;
position: absolute;
}
}

目前整体效果都已经出来了~

实现“推动”效果

需求中提到,这个侧弹会有“推动”和“覆盖”两种效果,我们先实现“推动”效果。这有一个简单的思路:获取到侧弹左边的元素的模板引用,通过修改元素的位置属性来实现。

照着这个思路开干!

初步实现

我们需要给侧弹添加上“显示”和“隐藏”两个自定义事件,用于触发我们想要的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
emits: ['show', 'hide'] // 在defineComponent中定义这两个事件

const toggleHelper = () => {
if (isShowHelper.value) {
animationClass.value = 'slide-out';
emit('hide'); // 在侧弹显示/隐藏时触发
setTimeout(() => {
isShowHelper.value = false;
}, 250);
} else {
isShowHelper.value = true;
animationClass.value = 'slide-in';
emit('show');
setTimeout(() => {
}, 250);
}
};

实例中的侧栏使用的是 bkui-vue3 组件库。在下文中,组件库的侧栏简称为“侧栏”,我们自己开发的成为“侧弹”。

在触发显示事件时,我们修改一下侧栏组件的位置即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineComponent, ref } from "vue";
import MySlider from "@/components/MySlider";
import { Sideslider } from "bkui-vue";

export default defineComponent({
name: 'PushDemo',
setup() {
const sidesliderRef = ref<InstanceType<typeof Sideslider>>(null);
const showStyle = {
right: '400px',
transition: 'right 0.25s'
}
const handleSliderShow = () => {
Object.assign(sidesliderRef.value.$el.style, showStyle);
}
return () => (
<div>
<Sideslider isShow ref={sidesliderRef}>
<MySlider onShow={handleSliderShow} />
</Sideslider >
</div >
)
}
})

不出意外的话,是出意外了:

​​

可以看到我们侧弹的位置也跟着侧栏一起移动了,可是侧弹不是设置了position:fixed​吗?这时候侧弹的定位应该是以视口为基准的,怎么会变成基于侧栏定位呢?

transform​属性

一顿分析发现,侧栏设置了transform属性:

​​

来看看W3C对transform的说明吧:

For elements whose layout is governed by the CSS box model, any value other than none for the transform property also causes the element to establish a containing block for all descendants. Its padding box will be used to layout for all of its absolute-position descendants, fixed-position descendants, and descendant fixed background attachments.

For elements whose layout is governed by the CSS box model, any value other than none for the transform property results in the creation of a stacking context.

翻译成中文:

对于那些布局由CSS盒模型控制的元素,transform​属性的任何非none​值会使该元素为所有后代元素建立一个包含块。它的内边距盒将被用来为所有绝对定位的后代元素、固定定位的后代元素,以及后代元素的固定背景附件进行布局。

对于那些布局受CSS盒模型控制的元素,transform​属性的任何非none​的值都会导致一个层叠上下文的创建。

这就说明了问题,由于父层设置了transform,会生成一个包含块,使得子代含有 position: fixed;​ 的变为相对于包含块定位,这就说明了为什么我们的侧弹会根据侧栏来定位。与transform​类似的,还有perspective​和filter​属性。

解决问题

现在有两个解决方法:

  • 修改组件库样式

    既然组件库设置了transform​,那把组件库的transform​设置为none​不就好了吗?答案是否定的,随意修改组件库的样式,可能会导致一些预期外的问题,在生产环境下是绝对不能这样做的。因此要选择一个可靠的方法(方法二)。

  • 修改侧弹渲染位置

    既然父组件设置了transform​,那我们把侧弹渲染的地方渲染到设置了transform​的元素外不就好了吗?刚好Vue提供了Teleport​这个内置组件,可以实现这个功能。

首先要修改一下我们侧弹的构造,现在侧弹要使用Teleport​来进行渲染,Teleport​接收一个to​属性,用于指定内容要渲染的地方,所以我们给侧弹添加一个props​,取名为placement​:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export default defineComponent({
name: 'MySlider',
emits: ['show', 'hide'],
props: {
placement: {
type: String,
default: 'body',
},
},
setup(props, { emit }) {
// 省略部分无关代码
return () => (
<>
<Button onClick={toggleHelper}>展示侧弹</Button>
{isShowHelper.value && (
<Teleport to={props.placement}>
<div class={['bv-slider', animationClass.value]}>
<div class="bv-slider-resizer"></div>
<div class="bv-slider-resizer-indicator">.....</div>
<div class="bv-slider-content">
<div class="bv-slider-title">
<CloseOutlined onClick={toggleHelper} />
</div>
content
</div>
</div>
</Teleport>
)}
</>
);
},
});

在使用的时候,我们指定渲染的位置为侧栏的父层,并加上隐藏的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default defineComponent({
name: 'PushDemo',
setup() {
const sideSliderRef = ref<InstanceType<typeof Sideslider>>(null);
const showStyle = {
right: '400px',
transition: 'right 0.25s'
}
const hideStyle = {
right: '0px',
transition: 'right 0.25s'
}
const handleSliderShow = () => {
Object.assign(sideSliderRef.value.$el.style, showStyle);
}
const handleSliderHide = () => {
Object.assign(sideSliderRef.value.$el.style, hideStyle);
}
return () => (
<div>
<Sideslider isShow ref={sideSliderRef} class="bv-push-sideslider">
<MySlider onShow={handleSliderShow} onHide={handleSliderHide} placement=".bv-push-sideslider" />
</Sideslider >
</div>
)
}
})

完美解决问题~,看看效果:

​​​

如果侧弹使用的地方不能获取到侧栏的模板引用(侧弹在侧栏的子组件的子组件里),这里也可以使用事件总线的方式修改,这里不过多赘述。

“覆盖”效果的完善

现在来实现一下“覆盖”效果,代码比“推动”会简单很多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default defineComponent({
name: 'HoverDemo',
setup() {
return () => (
<div>
<Sideslider isShow width={800}>
<div class="bv-hover-content">
<MySlider placement=".bv-hover-content"/>
</div>
</Sideslider >
</div>
)
}
})
​​​

效果是这样的,看着还行吧,但是设计稿中的效果是,侧弹是位于侧栏的内容里,不应该覆盖侧栏的标题。这是因为我们的侧弹设置了position:fixed​,所以导致以视口为标准布局。我们把侧弹的position​改成absolute​,然后给父层加上position:relative;​:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default defineComponent({
name: 'HoverDemo',
setup() {
return () => (
<div>
<Sideslider isShow width={800}>
<div class="bv-hover-content" style="height:100%;position:relative;">
<MySlider placement=".bv-hover-content" />
</div>
</Sideslider >
</div>
)
}
})
​​​​

优化

滚动条优化

在覆盖的例子中,侧弹还没完全出现时,会有一个滚动条:

​​​​​

我们给父层加上overflow:hidden;​即可:

1
2
3
<div class="bv-hover-content" style="height:100%;position:relative;overflow:hidden;">
<MySlider placement=".bv-hover-content" />
</div>

另外,我们的侧弹的宽度可以无限拉长:

​​​​​ ​​​​​

我们给resizer的样式添加max-width: 100%;​即可解决:

1
2
3
&-resizer {
max-width: 100%;
}

避免硬编码

上面的例子中,侧弹的宽度为400px,最小宽度也是400px,侧弹移动动画的位移也是400px,以及推动动画的位移也是400px,如果某一天要修改这个宽度,将要改很多地方,这里我们可以用css变量解决:

1
2
3
:root {
--sidebar-width: 400px;
}

css中的硬编码解决了,还需要解决js中的,我们可以在组件里导出一个变量:

1
export const sideBarWidth = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width');

在需要用到的地方引入即可:

1
2
3
4
5
6
import MySlider, { sideBarWidth } from "@/components/MySlider";

const showStyle = {
right: sideBarWidth,
transition: 'right 0.25s'
}

其他

为什么组件库的侧栏里要写一个transform:translate(0);​,这样一个看似没用什么用的样式?

这里其实是为了触发GPU加速,侧栏的显示/隐藏也是有动画效果的,使用GPU加速,可以大幅提升动画性能。因此在写动画时,也应该尽可能地使用transform​。