一、需求分析与交互拆解

先看两张图:

  • 图一(原型):上方是地图区域,下方是可拖拽的面板(包含tab滑块和滚动内容),面板可以上下滑动,滑到顶部时地图区域最小化,滑到底部时地图区域最大化,中间增加半展开态,实现三段式滑动。

  • 图二(滴滴原图):完整的滴滴首页,地图+底部服务面板的交互逻辑一致,支持多档位滑动,面板包含多种服务入口和滚动内容,地图支持缩放、拖拽,且与面板滑动互不冲突。

核心交互逻辑(优化版):

  1. 地图区域:采用绝对定位,高度随底部面板滑动动态变化(两种高度切换),支持地图自身的缩放、拖拽操作,与面板滑动事件隔离,避免冲突。

  2. 底部面板(A区):支持三段式拖拽滑动,包含三个关键状态,全rpx单位适配所有屏幕:

    1. 收起态(底部节点):面板仅露出一小部分,地图展示最大化(占屏幕70%)。

    2. 半展开态(中间节点):面板露出一半,地图保持中等高度(占屏幕50%),为默认初始状态。

    3. 展开态(顶部节点):面板高度占满屏幕大部分区域,地图仅保留顶部一小条,此时面板内部内容可独立滚动。

  3. 内部滚动:仅当面板处于完全展开态时,内部scroll-view才允许滚动,且滚动到顶部后,可继续拖拽面板下滑,解决滚动与拖拽的冲突问题。

  4. 全端适配:采用rpx单位+动态计算屏幕尺寸,适配不同手机型号,避免出现布局错乱、滑动异常等问题。


二、技术选型与核心思路

本文基于uni-app框架开发,兼容微信小程序、App等多端,核心技术选型如下:

  • CSS 定位与适配:页面容器采用相对定位,地图和底部面板采用绝对定位;使用rpx单位适配所有屏幕,通过系统API获取屏幕尺寸,动态计算面板和地图的高度、位置。

  • 触摸事件与事件隔离:监听touchstart、touchmove、touchend事件,区分地图触摸、面板触摸、scroll-view触摸,通过标记位隔离事件,避免滑动冲突;触摸坐标统一转换为rpx,确保多端滑动体验一致。

  • 动态样式与过渡动画:通过data变量控制面板的top值、高度和地图的高度,添加transition过渡动画,使滑动更流畅;通过isAnimating标记位,避免动画过程中触发重复滑动。

  • 滚动嵌套与冲突解决:给scroll-view添加触摸事件监听,判断滚动状态,当scroll-view滚动到顶部时,才允许拖拽面板下滑;通过watch监听面板位置,动态控制scroll-view的滚动权限。

  • 地图联动:绑定地图regionchange等事件,支持地图自身的缩放、拖拽,同时通过syncMap方法,实现面板滑动与地图高度的同步切换。


三、完整代码实现(uni-app版)

以下是可直接复制运行的完整代码,包含template结构、script逻辑和style样式,已解决滑动冲突、适配、滚动重置等核心问题,可直接用于项目开发。

