HarmonyOS Codelab 优秀样例——溪村小镇(ArkTS)

第10篇:打包上架:签名、打包 HAP、发布到华为应用市场 打包上架:签名、打包 HAP、发布到华为应用市场 阅读详情

一、介绍

溪村小镇是一款展示溪流背坡村园区风貌的应用,包括园区内的导航功能,小火车行车状态查看,以及各区域的风景展览介绍,主要用于展示HarmonyOS的ArkUI能力和动画效果。具体包括如下功能:

  1. 打开应用时进入启动页,启动页轮播展示溪村小镇风景图,之后进入应用首页。
  2. 在首页的“地图浏览”标签页,可以拖动和缩放查看地图,并查找相应地标建筑。
  3. 在首页的“区域导览”标签页,可以上下滑动查看溪村小镇不同区域的卡片,点击卡片可以进入对应的区域详情页并查看区域的详细介绍和高清建筑风景图。
  4. 在首页的“小火车”标签页,可以查看溪村小火车的运行路线图。

相关概念

  • Tabs组件:通过页签进行内容视图切换的容器组件,每个页签对应一个内容视图。
  • List组件:列表包含一系列相同宽度的列表项,包含子组件ListItem。
  • 点击手势:支持单击、双击和多次点击事件的识别。
  • 拖动手势:用于触发拖动手势事件,滑动的最小距离为5vp时拖动手势识别成功。
  • 捏合手势:用于触发捏合手势事件,触发捏合手势的最少手指为2指,最大为5指,最小识别距离为3vp。
  • 属性动画:组件的某些通用属性变化时,可以通过属性动画实现渐变过渡效果,提升用户体验。
  • 自定义弹窗:通过CustomDialogController类显示自定义弹窗。使用弹窗组件时,可优先考虑自定义弹窗,便于自定义弹窗的样式与内容。
  • Canvas画布:用于自定义绘制图形。

完整示例

gitee源码地址

二、环境搭建

我们首先需要完成HarmonyOS开发环境搭建,可参照如下步骤进行。

软件要求

硬件要求

  • 设备类型:华为手机或运行在DevEco Studio上的华为手机设备模拟器。
  • HarmonyOS系统:3.1.0 Developer Release。

环境搭建

  1. 安装DevEco Studio,详情请参考下载和安装软件。
  2. 设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,需要连接上网络才能确保工具的正常使用,可以根据如下两种情况来配置开发环境:如果可以直接访问Internet,只需进行下载HarmonyOS SDK操作。
    1. 如果网络不能直接访问Internet,需要通过代理服务器才可以访问,请参考配置开发环境。
  3. 开发者可以参考以下链接,完成设备调试的相关配置:使用真机进行调试
    1. 使用模拟器进行调试

三、代码结构解读

本篇Codelab只对核心代码进行讲解,对于完整代码,我们会在gitee中提供。

├──entry/src/main/ets	                 // 代码区
│  ├──common
│  │  ├──bean
│  │  │  ├──AddressItem.ets              // 地图地标类 
│  │  │  ├──BottomTabsItem.ets           // 底部标签类
│  │  │  ├──PositionItem.ets             // 搜索面板地标类
│  │  │  ├──TrainMap.ets                 // 小火车数据类
│  │  │  └──ZonesItem.ets	         // 区域介绍类
│  │  ├──constants 
│  │  │  ├──CommonConstants.ets          // 公共常量类
│  │  │  └──ZonesConstants.ets           // 区域常量类 
│  │  ├──images
│  │  └──utils
│  │     ├──Animation.ets                // 区域详情动效类          
│  │     ├──DeviceScreen.ets             // 获取设备信息类
│  │     ├──Geography.ets                // 地理坐标转换工具类
│  │     ├──Logger.ets                   // 日志打印类
│  │     └──WindowBar.ets                // 沉浸式导航栏
│  ├──control  
│  │  └──MapController.ets               // 地图控制类
│  ├──entryability
│  │  └──EntryAbility.ets	         // 程序入口类
│  ├──pages
│  │  ├──IntroductionPage.ets            // 区域详情页
│  │  ├──MainPage.ets                    // 应用首页
│  │  └──Splash.ets                      // 启动页
│  ├──view
│  │  ├──BottomTabsComponent.ets         // 底部标签栏
│  │  ├──BuildListItem.ets               // 区域详情建筑、地理位置ListItem组件
│  │  ├──ImageAnimate.ets                // 区域详情小图滑动组件
│  │  ├──ImageViewComponent.ets          // 查看大图弹窗
│  │  ├──MapComponent.ets                // 地图组件
│  │  ├──StyleListItem.ets               // 区域详情风格信息ListItem组件          
│  │  ├──SubTitleItem.ets                // 区域详情子标题ListItem组件
│  │  ├──SwiperListItem.ets              // 区域详情首图轮播组件
│  │  ├──TrainsComponent.ets             // 小火车轨迹更新
│  │  ├──TrainsTrack.ets                 // 小火车组件
│  │  └──ZonesComponent.ets	         // 区域导览组件
│  └──viewmodel
│     ├──ButtonTabsModel.ets             // 底部标签数据
│     ├──MapModel.ets                    // 地图数据及方法
│     ├──SplashModel.ets                 // 启动页数据
│     ├──TrainsMapModel.ets              // 小火车数据及方法
│     └──ZonesViewModel.ets              // 区域介绍信息
└──entry/src/main/resources	         // 资源文件目录

四、应用主页面与沉浸式设计

4.1 启动页

应用首次打开会进入启动页。在启动页内分三个时间段(白天、傍晚、晚上),会根据当前时间段轮播展示溪村小镇的优美风景。

在onWindowStageCreate生命周期中配置启动页入口。

// EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage) {
  // Main window is created, set main page for this ability
  hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
  windowStage.loadContent('pages/Splash', (err, data) => {
    if (err.code) {
      hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
      return;
    }
    hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? '');
  });
}

启动页会在aboutToAppear生命周期内初始化轮播图片资源及定时任务,会展示5秒溪村的优美风景,用户可以点击右上角的跳过直接进入应用主页,也可以等5秒结束自动进入应用主页;5秒倒计时结束、用户主动点击跳过或启动页面销毁时都会取消定时器任务。

// Splash.ets
@Entry
@Component
struct Splash {
  @State countdown: number = Const.COUNTDOWN;
  @State showSwiper: boolean = false;
  private swiperController: SwiperController = new SwiperController();
  private data: Resource[] = [];
  private timer = null;

  // 在此生命周期内根据当前时间段分配轮播展示的溪村小镇风景图
  aboutToAppear(): void {
    let hours = new Date().getHours();
    if (hours >= Const.MORNING_TIME && hours < Const.EVENING_TIME) {
      this.data = splashImages.day;
    } else {
      ...
    }

    // 启动画面展示3秒后 轮播展示溪村小镇风景
    setTimeout(() => {
      this.showSwiper = true;
      this.startTiming();
    }, Const.SPLASH_DURATION);
  }

  // 轮播展示溪村小镇风景倒计时5秒
  startTiming() {
    this.timer = setInterval(() => {
      this.countdown--;
      if (this.countdown === 0) {
        this.clearTiming();
        // 5秒钟后自动跳转到应用首页
        this.jumpToMainPage();
      }
    }, Const.DURATION);
  }

  // 清理定时器
  clearTiming() {
    if (this.timer !== null) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  // 跳转到应用首页
  jumpToMainPage() {
    this.clearTiming();
    router.replaceUrl({
      url: 'pages/MainPage'
    });
  }

  // 页面销毁时清理定时器
  aboutToDisappear() {
    this.clearTiming();
  }

  build() {
    Column() {
      Stack() {
        // 轮播展示溪村小镇风景
        if (this.showSwiper) {
          Swiper(this.swiperController) {
            ForEach(this.data, (item: Resource) => {
              Image(item)
                ...
            })
          }
          .loop(true)
          ...
          
          // 轮播倒计时,点击可进入应用主页
          Text() {
            Span($r('app.string.skip'))
            Span(`${this.countdown}`)
          }
          .onClick(() => this.jumpToMainPage())
          ...
          })
        } else { // 应用启动画面
          Image($r('app.media.splash_bg'))
            ...
          Image($r('app.media.ic_splash'))
            ...
          Column() {
            Text(Const.SPLASH_DES)
              ...
            Text(Const.SPLASH_WELCOME)
              ...
            }
        }
      }
    }
    ...
  }
}

4.2 应用首页

应用首页包括三个标签页,分别是“地图浏览”页、“区域导览”页和“小火车”页,效果如图所示:

该页面使用Tabs组件实现,使用一个自定义的底部标签栏组件来控制选中对应的标签页时的视觉效果。

// MainPage.ets
@Entry
@Component
struct MainPage {
  // 当底部标签栏index改变时调用onIndexChange()方法,改变Tabs组件的index
  @State @Watch('onIndexChange') bottomTabIndex: number = 1;
  private controller: TabsController = new TabsController();

  onIndexChange() {
    this.controller.changeIndex(this.bottomTabIndex);
  }

