当前位置: 首页 > news >正文

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

一、介绍

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

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

相关概念

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

完整示例

gitee源码地址

二、环境搭建

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

软件要求

  • DevEco Studio版本:DevEco Studio 3.1 Release。
  • HarmonyOS SDK版本:API version 9。

硬件要求

  • 设备类型:华为手机或运行在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) => {// 当标签页切换时改变底部标签栏组件的indexthis.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);// 返回实际偏移量0return 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);// 返回实际偏移量0return 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设为truethis.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滑动时记录切换的indexthis.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].: data[index + 1].x;const endY = index === length - 1 ? data[0].: 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.- end.x;
  let diffY = start.- 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.- end.x;
  let diffY = start.- 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(/ x);const angleA = Math.round(Const.PI_ANGLE / Math.PI * radianA);return angleA;
}

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

// TrainsTrack.ets
calcPosition(x, y, w, h) {if (+ 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 (+ this.bgX < Const.HORIZONTAL_THRESHOLD) {this.bgX = Math.abs(this.bgX + w / 2) < 0 ? 0 : this.bgX + w / 2;}if (+ 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 (+ this.bgY < Const.VERTICAL_THRESHOLD) {this.bgY = Math.abs(this.bgY + h / 2) < 0 ? 0 : this.bgY + h / 2;}
}

八、总结

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

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

相关文章:

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

一、介绍 溪村小镇是一款展示溪流背坡村园区风貌的应用&#xff0c;包括园区内的导航功能&#xff0c;小火车行车状态查看&#xff0c;以及各区域的风景展览介绍&#xff0c;主要用于展示HarmonyOS的ArkUI能力和动画效果。具体包括如下功能&#xff1a; 打开应用时进入启动页&a…...

Mybatis---第二篇

系列文章目录 文章目录 系列文章目录一、#{}和${}的区别是什么?二、简述 Mybatis 的插件运行原理,如何编写一个插件一、#{}和${}的区别是什么? #{}是预编译处理、是占位符, KaTeX parse error: Expected EOF, got # at position 27: …接符。 Mybatis 在处理#̲{}时,会将…...

6.2.3 【MySQL】InnoDB的B+树索引的注意事项

6.2.3.1 根页面万年不动窝 B 树的形成过程是这样的&#xff1a; 每当为某个表创建一个 B 树索引&#xff08;聚簇索引不是人为创建的&#xff0c;默认就有&#xff09;的时候&#xff0c;都会为这个索引创建一个 根节点 页面。最开始表中没有数据的时候&#xff0c;每个 B 树…...

前端面试话术集锦第 12 篇:高频考点(Vue常考基础知识点)

这是记录前端面试的话术集锦第十二篇博文——高频考点(Vue常考基础知识点),我会不断更新该博文。❗❗❗ 这一章节我们将来学习Vue的一些经常考到的基础知识点。 1. 生命周期钩子函数 在beforeCreate钩子函数调用的时候,是获取不到props或者data中的数据的,因为这些数据的…...

骨传导耳机危害有哪些?值得入手吗?

事实上&#xff0c;只要是正常使用&#xff0c;骨传导耳机并不会对身体造成伤害&#xff0c;并且在众多耳机种类中&#xff0c;骨传导耳机可以说是相对健康的一种耳机&#xff0c;这种耳机最独特的地方便是声波不经过外耳道和鼓膜&#xff0c; 而是直接将人体骨骼结构作为传声介…...

网络爬虫-----初识爬虫

目录 1. 什么是爬虫&#xff1f; 1.1 初识网络爬虫 1.1.1 百度新闻案例说明 1.1.2 网站排名&#xff08;访问权重pv&#xff09; 2. 爬虫的领域&#xff08;为什么学习爬虫 ?&#xff09; 2.1 数据的来源 2.2 爬虫等于黑客吗&#xff1f; 2.3 大数据和爬虫又有啥关系&…...

vue 功能:点击增加一项,点击减少一项

功能介绍&#xff1a; 默认为一列&#xff0c;当点击右侧"" 号&#xff0c;增加一列&#xff1b;点击 “-” 号&#xff0c;将当前列删除&#xff1b; 功能截图&#xff1a; 功能代码&#xff1a; //HTML <el-col :span"24"><el-form-item lab…...

我的编程学习笔记

1. 引言&#xff1a; 在开始编写任何代码之前&#xff0c;都需要理解编程的基本概念。编程是人与计算机进行交流的方式&#xff0c;它让计算机可以理解和执行特定的任务。编程语言是这种交流的工具&#xff0c;而学习编程就是学习如何用特定的语言表达出我们想要的计算机行为。…...

页面静态化、Freemarker入门

页面静态化介绍 页面的访问量比较大时&#xff0c;就会对数据库造成了很大的访问压力&#xff0c;并且数据库中的数据变化频率并不高。 那需要通过什么方法为数据库减压并提高系统运行性能呢&#xff1f;答案就是页面静态化。页面静态化其实就是将原来的动态网页(例如通过ajax…...

PCL (再探)点云配准精度评价指标——均方根误差

目录 一、算法原理二、代码实现三、代码解析四、备注本文由CSDN点云侠原创,原文链接。如果你不是在点云侠的博客中看到该文章,那么此处便是不要脸的爬虫。 一、算法原理 见: 点云配准精度评价指标——均方根误差PCL 点云配准精度评价——点到面的均方根误差Open3D(C++) 点…...

【Redis速通】基础知识1 - 虚拟机配置与踩坑

Ubuntu 配置 Redis 下载 redis 找到 redis 官网界面&#xff0c;下载 redis6.2LTS 点击前往 用 mobax 连接到 ubuntu 虚拟机&#xff0c;把下载好的 tar.gz 文件丢到任意一个文件夹下面 进入该文件夹&#xff0c;于此处打开终端&#xff0c;进行解压操作&#xff1a;tar -z…...

我的创作纪念日---从考研调剂到研一的旅程

文章目录 一、前言二、机缘三、收获四、日常五、憧憬 一、前言 大家好&#xff0c;我是小馒头学Python&#xff0c;小馒头学Python就是我&#xff0c;今天是我第一次收到创作纪念日的私信&#xff0c;去年的今天我还在考研&#xff0c;那个时候整天浑浑噩噩的&#xff0c;迷茫…...

Python-实现邮件发送:flask框架或django框架可以直接使用

在项目中&#xff0c;会使用到发送邮件的功能。不同框架的配置可能有所不同&#xff0c;直接写一个不依赖框架配置的邮件发送模块。 使用的功能&#xff1a; 1、可以发送给多个邮箱 2、可以实现抄送多个邮箱 3、可以添加多个文件附件 一、不使用多线程 import smtplib from…...

使用亚马逊云科技Amazon SageMaker,为营销活动制作广告素材

广告公司可以使用生成式人工智能和文字转图像根基模型&#xff0c;制作创新的广告素材和内容。在本篇文案中&#xff0c;将演示如何使用亚马逊云科技Amazon SageMaker从现有的基本图像生成新图像&#xff0c;这是一项完全托管式服务&#xff0c;用于大规模构建、训练和部署机器…...

conda环境安装opencv带cuda版本

主要是cmake编译选项需要修改 以下两个选项按照自己情况修改 -D OPENCV_EXTRA_MODULES_PATH../opencv_contrib/modules \ -D CUDA_TOOLKIT_ROOT_DIR/usr/local/cuda-12.2 \ 其中/home/lixin/anaconda3/envs/stereo 改成你自己的conda环境 cmake -D CMAKE_BUILD_TYPER…...

R语言中的数据结构----矩阵

目录 &#xff08;1&#xff09;创建矩阵 &#xff08;2&#xff09; 线性代数运算 &#xff08;3&#xff09;矩阵索引 &#xff08;4&#xff09;矩阵元素的筛选 &#xff08;5&#xff09;增加或删除矩阵的行或列 &#xff08;6&#xff09;apply()函数 &#xff08;…...

Llama-2 推理和微调的硬件要求总结:RTX 3080 就可以微调最小模型

大语言模型微调是指对已经预训练的大型语言模型&#xff08;例如Llama-2&#xff0c;Falcon等&#xff09;进行额外的训练&#xff0c;以使其适应特定任务或领域的需求。微调通常需要大量的计算资源&#xff0c;但是通过量化和Lora等方法&#xff0c;我们也可以在消费级的GPU上…...

C++多线程的用法(包含线程池小项目)

一些小tips: 编译命令如下&#xff1a; g 7.thread_pool.cpp -lpthread 查看运行时间&#xff1a; time ./a.out 获得本进程的进程id&#xff1a; this_thread::get_id() 需要引入的库函数有&#xff1a; #include<thread> // 引入线程库 #include<mutex> //…...

react ant ice3 实现点击一级菜单自动打开它下面最深的第一个子菜单

1.问题 默认的如果没有你的菜单结构是这样的&#xff1a; [{children: [{name: "通用配置"parentId: "1744857774620672"path: "basic"}],name: "系统管理"parentId: "-1"path: "system"} ]可以看到每层菜单的p…...

关于 Qt串口不同电脑出现不同串口号打开失败 的解决方法

若该文为原创文章&#xff0c;转载请注明原文出处 本文章博客地址&#xff1a;https://hpzwl.blog.csdn.net/article/details/132842297 红胖子(红模仿)的博文大全&#xff1a;开发技术集合&#xff08;包含Qt实用技术、树莓派、三维、OpenCV、OpenGL、ffmpeg、OSG、单片机、软…...

可观测性在灰度发布中的应用

前言 随着云计算的发展、云原生时代的来临&#xff0c;企业数字化转型进程不断深入&#xff0c;应用开发也越来越多地基于微服务化模式&#xff0c;快速迭代的能力使得应用开发更高效、更灵活。同时&#xff0c;也不得不面临应用版本快速升级所带来的的巨大挑战。 传统的发布方…...

vscode开发油猴插件环境配置指南

文章目录 一、环境配置1.1油猴插件开始编写代码1.2油猴插件配置1.2.1浏览器插件权限1.2.2插件自身权限 2. 油猴脚本API学习2.1 头文件2.2 油猴API 一、环境配置 1.1油猴插件开始编写代码 在vscode 中写入如下代码‘ // UserScript // name cds_test // namespace …...

网站不收录没排名降权怎么处理-紧急措施可恢复网站

网站降权对于SEO人员来说是非常致命的打击&#xff0c;因为网站一旦被搜索引擎降权&#xff0c;排名会严重地下降&#xff0c;网站的流量也会大幅下降&#xff0c;直接影响到收益。而且处理不好的话会导致恢复的时间周期无限拉长&#xff0c;所以网站被降权后我们要第一时间采取…...

C++vector模拟实现

vector模拟实现 1.构造函数2.拷贝构造3.析构赋值运算符重载4.iterator5.modifiers5.1push_back5.2pop_back5.3empty5.4insert5.5erase5.6swap 6.Capacity6.1size6.2capacity6.3reserve6.4resize6.5empty 7.Element access7.1operator[]7.2at 8.在谈reserve vector官方库实现的是…...

《DATASET DISTILLATION》

这篇文章提出了数据浓缩的办法&#xff0c;在前面已有的知识浓缩&#xff08;压缩模型&#xff09;的经验上&#xff0c;提出了不压缩模型&#xff0c;转而压缩数据集的办法&#xff0c;在压缩数据集上训练模型得到的效果尽可能地接近原始数据集的效果。 摘要 模型蒸馏的目的是…...

GDPU 数据结构 天码行空1

1. 病历信息管理 实现病历查询功能。具体要求如下: 定义一个结构体描述病人病历信息(病历号,姓名,症状)&#xff1b;完成功能如下: 输入功能:输入5个病人的信息&#xff1b; 查询功能:输入姓名&#xff0c;在5个病历中进行查找&#xff0c;如果找到则显示该人的信息&#xff0c…...

【C++】红黑树的模拟实现

&#x1f307;个人主页&#xff1a;平凡的小苏 &#x1f4da;学习格言&#xff1a;命运给你一个低的起点&#xff0c;是想看你精彩的翻盘&#xff0c;而不是让你自甘堕落&#xff0c;脚下的路虽然难走&#xff0c;但我还能走&#xff0c;比起向阳而生&#xff0c;我更想尝试逆风…...

【多线程】Thread 类 详解

Thread 类 详解 一. 创建线程1. 继承 Thread 类2. 实现 Runnable 接口3. 其他变形4. 多线程的优势-增加运行速度 二. Thread 类1. 构造方法2. 常见属性3. 启动线程-start()4. 中断线程-interrupt()5. 线程等待-join()6. 线程休眠-sleep()7. 获取当前线程引用 三. 线程的状态1. …...

LINUX 网络管理

目录 一、NetworkManager的特点 二、配置网络 1、使用ip命令临时配置 1&#xff09;查看网卡在网络层的配置信息 2&#xff09;查看网卡在数据链路层的配置信息 3&#xff09;添加或者删除临时的网卡 4&#xff09;禁用和启动指定网卡 2、修改配置文件 3、nmcli命令行…...

refresh rate

1920 x 1080 显卡刷新率 60...

乌鲁木齐做网站的公司/微信公众号推广网站

第一种方法&#xff1a;把弹框固定在底部&#xff0c;通过过渡弹框高度来实现。下面是完整demo,可复制。移动从底部向上滑动弹出.clickBtn{height: 40px;}.clickBtn button{float: right;}.willAlert{position:absolute;left:0;bottom: 0;width:100%;height: 0;overflow: hidde…...

php wordpress开发教程/推广软件有哪些

let&#xff0c;var&#xff0c;const 的简单区别 let 声明的变量 有局部作用域只能声明一次 var 声明的变量没有局部作用域可以声明多次 const 声明常量声明之后不允许改变一但声明必须初始化&#xff0c;否则会报错...

wordpress模板+免费下载/网站改版公司哪家好

天&#xff0c;仔细阅读了园子里面的一个朋友写的《一缕阳光&#xff1a;DDD&#xff08;领域驱动设计&#xff09;应对具体业务场景&#xff0c;如何聚焦 Domain Model&#xff08;领域模型&#xff09;&#xff1f;》(http://www.cnblogs.com/xishuai/p/3800656.html)这篇博客…...

做城市门户网站怎么发展/个人发布信息免费推广平台

介绍Python代码审计方法多种多样&#xff0c;但是总而言之是根据前人思路的迁移融合扩展而形成。目前Python代码审计思路&#xff0c;呈现分散和多样的趋势。Python微薄研发经验以及结合实际遇到的思路和技巧进行总结&#xff0c;以便于朋友们的学习和参考。SQL注入和ORM注入这…...

大丰做网站价格/世界500强企业排名

for循环与其他编程语言类似&#xff0c;Shell支持for循环。for循环一般格式为&#xff1a;for 变量 in 列表docommand1command2...commandNdone列表是一组值(数字、字符串等)组成的序列&#xff0c;每个值通过空格分隔。每循环一次&#xff0c;就将列表中的下一个值赋给变量。i…...

龙华民治网站建设公司/哈尔滨seo优化

电脑上的名言锁屏怎么设置的电脑键盘上的“Sleep”这个功能键的作用是&#xff0c;在你暂时不用电脑时&#xff0c;如果按Sleep键&#xff0c;电脑即进入睡眠状态&#xff1b;到要使用电脑时&#xff0c;按任意键又可唤醒电脑重新回到工作状态。如果我们设定在唤醒电脑时需要键…...