<template>
	<view class="page-container">
		<!-- 地图 - 动态绑定高度(rpx适配) -->
		<map id="my-map" longitude="116.397755" scale="15" latitude="39.903182" :markers="markers"
			@regionchange="onMapRegionChange" @touchstart="onMapTouchStart" @touchmove.stop="onMapTouchMove"
			@touchend="onMapTouchEnd" :style="{ height: mapHeight }"></map>

		<!-- A区 - 三段式滑动(全rpx单位) -->
		<view class="a-area" :style="{ 
				top: aAreaTop + 'rpx', 
				height: aAreaHeight + 'rpx',
				transition: isAnimating ? 'top 0.3s ease' : 'none' 
			}" @touchstart="onTouchStart" @touchmove.stop="onTouchMove" @touchend="onTouchEnd">
			<view style="height: 300rpx;line-height: 300rpx;background-color: aqua;color: #999999;text-align: center;">tab滑块</view>
			<!-- 修复:添加ref用于直接操作scroll-view -->
			<view :style="{ height: (aAreaHeight - 300) + 'rpx' }" @touchstart="onScrollTouchStart"
				@touchmove="onScrollTouchMove" @touchend="onScrollTouchEnd">
				<scroll-view class="a-scroll" :scroll-y="isScrollEnabled" :scroll-into-view="scrollIntoView"
					@scroll="onScroll">
					<!-- 页面下回时重置scroll-view回到顶部 -->
					<view id="s-top"></view>
					<!-- scroll-view内容(示例) -->
					<view class="scroll-content">
						<view class="item" v-for="i in 20" :key="i">滚动内容{{ i+1 }}</view>
					</view>
				</scroll-view>
			</view>
		</view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				markers: [{
					id: 1101, // 唯一标识
					callout: {
						content: '天安门广场',
						color: '#FFFFFF',
						fontSize: 14,
						bgColor: '#40ABDE',
						padding: 5,
						borderRadius: 20,
						anchorY: 5,
						display: 'ALWAYS'
					},
					longitude: 116.397755,
					latitude: 39.903182,
					iconPath: 'https://kuaiyixing.oss-cn-zhangjiakou.aliyuncs.com/wximg/wxImg/newhome/icon_loc_bottom.png',
					width: 28,
					height: 16,
					rotate: 0,
					alpha: 1
				}],
				// 地图高度:默认50%,A区到底部时改为80%
				mapHeight: '50%',
				// ===== A区三段滑动核心节点 =====
				// 顶部节点:距离顶部200px
				topNode: 180,
				// 中间节点:(顶部节点 + 底部节点) / 2(居中)
				middleNode: 0,
				// 底部节点:设备高度 - 300px(保留300px露出)
				bottomNode: 0,
				// ===== A区滑动状态 =====
				// A区当前位置(距离顶部的像素值)
				aAreaTop: 0, // 初始位置=顶部节点
				// A区上一次稳定停留的节点(用于回弹)
				lastStableNode: 0,
				// 触摸开始时的Y坐标
				startY: 0,
				// 触摸移动过程中最后一次的Y坐标
				lastY: 0,
				// 是否正在执行过渡动画(用于禁用滑动过程中的动画)
				isAnimating: false,
				// 滑动阈值:超过该值触发分段滑动
				threshold: 40,
				// 页面可视区域高度(从系统信息中获取)
				windowHeightRpx: 0,
				// A区自身高度:设备高度 - 200px
				aAreaHeight: 0,
				// 标记当前是否触摸的是地图(用于隔离事件)
				isTouchMap: false,
				isScrollEnabled: false,
				scrollTop: 0,
				scrollIntoView: '',
				isTouchScroll: false, // 标记是否触摸scroll-view(避免和A区滑动冲突)
				isExecuted: false, // 记录scroll-view是否滚动
			}
		},
		onLoad() {
			// 获取页面高度:改用同步API,避免异步时序问题
			const systemInfo = wx.getWindowInfo();
			// 屏幕宽度(px)转750rpx基准:1px = 750rpx / 屏幕宽度(px)
			const rpxRatio = 750 / systemInfo.windowWidth;
			// 屏幕高度(px)转rpx
			this.windowHeightRpx = systemInfo.windowHeight * rpxRatio;
			// A区高度 = 屏幕高度(rpx) - 200rpx
			this.aAreaHeight = this.windowHeightRpx - 180;
			// 底部节点:屏幕高度(rpx) - 300rpx(保留300rpx露出)
			this.bottomNode = this.windowHeightRpx - 700;
			// 中间节点 = 屏幕50%(rpx)→ 初始位置
			this.middleNode = this.windowHeightRpx * 0.4;
			// 初始位置 = 屏幕50%(rpx)
			this.aAreaTop = this.middleNode;
			this.lastStableNode = this.middleNode;
			// 初始禁用scroll-view滚动(非顶部节点)
			this.isScrollEnabled = false;
			this.isExecuted = false;
			this.scrollTop = 0;
			this.scrollIntoView = '';

			// 打印验证(rpx)
			console.log('屏幕高度(rpx):', this.windowHeightRpx)
			console.log('顶部节点(rpx):', this.topNode)
			console.log('中间节点(初始)(rpx):', this.middleNode)
			console.log('底部节点(rpx):', this.bottomNode)
			console.log('A区高度(rpx):', this.aAreaHeight)
		},
		watch: {
			// 监听A区位置变化,控制scroll-view滚动权限
			aAreaTop(newVal) {
				const shouldEnable = newVal === this.topNode
				this.isScrollEnabled = shouldEnable
				// 延迟执行,确保DOM更新完成
				this.resetScrollTop()
			},
			// 监听稳定节点变化,二次确认滚动权限(避免动画过程误触发)
			lastStableNode(newVal) {
				const shouldEnable = newVal === this.topNode
				this.isScrollEnabled = shouldEnable
				// 延迟执行,确保DOM更新完成
				this.resetScrollTop()
			}
		},
		methods: {
			// ==================== 核心修复:强制重置scroll-view到顶部 ====================
			resetScrollTop() {
				console.log("重置滚动高度")
				this.isScrollMoved = false;
				this.scrollTop = 0;
				this.scrollIntoView = 's-top';
			},

			/**
			 * 触摸地图开始:标记为触摸地图,禁用A区滑动
			 */
			onMapTouchStart() {
				this.isTouchMap = true;
			},

			/**
			 * 触摸地图移动:阻止事件冒泡(微信小程序通过return false实现)
			 * @param {Object} e - 触摸事件
			 */
			onMapTouchMove(e) {},

			/**
			 * 触摸地图结束:重置标记,恢复A区滑动响应
			 */
			onMapTouchEnd() {
				this.isTouchMap = false;
			},
			
			// ==================== scroll-view触摸事件(优化:精准判断) ====================
			onScroll(e) {
				this.scrollTop = e.detail.scrollTop;
			},
			onScrollTouchStart(e) {
				if(this.scrollTop === 0 && this.scrollIntoView === '') {
					this.onTouchStart(e)
				}else {
					this.scrollTop = 0;
					this.scrollIntoView = '';
					console.log('onScrollTouchStart')
					// 只有在scroll-view启用时才标记触摸状态
					this.isTouchScroll = true;
				}
			},
			onScrollTouchMove(e) {
				if(this.scrollTop === 0 && this.scrollIntoView === '') {
					this.onTouchMove(e)
				}
			},
			onScrollTouchEnd(e) {
				if(this.scrollTop === 0 && this.scrollIntoView === '') {
					this.onTouchEnd(e)
				}
			},

			// ==================== A区触摸开始 ====================
			onTouchStart(e) {
				console.log('onTouchStart', this.isTouchMap, this.isTouchScroll, this.isScrollEnabled);
				// 如果触摸的是地图,直接返回
				if (this.isTouchMap) return

				// 如果触摸的是scroll-view且scroll-view已启用,不执行A区滑动逻辑
				if (this.isTouchScroll && this.isScrollEnabled) return

				// 触摸坐标(px)转rpx
				const rpxRatio = 750 / wx.getWindowInfo().windowWidth
				this.startY = e.touches[0].clientY * rpxRatio
				this.lastY = e.touches[0].clientY * rpxRatio
				this.isAnimating = false
			},

			// ==================== A区跟随手指滑动(rpx) ====================
			onTouchMove(e) {
				console.log('onTouchMove', this.isTouchMap, this.isTouchScroll, this.isScrollEnabled, this.isAnimating)
				// 触摸地图/scroll-view/正在执行动画时,不处理滑动
				if (this.isTouchMap || (this.isTouchScroll && this.isScrollEnabled) || this.isAnimating) return

				// 触摸坐标(px)转rpx
				const rpxRatio = 750 / wx.getWindowInfo().windowWidth
				const currentY = e.touches[0].clientY * rpxRatio
				// 移动距离(rpx):deltaY为正=手指上滑,为负=手指下滑
				const deltaY = this.lastY - currentY

				// 更新A区位置(rpx)
				this.aAreaTop -= deltaY
				// 限制滑动范围(rpx):仅在顶部/底部节点之间
				this.aAreaTop = Math.max(this.topNode, Math.min(this.bottomNode, this.aAreaTop))

				this.lastY = currentY
			},

			// ==================== 触摸结束 → 三段滑动(rpx) ====================
			onTouchEnd(e) {
				console.log('onTouchEnd', this.isTouchMap, this.isTouchScroll, this.isScrollEnabled)
				// 如果触摸的是地图,直接返回
				if (this.isTouchMap) return

				// 如果触摸的是scroll-view且scroll-view已启用,不执行A区滑动逻辑
				if (this.isTouchScroll && this.isScrollEnabled) {
					this.isTouchScroll = false
					return
				}

				// 触摸坐标(px)转rpx
				const rpxRatio = 750 / wx.getWindowInfo().windowWidth
				const endY = e.changedTouches[0].clientY * rpxRatio
				// 总滑动距离(rpx)
				const totalDeltaY = this.startY - endY

				this.isAnimating = true

				if (totalDeltaY > 0) {
					// 上滑(rpx)
					this.handleUp(totalDeltaY)
				} else if (totalDeltaY < 0) {
					// 下滑(rpx,取绝对值)
					this.handleDown(Math.abs(totalDeltaY))
				} else {
					// 无滑动 → 回弹到上次位置(rpx)
					this.aAreaTop = this.lastStableNode
					this.syncMap()
				}
			},

			// 上滑逻辑(三段,rpx)
			handleUp(delta) {
				if (this.lastStableNode === this.bottomNode) {
					// 底部 → 上滑超过20rpx → 中间
					if (delta > this.threshold) {
						this.aAreaTop = this.middleNode
						this.lastStableNode = this.middleNode
					} else {
						this.aAreaTop = this.bottomNode
					}
				} else if (this.lastStableNode === this.middleNode) {
					// 中间 → 上滑超过20rpx → 顶部
					if (delta > this.threshold) {
						this.aAreaTop = this.topNode
						this.lastStableNode = this.topNode
					} else {
						this.aAreaTop = this.middleNode
					}
				} else if (this.lastStableNode === this.topNode) {
					this.aAreaTop = this.topNode
				}
				this.syncMap()
			},

			// 下滑逻辑(三段,rpx)
			handleDown(delta) {
				if (this.lastStableNode === this.topNode) {
					// 顶部 → 下滑超过20rpx → 中间
					if (delta > this.threshold) {
						this.aAreaTop = this.middleNode
						this.lastStableNode = this.middleNode
					} else {
						this.aAreaTop = this.topNode
					}
				} else if (this.lastStableNode === this.middleNode) {
					// 中间 → 下滑超过20rpx → 底部
					if (delta > this.threshold) {
						this.aAreaTop = this.bottomNode
						this.lastStableNode = this.bottomNode
					} else {
						this.aAreaTop = this.middleNode
					}
				} else if (this.lastStableNode === this.bottomNode) {
					this.aAreaTop = this.bottomNode
				}
				this.syncMap()
			},

			// 同步地图高度(%适配,无需rpx)
			syncMap() {
				this.mapHeight = this.lastStableNode === this.bottomNode ? '70%' : '50%'
			}
		}
	}