  build() {
    Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.End, justifyContent: FlexAlign.End }) {
      Tabs({ barPosition: BarPosition.End, index: 1, controller: this.controller }) {
        TabContent() {
          // “地图浏览”页
          Map()
        }
        TabContent() {
          // “区域导览”页
          Zones()
        }
        TabContent() {
          // “小火车”页
          Trains()  
        }
      }
      ...
      .onChange((index: number) => {
        // 当标签页切换时改变底部标签栏组件的index
        this.bottomTabIndex = index;
      })

      // 底部标签栏组件
      BottomTabs({ bottomTabIndex: $bottomTabIndex })
    }
    .width(Const.FULL_PERCENT)
  }
}

五、地图浏览

地图浏览模块提供了“溪村小镇”的全景地图,方便用户了解“溪村小镇”内部的地理概况。包含以下功能:

  1. 搜索指定类型地标,并在地图上展示。
  2. 通过手势对地图进行操作,包括放大、缩小和拖拽。

5.1 初始化地图

地图使用Stack组件实现,地图初始化的宽高是由图片的宽高和地图父组件的宽高计算得出,地图位置由地图宽度和地图父组件计算得出。

// MapComponent.ets
build() {
  Stack({ alignContent: Alignment.BottomEnd }) {
    Column() {
      // 地图组件
      Stack({ alignContent: Alignment.TopStart }) {
        ...
      }
      // 地图图片
      .backgroundImage($r('app.media.ic_nav_map'))
      .backgroundImageSize(ImageSize.Cover)
      // 地图宽度
      .width(this.mapWidth)
      // 地图高度
      .height(this.mapHeight)
      // 地图左上角位置
      .offset({ x: this.mapX, y: this.mapY })
    }
    ....
    .onAreaChange((oldVal: Area, newVal: Area) => {
      if (this.screenMapWidth === 0 || this.screenMapHeight === 0) {
        // 获取地图父组件宽高
        this.screenMapWidth = Number(newVal.width);
        this.screenMapHeight = Number(newVal.height);
        // 初始化地图组件
        MapController.initMap(this);
      }
    })
    ...
  }
}
...

地图高度默认为地图父组件高度,地图宽度根据地图原始宽高比得出。

// MapController.ets
initMap(mapContext): void {
  this.mapContext = mapContext;
  this.mapContext.mapHeight = this.mapContext.screenMapHeight;
  // 计算地图宽度
  this.mapContext.mapWidth = Const.MAP_WIDTH * this.mapContext.mapHeight / Const.MAP_HEIGHT;
  // 设备屏幕默认显示地图中心位置
  this.mapContext.mapX = (this.mapContext.screenMapWidth - this.mapContext.mapWidth) / Const.DOUBLE_OR_HALF;
  // 计算地图左上角最大移动距离
  this.leftTop = [(this.mapContext.screenMapWidth - this.mapContext.mapWidth), 0];
}

5.2 搜索并展示指定类型地标

“地图浏览”完成地图初始化后,界面会默认展示查询搜索面板。用户通过搜索框输入指定类型进行搜索,也可以直接点击面板中已展示的类型进行搜索。

搜索面板为自定义组件CustomPanel,主要由展开/收起图标(Image组件)、搜索栏(Search组件)、地标网格(Grid组件)构成。用户点击地标网格中的网格、通过搜索框搜索指定类型或点击展开/收起图标,都会改变操作面板的显示状态(展示或隐藏)。通过属性动画(animation)可以实现操作面板的渐变过渡效果。

// MapComponent.ets
@Component
struct CustomPanel {
  @State positionList: Array<PositionItem> = PositionList;
  ...

  build() {
    Column() {
      Column() {
        Image(this.isDownImage ? $r('app.media.ic_panel_down') : $r('app.media.ic_panel_up'))
          .enabled(this.imageEnable)
          ...
      }
      .opacity(this.iconOpacity)
      ...

      Column() {
	    ...
        Grid() {
          ForEach(this.positionList, (item: PositionItem) => {
            GridItem() {
              PositionGridView({ positionItem: item })
                .enabled(this.imageEnable)
                ...
            }
          }, item => JSON.stringify(item))
        }
        ...
      }
      .opacity(this.panelOpacity)
      .height(this.panelHeight)
      .animation({
        duration: Const.ANIMATION_DURATION,
        curve: Curve.EaseOut,
        iterations: 1,
        playMode: PlayMode.Normal
      })
      ...
    }
    ...
  }

  upAndDown() {
    // 配合属性动画实现渐变过渡效果
    this.imageEnable = false;
    if (this.isDownImage) {
      this.panelOpacity = 0;
      this.panelHeight = 0;
      this.iconOpacity = Const.PANEL_LOW_OPACITY;
    } else {
      this.panelHeight = Const.PANEL_FULL_HEIGHT;
      this.panelOpacity = Const.PANEL_HIGH_OPACITY;
      this.iconOpacity = Const.PANEL_HIGH_OPACITY;
    }
    this.isDownImage = !this.isDownImage;
    this.imageEnable = true;
  }
}

通过搜索框或点击网格中的图标对地标进行搜索,并在地图上展示。每种类型的地标均有预置的经纬度数据,根据地标经纬度数据和地图组件宽高计算地标初始位置。

// MapModel.ets
// 根据坐标经纬度和地标信息初始化地标对象
calCoordinateByLonAndLat(geoCoordinates: Array<GeoCoordinates>, type: number, mapContext: any): AddressItem {
  this.mapContext = mapContext;
  this.data = undefined;
  if (!this.addressArray[type - 1]) {
    // 初始化地标对象
    this.addressArray[type - 1] = new AddressItem(
      mapLandmarksName[type - 1],
      mapLandmarksIcon[type - 1],
      // 根据经纬度计算地标在地图中的坐标
      this.initLocationData(geoCoordinates),
      mapLandmarksTextColor[type - 1]
    );
  }
  this.data = this.addressArray[type - 1];
  return this.data;
}

// MapModel.ets
// 根据地标经纬度和地图宽高获取地标在地图中的初始坐标值
initLocationData(geoCoordinates: Array<GeoCoordinates>): Array<Location> {
  let locations: Array<Location> = [];
  geoCoordinates.forEach((item: GeoCoordinates) => {
    // 根据经纬度和地图默认宽高计算地标在地图中的初始坐标值
    let pixelCoordinates = Geography.toPixelCoordinates(item.latitude, item.longitude);
    // 根据地图放大倍数计算地标在地图中的水平方向坐标值
    let positionX = pixelCoordinates.coordinateX * this.mapContext.mapWidth / MapController.mapMultiples() /
      Const.MAP_WIDTH;
    // 根据地图放大倍数计算地标在地图中的水平方向坐标值
    let positionY = pixelCoordinates.coordinateY / Const.MAP_HEIGHT *
      this.mapContext.mapHeight / MapController.mapMultiples();
    locations.push(new Location(positionX, positionY));
  })
  return locations;
}

根据获取地标的初始坐标值和地图放大倍数计算地标的的实际坐标值。

// MapModel.ets
calLandmarksPosition(): void {
  this.mapContext.data.locations = this.mapContext.data.locations.map((item: Location) => {
    item.positionX = item.oriPositionX * this.mapMultiples() -
      Const.MAP_LANDMARKS_SIZE * Const.MAP_ZOOM_RATIO / Const.DOUBLE_OR_HALF;
    item.positionY = item.oriPositionY * this.mapMultiples() -
      Const.MAP_LANDMARKS_SIZE * Const.MAP_ZOOM_RATIO;
    return item;
  })
}

地标初始化完成之后,默认将第一个地标展示在设备屏幕范围内(水平方向居中)。

// MapController.ets
setFirstLandmarksCenter(): void {
  let locations = this.mapContext.data.locations;
  if (locations.length > 0) {
    // 计算地图左上角水平方向坐标
    this.mapContext.mapX = this.mapContext.screenMapWidth / Const.DOUBLE_OR_HALF - locations[0].positionX;
    // 判断地图左上角是否超出边界
    if (this.mapContext.mapX > 0) {
      this.mapContext.mapX = 0;
    }
    if (this.mapContext.mapX < (this.mapContext.screenMapWidth - this.mapContext.mapWidth)) {
      this.mapContext.mapX = this.mapContext.screenMapWidth - this.mapContext.mapWidth;
    }
    // 判断地图右下角是否超出边界
    this.mapContext.mapY = this.mapContext.screenMapHeight / Const.DOUBLE_OR_HALF - locations[0].positionY;
    if (this.mapContext.mapY > 0) {
      this.mapContext.mapY = 0;
    }
    if (this.mapContext.mapY < (this.mapContext.screenMapHeight - this.mapContext.mapHeight)) {
      this.mapContext.mapY = this.mapContext.screenMapHeight - this.mapContext.mapHeight;
    }
  }
}

5.3 使用手势操作地图

操作地图的手势由捏合手势(PinchGesture)、点击手势(TapGesture)、平移手势(PanGesture)组合而成,可放大、缩小和移动地图。

