uniapp跨端日历组件:左滑收起展开手势与动画实现
2026/9/9 11:25:40 网站建设 项目流程

最近在做一个uniapp跨端项目,排期里有个日历选择需求。第一反应是去插件市场找个现成的组件,结果筛了一圈:要么是H5端表现还行、小程序端动画明显掉帧,要么是功能太重,光是日期范围选择就塞了几百行配置。更关键的是,产品提了一个交互需求——日历默认收起,只保留一行摘要信息,用户左滑之后完整月历展开,再次左滑收起。这个交互放在原生App里很常见,但在uniapp里要同时兼容小程序、App和H5,确实踩了不少坑。

我干脆自己手写了一个日历组件,把左滑收起展开的完整手势识别、动画优化、跨端适配过程都记录下来。这篇文章不只是贴代码,还会把每一步为什么这么写、参数怎么定、真机上遇到过什么问题都讲清楚,给后面要做类似交互的朋友一个参考。

1. 为什么要在uniapp里自己写日历组件

1.1 现成组件用不上的真实原因

先说实话,插件市场里优质的日历组件是有几款的,功能也相对完整,但实际接入之后发现几个很难绕开的问题。

功能冗余。很多日历组件默认带农历、节气、节日、日期范围选择、周月切换,甚至还有待办事项标记。可我只需要单选日期加收起展开,结果光是传参配置就研究了半天,很多不需要的功能还无法裁剪,打包体积白白增加。

样式定制成本高。日历组件如果做得封闭,样式用deep修改都要各种穿透,一旦遇到设计稿要求某个格子圆角、选中态带渐变、周末字色不同,改起来处处都是补丁。而业务方往往是改了第一版、第二版还要微调,这种情况下自研反而更可控。

跨端表现不稳定。这一点是促使我动手写的主要原因。uni-app的组件在小程序端和App端用的是不同的渲染引擎,有些H5上跑得很流畅的CSS动画,到小程序里首次渲染就卡。尤其是收起展开这种依赖动态高度的交互,现成组件对跨端细节打磨往往不够精细。

所以我的判断是:如果一个需求只是“日历”本身,可以直接用现成的。但如果交互上带有定制化的手势、动画、状态切换,自研的成本反而低于改造一个大而全的组件。

1.2 收起展开交互背后的产品逻辑

产品要求“默认收起、左滑展开”,一开始我并不理解,觉得多此一举。后来做完了才想明白,这个交互本质上是在解决信息密度的问题。

日历在移动端是一个典型的高信息密度控件。一个月最多31天,如果按6行周网格渲染,加上星期表头和顶部的年月切换,至少占掉屏幕三分之一的高度。假如页面同时要展示日程列表、数据统计、筛选条件,日历一直展开会把关键内容全部挤到首屏之外。

收起来之后,整个组件只占一行,显示“2025年03月”加一个“今天”按钮,用户有需要时左滑呼出完整月历,选中日期后再左滑收起,整条交互路径很顺畅。这种设计在日程类App里经常见到,主要目的就是让用户“按需展开”,而不是让日历一直占据视觉重心。

1.3 方案选型的边界界定

动手之前我先明确了这个组件不做什么:不做农历节气、不做日期范围多选、不做日程标记、不做月份切换动画。组件只解决三件事:渲染当月日历、单选日期、左滑收起展开。

技术选型上,手势部分我没有用uni-app封装好的swiper或movable-area。swiper更适合整页轮播,它的滑动阈值、方向锁定都是针对页面级交互设计的,用来做一个小部件的收起展开反而别扭。movable-area倒是能模拟拖拽,但它的定位模型和动画手感更偏向“拖动某个DOM”,和“滑动后自动吸附到收起或展开状态”这种需求也有差异。

我最终选择直接用touch事件自己实现手势识别,好处是逻辑完全可控,阈值、方向、动画时长、状态联动都能按需求调整,而且这套逻辑在H5、小程序、App端都能跑,不依赖平台特性。组件框架上采用Vue2的options写法,因为当前项目的uniapp版本还是Vue2,如果你项目是Vue3,代码结构基本一致,改一下setup语法即可。

2. 日历组件的日期核心逻辑

2.1 第一步:构建月份矩阵

日历组件最基础的工作是生成月份的日期矩阵。我在组件里维护一个核心方法getMonthMatrix(year, month),传入年份和月份,返回一个二维数组,每一行代表一周。

