免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

uniapp跨端日历组件:左滑收起展开手势与动画实现

uniapp跨端日历组件:左滑收起展开手势与动画实现 最近在做一个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-110代表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这样跟后端接口对接时可以直接用避免各种日期对象转换的混乱。每次点击日期格子时先判断这个日期是否在可选范围内。我在组件里加了minDate和maxDate两个属性默认不限制需要时由父组件传入。判断逻辑放在一个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-1和2025-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几乎一样touchstart、touchmove、touchend三个事件就能覆盖整个手势生命周期。组件结构大致是这样view classcalendar-container touchstarthandleTouchStart touchmove.stop.preventhandleTouchMove touchendhandleTouchEnd clickhandleClick !-- 收起状态显示一行 -- view v-if!expanded classcalendar-collapsed text{{ collapsedText }}/text /view !-- 展开状态显示完整月历 -- view v-else classcalendar-expanded view classcal-header text classcal-year-month{{ year }}年{{ month 1 }}月/text text classcal-today clickgoToday今天/text /view view classcal-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。如果不设这个标记用户在日历上只是轻轻点了一下touchend时deltaX可能只有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 classcalendar-body :style{ height: contentHeight px } !-- 日历内容 -- /view4.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的更新频率直接决定组件的流畅度。日历组件中左滑收起展开会改变expanded、contentHeight、selectedDate等多个数据如果全部塞在一次setData里在小程序端会有明显的等待时间。我做了两个优化第一把动画相关的高频变化和业务状态变化分开。contentHeight在动画过程中会多次更新我单独用一个data字段控制并且在动画结束后立即用setTimeout置回一个固定值避免后续不必要的diff。第二日期格子使用v-for渲染其中需要动态绑定的class尽量用computed计算不要在模板里写复杂的三元表达式。view v-for(cell, index) in currentMonthCells :keyindex classcal-cell :classcellClass(cell) clickhandleClick text{{ cell.day }}/text /viewcellClass(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 后续可扩展的几个方向日历组件做完之后我又梳理了几个后续可以扩展的方向如果你需要用得更深可以参考。范围选择。当前组件只做单选改成范围选择需要多维护一个startDate和endDate核心逻辑不复杂但起止日期在跨月时需要联动处理月份变化。日程标记。在日期格子上展示当天的日程小圆点数据来源可以是父组件传入的Map对象key是日期字符串value是标记颜色。格子渲染时读取这个Map给对应日期加一个圆点成本很低但实用性提升很大。周月视图切换。很多日历是月视图和周视图之间切换实现上其实可以复用这套收起展开的动画逻辑——收起状态显示一行周信息展开状态显示完整月历。当前组件的收起状态是摘要条改成周视图也是一种合理演化。最后再说一个个人经验日期组件看起来简单但真正写起来坑不少尤其是跨端兼容。如果你的业务也涉及类似的收起展开交互建议从触摸事件的最底层开始写不要一上来就找现成封装。自己写过一遍之后你会对uniapp的跨端差异有一个非常清晰的认识以后再遇到类似交互就有了判断依据。实测下来这套组件在小程序端和App端运行稳定动画流畅度在中等配置的安卓机上也能保持在每秒50帧以上基本满足上线标准。
返回列表