// MapComponent.ets
build() {
  Stack({ alignContent: Alignment.BottomEnd }) {
    Column() {
      ...
    }
    ...
    // 地图父组件绑定手势事件
    .gesture(
      GestureGroup(GestureMode.Exclusive,
	// 捏合手势,放大缩小地图
        PinchGesture({ fingers: Const.MAP_FINGER_COUNT })
          .onActionUpdate((event: GestureEvent) => {
            MapController.pinchUpdate(event);
          })
          .onActionEnd(() => {
            MapController.pinchEnd();
          }),
	// 点击手势,放大地图
        TapGesture({ count: Const.MAP_FINGER_COUNT })
          .onAction(() => {
            MapController.tapAction();
          }),
	// 平移手势,拖动地图
        PanGesture(this.panOption)
          .onActionUpdate((event: GestureEvent) => {
            MapController.panUpdate(event);
          })
          .onActionEnd(() => {
            MapController.panEnd();
          })
      )
    )
    ...
  }
}

捏合手势是通过双指向外拨动放大地图、向内捏合缩小地图,每次将地图放大或缩小1.1倍,最多放大4次。当地图缩放到初始化时的大小后不再进行缩小,地图进行缩放时总是以地图所在屏幕中心位置进行缩放,效果如图所示:

// MapController.ets
pinchUpdate(event: GestureEvent): void {
  // 获取当前捏合手势的数值
  let scale = parseFloat(event.scale.toFixed(Const.MAP_SCALE_ACCURACY));
  let ratio = 1;
  // 防止手指一直按压屏幕操作
  if (this.previousScale !== scale) {
    // 向内捏合手势
    if (event.scale < 1) {
      ratio = 1 / Const.MAP_ZOOM_RATIO;
      this.pinchCount--;
    } else {
      // 向外拨动手势
      ratio = Const.MAP_ZOOM_RATIO;
      this.pinchCount++;
    }
    // 只允许放大4次
    if (this.pinchCount > Const.ZOOM_MAX_TIMES) {
      this.pinchCount = Const.ZOOM_MAX_TIMES;
      return;
    }
    // 防止无限缩小
    if (this.pinchCount < 0) {
      this.pinchCount = 0;
      return;
    }
    // 根据缩放倍数,实时计算地图宽高
    this.mapContext.mapWidth *= ratio;
    this.mapContext.mapHeight *= ratio;
    // 以当前设备中心对地图进行缩放
    let offsetX = (1 - ratio) * (this.mapContext.screenMapWidth /
      Const.DOUBLE_OR_HALF - this.mapContext.mapX);
    let offsetY = (1 - ratio) * (this.mapContext.mapHeight /
      Const.MAP_ZOOM_RATIO / Const.DOUBLE_OR_HALF - this.mapContext.mapY);
    this.mapContext.mapX += offsetX;
    this.mapContext.mapY += offsetY;
    // 重新计算地标的坐标值
    this.calLandmarksPosition();
    // 判断地图是否超出边界
    this.zoomOutCheck();
  }
  this.previousScale = scale;
}

地图缩放过程中根据当前地图宽高对地标进行位置偏移。

// MapController.ets
calLandmarksPosition(): void {
  this.mapContext.data.locations = this.mapContext.data.locations.map((item: Location) => {
    item.positionX = item.oriPositionX * this.mapMultiples() -
      Const.MAP_LANDMARKS_SIZE * Const.MAP_ZOOM_RATIO / Const.DOUBLE_OR_HALF;
    item.positionY = item.oriPositionY * this.mapMultiples() -
      Const.MAP_LANDMARKS_SIZE * Const.MAP_ZOOM_RATIO;
    return item;
  })
}

地图移动过程中根据左上角坐标判断是否超出临界点,若地图缩小超出临界点,就以临界点位置进行地图缩小。

// MapController.ets
zoomOutCheck(): void {
  if (this.mapContext.mapX > 0) {
    this.mapContext.mapX = 0;
  }
  if (this.mapContext.mapY > 0) {
    this.mapContext.mapY = 0;
  }
  if ((this.mapContext.mapX + this.mapContext.mapWidth) < this.mapContext.screenMapWidth) {
    this.mapContext.mapX = this.mapContext.screenMapWidth - this.mapContext.mapWidth;
  }
  if ((this.mapContext.mapY + this.mapContext.mapHeight) < (this.mapContext.mapHeight / this.mapMultiples())) {
    this.mapContext.mapY = this.mapContext.mapHeight / this.mapMultiples() - this.mapContext.mapHeight;
  }
}

点击手势通过双击地图组件放大地图(缩小地图只能通过捏合手势),每次将地图放大1.1倍,最多放大4次,地图进行放大时总是以地图所在屏幕中心位置进行放大,效果如图所示:

// MapController.ets
tapAction(): void {
  if (++this.pinchCount > Const.ZOOM_MAX_TIMES) {
    this.pinchCount = Const.ZOOM_MAX_TIMES;
    return;
  }
  this.mapContext.mapWidth *= Const.MAP_ZOOM_RATIO;
  this.mapContext.mapHeight *= Const.MAP_ZOOM_RATIO;
  let offsetX = (1 - Const.MAP_ZOOM_RATIO) *
    (this.mapContext.screenMapWidth / Const.DOUBLE_OR_HALF - this.mapContext.mapX);
  let offsetY = (1 - Const.MAP_ZOOM_RATIO) * (this.mapContext.mapHeight /
    Const.MAP_ZOOM_RATIO / Const.DOUBLE_OR_HALF - this.mapContext.mapY);
  this.mapContext.mapX += offsetX;
  this.mapContext.mapY += offsetY;
  // 重新计算地标的坐标
  this.calLandmarksPosition();
  // 计算地图左上角可移动范围
  let minX = (this.mapContext.screenMapWidth - this.mapContext.mapWidth);
  let minY = this.mapContext.mapHeight / this.mapMultiples() - this.mapContext.mapHeight;
  this.leftTop = [minX, minY];
}

平移手势通过单指拖动地图组件移动地图,当地图位于边界时,禁止拖动。效果如图所示:

// MapController.ets
// 根据手指滑动距离设置地图滑动距离
panUpdate(event: GestureEvent): void {
  let panX = parseInt(event.offsetX.toFixed(0));
  let panY = parseInt(event.offsetY.toFixed(0));
  if ((this.panX !== panX) || (this.panY !== panY)) {
    this.panCheck(panX, panY);
  }
  this.panX = panX;
  this.panY = panY;
}

// 计算地图滑动距离并判断临界点
panCheck(panX, panY): void {
  let mapPanX = panX - this.panX;
  let mapPanY = panY - this.panY;
  this.mapContext.mapX += mapPanX;
  this.mapContext.mapY += mapPanY;
  if (this.mapContext.mapX < this.leftTop[0]) {
    this.mapContext.mapX = this.leftTop[0];
  }
  if (this.mapContext.mapX > 0) {
    this.mapContext.mapX = 0;
  }
  if (this.mapContext.mapY < this.leftTop[1]) {
    this.mapContext.mapY = this.leftTop[1];
  }
  if (this.mapContext.mapY > 0) {
    this.mapContext.mapY = 0;
  }
}

六、区域导览

6.1 区域导览卡片

区域导览页展示了一个由各个区域导览卡片组成的可上下滑动的轮播图,效果如图所示:

轮播效果是Stack组件结合拖动手势来实现的,根据手势拖动的距离来判断是否需要切换图片,同时根据手势拖动的方向来决定是切换到上一张还是下一张图片。在图片切换的过程中,会根据图片和最上层图片的下标的差值,来计算它的模糊度、透明度、纵向偏移量、宽度等属性值。

// ZonesComponent.ets
Stack() {
  ForEach(this.zonesList, (item: ZonesItem, index: number) => {
    Row() {
      Image(item.thumbnail)
        ...
        .shadow({
          radius: Const.SHADOW_RADIUS,
          color: `rgba(0,0,0,0.3)`,
          offsetY: Const.SHADOW_RADIUS / Const.DOUBLE_NUM
        })
        // 图片透明度
        .opacity(1 - Math.min(ZoneConst.HALF_COUNT,
          Math.abs(this.getImgCoefficients(index))) * ZoneConst.OPACITY_COEFFICIENTS)
    }
    // 图片宽度
    .width(index != this.aheadIndex && this.getImgCoefficients(index) === 0 ? 
      Const.SWIPER_DEFAULT_WIDTH : 
      `${ZoneConst.ITEM_WIDTH - ZoneConst.OFFSET_COEFFICIENTS * Math.abs(this.getImgCoefficients(index))}%`)
    .offset({ x: 0, y: this.getOffSetY(index) })
    .zIndex(index != this.aheadIndex && this.getImgCoefficients(index) === 0 ? 
      0 : ZoneConst.HALF_COUNT - Math.abs(this.getImgCoefficients(index)))
    // 毛玻璃效果
    .blur(ZoneConst.OFFSET_COEFFICIENTS * Math.abs(this.getImgCoefficients(index)))
  }, (item: ZonesItem) => JSON.stringify(item))
}
.gesture(
  // 添加手势,通过手势实现图片的动态效果
  PanGesture({ direction: PanDirection.Vertical })
    .onActionStart((event: GestureEvent) => {
      this.changedIndex = false;
      this.handlePanGesture(event.offsetY);
    })
    .onActionUpdate((event: GestureEvent) => {
      this.handlePanGesture(event.offsetY);
    })
    .onActionEnd(() => {
      animateTo({
        duration: Const.SWIPER_DURATION,
      }, () => {
        this.marginBottom = 0;
      });
    })
...
)