先看月份的第一天是星期几,这决定了第0行前面要补几个空位。

function getMonthMatrix(year, month) { // 注意:month是0-11,0代表1月 const firstDay = new Date(year, month, 1); const weekOfFirst = firstDay.getDay(); // 0是周日,6是周六 // 当前月总天数 const daysInMonth = new Date(year, month + 1, 0).getDate(); const cells = []; // 补上一个月末尾的日期 const prevMonthDays = new Date(year, month, 0).getDate(); for (let i = 0; i < weekOfFirst; i++) { cells.push({ day: prevMonthDays - weekOfFirst + i + 1, inCurrentMonth: false }); } // 当前月的日期 for (let d = 1; d <= daysInMonth; d++) { cells.push({ day: d, inCurrentMonth: true }); } // 补下一个月开头的日期,保证总格子数是7的倍数 const remainder = cells.length % 7; if (remainder !== 0) { for (let i = 1; i <= 7 - remainder; i++) { cells.push({ day: i, inCurrentMonth: false }); } } // 按周切分 const matrix = []; for (let i = 0; i < cells.length; i += 7) { matrix.push(cells.slice(i, i + 7)); } return matrix; }

这里有个很容易踩的坑:new Date(year, month, 1).getDay()中的month是0-based的,也就是0代表1月,11代表12月。我在联调时发现每隔几个月日历就会错位一天,排查了半天最后发现是月份传参少了1。

2.2 固定42格还是动态行数

日历网格有两种常见方案:固定渲染6行(42格),或者按当前月份的实际情况动态渲染4到6行。

固定42格的好处是日历区域的高度是稳定的,展开收起时的高度不会随月份变化而跳变,动画可以用固定值。坏处是视觉上很多格子是空的,尤其2月只有28天时,后半部分基本就是下个月的日期,用户容易误点。

动态行数则更贴近真实产品形态。我根据matrix.length来判断当前月份需要几行,展开时日历的高度就是行数 * 每个格子高度,而不是写死6行。这样2月的日历看起来更紧凑,也不会因为空行而显得松散。

但动态高度给动画带来了一点麻烦:收起时的高度容易计算,展开时的高度是变量,需要动态测量。我的解法是给日历内容区加一个ref,通过uniapp的uni.createSelectorQuery()获取实际高度,再把这个高度用于动画过渡。

getContentHeight() { return new Promise((resolve) => { const query = uni.createSelectorQuery().in(this); query.select('.calendar-grid').boundingClientRect((rect) => { resolve(rect ? rect.height : 0); }).exec(); }); }

如果拿不到高度,就回退到固定估算值:行数 * 44 + 表头高度。真机上实测基本没有偏差,因为格子高度是CSS写死的。

2.3 选中态与禁用态的状态管理

日期选中态我用一个selectedDate字符串来存储,格式是YYYY-MM-DD,这样跟后端接口对接时可以直接用,避免各种日期对象转换的混乱。

每次点击日期格子时,先判断这个日期是否在可选范围内。我在组件里加了minDatemaxDate两个属性,默认不限制,需要时由父组件传入。判断逻辑放在一个isDisabled方法里,返回true的日期格子会置灰,并且点击事件直接return,不更新选中态。

isDisabled(year, month, day) { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; if (this.minDate && dateStr < this.minDate) return true; if (this.maxDate && dateStr > this.maxDate) return true; return false; }

这里用字符串比较有个前提:年份和月份都补零成两位,日期也补零。否则2025-3-12025-12-1这种字符串比较会出错。

2.4 日期的格式化与跨端时区处理

说到日期格式化,还有一个跨端时区的隐患。uniapp的App端如果系统时区不是中国标准时间,new Date('2025-03-01')可能被解析成前一天。因为YYYY-MM-DD格式在JavaScript里解析时,某些环境会把它当作UTC时间,而不是本地时间。

我统一用一个工具函数来生成日期字符串,避免直接依赖字符串解析:

formatDate(year, month, day) { const y = year; const m = String(month + 1).padStart(2, '0'); const d = String(day).padStart(2, '0'); return `${y}-${m}-${d}`; }

获取当前月份时,也不直接用new Date()的getMonth,因为如果组件所在的页面长时间挂载,跨过零点后月份不会自动更新。我在组件的onShow生命周期里重新拉一次当前时间,确保日历数据是最新的。

3. 左滑手势识别与收起展开的动作处理

3.1 触摸事件的完整生命周期

手势识别我从零开始写,不依赖任何手势库。触摸事件在uniapp里跟原生Web几乎一样,touchstarttouchmovetouchend三个事件就能覆盖整个手势生命周期。

组件结构大致是这样:

<view class="calendar-container" @touchstart="handleTouchStart" @touchmove.stop.prevent="handleTouchMove" @touchend="handleTouchEnd" @click="handleClick" > <!-- 收起状态显示一行 --> <view v-if="!expanded" class="calendar-collapsed"> <text>{{ collapsedText }}</text> </view> <!-- 展开状态显示完整月历 --> <view v-else class="calendar-expanded"> <view class="cal-header"> <text class="cal-year-month">{{ year }}年{{ month + 1 }}月</text> <text class="cal-today" @click="goToday">今天</text> </view> <view class="cal-grid"> <!-- 日期格子 --> </view> </view> </view>

注意我用了@touchmove.stop.prevent,这个很关键。在小程序端,如果日历组件嵌在一个可滚动的页面里,手指在日历上左滑时页面纵向滚动也会跟着触发。加上stop.prevent之后,能阻止日历区域内的触摸事件冒泡到页面滚动层。但这样做的副作用是日历区域内无法纵向滚动页面,后面我在第5节再详细说怎么权衡。

3.2 手势方向判定与滑动阈值

手势识别的核心是区分“左滑”和“右滑”,并且要和“点击”区分开。判断逻辑不算复杂:记录touchstart时的起始坐标,在touchend时计算水平和垂直方向上的位移差。

handleTouchStart(e) { const touch = e.touches[0]; this.touchStartX = touch.clientX; this.touchStartY = touch.clientY; this.touchMoveDetected = false; }, handleTouchMove(e) { const touch = e.touches[0]; const deltaX = touch.clientX - this.touchStartX; const deltaY = touch.clientY - this.touchStartY; if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) { this.touchMoveDetected = true; } }, handleTouchEnd(e) { if (!this.touchMoveDetected) return; const touch = e.changedTouches[0]; const deltaX = touch.clientX - this.touchStartX; const deltaY = touch.clientY - this.touchStartY; // 水平位移大于阈值,且水平分量大于垂直分量 if (Math.abs(deltaX) > 30 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { if (deltaX < 0) { this.collapse(); } else { this.expand(); } } }

我来解释几个关键数值的确定思路。

位移阈值30px,这个值是参考了系统返回手势和Swiper组件的默认阈值。小于这个距离的移动大概率是用户点击时的轻微抖动,不该触发滑动。垂直方向判定系数1.5,是为了防止用户纵向滚动页面时被误判成左滑。手指在屏幕上滑动很少是完全水平的,总会带一点纵向分量,如果垂直位移大于水平位移的一半以上,就认为这次滑动的主方向是纵向,不触发收起展开。

这里还有一个容易被忽视的点:touchMoveDetected的判断阈值10px。如果不设这个标记,用户在日历上只是轻轻点了一下,touchenddeltaX可能只有1到2像素,不会触发滑动,但会和点击事件冲突。加上这个标记后,只有真正发生明显位移才进入手势分支。

3.3 收起与展开的交互约定

交互方向我定为:左滑收起展开的月历,右滑展开月历。这个方向符合大部分用户的心智模型——左滑通常代表“收起、关闭、进入下一下”,右滑代表“返回上一层、展开”。

除了手势,我还提供了三个辅助入口:

  • 收起状态下,点击整行区域可以直接展开。
  • 展开状态下,点击顶部的年月区域可以收起。
  • 日期格子上方保留一个“今天”快捷按钮。

这样设计是因为纯手势操作对部分用户并不友好,尤其是第一次使用根本不知道可以左滑。辅助入口保证功能可发现性,手势只是提供更快捷的交互方式。

3.4 手势与点击的冲突处理

这是最容易踩坑的地方。日历组件内部既有点击日期格子的需求,又有左滑收起展开的需求,两者都以触摸事件为基础,如果没有处理好,用户想点一个日期,结果整个日历收起来了。

我的处理方式是在handleClick中增加一个判断:如果touchMoveDetected为true,说明刚才发生了滑动,这一次点击不响应。也就是把点击事件和滑动事件做了一个互斥。

handleClick(e) { if (this.touchMoveDetected) return; const dataset = e.currentTarget.dataset; if (dataset.year && dataset.month !== undefined && dataset.day) { this.selectedDate = this.formatDate( Number(dataset.year), Number(dataset.month), Number(dataset.day) ); this.$emit('select', this.selectedDate); } }

在H5端还要额外处理一个情况:点击结束后手指有微小偏移,系统仍然触发click。所以我在touchend里延迟200毫秒再重置touchMoveDetected,保证click事件触发时还能读到这个标记。小程序端对click的触发机制有差异,实测下来即使不加这个延迟也表现正常,但为了统一行为,我保留了这段逻辑。

4. 动画优化的落地实现

4.1 收起展开的高度计算

收起展开的核心是日历主体区域的高度变化。展开时显示完整的月历网格,收起时只显示一行摘要。

展开高度:通过uni.createSelectorQuery获取.cal-grid的实际高度,或者用行数 * 格子高度估算。

收起高度:我让收起状态显示一行固定的摘要条,高度设为52px,包含一个指向右侧的箭头图标和当前选中日期的文字描述。

collapse() { if (!this.expanded) return; const targetHeight = 52; this.animateTo(targetHeight); this.expanded = false; }, expand() { if (this.expanded) return; this.expanded = true; // 等待DOM渲染完成后再测量高度 this.$nextTick(() => { this.getContentHeight().then((height) => { this.animateTo(height); }); }); }

动画的具体实现我是用Vue的data属性控制contentHeight,再通过:style绑定到日历主体上:

<view class="calendar-body" :style="{ height: contentHeight + 'px' }"> <!-- 日历内容 --> </view>

4.2 为什么用height而不用max-height

很多教程会建议用max-height配合transition来做折叠动画,因为不需要知道内容的精确高度。但我的实际经验是,日历这种场景用精确height更合适。

max-height的动画有一个致命弱点:展开时动画速度不均匀。比如设置max-height为400px,实际内容高度只有250px,动画执行完250px之后还有150px的过渡距离,这个“空转”会导致后半段动画明显变慢,手感很生涩。

精确高度没有这个问题,展开和收起都是完整的height过渡,动画速度由cubic-bezier曲线统一控制,手感线性可控。代价是每次展开都需要测量高度,但日历组件结构简单,测量开销极小,实测在低端安卓机上也没明显卡顿。

给日历主体加上过渡属性:

.calendar-body { overflow: hidden; transition: height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); }

cubic-bezier(0.25, 0.8, 0.25, 1)是Material Design常用的标准缓动曲线,快进慢出,展开时前半段比较迅速,后半段逐渐稳定,视觉上不拖沓。如果你想要更“弹”一点的手感,可以试cubic-bezier(0.34, 1.56, 0.64, 1),但实测在部分安卓机上会有一帧明显的回弹,不够干净。

4.3 动画性能与真机调优建议

动画卡顿主要看两个指标:渲染帧率和布局稳定性。

日历组件展开时,42个日期格子要同时显示,如果每个格子都带有复杂的阴影、渐变、边框,在低端机上首次渲染会掉帧。我一开始给选中日期加了box-shadow效果,结果小米10上展开动画明显有卡一下的感觉。后来把box-shadow换成了纯背景色加内联白边,动画立刻流畅了。阴影这种效果看着好看,但代价是每个格子的渲染成本提升一个量级。

另一个关键点是避免在动画过程中修改布局属性。比如收起动画时,如果同时改变格子的font-size、padding,浏览器会不断触发重排,动画几乎不可能流畅。我建议动画期间只动height这一个属性,内容区的内部样式保持绝对稳定。

.calendar-grid { /* 动画期间不要修改这些 */ display: flex; flex-wrap: wrap; padding: 8px 12px; }

还有一个细节:给.calendar-body加上will-change: height。这个属性提前告诉浏览器height会变化,浏览器会为这个元素单独创建合成层,动画更平滑。但注意不要乱加will-change: transform之类和动画无关的属性,合成层太多会占用内存,反而拖慢低端机的表现。

5. 常见问题与真机调试避坑

5.1 手势与页面滚动的冲突

日历组件嵌入到可滚动页面之后,第一个遇到的问题就是手势冲突。用户在日历上左滑时,页面本身可能也在纵向滚动,两个动作同时触发,表现就很奇怪。

我的处理方案是:日历内部禁止纵向滚动,所有触摸事件的纵向位移都用来和水平位移做方向判定,不参与页面滚动。具体做法就是在touchmove中调用prevent阻止默认行为。

但这里有一个代价:如果日历处于展开状态,占了大半个屏幕,用户在这个区域内无法上下滑动页面。这个问题在产品和交互层面需要提前同步。我最终的方案是:收起状态时,整行区域允许页面滚动(因为收起状态只有一行,没有滑动需求);展开状态时,日历内部拦截纵向滚动,用户要滑页面需要把手指移到日历之外的区域。

这个体验不算完美,但在实际业务中用户基本可以接受,因为日历展开时主要任务是选日期,选了之后会自动收起,然后就能正常滚动页面了。

5.2 iOS橡皮筋与下拉刷新穿透

iOS的WebView和App端都有橡皮筋效果,手指在日历上滑动时,即使触发了prevent,在某些iOS版本上还是会出现整个页面跟随手指移动的视觉穿透。

这个问题在小程序端尤其明显,因为小程序页面的滚动容器和H5不一样,prevent的生效范围受限于组件边界。我的实测经验是:@touchmove.stop.prevent在小程序端能拦截滚动,但在iOS的WXWebView里,如果日历组件本身不在一个scroll-view内,而是直接嵌在页面根部,橡皮筋还是会偶尔穿透。

处理方案是给日历外层加一层overflow: hidden的容器,并且将页面级滚动改为scroll-view组件来控制,让日历区域不参与页面滚动容器的滚动链。如果你不想大改页面结构,也可以接受这个偶发问题,毕竟它不影响核心功能,只是视觉上有点瑕疵。

5.3 小程序端日历渲染的性能对策

小程序端的渲染机制和H5有很大差异,setData的更新频率直接决定组件的流畅度。日历组件中,左滑收起展开会改变expandedcontentHeightselectedDate等多个数据,如果全部塞在一次setData里,在小程序端会有明显的等待时间。

我做了两个优化:

第一,把动画相关的高频变化和业务状态变化分开。contentHeight在动画过程中会多次更新,我单独用一个data字段控制,并且在动画结束后立即用setTimeout置回一个固定值,避免后续不必要的diff。

第二,日期格子使用v-for渲染,其中需要动态绑定的class尽量用computed计算,不要在模板里写复杂的三元表达式。

<view v-for="(cell, index) in currentMonthCells" :key="index" class="cal-cell" :class="cellClass(cell)" @click="handleClick" > <text>{{ cell.day }}</text> </view>
cellClass(cell) { const isDisabled = this.isDisabled(cell.year, cell.month, cell.day); const isSelected = this.isSelected(cell.year, cell.month, cell.day); return { 'cell-disabled': isDisabled, 'cell-selected': isSelected, 'cell-other-month': !cell.inCurrentMonth }; }

这样每次状态变化时,小程序端只需要计算变化的类名,而不是在模板里逐个判断,性能提升在小程序端非常明显。

5.4 后续可扩展的几个方向

日历组件做完之后,我又梳理了几个后续可以扩展的方向,如果你需要用得更深,可以参考。

范围选择。当前组件只做单选,改成范围选择需要多维护一个startDateendDate,核心逻辑不复杂,但起止日期在跨月时需要联动处理月份变化。

日程标记。在日期格子上展示当天的日程小圆点,数据来源可以是父组件传入的Map对象,key是日期字符串,value是标记颜色。格子渲染时读取这个Map给对应日期加一个圆点,成本很低,但实用性提升很大。

周月视图切换。很多日历是月视图和周视图之间切换,实现上其实可以复用这套收起展开的动画逻辑——收起状态显示一行周信息,展开状态显示完整月历。当前组件的收起状态是摘要条,改成周视图也是一种合理演化。

最后再说一个个人经验:日期组件看起来简单,但真正写起来坑不少,尤其是跨端兼容。如果你的业务也涉及类似的收起展开交互,建议从触摸事件的最底层开始写,不要一上来就找现成封装。自己写过一遍之后,你会对uniapp的跨端差异有一个非常清晰的认识,以后再遇到类似交互,就有了判断依据。实测下来,这套组件在小程序端和App端运行稳定,动画流畅度在中等配置的安卓机上也能保持在每秒50帧以上,基本满足上线标准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询