系列背景:这个系列记录一个本地优先的三国历史知识 App 从工程骨架、主题、内容模型、听书、搜索、资源到地图交互的完整实现。第 17 篇讲的是 ArticleRecord 如何把专题文章接入收藏、笔记和听书;第 18 篇换一个完全不同的问题:地图不是文本内容,用户需要放大、拖动、横屏查看,并且退出全屏后不能污染普通页面状态。

当前系列文章所述应用《耳畔三国·将星落》已上架鸿蒙应用商店,欢迎各位天才程序员尝鲜、吐槽!

一、真实工程问题:地图卡片不等于地图查看器

历史类 App 很容易把地图做成一张静态图片:页面上放一张 Image,下面写几行说明,任务似乎就完成了。但在《耳畔三国·将星落》这种按年份查看势力变化的场景里,普通卡片只能解决“看见地图”,不能解决“读懂地图”。

用户进入地图模块后会做几件事:

用户动作 页面需要回应 如果只用静态图片会怎样
切换 184、190、200、208、222、263 年 地图图源、势力按钮、说明文案同步变化 年份切换可做,但细节太小
点击全屏查看 进入更大的地图阅读空间 普通页面卡片仍受列表布局限制
双指放大 地名、边界和势力范围更清楚 图片被固定在卡片里,无法查看细节
单指拖动 放大后查看地图不同区域 放大后只能看中心区域
横屏或平板查看 控件避让地图主体 控件覆盖地图,阅读体验变差
关闭全屏 回到普通页面且缩放复位 上一次的偏移残留到下次打开

这篇文章分析的不是“如何生成三国地图图片”,而是地图图片已经在本地资源目录里后,ArkUI 页面如何把它做成一个可用的全屏查看器。源码对象集中在 library2/src/main/ets/pages/MainFrame.ets,没有改网络接口,也不依赖服务器。

手机端地图卡片与全屏入口

二、源码对象总览:地图交互集中在 MainFrame.ets

第 18 篇涉及的代码对象比第 17 篇更偏页面交互,核心仍然在一个页面主控文件里:

对象 所在位置 职责
mapZoomScale MainFrame.ets 状态区 当前全屏地图缩放倍率
mapOffsetX / mapOffsetY MainFrame.ets 状态区 放大后地图拖动偏移
mapBaseScale MainFrame.ets 私有字段 手势开始时的缩放基准
mapTouchDistance MainFrame.ets 私有字段 双指缩放的初始距离
selectedMapFullScreenImage() MainFrame.ets 方法区 按年份选择全屏大图资源
openMapFullScreen() MainFrame.ets 方法区 打开全屏前复位变换状态
handleMapTouch() MainFrame.ets 方法区 处理 Down、Move、Up、Cancel
mapPortraitFullScreenOverlay() MainFrame.ets Builder 竖屏全屏地图布局
mapLandscapeFullScreenOverlay() MainFrame.ets Builder 横屏全屏地图布局

这套实现的边界很清楚:地图资源仍然是本地图片;全屏层只处理显示、手势和状态复位;年份、势力、收藏等业务状态仍由普通地图面板维护。这样做可以避免一个全屏浮层反过来接管整个地图模块。

三、状态设计:缩放、偏移和手势基准必须分开

全屏地图最容易写乱的是手势状态。放大倍率、当前偏移、手势起点、双指距离如果混在一起,第一次缩放可能正常,第二次拖动就开始跳。当前项目把可响应 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 module=entry@default 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.UpTouchType.Cancel 会刷新下一轮手势基准。
  • 竖屏全屏层保留标题、年份说明和关闭按钮。
  • 横屏全屏层使用侧栏避让地图主体。
  • 全屏地图图片使用 ImageFit.Contain,并通过 .scale().translate() 应用变换。
  • 地图容器开启 .clip(true),放大后不会污染外部布局。

十三、小结:地图交互的核心不是放大,而是状态边界

第 18 篇拆的是一个很具体的 ArkUI 交互问题:三国势力地图进入全屏后,如何在手机竖屏、平板横屏、双指缩放和单指拖动之间保持稳定。实现上并没有引入复杂手势库,而是用 TouchEvent、少量状态和明确的横竖屏 Builder 完成了一个可维护的地图查看器。

这套方案的价值在于边界清楚:普通地图面板负责业务状态,全屏覆盖层负责临时查看状态;@State 驱动 UI,私有字段记录手势过程;缩放倍率和拖动偏移都被限制在可读范围内。对历史知识类 App 来说,这比堆更多动画更重要。

下一篇会继续沿着“长期使用体验”往下走,拆解深浅色跟随系统时,EntryAbility.etsConfigurationConstant.ColorMode 和主题 token 如何协作,避免系统模式变化后页面颜色和用户偏好不同步。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