// 计算图片和最上方图片的下标相对差值
getImgCoefficients(index: number): number {
  let coefficient = this.aheadIndex - index;
  let tempCoefficient = Math.abs(coefficient);
  if (tempCoefficient <= ZoneConst.HALF_COUNT) {
    return coefficient;
  }
  let dataLength = this.zonesList.length;
  let tempOffset = dataLength - tempCoefficient;
  if (tempOffset <= ZoneConst.HALF_COUNT) {
    if (coefficient > 0) {
      return -tempOffset;
    }
    return tempOffset;
  }
  return 0;
}

// 计算图片y轴方法的偏移量
getOffSetY(index: number): number {
  let offsetIndex = this.getImgCoefficients(index);
  let tempOffset = Math.abs(offsetIndex);
  let offsetY = this.marginBottom / (tempOffset + 1);
  if (tempOffset === 1) {
    offsetY += -offsetIndex * ZoneConst.MAX_OFFSET_Y;
  } else if (tempOffset === ZoneConst.HALF_COUNT) {
    offsetY += -offsetIndex * (ZoneConst.MAX_OFFSET_Y - ZoneConst.OFFSET_COEFFICIENTS);
  }
  return offsetY;
}

// 动态滚动切换最上方图片
startAnimation(isUp: boolean): void {
  animateTo({
    duration: Const.SWIPER_DURATION,
  }, () => {
    let dataLength = this.zonesList.length;
    let tempIndex = isUp ? this.aheadIndex + 1 : dataLength + this.aheadIndex - 1;
    this.aheadIndex = tempIndex % dataLength;
    this.marginBottom = 0;
  });
}

// 判断是否需要切换最上方图片
handlePanGesture(offsetY: number): void {
  if (Math.abs(offsetY) < ZoneConst.MAX_MOVE_OFFSET) {
    this.marginBottom = offsetY;
  } else {
    if (this.changedIndex) {
      return;
    }
    this.changedIndex = true;
    this.startAnimation(offsetY < 0);
  }
}

在区域导览页点击跳转到区域详情页时,使用pageTransition函数实现了界面跳转过程中的动画效果,效果如图所示:

为实现图中效果,需要在区域导览所在的@Entry界面和详情页所在的@Entry界面里使用pageTransition函数来改变他们入场和出场时的方向、缩放大小和透明度等。

// MainPage.ets
@Entry
@Component
struct MainPage {
  pageTransition() {
    PageTransitionEnter({ duration: Const.SHARED_DURATION })
      .slide(SlideEffect.Top);
    PageTransitionExit({ delay: Const.EXIT_DELAY })
      .opacity(0);
  }
}

// IntroductionPage.ets
@Entry
@Component
struct IntroductionPage {
  pageTransition() {
    PageTransitionEnter({ duration: Const.SHARED_DURATION })
      .slide(SlideEffect.Bottom)
      .scale({
        x: 0,
        y: 0,
        z: 0,
        centerX: Const.HALF_PERCENT,
        centerY: Const.HALF_PERCENT
      });
    PageTransitionExit({ delay: Const.SWIPER_DURATION })
      .slide(SlideEffect.Bottom)
      .scale({
        x: 0,
        y: 0,
        z: 0,
        centerX: Const.HALF_PERCENT,
        centerY: Const.HALF_PERCENT
      });
  }
}

6.2 区域详情页

区域详情页包括首图轮播、滑动缩放、标题吸顶以及图片查看等功能,效果如图所示:

首先通过imageHeight属性设置轮播的启用与禁用,imageHeight为全屏时轮播启用,左右滑动可以查看不同区域的介绍信息,当imageHeight小于全屏时轮播禁用。

// SwiperListItem.ets
@Component
export default struct SwiperListItem {
  @Prop imageHeight: number;
  ...

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Swiper(this.swiperController) {
        ...
      }
      ...
      .indicator(this.imageHeight < CommonConstants.FULL_PERCENT_NUMBER ? false : new DotIndicator())
      .disableSwipe(this.imageHeight < CommonConstants.FULL_PERCENT_NUMBER ? true : false)
    }
  }
}

接着根据List触屏滑动的偏移量,计算滑动缩放的比例。onScrollFrameBegin方法接收offset参数,返回List的实际偏移量。其中offset大于0为向上滑动,图片缩小;小于0为向下滑动,图片放大。

// IntroductionPage.ets
import { Animation } from '../common/utils/Animation';
...

@Entry
@Component
struct IntroductionPage {
  @State listPosition: number = Const.LIST_POSITION_BEGIN;
  @State imageHeight: number = Const.FULL_PERCENT_NUMBER;
  @State arrowIconOpacity: number = Const.OPACITY_MAX;
  ...

  build() {
    Column() {
      Stack({ alignContent: Alignment.Bottom }) {
        Column() {
          List({ scroller: this.scrollerForList }) {
            ...
          }
          .onScrollFrameBegin((offset: number, state: ScrollState) => {
            let realOffset = Animation.controlImageScale.call(this, offset, state);
            return { offsetRemain: realOffset };
          })
          ...
        }
      }
    }
  }
}

在滑动缩放过程中,为避免首图滑出屏幕顶端,需要设置List实际偏移量为0。并且基于offset值实时更新imageHeight与arrowIconOpacity实现缩放。

// Animation.ets
import { Const} from '../constants/CommonConstants';

export class Animation {
  public static controlImageScale(this, offset: number, state: ScrollState): number {
    if ((offset > 0) && (this.imageHeight > CommonConstants.MIN_IMAGE_HEIGHT)) {
      // 图片缩小逻辑
      let offsetHeight = (Math.abs(offset) * CommonConstants.FULL_PERCENT_NUMBER) / Number(this.screenHeight);
      let heightOffset = this.imageHeight - CommonConstants.MIN_IMAGE_HEIGHT > offsetHeight ?
        offsetHeight : this.imageHeight - CommonConstants.MIN_IMAGE_HEIGHT;
      this.imageHeight = this.imageHeight - heightOffset;
      this.arrowIconOpacity = this.arrowIconOpacity -
        heightOffset / (CommonConstants.FULL_PERCENT_NUMBER - CommonConstants.MIN_IMAGE_HEIGHT);
      // 返回实际偏移量0
      return 0;
    }
    if ((this.listPosition === CommonConstants.LIST_POSITION_BEGIN) && (offset < 0)
      && (this.imageHeight < CommonConstants.FULL_PERCENT_NUMBER)) {
      // 图片放大逻辑
      let offsetHeight = (Math.abs(offset) * CommonConstants.FULL_PERCENT_NUMBER) / Number(this.screenHeight);
      let heightOffset = CommonConstants.FULL_PERCENT_NUMBER - this.imageHeight > offsetHeight ?
        offsetHeight : CommonConstants.FULL_PERCENT_NUMBER - this.imageHeight;
      this.imageHeight = this.imageHeight + heightOffset;
      this.arrowIconOpacity = this.arrowIconOpacity +
        heightOffset / (CommonConstants.FULL_PERCENT_NUMBER - CommonConstants.MIN_IMAGE_HEIGHT);
      // 返回实际偏移量0
      return 0;
    }
    ...
    // 返回传参offset,此时缩放过程完毕
    return offset;
  }
}

介绍信息包括风格、建筑以及地理位置三部分,其中标题部分吸顶,并且需要根据滑动偏移量更新图标。通过scaleIcon数组设置不同标题图标的缩放比,在changeTitleIcon方法中基于偏移量改变scaleIcon数组的值。

// IntroductionPage.ets
import { Animation } from '../common/utils/Animation';
...
@Entry
@Component
struct IntroductionPage {
  @State scaleIcon: Array<number> = [Const.OPACITY_MAX, Const.OPACITY_MIN,
    Const.OPACITY_MIN];
  ...

  @Builder StickyHeader() {
    Column() {
      ...
    }
  }

  @Builder StickyFooter() {
    Column()
      .height($r('app.float.introduction_page_padding_bottom'))
    }
  }

  build() {
    Column() {
      Stack({ alignContent: Alignment.Bottom }) {
        Column() {
          List({ scroller: this.scrollerForList }) {
            ...
          }
          .onScroll(() => {
            this.startAnimation();
          })
        }
      }
    }
  }

  // 标题图标动画部分
  startAnimation() {
    Animation.changeTitleIcon.apply(this);
  }
  ...
}

计算offsetSum整体偏移量,当滑动到相应介绍模块时改变iconTitle与scaleIcon。

// Animation.ets
import { CommonConstants as Const} from '../constants/CommonConstants';