</script>

<style scoped>
	/* 页面容器:rpx适配,全屏布局 */
	.page-container {
		width: 750rpx;
		/* 固定750rpx,适配所有屏幕宽度 */
		height: 100vh;
		/* 高度用vh,适配屏幕高度 */
		position: relative;
		overflow: hidden;
	}

	/* 地图样式:rpx适配 */
	#my-map {
		width: 750rpx;
		/* 宽度750rpx,全屏 */
		position: absolute;
		top: 0rpx;
		left: 0rpx;
		z-index: 1;
		transition: height 0.3s ease;
		/* 高度过渡动画 */
	}

	/* A区样式:全rpx适配 */
	.a-area {
		position: absolute;
		left: 0rpx;
		width: 750rpx;
		/* 宽度750rpx,全屏 */
		background: rgba(76, 175, 80, 0.9);
		color: #fff;
		box-sizing: border-box;
		z-index: 10;
		/* 避免A区滚动影响 */
		overflow: hidden;
	}

	/* scroll-view样式:优化滚动体验 */
	.a-scroll {
		width: 100%;
		height: 100%;
		box-sizing: border-box;
		/* 增加滚动手感 */
		-webkit-overflow-scrolling: touch;
		overflow-scrolling: touch;
	}

	/* 滚动内容示例 */
	.scroll-content {
		padding: 20rpx;
		box-sizing: border-box;
	}

	.item {
		height: 100rpx;
		line-height: 100rpx;
		text-align: center;
		border-bottom: 1rpx solid #fff;
		margin-bottom: 10rpx;
	}
