三态框架实现
约 2855 字大约 10 分钟
2026-05-03
组件概述
移动端 / 小程序中,需要用底部面板承载不同层级的内容,同时通过滑动切换面板高度,适配不同的业务场景(如首页信息流底部面板、筛选面板、详情面板等),替代单一的弹窗 / 抽屉,交互更流畅、层级更清晰。
组件整体结构
1. 模板结构
组件分为蒙层(mask) 和 滑动面板(slide-panel) 两个核心部分,结构极简、职责清晰:
<template>
<view class="slide-panel-wrapper">
<!-- 蒙层:仅在指定区间显示,透明度随面板位置动态变化 -->
<view
wx:if="{{showMask}}"
class="mask"
wx:style="{{{opacity: maskOpacity}}}"
catchtap="handleMaskClick"
></view>
<!-- 滑动面板:核心交互区域,通过translateY控制显示高度 -->
<view
class="slide-panel"
wx:style="{{{transform: 'translateY('+ translateY +'px)', transition: transition}}}"
bindtouchstart="handleTouchStart"
bindtouchend="handleTouchEnd"
catchtouchmove="handleMove"
>
<!-- 插槽:支持自定义面板内容 -->
<slot></slot>
</view>
</view>
</template>结构职责说明
| 结构 | 核心作用 | 关键细节 |
|---|---|---|
| 蒙层(mask) | 背景遮罩,增强视觉层级,点击可关闭面板 | 条件渲染、动态透明度、点击事件拦截 |
| 滑动面板(slide-panel) | 内容载体,核心交互区域 | 绝对定位底部对齐、translateY控制位移、触摸事件绑定、插槽扩展 |
2. 核心样式设计
样式是组件交互的基础,核心设计逻辑如下:
// 面板根容器,避免样式污染
.slide-panel-wrapper {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
pointer-events: none;
z-index: 999999;
}
// 蒙层样式
.mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1;
background-color: rgba(0, 0, 0, 0.5);
pointer-events: auto;
transition: opacity 0.2s linear;
}
// 滑动面板核心样式
.slide-panel {
width: 100%;
position: absolute;
left: 0;
height: 100vh; /* 面板本身为全屏高度,通过位移控制显示区域 */
bottom: 0; /* 底部固定,保证向下位移时面板不会脱离屏幕底部 */
box-sizing: border-box;
touch-action: none; /* 禁用浏览器默认触摸行为,避免滑动穿透/页面回弹 */
pointer-events: auto;
background: #fff;
border-radius: 16px 16px 0 0;
z-index: 2;
overflow-y: auto; /* 支持内容滚动 */
}样式关键设计点
- 面板高度设计:面板本身设置
height: 100vh,而非动态设置高度,通过translateY控制显示区域,避免高度变化导致的动画卡顿、内容重排; - 定位逻辑:面板
bottom: 0底部固定,translateY向下位移时,只会隐藏面板上半部分,底部永远贴合屏幕,符合底部面板的交互直觉; - 事件穿透控制:根容器
pointer-events: none,蒙层和面板单独设置pointer-events: auto,避免面板遮挡页面导致的页面点击失效; - 触摸行为禁用:
touch-action: none禁用浏览器默认的触摸回弹、滚动行为,保证滑动跟手,避免小程序/H5的手势冲突。
配置项说明
1. 外部Props配置(使用者可传入)
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| slideOption | Object | 否 | 见下方默认配置 | 面板核心状态配置,控制三态高度、初始状态、单位 |
| mask | Boolean | 否 | true | 是否启用蒙层,关闭后全程不显示蒙层 |
slideOption 完整配置说明
// 默认配置
{
initStatus: 'middle', // 初始状态,可选值:full/middle/little
full: 88, // 全屏状态:面板显示高度占比/留白高度,由unit决定
middle: 49, // 中屏状态:同上
little: 49, // 小屏状态:同上
unit: 'vh' // 单位,可选值:vh(视口高度百分比)/ px(像素)
}单位说明:
- 单位为
vh时:配置值为面板显示高度的视口占比,如full: 88代表面板显示高度为88vh;- 单位为
px时:配置值为面板顶部的留白高度,如full: 120代表面板顶部留白120px,显示高度为屏幕高度-120px。
2. 内部状态管理(组件内部控制)
| 状态变量 | 类型 | 核心作用 |
|---|---|---|
| windowHeight | Number | 屏幕高度(px),单位转换的核心基准,初始化时获取 |
| realSlideOption | Object | 转换后的内部配置,统一为px单位的留白高度,所有计算均基于此变量 |
| translateY | Number | 核心控制变量,面板当前的垂直位移(px),等于面板顶部的留白高度 |
| cardStatus | String | 当前面板状态,可选值:full/middle/little |
| transition | String | 面板过渡动画配置,滑动时关闭,状态切换时开启,避免滑动卡顿 |
| startY | Number | 触摸开始时的手指Y坐标,用于计算滑动距离 |
| tempTranslateY | Number | 触摸开始时的面板位移,滑动计算的固定基准,避免滑动过程中基准跳变 |
| showMask | Boolean | 蒙层是否显示,由mask配置和面板位置共同决定 |
| maskOpacity | Number | 蒙层透明度(0-1),随面板位置动态计算 |
核心逻辑全流程
1. 初始化逻辑(created生命周期)
核心目标
完成单位统一转换,把用户传入的灵活配置,转换成组件内部可计算的、统一px单位的留白高度,设置面板初始状态。
完整逻辑
- 获取系统屏幕高度
windowHeight,作为单位转换的基准; - 根据用户配置的单位,把三态高度统一转换为px单位的顶部留白高度,生成
realSlideOption; - 根据初始状态
initStatus,设置面板初始cardStatus和translateY值。
代码实现(修正原代码bug)
created() {
// 1. 获取屏幕高度,跨平台兼容
this.windowHeight = getSystemInfo().windowHeight;
const { slideOption } = this;
// 2. 单位统一转换,生成内部计算用的配置
if (slideOption.unit === 'vh') {
// 单位vh:用户配置的是显示高度占比,反向计算留白高度
this.realSlideOption = {
full: ((100 - slideOption.full) * this.windowHeight) / 100,
middle: ((100 - slideOption.middle) * this.windowHeight) / 100,
little: ((100 - slideOption.little) * this.windowHeight) / 100,
unit: 'px'
};
} else if (slideOption.unit === 'px') {
// 单位px:用户配置的直接是留白高度,直接使用
this.realSlideOption = {
full: slideOption.full,
middle: slideOption.middle,
little: slideOption.little,
unit: 'px'
};
}
// 3. 设置初始状态
this.cardStatus = slideOption.initStatus || 'middle';
this.translateY = this.realSlideOption[this.cardStatus];
}2. 手势交互全流程
手势交互是组件的核心,分为触摸开始→滑动中→触摸结束三个阶段,完整实现跟手滑动、边界限制、状态吸附。
阶段1:触摸开始(handleTouchStart)
核心目标:记录滑动基准,关闭过渡动画,保证滑动跟手。
handleTouchStart(e) {
// 记录手指触摸起始Y坐标
this.startY = e.touches[0].clientY;
// 记录当前面板位移作为固定基准,避免滑动过程中基准跳变
this.tempTranslateY = this.translateY;
// 关闭过渡动画,避免滑动时出现延迟、卡顿
this.transition = 'none';
}阶段2:滑动中(handleMove)
核心目标:根据手指滑动距离实时更新面板位置,限制边界避免越界,同步更新蒙层透明度。
handleMove(e) {
// 1. 计算手指滑动距离:向下滑为正,向上滑为负
const currentY = e.touches[0].clientY;
const diffY = currentY - this.startY;
// 2. 计算新的面板位移:基准值 + 滑动距离
let newTranslateY = this.tempTranslateY + diffY;
const { full, little } = this.realSlideOption;
// 3. 边界限制:避免面板滑出屏幕
// 向上滑动不能超过全屏状态(留白不能小于full)
if (newTranslateY < full) newTranslateY = full;
// 向下滑动不能超过小屏状态(留白不能大于little)
if (newTranslateY > little) newTranslateY = little;
// 4. 更新面板位置,同步蒙层透明度
this.translateY = newTranslateY;
this.dealMaskOpacity();
}阶段3:触摸结束(handleTouchEnd)
核心目标:根据当前面板位置,判断目标状态,实现惯性吸附,平滑切换到目标状态。
handleTouchEnd(e) {
let targetStatus = '';
const { full, middle, little } = this.realSlideOption;
// 1. 计算状态切换阈值:两个状态的中间点,超过中间点就吸附到下一个状态
const fullMiddleThreshold = (middle - full) / 2 + full; // 全屏 ↔ 中屏 分界点
const middleLittleThreshold = (little - middle) / 2 + middle; // 中屏 ↔ 小屏 分界点
// 2. 根据当前位置判断目标状态
if (this.translateY <= fullMiddleThreshold) {
// 超过全屏分界点,吸附到全屏状态
targetStatus = 'full';
this.showMask = this.mask;
this.maskOpacity = 1;
} else if (this.translateY < middleLittleThreshold) {
// 超过中屏分界点,吸附到中屏状态
targetStatus = 'middle';
this.showMask = false;
} else {
// 其他情况,吸附到小屏状态
targetStatus = 'little';
this.showMask = false;
}
// 3. 开启过渡动画,平滑切换到目标状态
this.transition = 'transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)';
this.handleChangeStatus(targetStatus);
}3. 状态切换核心方法
对外暴露的通用状态切换方法,支持手势自动切换和外部手动调用。
/**
* 切换面板状态
* @param {String} status 目标状态:full/middle/little
*/
handleChangeStatus(status) {
if (!['full', 'middle', 'little'].includes(status)) return;
this.cardStatus = status;
this.translateY = this.realSlideOption[status];
// 状态切换后同步蒙层
this.dealMaskOpacity();
// 对外触发状态变化事件,父组件可监听
this.triggerEvent('statusChange', {
status: this.cardStatus,
translateY: this.translateY
});
}4. 蒙层联动逻辑
核心规则:仅当启用蒙层时,面板在「全屏 ↔ 中屏」区间内才显示蒙层,透明度随面板位置线性变化。
dealMaskOpacity() {
// 未启用蒙层,直接返回
if (!this.mask) {
this.showMask = false;
this.maskOpacity = 0;
return;
}
const { full, middle } = this.realSlideOption;
// 计算全屏到中屏的总区间长度
const totalDistance = middle - full;
// 仅在全屏-中屏区间内显示蒙层
if (this.translateY >= full && this.translateY <= middle) {
// 透明度线性计算:越接近全屏,透明度越高
const currentDistance = middle - this.translateY;
this.maskOpacity = Math.min(Math.max(currentDistance / totalDistance, 0), 1);
this.showMask = true;
} else {
// 不在区间内,隐藏蒙层
this.showMask = false;
this.maskOpacity = 0;
}
}
// 蒙层点击事件:点击蒙层关闭面板,切换到小屏状态
handleMaskClick() {
this.handleChangeStatus('little');
}五、完整可运行代码
<template>
<view class="slide-panel-wrapper">
<view
wx:if="{{showMask}}"
class="mask"
wx:style="{{{opacity: maskOpacity}}}"
catchtap="handleMaskClick"
></view>
<view
class="slide-panel"
wx:style="{{{transform: 'translateY('+ translateY +'px)', transition: transition}}}"
bindtouchstart="handleTouchStart"
bindtouchend="handleTouchEnd"
catchtouchmove="handleMove"
>
<slot></slot>
</view>
</view>
</template>
<script>
import { createComponent } from '@mpxjs/core';
import { getSystemInfo } from '../../cross/utils/mpx';
createComponent({
options: {
multipleSlots: true,
styleIsolation: 'apply-shared',
},
properties: {
slideOption: {
type: Object,
value: {
initStatus: 'middle',
full: 88,
middle: 49,
little: 49,
unit: 'vh',
},
},
mask: {
type: Boolean,
value: true,
},
},
data: {
windowHeight: 0,
realSlideOption: {},
showMask: false,
translateY: 0,
cardStatus: 'middle',
transition: 'transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)',
startY: 0,
maskOpacity: 0,
tempTranslateY: 0,
},
created() {
this.windowHeight = getSystemInfo().windowHeight;
const { slideOption } = this;
if (slideOption.unit === 'vh') {
this.realSlideOption = {
full: ((100 - slideOption.full) * this.windowHeight) / 100,
middle: ((100 - slideOption.middle) * this.windowHeight) / 100,
little: ((100 - slideOption.little) * this.windowHeight) / 100,
unit: 'px',
};
} else if (slideOption.unit === 'px') {
this.realSlideOption = {
full: slideOption.full,
middle: slideOption.middle,
little: slideOption.little,
unit: 'px',
};
}
this.cardStatus = slideOption.initStatus || 'middle';
this.translateY = this.realSlideOption[this.cardStatus];
this.dealMaskOpacity();
},
methods: {
handleChangeStatus(status) {
if (!['full', 'middle', 'little'].includes(status)) return;
this.cardStatus = status;
this.translateY = this.realSlideOption[status];
this.dealMaskOpacity();
this.triggerEvent('statusChange', {
status: this.cardStatus,
translateY: this.translateY
});
},
handleTouchStart(e) {
this.startY = e.touches[0].clientY;
this.tempTranslateY = this.translateY;
this.transition = 'none';
},
handleMove(e) {
const currentY = e.touches[0].clientY;
const diffY = currentY - this.startY;
let newTranslateY = this.tempTranslateY + diffY;
const { full, little } = this.realSlideOption;
if (newTranslateY < full) newTranslateY = full;
if (newTranslateY > little) newTranslateY = little;
this.translateY = newTranslateY;
this.dealMaskOpacity();
},
handleTouchEnd() {
let targetStatus = '';
const { full, middle, little } = this.realSlideOption;
const fullMiddleThreshold = (middle - full) / 2 + full;
const middleLittleThreshold = (little - middle) / 2 + middle;
if (this.translateY <= fullMiddleThreshold) {
targetStatus = 'full';
} else if (this.translateY < middleLittleThreshold) {
targetStatus = 'middle';
} else {
targetStatus = 'little';
}
this.transition = 'transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)';
this.handleChangeStatus(targetStatus);
},
dealMaskOpacity() {
if (!this.mask) {
this.showMask = false;
this.maskOpacity = 0;
return;
}
const { full, middle } = this.realSlideOption;
const totalDistance = middle - full;
if (this.translateY >= full && this.translateY <= middle) {
const currentDistance = middle - this.translateY;
this.maskOpacity = Math.min(Math.max(currentDistance / totalDistance, 0), 1);
this.showMask = true;
} else {
this.showMask = false;
this.maskOpacity = 0;
}
},
handleMaskClick() {
this.handleChangeStatus('little');
}
},
});
</script>
<script type="application/json">
{
"component": true
}
</script>
<style lang="scss">
.slide-panel-wrapper {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
pointer-events: none;
z-index: 999999;
}
.mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1;
background-color: rgba(0, 0, 0, 0.5);
pointer-events: auto;
transition: opacity 0.2s linear;
}
.slide-panel {
width: 100%;
position: absolute;
left: 0;
height: 100vh;
bottom: 0;
box-sizing: border-box;
touch-action: none;
pointer-events: auto;
background: #fff;
border-radius: 16px 16px 0 0;
z-index: 2;
overflow-y: auto;
}
</style>六、组件使用示例
<template>
<view class="page">
<button bindtap="openFullPanel">打开全屏面板</button>
<button bindtap="openMiddlePanel">打开中屏面板</button>
<!-- 引入三态面板组件 -->
<slide-panel
slideOption="{{slideOption}}"
mask="{{true}}"
bind:statusChange="onPanelStatusChange"
>
<view class="panel-content">
<view>面板自定义内容</view>
<!-- 你的业务内容 -->
</view>
</slide-panel>
</view>
</template>
<script>
import { createPage } from '@mpxjs/core';
createPage({
data: {
slideOption: {
initStatus: 'little',
full: 88,
middle: 49,
little: 90,
unit: 'vh'
}
},
methods: {
openFullPanel() {
this.selectComponent('slide-panel').handleChangeStatus('full');
},
openMiddlePanel() {
this.selectComponent('slide-panel').handleChangeStatus('middle');
},
onPanelStatusChange(e) {
console.log('面板状态变化:', e.detail);
}
}
});
</script>