export class Animation {
  public static changeTitleIcon(this) {
    this.offsetSum = this.scrollerForList.currentOffset().yOffset;
    ...

    // 滑动设置listPosition标注当前所在listitem的索引
    if (this.offsetSum > Const.OFFSET_SUM_THRESHOLD) {
      this.listPosition = Const.LIST_POSITION_MIDDLE;
    }

    if (this.offsetSum > firstStarLine + Const.FIXED_ITEM_HEIGHT / 2) {
      this.geographicPicType = Const.GEOGRAPHY_LIGHT;
    }

    if (this.offsetSum <= firstStarLine + Const.FIXED_ITEM_HEIGHT / 2) {
      this.geographicPicType = Const.GEOGRAPHY_DARK;
    }
    // 基于总偏移量更新scaleIcon与iconTitle属性
    if ((this.currentListIndex === 2) && (this.offsetSum <= firstStarLine)) {
      this.iconTitle = Const.ICON_SUBTITLE_ARRAY[0];
      this.scaleIcon = [Const.OPACITY_MAX, Const.OPACITY_MIN, Const.OPACITY_MIN];
    }
    if ((this.currentListIndex === 2) && (this.offsetSum > firstStarLine) && (this.offsetSum < secondStarLine)) {
      this.iconTitle = Const.ICON_SUBTITLE_ARRAY[1];
      this.scaleIcon = [Const.OPACITY_MIN, Const.OPACITY_MAX, Const.OPACITY_MIN];
    }
    if ((this.currentListIndex === 2) && (this.offsetSum >= secondStarLine) && (this.offsetSum < thirdStarLine)) {
      this.iconTitle = Const.ICON_SUBTITLE_ARRAY[2];
      this.scaleIcon = [Const.OPACITY_MIN, Const.OPACITY_MIN, CommonConstants.OPACITY_MAX];
    }
  }
  ...
}

6.3 查看大图

在区域详情页的“设计风格”部分,提供了一个可以左右滑动查看的图库,展示了该区域的建筑风貌。点击当前展示的图片,将打开可以查看大图的弹窗,可以左右滑动查看对应图片的高清大图,支持双指缩放和拖动。

ImageAnimate自定义组件提供了左右滑动查看的图库功能,为了实现查看大图的弹窗,在此组件内添加自定义弹窗控制器,并通过点击事件打开弹窗。

// ImageAnimate.ets
// 导入自定义弹窗组件
import { ImageView } from '../view/ImageViewComponent';

@Component
export default struct ImageAnimation {
  // 添加自定义弹窗控制器
  dialogController: CustomDialogController = new CustomDialogController({ 
    builder: ImageView({ currentImageId: this.currentIndex }),
    customStyle: true,
    alignment: DialogAlignment.Bottom,
  });

  build(){
    Stack() {
      ForEach(this.introductionData.imageList, (item: ResourceStr, index: number) => {
        Row() {
          ...
        }
        .onClick(() => {
          // 打开弹窗
          this.dialogController.open();
        })
      }, item => JSON.stringify(item))
    }
  }
}

在自定义弹窗中,使用Swiper组件实现滑动查看大图的功能,使用Image组件通过控制组件大小实现缩放功能。两者作为Stack的子组件,通过手势识别判断当前是滑动查看不同大图的操作还是图片缩放和拖动的操作,控制对应组件的显隐,来实现对应功能。

// ImageViewComponent.ets
@CustomDialog
export struct ImageView {
  build() {
    Stack(){
      Swiper() {
        ForEach(this.introductionData.imageList, (item: Resource) => {
          Column() {
            Blank()
              .onClick(() => {
                // 使用Blank组件填充空白处,点击可以关闭弹窗
                this.controller.close();  
              })

            Image(item)
              .gesture(
                // 触发捏合手势
                PinchGesture()
                  .onActionStart(() => {
                    // 识别到手势操作,将isGesture设为true
                    this.isGesture = true;
                  })
                  .onActionUpdate((event: GestureEvent) => {
                    // 随着捏合操作的过程,逐渐调整图片缩放倍数
                    this.imgScale = this.curScale * event.scale;
                  })
                  .onActionEnd(() => {
                    // 为了避免图片无限缩放,在捏合操作结束时控制缩放倍数的值
                    this.limitScale(false);
                  })
              )
              // 根据isGesture的值来控制显隐
              .visibility(this.isGesture ? Visibility.Hidden : Visibility.Visible)

            Blank()
              .onClick(() => {
                this.controller.close();
              })
          }
          ...
        }, item => JSON.stringfy(item))
      }
      ...
      .onChange((index: number) => {
        // swiper滑动时记录切换的index
        this.curIndex = index;
      })

      Row() {
        // 根据切换的index显示对应的图片
        Image(this.introductionData.imageList[this.curIndex])
          .objectFit(ImageFit.Contain)
          // 控制图片缩放倍数
          .scale({ x: this.imgScale, y: this.imgScale }) 
          // 控制图片偏移值
          .translate({ x: this.imgOffsetX, y: this.imgOffsetY })
          .onComplete((event) => {
            this.imgWidth = event.width;
            this.imgHeight = event.height;
            // 根据图片宽高计算图片不缩放时的实际高度
            this.displayHeight = this.deviceWidth * this.imgHeight / this.imgWidth;
          })
      }
      .gesture(
        // 触发捏合手势
        PinchGesture()
          .onActionUpdate((event: GestureEvent) => {
            // 随着捏合操作的过程,逐渐调整图片缩放倍数
            this.imgScale = this.curScale * event.scale;
          })
          .onActionEnd(() => {
            // 为了避免图片偏移超出屏幕边界,检测到偏移值到达最大时停止继续偏移
            this.detectBoundary();
            // 为了避免图片无限缩放,在捏合操作结束时控制缩放倍数的值,并重置偏移值
            this.limitScale(true);
          })
      )
      .gesture(
        // 触发拖动手势
        PanGesture()
          .onActionStart(() => {
            // 记录先前的偏移值
            this.preOffsetX = this.imgOffsetX;
            this.preOffsetY = this.imgOffsetY;
          })
          .onActionUpdate((event: GestureEvent) => {
            // 随着拖动操作的过程,不断改变图片偏移值
            this.imgOffsetX = this.preOffsetX + event.offsetX;
            this.imgOffsetY = this.preOffsetY + event.offsetY;
          })
          .onActionEnd(() => {
            // 为了避免图片偏移超出屏幕边界,检测到偏移值到达最大时停止继续偏移
            this.detectBoundary();
          })
      )
      // 根据isGesture的值来控制显隐
      .visibility(this.isGesture ? Visibility.Visible : Visibility.Hidden)
    }
    ...
  }
}

七、小火车

小火车模块提供了溪村小镇小火车的相关信息,主要功能如下:

  1. 查看溪村小火车三条路线的概览图。
  2. 点击对应路线展示当前时间每条路线上小火车运营状态、时刻表、所在的位置、运动方向、及实时运动轨迹。效果如图所示:

注意:非运营时间内,不展示路线图。

7.1 页面布局

主页面以Navigation组件作为根组件,可直接设置标题,上方Image组件展示小火车的路线概览图,下方List组件循环展示三条路线的信息及火车轨迹动图。

// TrainsComponent.ets
build() {
  Navigation() {
    Column({ space: Const.TRAIN_SPACE }) {
      Image($r('app.media.ic_train_map'))
        .aspectRatio(Const.TRAIN_ASPECT_RATIO)
        .objectFit(ImageFit.Cover)
        .borderRadius(Const.TRAIN_BORDER_RADIUS)

      List({ space: Const.TRAIN_SPACE }) {
        ForEach(this.trainsMapData, (item: TrainsMap, index: number) => {
          ListItem() {
            Column({ space: Const.TRAIN_SPACE }) {
	      ...
              if (this.fetchShowMap(index)) {
                // 小火车运行轨迹组件
                TrainsTrack({ trainsInfo: this.trainsMapData[index], trainLine: index })
              }
            }
            ...
          }
        }, item => JSON.stringify(item))
      }
      .layoutWeight(1)
      .edgeEffect(EdgeEffect.None)
    }
    .padding({ left: Const.TRAIN_PADDING_LEFT, right: Const.TRAIN_PADDING_RIGHT })
    .height(Const.FULL_SIZE)
    .backgroundColor($r("app.color.train_background"))
  }
  .title(Const.TRAIN_TITLE)
  .titleMode(NavigationTitleMode.Full)
  .hideToolBar(true)
  .hideBackButton(true)
  .backgroundColor($r("app.color.train_background"))
}

火车轨迹更新页面封装在子组件TrainsTrack.ets中,以Stack组件为根组件,地图背景使用Canvas组件绘制,小火车的箭头使用Image组件定位绘制,小火车图标的其他地方均使用第二个Canvas组件绘制。