</style>

四、核心优化点与问题解决

  1. 三段式滑动实现:通过topNode、middleNode、bottomNode三个节点控制滑动档位,触摸结束后根据滑动距离判断是否切换档位,实现滴滴原版的多态滑动效果,比基础版的两态滑动更贴合实际需求。

  2. 全端适配优化:放弃固定px单位,采用rpx单位+动态计算屏幕尺寸,通过wx.getWindowInfo()获取系统信息,转换rpx比例,确保在不同尺寸手机上布局一致、滑动流畅。

  3. 滑动冲突彻底解决:通过isTouchMap、isTouchScroll两个标记位,区分地图、面板、scroll-view的触摸事件,避免三者之间的滑动冲突;同时优化scroll-view触摸逻辑,仅当滚动到顶部时才允许拖拽面板。

  4. 滚动重置功能:添加resetScrollTop方法,通过scrollIntoView锚点强制重置scroll-view到顶部,解决面板切换时scroll-view滚动位置错乱的问题,提升交互体验。

  5. 动画与流畅度优化:给面板top值和地图height添加过渡动画,通过isAnimating标记位避免动画过程中触发重复滑动,使滑动过程更丝滑;地图与面板高度同步切换,视觉衔接更自然。

  6. 地图功能完善:添加地图标记点、callout提示,支持地图缩放、拖拽,与滴滴原版地图体验一致;通过事件隔离,确保地图操作不影响面板滑动。


五、总结

本文基于uni-app框架,实现了滴滴出行首页核心的三段式滑动交互,完整覆盖了「地图联动、面板滑动、scroll-view滚动、全端适配」等核心需求,解决了滑动冲突、布局错乱、滚动重置等常见问题,代码可直接复制运行,快速迁移到实际项目中。

这种交互模式不仅适用于出行类小程序,还可迁移到地图类、服务类等需要底部滑动面板的场景。掌握本文的技术思路和代码细节,可大幅提升开发效率,减少踩坑成本,打造贴合用户习惯的滑动交互体验。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