源码地址 Vue3+Vant3仿卖座电影
样式优化
宽高溢出视口校正
- 全局根布局与视口等宽等高,溢出区域使用滚动浏览;
- 注意配合box-sizing值border-box,即width/height包含padding与margin;
src/assets/base.css
* {
/*
默认值是content-box width/height为内容宽高 元素占据空间的宽高事实上为 内容宽高width/height + 内边距padding + 边框宽度border + 外边距/margin
修改为border-box(指定的宽高width/height)包含边框+内边距 但不包含margin
*/
box-sizing: border-box;
...
}
src/App.vue
<template>
<!-- 全局根布局 -->
<div id="root">
...
</div>
</template>
src/assets/main.scss
#root {
// 与视口等宽
width: 100vw;
// 与视口等高
height: 100vh;
// 高度溢出区域使用滚动
overflow: scroll;
}
可复用组件与钩子
自定义钩子useScroll
此hook实现以下功能:
- 组件挂载时给目标元素绑定scroll监听器;
- 在scroll监听器中实时获取目标元素的滚动高度,保存在一个响应式数据中;
- 这个实时滚动高度返回给调用者;
- 组件卸载时移除scroll事件监听器;
src/hooks/useScroll.js核心代码
import { onMounted, onUnmounted, ref } from "vue";
export default function (target = window, dataRef = null) {
/* 响应式数据实时滚动距离 */
const scrollTop = ref(0);
/* 滚动事件监听器 */
const scrollHandler = (e) => {
// console.log("scroll");
// 实时同步scrollTop
scrollTop.value = target.scrollTop;
};
/* 组件挂载时添加scrollHandler */
onMounted(() => {
target.addEventListener("scroll", scrollHandler);
});
/* 组件卸载时移除scrollHandler */
onUnmounted(() => {
target.removeEventListener("scroll", scrollHandler);
});
/* 实时滚动距离返回给调用者 */
return scrollTop;
}
MyNavBar实现背景可透明
src/components/common/MyNavBar.vue
定义props
props: {
...
/* 默认不使用透明标题 */
titleTransparent: {
type: Boolean,
default: false,
},
...
},
根据props确定是否要加transparent样式类
<!-- 根据props确定是否加transparent样式 :class="{ transparent: titleTransparent }" -->
<van-nav-bar
:title="title"
@click-left="(onClickLeft || defaulOnClickLeft)()"
class="mynavbar"
:class="{ transparent: titleTransparent }"
>
<template #left>...</template>
<template #title>
<span class="title">{{ title }}</span>
</template>
<template #right v-if="showRight">...</template>
</van-nav-bar>
定义transparent样式
.transparent {
// 背景透明
background-color: transparent;
// border-bottom取消
border-bottom: none;
// 属性变化时加转换动画
transition: all .5s ease;
// 标题文字也跟着透明
.title {
color: transparent;
}
}
覆盖一下 Vant的默认边框颜色
src/assets/main.scss
/* 覆盖vant的原始样式 */
:root {
--van-border-color: transparent;
}
PS:这里是一个坑点,需要自己调试才能发现
详情页交互
摘要的折叠与展开
src/views/DetailView.vue
模板
<!-- 影片摘要 -->
<!-- :class="{ hidde: hideSynopsis }" 动态判断是否使用折叠 -->
<!-- :style="{ height: synopsisHeight }" 动态计算一下展开高度 -->
<div
class="film-synopsis grey-text"
:class="{ hidde: hideSynopsis }"
:style="{ height: synopsisHeight }"
>
{{ film.synopsis }}
</div>
<!-- 点击时在折叠与展开之间来回切换 -->
<div class="toggle" @click="hideSynopsis = !hideSynopsis">
<!-- 折叠时使用↓否则使用↑ -->
<van-icon
:name="hideSynopsis ? 'arrow-down' : 'arrow-up'"
size="12"
color="#666"
/>
</div>
样式定义
.film .film-detail .film-synopsis {
// 当高度变化时使用动画
transition: height 0.5s ease;
// 这里不适合写死 而是根据摘要长度动态计算
// height: 120px;
// 加折叠时 使用固定的高度
&.hidde {
height: 50px !important;
}
}
动态数据
// 摘要是否使用折叠
const hideSynopsis = ref(true);
// 根据摘要长度变化动态计算展开时的高度
const synopsisHeight = computed(() => {
// 这里假设一行25个字 行高是16px
return Math.ceil(film.value.synopsis.length / 25) * 16 + "px";
});
NavBar随着滚动透明
src/views/DetailView.vue
设置MyNavBar在导航栏位置
<!-- :titleTransparent="scrollTop < 50" 当纵向滚动不足50时MyNavBar使用透明标题 -->
<MyNavBar
:title="film.name"
:showRight="false"
:onClickLeft="$router.back"
:titleTransparent="scrollTop < 50"
>
<!-- 将一个设置好样式的返回按钮塞在myleft插槽 -->
<template #myleft>
<span
style="
background-color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
padding: 1px;
display: inline-box;
line-height: 0;
"
>
<van-icon name="arrow-left" size="24" color="#666" />
</span>
</template>
</MyNavBar>
使用自定义钩子useScroll实时获取滚动高度
// 导入自定义hook
import useScroll from "../hooks/useScroll";
const scrollTop = useScroll(
// 现在滚动的不再是window而是根组件的根元素了
document.querySelector("#root")
);
其它互动优化
NavBar吸顶与Tabs吸顶协调
- 令Tabs吸顶拥有比NabBar更高的z-index优先级;
- 即二者事实上同时吸顶,但Tabs拥有更高的z-index优先级,因此视觉上NabBar在Tabs吸顶时自动被隐藏;
- 最终实现效果见文章末尾的动图;
src/components/common/MyNavBar.vue
<template>
<!-- 自带吸顶效果的导航条 -->
<!-- 注意这里的z-index="10"!!! -->
<van-sticky v-if="sticky" z-index="10">
<van-nav-bar
:title="title"
@click-left="(onClickLeft || defaulOnClickLeft)()"
class="mynavbar"
:class="{ transparent: titleTransparent }"
>
<template #left>
<!-- 自带默认内容的插槽 如果用户传递了myleft插槽内容 则覆盖myleft -->
<slot name="myleft">
<!-- 如果用户没有传递myleft的内容 则默认使用如下内容 -->
<span>广州</span>
<van-icon name="arrow-down" size="12" color="#666" />
</slot>
</template>
<template #title>
<span class="title">{{ title }}</span>
</template>
<template #right v-if="showRight">
<van-icon name="search" size="24" color="#666" />
</template>
</van-nav-bar>
</van-sticky>
</template>
src/views/FilmView.vue
<template>
<div>
<!-- 吸顶但被下方同时吸顶的Tabs覆盖 -->
<MyNavBar :showRight="false" title="电影"/>
<!-- Tabs吸顶时拥有比MyNavBar更高的z-index优先级,视觉上覆盖同时吸顶MyNavBar -->
<van-sticky z-index="100">
<van-tabs
...
>
<van-tab title="正在热映"></van-tab>
<van-tab title="即将上映"></van-tab>
</van-tabs>
</van-sticky>
<main>
<!-- 路由出口 router-link对应的内容会呈现在RouterView容器中 -->
<RouterView />
</main>
</div>
</template>
在【即将上映】页刷新时校正高亮Tab
- 此时要确保被激活的Tab同时更新为“即将上映”(1号而非0号Tab);
- 我们给Tabs的active双向绑定一个计算属性activeTab;
- 当路由变化时activeTab会在0/1之间动态切换;
- 最终实现效果见文章末尾的动图;
src/views/FilmView.vue
<!-- v-model:active="activeTab" 激活的Tab项动态关联数据activeTab -->
<van-tabs
v-model:active="activeTab"
color="#ff5f16"
line-height="1.5px"
line-width="80px"
@click-tab="onClickTab"
:sticky="true"
>
<van-tab title="正在热映"></van-tab>
<van-tab title="即将上映"></van-tab>
</van-tabs>
/* data */
data() {
return {
activeTab: 0,
};
},
/* 组件挂载时重新确定一下该激活哪个Tab */
mounted() {
console.log("fv mounted");
switch (this.$route.path) {
case "/films/nowPlaying":
this.activeTab = 0;
break;
case "/films/comingSoon":
this.activeTab = 1;
break;
}
},
返回电影页时恢复原来的子Tab
- 给RouterLink的to属性使用动态数据currentFilmPath;
- 监听route.path的变化,实时记录当前显示的电影子Tab路径,并同步给currentFilmPath;
- 这样当再次点击TabBar中的【电影】时,就会显示之前最后一次切换到的子Tab了;
src/App.vue
数据定义
let currentFilmPath = ref("/movie/nowPlaying");
RouterLink关联动态动态数据
<RouterLink
class="tab"
:class="{ active: $route.path.startsWith('/films/') }"
:to="currentFilmPath"
active-class="active"
>
<p><i class="fa fa-youtube-play"></i></p>
<p>电影</p>
</RouterLink>
实时记录电影子Tab的位置
const route = useRoute()
/* 侦听route变化 */
watch(
() => route.path,
(nv, ov) => {
// 实时记录电影子Tab位置(这样下次切换回电影页时 RouterLink就能找回上次的位置了)
nv.startsWith("/films") && (currentFilmPath.value = nv);
},
{
immediate: true,
}
);
</s