// TrainsTrack.ets
build() {
  Stack() {
    // 小火车运行背景轨迹
    Canvas(this.context)
      .width(Const.FULL_SIZE)
      .aspectRatio(Const.CANVAS_ASPECT_RADIO)
      .borderRadius(Const.CANVAS_BORDER_RADIUS)
      .onReady(() => {
        ...
      })

    Image(Const.ARROW_URL)
      .width(Const.ARROW_WIDTH)
      .height(Const.ARROW_HEIGHT)
      .position({
        x: this.trainX + this.bgX - Const.ARROW_OFFSET_X,
        y: this.trainY + this.bgY - Const.ARROW_OFFSET_Y
      })
      .rotate({
        x: 0,
        y: 0,
        z: 1,
        angle: this.rotateAngle
      })
    
    // 小火车导航图
    Canvas(this.contextTrainLine)
      .width(Const.FULL_SIZE)
      .aspectRatio(Const.CANVAS_ASPECT_RADIO)
      .borderRadius(Const.CANVAS_BORDER_RADIUS)
      .onReady(() => {
        ...
      })
  }
}

7.2 初始化小火车信息

点击对应路线的小火车,会显示或隐藏小火车的运动轨迹,当首次显示小火车的运动轨迹时,需要初始化一些信息,包括小火车当前时间运动的位置、背景区域的位置、小火车的轨迹更新时间等。

在子组件的aboutToAppear中,调用自定义的初始化方法calcDistance,方法接收一个参数即小火车的运动坐标点数组,根据路线参数不同可以获取不同路线小火车运动一周的总路程。

// TrainsTrack.ets
aboutToAppear() {
  this.sumDistance = TrainsMapModel.calcDistance(this.trainsInfo.lineData);
  ...
}
// TrainsMapModel.ets
calcDistance(data: Position[]): number {
  let sumDistance: number = 0;
  const length = data.length;
  data.forEach((item: Position, index: number) => {
    const startX = item.x;
    const startY = item.y;
    const endX = index === length - 1 ? data[0].x : data[index + 1].x;
    const endY = index === length - 1 ? data[0].y : data[index + 1].y;
    if (Math.abs(startX - endX) >= Math.abs(startY - endY)) {
      sumDistance += Math.abs(startX - endX);
    } else {
      sumDistance += Math.abs(startY - endY);
    }
  })
  return sumDistance;
}

获取总路程后,通过获取当前的时间、小火车的出发时间、以及始发站坐标,计算小火车在当前时间从始发站开始走过的距离。

