【三国志 App 实战系列 18】HarmonyOS ArkTS 地图全屏交互实战:触摸缩放、横屏适配与边界控制

📅 2026/6/21 7:03:15
【三国志 App 实战系列 18】HarmonyOS ArkTS 地图全屏交互实战:触摸缩放、横屏适配与边界控制
系列背景这个系列记录一个本地优先的三国历史知识 App 从工程骨架、主题、内容模型、听书、搜索、资源到地图交互的完整实现。第 17 篇讲的是 ArticleRecord 如何把专题文章接入收藏、笔记和听书第 18 篇换一个完全不同的问题地图不是文本内容用户需要放大、拖动、横屏查看并且退出全屏后不能污染普通页面状态。当前系列文章所述应用《耳畔三国·将星落》已上架鸿蒙应用商店欢迎各位天才程序员尝鲜、吐槽一、真实工程问题地图卡片不等于地图查看器历史类 App 很容易把地图做成一张静态图片页面上放一张Image下面写几行说明任务似乎就完成了。但在《耳畔三国·将星落》这种按年份查看势力变化的场景里普通卡片只能解决“看见地图”不能解决“读懂地图”。用户进入地图模块后会做几件事用户动作页面需要回应如果只用静态图片会怎样切换 184、190、200、208、222、263 年地图图源、势力按钮、说明文案同步变化年份切换可做但细节太小点击全屏查看进入更大的地图阅读空间普通页面卡片仍受列表布局限制双指放大地名、边界和势力范围更清楚图片被固定在卡片里无法查看细节单指拖动放大后查看地图不同区域放大后只能看中心区域横屏或平板查看控件避让地图主体控件覆盖地图阅读体验变差关闭全屏回到普通页面且缩放复位上一次的偏移残留到下次打开这篇文章分析的不是“如何生成三国地图图片”而是地图图片已经在本地资源目录里后ArkUI 页面如何把它做成一个可用的全屏查看器。源码对象集中在library2/src/main/ets/pages/MainFrame.ets没有改网络接口也不依赖服务器。二、源码对象总览地图交互集中在 MainFrame.ets第 18 篇涉及的代码对象比第 17 篇更偏页面交互核心仍然在一个页面主控文件里对象所在位置职责mapZoomScaleMainFrame.ets状态区当前全屏地图缩放倍率mapOffsetX / mapOffsetYMainFrame.ets状态区放大后地图拖动偏移mapBaseScaleMainFrame.ets私有字段手势开始时的缩放基准mapTouchDistanceMainFrame.ets私有字段双指缩放的初始距离selectedMapFullScreenImage()MainFrame.ets方法区按年份选择全屏大图资源openMapFullScreen()MainFrame.ets方法区打开全屏前复位变换状态handleMapTouch()MainFrame.ets方法区处理 Down、Move、Up、CancelmapPortraitFullScreenOverlay()MainFrame.etsBuilder竖屏全屏地图布局mapLandscapeFullScreenOverlay()MainFrame.etsBuilder横屏全屏地图布局这套实现的边界很清楚地图资源仍然是本地图片全屏层只处理显示、手势和状态复位年份、势力、收藏等业务状态仍由普通地图面板维护。这样做可以避免一个全屏浮层反过来接管整个地图模块。三、状态设计缩放、偏移和手势基准必须分开全屏地图最容易写乱的是手势状态。放大倍率、当前偏移、手势起点、双指距离如果混在一起第一次缩放可能正常第二次拖动就开始跳。当前项目把可响应 UI 的状态和手势过程变量分开State mapZoomScale: number 1; State mapOffsetX: number 0; State mapOffsetY: number 0; private mapBaseScale: number 1; private mapBaseOffsetX: number 0; private mapBaseOffsetY: number 0; private mapTouchDistance: number 0; private mapTouchStartX: number 0; private mapTouchStartY: number 0;这里的分层很重要。State字段负责驱动Image.scale()和Image.translate()刷新私有字段负责记录某一次手势的基准值不需要触发 UI 更新。换句话说页面只关心最终变换结果手势计算过程不必每一步都变成响应式状态。3.1 打开和关闭都要复位全屏地图是临时查看状态不应该把上次缩放后的比例带到下一次打开。项目在打开和关闭时都调用resetMapTransform()private openMapFullScreen(): void { this.resetMapTransform(); this.isMapExpanded true; } private closeMapFullScreen(): void { this.isMapExpanded false; this.resetMapTransform(); } private resetMapTransform(): void { this.mapZoomScale 1; this.mapOffsetX 0; this.mapOffsetY 0; this.mapBaseScale 1; this.mapBaseOffsetX 0; this.mapBaseOffsetY 0; this.mapTouchDistance 0; this.mapTouchStartX 0; this.mapTouchStartY 0; }这不是洁癖而是体验边界。用户在 222 年地图上放大到 3 倍后关闭再切到 184 年重新打开如果仍然停留在上一次偏移位置就会误以为地图没有正常加载。临时查看器必须有明确的生命周期。四、图源选择普通卡片图和全屏大图可以不同普通地图卡片需要适配列表宽度图源可以是裁切后更适合卡片展示的版本全屏地图则更强调细节可读性。项目里单独提供selectedMapFullScreenImage()private selectedMapFullScreenImage(): ResourceStr { if (this.selectedMapYear 184年) { return $r(app.media.map_full_184); } if (this.selectedMapYear 190年) { return $r(app.media.map_full_190); } if (this.selectedMapYear 200年) { return $r(app.media.map_full_200); } if (this.selectedMapYear 222年) { return $r(app.media.map_full_222); } if (this.selectedMapYear 263年) { return $r(app.media.map_full_263); } return $r(app.media.map_full_208); }这段代码看起来只是if分支但它把一个关键边界固定住了普通页面用什么图、全屏查看用什么图不必绑定死。后续如果某个年份需要更高清或不同裁切比例的全屏资源不需要重写地图面板。五、缩放边界倍率必须有上限和下限手势缩放不能无限放大。倍率太小会出现反向缩小的空白感倍率太大会导致用户迷失在图片局部拖动范围也会被放大到难以控制。当前项目使用 1 到 4 的倍率区间private clampMapScale(scale: number): number { return Math.min(4, Math.max(1, scale)); }偏移也要跟着缩放倍率限制。项目目前使用一个简单但稳定的规则缩放越大允许拖动的最大范围越大未放大时偏移范围为 0。private clampMapOffset(value: number): number { const maxOffset: number Math.max(0, (this.mapZoomScale - 1) * 180); return Math.min(maxOffset, Math.max(-maxOffset, value)); }这个实现不是物理引擎也没有按图片真实像素和容器尺寸精确计算边界。它的目标更务实在当前 App 的地图比例、屏幕尺寸和ImageFit.Contain组合下给用户一个可控的拖动空间同时避免地图被拖到完全看不见。5.1 为什么先做稳定边界而不是追求复杂惯性地图交互常见诱惑是加惯性、回弹、双击缩放、边缘吸附。对这个项目来说第一阶段更重要的是稳定放大能读字拖动不丢图关闭能复位。历史知识 App 的地图不是专业 GIS 工具交互复杂度要服务阅读而不是抢走阅读焦点。六、TouchEvent 处理双指缩放和单指拖动拆成两条路径handleMapTouch()是本篇的核心。它按事件类型和触点数量分支Down记录基准Move里双指处理缩放单指在已放大时处理拖动Up/Cancel刷新下一轮手势基准。private mapTouchDistanceOf(touches: TouchObject[]): number { if (touches.length 2) { return 0; } const deltaX: number touches[0].x - touches[1].x; const deltaY: number touches[0].y - touches[1].y; return Math.sqrt(deltaX * deltaX deltaY * deltaY); }双指距离只在触点数量达到 2 时有效。这里没有把距离计算写进handleMapTouch()主流程是为了让缩放公式更容易看清楚也方便后续如果要加双指旋转或更多手势时继续拆分。private handleMapTouch(event: TouchEvent): void { if (event.type TouchType.Down) { this.mapBaseScale this.mapZoomScale; this.mapBaseOffsetX this.mapOffsetX; this.mapBaseOffsetY this.mapOffsetY; this.mapTouchStartX event.touches.length 0 ? event.touches[0].x : 0; this.mapTouchStartY event.touches.length 0 ? event.touches[0].y : 0; this.mapTouchDistance this.mapTouchDistanceOf(event.touches); return; } }Down阶段不直接改 UI只记录基准。这个细节能避免拖动时从旧坐标突然跳到新坐标。用户每次按下时当前地图状态就是新一轮手势的原点。6.1 双指缩放用距离比例计算新倍率双指缩放的公式非常直接当前双指距离除以起始双指距离再乘以手势开始时的倍率。if (event.type TouchType.Move) { if (event.touches.length 2) { const distance: number this.mapTouchDistanceOf(event.touches); if (this.mapTouchDistance 0) { this.mapTouchDistance distance; this.mapBaseScale this.mapZoomScale; return; } this.mapZoomScale this.clampMapScale(this.mapBaseScale * distance / this.mapTouchDistance); this.mapOffsetX this.clampMapOffset(this.mapOffsetX); this.mapOffsetY this.clampMapOffset(this.mapOffsetY); return; } }缩放后立即重新裁剪偏移是为了处理一个细节如果用户从 4 倍拖到边界再缩回 1.5 倍旧偏移可能已经超过新倍率允许的范围。此时不裁剪图片会停在一个不合理的位置。6.2 单指拖动只有放大后才允许平移未放大时拖动地图没有意义反而会和页面滚动、全屏层的触摸反馈互相干扰。因此项目只在mapZoomScale 1时允许单指拖动if (event.touches.length 1 this.mapZoomScale 1) { this.mapOffsetX this.clampMapOffset(this.mapBaseOffsetX event.touches[0].x - this.mapTouchStartX); this.mapOffsetY this.clampMapOffset(this.mapBaseOffsetY event.touches[0].y - this.mapTouchStartY); }这段逻辑的关键不是translate本身而是“基准偏移 本轮手势位移”。如果直接用当前偏移持续叠加当前触点坐标很容易随着事件频率产生漂移基于Down时的起点计算结果更稳定。七、竖屏全屏层标题、关闭按钮和地图主体分区竖屏全屏层仍然保留标题、年份和关闭按钮因为手机竖屏下用户需要明确知道自己正在看哪一个年份。地图主体用layoutWeight(1)占据剩余空间并将手势绑定在包含图片的Stack上Builder private mapPortraitFullScreenOverlay() { Column() { Row() { Column() { Text(乱世势力地图) Text(this.selectedMapYear · this.markersForYear()) } .layoutWeight(1) Text(关闭) .onClick(() { this.closeMapFullScreen(); }) } Stack({ alignContent: Alignment.Center }) { Image(this.selectedMapFullScreenImage()) .width(100%) .height(100%) .objectFit(ImageFit.Contain) .scale({ x: this.mapZoomScale, y: this.mapZoomScale }) .translate({ x: this.mapOffsetX, y: this.mapOffsetY }) } .layoutWeight(1) .clip(true) .onTouch((event: TouchEvent) { this.handleMapTouch(event); }) } }这里的.clip(true)不能省。地图放大后如果不裁剪图片会越过全屏容器边界压到标题或关闭按钮上。全屏查看器不是无限画布它仍然有自己的视觉边界。八、横屏全屏层控件侧栏避让地图主体横屏下的处理和竖屏不同。横屏空间更宽如果仍把标题和关闭按钮放在顶部会压缩地图高度项目把控制区收成左侧窄栏地图主体占满剩余空间Builder private mapFullScreenOverlay() { if (this.isLandscapeViewport()) { this.mapLandscapeFullScreenOverlay(); } else { this.mapPortraitFullScreenOverlay(); } }横屏覆盖层里地图Stack铺满全屏左侧栏负责关闭、年份选择和简短说明。这样用户在平板或横屏设备上看到的是一个更接近“地图查看器”的界面而不是一个被放大的手机卡片。Builder private mapLandscapeFullScreenOverlay() { Stack({ alignContent: Alignment.Center }) { Stack({ alignContent: Alignment.Center }) { Image(this.selectedMapImage()) .width(100%) .height(100%) .objectFit(ImageFit.Contain) .scale({ x: this.mapZoomScale, y: this.mapZoomScale }) .translate({ x: this.mapOffsetX, y: this.mapOffsetY }) } .width(100%) .height(100%) .clip(true) .onTouch((event: TouchEvent) { this.handleMapTouch(event); }) Column() { Text(关闭) .onClick(() { this.closeMapFullScreen(); }) Text(势力地图) this.mapYearRail(); } .width(94) .height(100%) .position({ x: 0, y: 0 }) } }这段代码体现了横竖屏适配的一个朴素原则不要只按宽高比缩放同一套布局。横屏不是“更宽的竖屏”它有不同的阅读路径和控件摆放方式。九、普通地图面板和全屏层如何衔接普通地图面板负责年份、势力、地图预览和全屏入口。用户点击“全屏查看”时只切换isMapExpanded不改变当前年份和势力Builder private mapPanel() { Column() { Text(乱世势力地图) this.mapYearBar(); this.factionSelectorBar(); this.mapCanvas(); Row() { Text(全屏查看) .onClick(() { this.openMapFullScreen(); }) Blank() Text(按年份查看势力变迁) } Text(当前阶段 this.selectedMapYear · this.markersForYear()) this.factionDetailCard(); this.mapMarkerList(); } }这让全屏层更像一个“查看状态”而不是新的业务页面。它继承普通面板的selectedMapYear关闭后也回到原来的地图模块。对本地知识 App 来说这种关系比路由跳转更轻也更符合用户临时查看大图的心理模型。十、调试命令与日志先确认对象再确认交互本次文章分析的是已有实现没有修改 ArkTS 源码。排查和写稿时我先用命令确认相关对象集中在哪里git status --short这个命令用于确认工作区已有改动避免把发布文章的操作和用户正在做的代码改动混在一起。rg -n mapZoomScale|mapOffsetX|mapOffsetY|handleMapTouch library2/src/main/ets/pages/MainFrame.ets用于定位手势状态和触摸处理主函数。rg -n selectedMapFullScreenImage|openMapFullScreen|closeMapFullScreen|resetMapTransform library2/src/main/ets/pages/MainFrame.ets用于确认全屏图源选择和生命周期复位逻辑。rg -n mapFullScreenOverlay|mapPortraitFullScreenOverlay|mapLandscapeFullScreenOverlay library2/src/main/ets/pages/MainFrame.ets用于确认横竖屏覆盖层入口和两个 Builder 的职责边界。如果后续继续改交互逻辑可以再加上 HarmonyOS 构建和真机验证.\hvigorw.bat --mode module -p moduleentrydefault assembleHap hdc list targets hdc shell uitest dumpLayout当前写稿没有改 ArkTS 源码所以本轮重点验证的是本地文章结构、截图素材和线上 CSDN 发布状态真正改全屏手势时才需要把 Hvigor 构建和真机触摸验证作为交付门槛。十一、问题复盘地图全屏最容易踩的坑这类功能看起来只是“图片放大”实际有几个容易被忽略的坑踩坑点现象当前项目的处理打开全屏不复位上次放大和偏移残留openMapFullScreen()先调用resetMapTransform()关闭全屏不复位下次打开仍然偏移closeMapFullScreen()再次复位单指未放大也拖动页面触摸反馈混乱mapZoomScale 1才允许拖动缩小后不裁剪偏移图片停在不合理位置缩放后调用clampMapOffset()放大图片不裁剪图片压住标题和关闭按钮容器使用.clip(true)横屏沿用竖屏布局顶部控件占用地图高度横屏使用侧栏布局全屏层接管业务状态关闭后页面状态不清晰全屏只做查看年份和势力仍由地图面板维护最关键的复盘结论是地图查看器要有清楚的临时状态边界。缩放、拖动、关闭按钮都属于全屏查看器年份、势力、收藏、关键地点仍属于地图业务面板。两个边界混在一起后续扩展会很痛苦。十二、工程验收清单第 18 篇对应的检查点这篇文章对应的实现可以按下面清单验收普通地图面板存在明确的“全屏查看”入口。点击全屏前会调用resetMapTransform()初始倍率为 1。关闭全屏后isMapExpanded变为false缩放和偏移状态清零。selectedMapFullScreenImage()能按年份选择全屏地图资源。clampMapScale()把缩放倍率限制在 1 到 4。clampMapOffset()根据当前缩放倍率限制拖动范围。mapTouchDistanceOf()能基于两个触点计算双指距离。handleMapTouch()在TouchType.Down阶段记录缩放和偏移基准。双指Move使用距离比例计算新倍率并同步裁剪偏移。单指Move只在地图已放大时更新mapOffsetX/Y。TouchType.Up和TouchType.Cancel会刷新下一轮手势基准。竖屏全屏层保留标题、年份说明和关闭按钮。横屏全屏层使用侧栏避让地图主体。全屏地图图片使用ImageFit.Contain并通过.scale()和.translate()应用变换。地图容器开启.clip(true)放大后不会污染外部布局。十三、小结地图交互的核心不是放大而是状态边界第 18 篇拆的是一个很具体的 ArkUI 交互问题三国势力地图进入全屏后如何在手机竖屏、平板横屏、双指缩放和单指拖动之间保持稳定。实现上并没有引入复杂手势库而是用TouchEvent、少量状态和明确的横竖屏 Builder 完成了一个可维护的地图查看器。这套方案的价值在于边界清楚普通地图面板负责业务状态全屏覆盖层负责临时查看状态State驱动 UI私有字段记录手势过程缩放倍率和拖动偏移都被限制在可读范围内。对历史知识类 App 来说这比堆更多动画更重要。下一篇会继续沿着“长期使用体验”往下走拆解深浅色跟随系统时EntryAbility.ets、ConfigurationConstant.ColorMode和主题 token 如何协作避免系统模式变化后页面颜色和用户偏好不同步。