// TrainsMapModel.ets
travelDistance(distance: number, startTime: string, line: number): number {
  let ret: number = 0;
  const date = new Date();
  const dateStr = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} `;
  const startDate = new Date(dateStr + startTime).getTime();
  const currentDate = date.getTime();
  const diff = currentDate - startDate;

  switch (line) {
    case TrainsLine.LINE_ONE:
      ret = distance * (diff % (Const.LINE_ONE_TIME)) / (Const.LINE_ONE_TIME);
      break;
    case TrainsLine.LINE_TWO:
      ret = distance * (diff % (Const.LINE_TWO_TIME)) / (Const.LINE_TWO_TIME);
      break;
    case TrainsLine.LINE_THREE:
      ret = distance * (diff % (Const.LINE_THREE_TIME)) / (Const.LINE_THREE_TIME);
      break;
    default:
      ret = 0;
  }
  return Number(ret.toFixed(2));
}

在上一个方法中获取到小火车从始发站走过的距离,再通过路线坐标点可以得到当前小火车在路线图上的最近坐标位置。

// TrainsTrack.ets
calcFirstDistance(data: Position[], travelDistance: number) {
  let sumDistance: number = 0;
  const length = data.length;

  for (let index = 0;index < length; index++) {
    if (sumDistance > travelDistance) {
      this.currentIndex = index - 1;
      this.trainX = data[index - 1].x;
      this.trainY = data[index - 1].y;
      this.calcPosition(this.trainX, this.trainY, this.canvasWidth, this.canvasHeight);
      break;
    } else {
      const startX = data[index].x, startY = data[index].y;
      const endX = index === length - 1 ? data[0].x : data[index+1].x,
        endY = index === length - 1 ? data[0].y : data[index+1].y;
      if (Math.abs(startX - endX) >= Math.abs(startY - endY)) {
        sumDistance += Math.abs(startX - endX);
      } else {
        sumDistance += Math.abs(startY - endY);
      }
    }
  }
}

小火车的路线与运行一圈的时间均不相同,但是每次更新位置的距离都是1vp,所以需要计算每条路线小火车更新轨迹的时间间隔,这里计算时间间隔通过总距离/运行一圈时间获得。

// TrainsMapModel.ets
calcDelay(distance: number, line: number): number {
  let ret: number = 0;
  switch (line) {
    case TrainsLine.LINE_ONE:
      ret = Const.LINE_ONE_TIME / distance;
      break;
    case TrainsLine.LINE_TWO:
      ret = Const.LINE_TWO_TIME / distance;
      break;
    case TrainsLine.LINE_THREE:
      ret = Const.LINE_THREE_TIME / distance;
      break;
    default:
      ret = 0;
  }
  return ret;
}


7.3 更新小火车轨迹

根据初始化计算的更新时间及下次小火车即将到达的地点,更新小火车的位置,同时计算更新底层地图的显示位置。

通过setInterval函数,每隔一段时间重新清除画布上的小火车,并重新绘制小火车,包括位置、方向等。

// TrainsTrack.ets
drawTrainPosition() {
  if (Math.abs(this.trainX - this.positionEnd.x) <= Const.AVERAGE_ERROR &&
  Math.abs(this.trainY - this.positionEnd.y) <= Const.AVERAGE_ERROR) {
    this.trainX = this.positionEnd.x;
    this.trainY = this.positionEnd.y;
    if (this.currentIndex === this.trainsInfo.lineData.length - 2) {
      this.currentIndex = 0;
    } else {
      this.currentIndex += 1;
    }
  }
  this.positionStart = this.trainsInfo.lineData[this.currentIndex];
  this.positionEnd = this.trainsInfo.lineData[this.currentIndex + 1];
  this.rotateAngle = Const.BASIC_ROTATE_ANGLE + TrainsMapModel.fetchDirection(this.positionStart.x, this.positionStart.y, this.positionEnd.x, this.positionEnd.y);

  this.contextTrainLine.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.trainX += TrainsMapModel.fetchRatioX(this.positionStart, this.positionEnd);
  this.trainY += TrainsMapModel.fetchRatioY(this.positionStart, this.positionEnd);
  this.calcPosition(this.trainX, this.trainY, this.canvasWidth, this.canvasHeight);
  this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.context.drawImage(this.trainsInfo.imgBg, this.bgX, this.bgY, Const.CANVAS_WIDTH, Const.CANVAS_HEIGHT);
  this.contextTrainLine.drawImage(
    this.imgTrain,
    this.trainX + this.bgX - Const.TRAIN_OFFSET_X,
    this.trainY + this.bgY - Const.TRAIN_OFFSET_Y,
    Const.TRAIN_WIDTH,
    Const.TRAIN_HEIGHT
  );
  this.contextTrainLine.drawImage(
    this.imgCircle,
    this.trainX + this.bgX - Const.CIRCLE_OFFSET_X,
    this.trainY + this.bgY - Const.CIRCLE_OFFSET_Y,
    Const.CIRCLE_WIDTH,
    Const.CIRCLE_HEIGHT
  );
} 

build() {
  Stack() {
    ...
    Canvas(this.contextTrainLine)
      .width(Const.FULL_SIZE)
      .aspectRatio(Const.CANVAS_ASPECT_RADIO)
      .borderRadius(Const.CANVAS_BORDER_RADIUS)
      .onReady(() => {
        ...
        setInterval(() => {
          this.drawTrainPosition();
        }, this.delay)
      })
  }
}

每次需要计算小火车横向和竖向运动的距离,这里我们以每次运动1vp为单位,如果当前坐标相对于下次坐标,横向运动距离更长,为了保持匀速,那么竖向运动距离需要计算一个运动比例,反之一样。

// TrainsMapModel.ets
fetchRatioX(start: Position, end: Position): number {
  let diffX = start.x - end.x;
  let diffY = start.y - end.y;
  if (Math.abs(diffX) >= Math.abs(diffY)) {
    return -diffX / Math.abs(diffX);
  } else {
    return -Number((diffX / Math.abs(diffY)).toFixed(2));
  }
}

fetchRatioY(start: Position, end: Position): number {
  let diffX = start.x - end.x;
  let diffY = start.y - end.y;
  if (Math.abs(diffY) >= Math.abs(diffX)) {
    return -diffY / Math.abs(diffY);
  } else {
    return -Number((diffY / Math.abs(diffX)).toFixed(2));
  }
}

小火车图标包含方向箭头,默认是指向上方,当小火车每次运动的时候,需要更新小火车的箭头方向,通过计算开始坐标与结束坐标的比例,计算旋转角度。

// TrainsMapModel.ets
fetchDirection(startX: number, startY: number, endX: number, endY: number): number {
  let ret;
  if (startX === endX) {
    return startY > endY ? 0 : Const.DIRECTION * 2;
  }
  if (startY === endY) {
    return startX > endX ? -Const.DIRECTION : Const.DIRECTION;
  }
  let angle = this.calcAngle(startX, startY, endX, endY);
  if (startX > endX && startY > endY) {
    ret = -(Const.DIRECTION - angle);
  }
  if (startX > endX && startY < endY) {
    ret = -Const.DIRECTION - angle;
  }
  if (startX < endX && startY > endY) {
    ret = Const.DIRECTION - angle;
  }
  if (startX < endX && startY < endY) {
    ret = Const.DIRECTION + angle;
  }
  return ret;
}

calcAngle(startX: number, startY: number, endX: number, endY: number): number {
  const x = Math.abs(startX - endX);
  const y = Math.abs(startY - endY);
  const radianA = Math.atan(y / x);
  const angleA = Math.round(Const.PI_ANGLE / Math.PI * radianA);
  return angleA;
}

因为小火车是实时运动的,下方的轨迹地图比展示区域要大很多,所以当小火车即将运动出显示范围的时候,需要实时更新下方地图的位置,保证小火车一直在展示区域的轨迹上运动。

// TrainsTrack.ets
calcPosition(x, y, w, h) {
  if (x + this.bgX > w - Const.HORIZONTAL_THRESHOLD) {
    this.bgX = Math.abs(this.bgX - w / 2) > Const.CANVAS_WIDTH - w ? -Const.CANVAS_WIDTH + w : this.bgX - w / 2;
  }

  if (x + this.bgX < Const.HORIZONTAL_THRESHOLD) {
    this.bgX = Math.abs(this.bgX + w / 2) < 0 ? 0 : this.bgX + w / 2;
  }

  if (y + this.bgY > h - Const.VERTICAL_THRESHOLD) {
    this.bgY = Math.abs(this.bgY - h / 2) > Const.CANVAS_HEIGHT - h ? -Const.CANVAS_HEIGHT + h : this.bgY - h / 2;
  }

  if (y + this.bgY < Const.VERTICAL_THRESHOLD) {
    this.bgY = Math.abs(this.bgY + h / 2) < 0 ? 0 : this.bgY + h / 2;
  }
}

八、总结

您已经完成了本次Codelab的学习,并了解到以下知识点:

  1. 实现页面跳转和自定义弹窗。
  2. 识别用户操作手势并触发相应事件。
  3. 添加页面动画效果。
  4. 使用画布组件自定义绘制图形。
VLOOKUP_fixed.py 立即下载

相关推荐

YOLO26算法心电图十二导联目标检测+训练好的模型+368张数据集+pyqt可视化界面.zip

附训练好的 YOLO 权重与 PyQt 可视化检测界面,页面底部含可视化效果预览,毕设/课设开箱即用。 【数据集概况】 · 检测类别(中文):[导联I(I), 导联II(II), 导联III(III), 胸导联V1(V1), 胸导联V2(V2), 胸导联V3(V3), 胸导联V4(V4), 胸导联V5(V5), 胸导联V6(V6), 加压肢体导联aVF(aVF), 加压肢体导联aVL(aVL), 加压肢体导联aVR(aVR)] · 训练集:313 张 · 验证集:37 张 · 测试集:18 张 · 总计:368 张 该数据集聚焦于临床心电图信号的精准识别与定位,其核心检测目标为标准12导联心电图中的各个独立导联波形区域,涵盖肢体导联(I、II、III)与加压肢体导联(aVR、aVL、aVF),以及胸前导联(V1至V6),适用于心电图自动分析系统中对各导联位置的快速准确标注与分类。数据采集场景真实还原了医院心电图室或急诊监护环境下的标准心电图记录纸,波形清晰、基线稳定,具有高度临床参考价值。... 【训练曲线与评估图】 【模型训练配置】 参数 | 值 模型 | yolo26n 训练轮数 | 100 epochs 输入尺寸 | 640x640 批次大小 | 24 优化器 | auto 初始学习率 | 0.01 训练设备 【关键指标汇总】 训练了 57 个 epoch,最终轮指标: 指标 | 数值 mAP50 | **0.9708** mAP50-95 | 0.6344 Precision | 0.8990 Recall | 0.9191 train/box_loss | 1.2231 train/cls_loss | 0.9703 val/box_loss | 1.2779 val/cls_loss | 0.... 使用场景:毕业设计、课程设计、深度学习入门、工业场景落地验证。

开源 Agent 小屋

知乎:何枝地址:https://zhuanlan.zhihu.com/p/9096314010Live Demo(网站在进入前可能会加载一段时间,需要等一等)人物观测:Agent Life Live Demo[1]行为统计:Agent Life Action Logging Board[2]去年第一次看到 斯坦福小镇[3] 时,大概是骨子里喜欢模拟类游戏的原因,当时激动了好久好久,后来 AI Town[4]出现,允许注册玩家进入到虚拟小镇里和里面的 NPC 对话,又把这类玩法拔了一个高度。斯坦福小镇(图左)

AIBigModel的博客 1678

企业级AI助手平台 开箱即用的全栈AI平台,支持多智能体协同、Supervisor模式编排、多种决策模式、RAG技术和流程编排能力 支持多厂商大模型统一接入与管理,安全可控的企业知识库与高精度检索优化

企业级AI应用开发框架,支持多厂商大模型统一接入与管理,安全可控的企业知识库与高精度检索优化能力,提供可视化流程编排、自主决策智能体与多智能体协同调度,兼容主流 Agent Skill 协议,帮助企业与开发者零门槛快速构建安全、高效、可落地的AI智能体应用与行业解决方案。

【HarmonyOS NEXT】codelab汇总-应用服务

Game Service Kit(游戏服务)主要提供快速、低成本构建游戏基本能力与游戏场景优化服务,有效提升游戏开发效率,帮助您进行游戏运营。

qq_45789342的博客 1584

基于动态窗口法DWA的机器人路径规划研究(Matlab代码实现)

内容概要:本文系统研究了基于动态窗口法(DWA)的机器人局部路径规划算法,深入剖析其在动态环境中实现自主避障与实时轨迹优化的核心机制。文章详细阐述了DWA算法的速度空间采样策略、轨迹预测模型、动力学约束处理及多目标代价函数的设计原理,并通过构建完整的Matlab仿真框架验证算法有效性。结合典型场景的仿真实验,展示了机器人在复杂障碍物环境下如何实时生成安全、平滑且符合运动学约束的最优路径,充分体现了DWA在移动机器人导航中的实用性与鲁棒性。; 适合人群:具备机器人运动规划基础知识和Matlab编程能力,从事智能移动机器人、自动驾驶或服务机器人研发的研究生、科研人员及工程技术人员。; 使用场景及目标:①深入理解DWA算法的理论基础与实现细节;②掌握机器人局部路径规划的仿真实现方法;③为实际机器人系统的导航模块开发提供可复用的算法原型与代码参考;④作为进一步研究高级导航算法(如TEB、MPC)的技术基础。; 阅读建议:建议结合提供的Matlab代码逐模块分析与调试,重点关注速度采样、轨迹积分、障碍物距离计算及代价评估等关键环节,通过调整参数在不同地图环境中进行对比实验,以深刻把握算法性能与参数之间的关系,进而提升对实时路径规划系统设计的理解与应用能力。

本地使用大模型:Ollama+open-webui+AnythingLLM(Windows+linux)

随着大模型的发展,大量的开源模型可以被直接使用。大模型的使用可以选择在云端或者在本地,如果本地存储空间尚可,而且模型需求不是很大的话,我们可以选择在本地部署一个大模型来使用。针对特定的任务需求,进一步在本地去微调我们的大模型。我在windows和linux上都尝试了一遍,这里记录一下过程。

wpqgt的博客 2165

华为昇腾910B4(A2)服务器环境搭建笔记

Qwen3.5 默认支持思考模式。首次测试建议关闭思考模式,更容易判断模型是否成功回答。我建议把模型统一存放在宿主机的 /data/models 目录中。这样即使删除 Docker 容器,模型文件也不会丢失。这里先进行单卡测试,确保容器能正确调用 NPU。安装ModelScope 用于下载模型。继续在刚才的容器内部执行。

guoqingru0311的博客 296

RedVQA:轻量级无参考视频质量评估算法原理与工程实践

视频质量评估是计算机视觉领域的关键技术,旨在通过算法自动量化视频的观看体验。其核心原理在于模拟人眼视觉系统,从像素数据中提取并融合空域和时域特征,以预测主观质量分数。这项技术的价值在于,它能自动化处理海量视频内容的质量监控,为流媒体服务、移动设备影像优化等场景提供关键指标。无参考视频质量评估作为该领域的前沿方向,无需原始无损视频作为参考,更贴合实际应用。本文以RedVQA项目为例,深入剖析了如何利用轻量化的深度网络架构,如MobileNetV3,结合高效的时空特征融合模块,在保证评估精度的同时,大幅降低计算

weixin_28698129的博客 575

IPD咨询洞察:华为项目文化:让胜利成为一种信仰

华为“让胜利成为信仰”的本质是使命必达,落地为五项内核:开放创新、勇于担当、团队协同、契约精神、善思善结。使命必达不能停留在墙面标语,须转化为可落地、可考核的行为准则,胜利才能真正成为团队习惯。

weixin_58096508的博客 103

SAP WM模块事务码(TCODE)大全

OL07 Data transfer stor.bins LDK34->RLPLA 仓储位LDK34 ->RLPLA

matinal 當冬夜漸暖 。公众号:matinal 5845

用Python可视化硅晶体生长:3D图解<100>/<110>/<111>晶向差异

本文通过Python 3D可视化技术,详细解析了硅晶体生长中<100>、<110>、<111>三种关键晶向的差异及其在半导体工业中的应用。从密勒指数转换到原子密度计算,结合交互式3D模型展示不同晶向的原子排列特性,帮助读者深入理解晶向对芯片性能的影响。

weixin_42567752的博客 567

手头只有华为配置,新设备是华三?粘进来自动翻

手头有华为配置,新设备是华三?用「跨厂商转换」工具,粘贴配置后自动识别源厂商,一键转成目标厂商语法,支持华为、华三、锐捷、迈普、思科等主流设备。精准处理VLAN、access/trunk口、SVI IP、VTY/SSH等差异,避免手动修改时漏改“拼写级”错误。转换后需人工核对核心项与独有特性,建议结合备份、比对与模拟验证,确保安全上机。

qq_42614669的博客 389

工控上位机开发学习路线与核心技术解析

工控上位机开发是工业自动化领域的关键技术,通过计算机软件实现对工业设备的监控与控制。其核心在于掌握工业通信协议(如Modbus、OPC UA)、数据处理算法和可视化开发三大能力。上位机系统需要高效解析设备通信协议,处理实时数据,并提供直观的人机交互界面。在智能制造浪潮下,掌握C#语言和工业协议开发能力成为工程师的核心竞争力。典型应用场景包括生产线监控、设备远程运维等。本文基于工业现场实践经验,详解从协议解析到界面优化的全链路开发要点,特别针对Modbus协议调优、WPF性能优化等高频问题提供解决方案。

weixin_30376083的博客 550

【2026-11-06】华为研发机试三道编程题解

本文整理了华为11月6日研发岗三道编程题解法:第一题为按列填充句子,通过枚举行数模拟列宽计算,求最小行数;第二题为生鲜库存优化,利用动态分配思想,预处理“采购-销售”成本,贪心选择最便宜来源满足需求;第三题涉及AC自动机与动态规划结合的序列化奖励问题。代码均以Java实现,思路清晰,适合笔试刷题参考。关注公众号【TechGuide】获取更多真题解析。

试问道几许 360

服务器AutoDL自己部署QWen3

说明:核心参数:30B 模型原精度权重文件约 60GB,单张 PRO 6000 拥有 48GB 显存,单卡塞不下原精度模型,2张 PRO 6000 = 96GB 总显存,这不仅能装下模型,还能留出约 30GB 显存给 KV Cache(上下文长度) 以及 Omni 多模态组件 处理图片/视频输入时的额外开销。1. 前提准备模型选择:直接下载到 AutoDL 服务器上=》原精度模型(BF16/FP16) 的 Qwen3-Omni-30B,选择的示例和环境如下。# 3. 正式执行模型下载。

xiaoxiao_00001的博客 414

AG32VF407直流电流采集仪设计:从1uA到10A宽范围采样与FPGA加速方案

本文详细介绍了基于AG32VF407芯片的直流电流采集仪设计方案,实现了从1uA到10A的宽范围采样。方案采用低噪声电源、精密运放调理、高速比较器自动档位切换及高精度ADC同步采样,并重点阐述了利用AG32VF407片内FPGA实现硬件加速以提升性能的架构思路,为精密电流测量提供了完整的硬件与FPGA加速解决方案。

weixin_33245447的博客 310

华为HCIP网络工程师认证—网络管理与运维

随着网络的规模越来越庞大,网络中的设备种类繁多,如何对越来越复杂的网络进行有效的管理,从而提供高质量的网络服务,已成为网络管理所面临的巨大挑战。网络的管理与运维手段多样,本博客将对几种网络管理与运维手段展开介绍。网络管理与运维,就是让你从“一台一台跑去敲命令的消防员”,变成“坐在总控室看大屏的指挥官”的一套技术体系。 它的核心目标就四个字:可视、可控——你得先“看见”网络在发生什么,然后才能“控制”它不出事。网络管理与运维,在 HCIP 里不像 OSPF、VLAN 那样需要算得很深,但它是一个“框架性”……

极客园地 248

诊断CAPL自动化(5) ——没有CANdela,无法编辑cdd数据库文件,也能轻松完成诊断测试

🍅我们知道cdd数据库文件,是CANoe测试环境的重要组件,它可以让我们更加轻松的完成诊断测试工作;但是创建和编辑cdd文件是需要CANdela 来完成的,一个 liscense 就要将近二十万,成本确实高🍅特别是在处理22/2E/31服务的时候,频繁的增加功能,可能需要频繁的用CANdela 修改CDD,我测试开发过程中,一般很少去修改cdd,有这个时间,纯手撸也开发完了,就一个CANdela,鬼知道此时此刻到了谁手里,我也懒得去找。🍅演示软硬件环境;目录📘前言。

蚂蚁小兵 5756

华为VXLAN OVER EVPN(EBGP)

本文基于 eNSP Pro 模拟器,完整复现 Spine-Leaf 架构下 VXLAN over EVPN 的二层互通实验。实验目标是:通过 EBGP 在 Spine 与 Leaf 之间建立 Underlay 路由,再借助 BGP EVPN 承载 VXLAN 隧道,最终实现跨 Leaf 的同一子网二层互通,并验证头端复制(Head-End Replication)方式的 BUM 流量转发。

257

visio安装包

Microsoft Office Visio 是一款专业的制图软件。利用经过更新的形状、协作工具和数据链接图表,创建专业级图表以简化复杂的信息。

假人有限元模型

假人模型,车辆碰撞假人有限元模型,适合做乘员安全的研究者利用

华为云云硬盘 EVS:挂载、初始化与快照回滚,一块数据盘的完整生命周期

一块云硬盘的生命周期是「买 → 挂 → 初始化 → 用 → 卸 → 再挂」。Windows 靠磁盘管理初始化、靠脱机保证能换机;Linux 靠。

xbzb的博客 707

android 蓝牙通讯demo

android 下蓝牙通讯demo,实现了发现蓝牙,使能蓝牙,通过蓝牙发送消息。

YOLOv11m 部署华为 Atlas 300I Pro

部署到的全过程拆成可照抄的步骤:装驱动固件 → 装 CANN toolkit 与算子包 → 装 Ultralytics / ais_bench → 导出 ONNX → ATC 编译成.om→ 命令行/Python 推理 → CPU 端解码 NMS。给出两条路线(一键导出 / 手工 ATC)、完整可运行的预处理与后处理代码、报错排查表和验收清单。先认清硬件:300I Pro 到底是颗什么芯片部署总览:软件栈与两条技术路线环境与软件清单(开工前先对齐)

巽视达的专栏 576

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

变相解决华为Mate 8锁屏后无法建立入站网络连接

当你的华为Mate 8手机锁屏时,拒绝一切入站网络连接……

xyzzklk的博客 366

华为VPLS over SRV6

IGP IPv6:在 P 与 PE 上启用 IS-IS Level-2 和 IPv6 拓扑,并在物理接口与 LoopBack0 上使能 IS-IS IPv6,打通底层 IPv6 可达性。BGP 邻居:以 LoopBack0 为源建立 IBGP 邻居,P 作为反射器,为 EVPN 路由传递提供通道。EVPN 对等体:在 l2vpn-family evpn 下使能邻居并声明 SRv6 封装类型,确保 EVPN 路由可正常收发。locator 与 SID 动态分配。

277

鸿蒙远程控制软件哪个好?华为远程PC、ToDesk、向日葵三款实测

综合来看,本次横测最终更推荐ToDesk:它在支持跨品牌连接的基础上,外接鼠标、全屏远控、移动设备协助和多屏功能覆盖更完整,更适合作为主力远控工具。

2401_85083137的博客 341

华为CE设备配置EVPN OVER VXLAN

本文基于华为 CE 交换机,完整演示 EVPN 与 RFC 5549 的配置过程。全文以 IPv6 链路本地地址建立 EBGP 邻居,无需配置互联接口 IP,即可用 IPv6 下一跳转发 IPv4 地址;随后通过 EBGP 发布环回地址,并用 IPv6 环回地址建立 EVPN 邻居。内容依次覆盖接口配置、BGP unnumbered、EVPN BGP 邻居、EVPN VXLAN、分布式网关与 TYPE5 路由引入、三层 VPC 模拟,以及 TYPE2/TYPE5 路由与三层 VPN 路由表的查看验证。

29
上一篇: HarmonyOS应用开发—资源分类与访问
下一篇: HarmonyOS应用侧与前端页面数据通道建立
「已注销」
博客等级 码龄5年 1045粉丝 297原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

「已注销」

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值