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. 使用画布组件自定义绘制图形。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/77450.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

哪种IP更适合你的数据抓取需求?

程序员大佬们好&#xff01;今天我要和大家分享一个关于数据抓取的话题&#xff0c;那就是Socks5爬虫ip和动态IP之间的比较。在进行数据抓取时&#xff0c;选择适合自己需求的工具和技术是非常重要的。Socks5爬虫ip和动态IP都是常见的网络工具&#xff0c;它们在数据抓取方面都…

Spring Boot 中的参数验证和自定义响应处理,使用 @Valid 注解

&#x1f60a; 作者&#xff1a; 一恍过去 &#x1f496; 主页&#xff1a; https://blog.csdn.net/zhuocailing3390 &#x1f38a; 社区&#xff1a; Java技术栈交流 &#x1f389; 主题&#xff1a; Spring Boot 中的参数验证和自定义响应处理&#xff0c;使用 Valid 注解…

【sgCreateAPI】自定义小工具:敏捷开发→自动化生成API接口脚本(接口代码生成工具)

<template><div :class"$options.name"><div class"sg-head">接口代码生成工具</div><div class"sg-container"><div class"sg-start "><div style"margin-bottom: 10px;">接口地…

做期权卖方一般会怎么选择合约?

我们知道期权有多种获利方式&#xff0c;其中靠时间能赚钱的是做期权卖方策略&#xff0c;虽然赚得慢&#xff0c;但可以稳稳地收入权利金&#xff0c;适合某些稳健风格的投资者&#xff0c;胜率对比买方也是高了很多&#xff0c;那么做期权卖方一般会怎么选择合约&#xff1f;…

LeetCode(力扣)45. 跳跃游戏 IIPython

LeetCode45. 跳跃游戏 II 题目链接代码 题目链接 https://leetcode.cn/problems/jump-game-ii/description/ 代码 class Solution:def jump(self, nums: List[int]) -> int:if len(nums) 1:return 0curdis 0nextdis 0step 0for i in range(len(nums)):nextdis max(…

华为云耀云服务器HECS安装Docker

先去购买服务器&#xff0c;这里就不多说了 1、进入自己买的服务器&#xff0c; 找到切换系统 2、选择centOs镜像 安装docker 卸载旧版本 较旧的 Docker 版本称为 docker 或 docker-engine 。如果已安装这些程序&#xff0c;请卸载它们以及相关的依赖项。 yum remove docker…

职业规划就问它!海量知识与智慧,AIGC助你冲破择业迷茫

数字化时代的兴起改变了我们的日常生活和职业工作方式。科技迅猛的发展&#xff0c;尤其是人工智能的崛起&#xff0c;将我们引入了一个崭新的智能化时代。在这个时代中&#xff0c;AI被认为是从"数字时代"向"数智时代"转变的关键元素&#xff0c;引领着这…

2023年Gartner新技术与AI成熟度曲线

1. Gartner 将生成式 AI 置于 2023 年新技术成熟度曲线的顶峰&#xff0c;新兴人工智能将对商业和社会产生深远影响 根据 Gartner, Inc. 2023 年新兴技术成熟度曲线&#xff0c;生成式人工智能 (AI) 处于成熟度曲线期望的顶峰&#xff0c;预计将在两到五年内实现转型效益。生成…

htaccess绕过上传实验

实验目的 利用上传htaccess文件解析漏洞绕过验证进行上传PHP脚本木马 实验工具 火狐&#xff1a;Mozilla Firefox&#xff0c;中文俗称“火狐”&#xff08;正式缩写为Fx或fx&#xff0c;非正式缩写为FF&#xff09;&#xff0c;是一个自由及开放源代码网页浏览器&#xff0…

Linux OpenGauss 数据库远程连接

目录 前言 1. Linux 安装 openGauss 2. Linux 安装cpolar 3. 创建openGauss主节点端口号公网地址 4. 远程连接openGauss 5. 固定连接TCP公网地址 6. 固定地址连接测试 前言 openGauss是一款开源关系型数据库管理系统&#xff0c;采用木兰宽松许可证v2发行。openGauss内…

playwright自动化上传附件

需求 自动设置上传头像 过程 1. 首先保存本地一个文件&#xff0c;例如 aaa.php file_path files/aaa.png 2. 获取输入类型为 "file" 的按钮 file_input_element page.locator(input[typefile]) 3. 将本地保存的图片路径赋值 file_input_element.set_input_…

单例模式-饿汉模式、懒汉模式

单例模式&#xff0c;是设计模式的一种。 在计算机这个圈子中&#xff0c;大佬们针对一些典型的场景&#xff0c;给出了一些典型的解决方案。 目录 单例模式 饿汉模式 懒汉模式 线程安全 单例模式 单例模式又可以理解为是单个实例&#xff08;对象&#xff09; 在有些场…

深圳唯创知音电子将参加IOTE 2023第二十届国际物联网展•深圳站

​ 2023年9月20~22日&#xff0c;深圳唯创知音电子将在 深圳宝安国际会展中心&#xff08;9号馆9B1&#xff09;为您全面展示最新的芯片产品及应用方案&#xff0c;助力传感器行业的发展。 作为全球领先的芯片供应商之一&#xff0c;深圳唯创知音电子一直致力于为提供高质量、…

【Web】vue开发环境搭建教程(详细)

系列文章 C#底层库–记录日志帮助类 本文链接&#xff1a;https://blog.csdn.net/youcheng_ge/article/details/124187709 文章目录 系列文章前言一、安装准备1.1 node.js1.2 国内镜像站1.3 Vue脚手架1.4 element ui1.5 Visual Studio Code 二、安装步骤2.1 下载msi安装包2.2 …

骨传导耳机对人体有危险吗?会损害听力吗?

如果在使用骨传导耳机的时候控制好时间和音量&#xff0c;是不会对人体带来危险和造成伤害的。 下面跟大家解释一下为什么骨传导耳机对人体没有危害&#xff0c;最大的原因就是骨传导耳机不需要空气传导&#xff0c;而是通过颅骨传到听觉中枢&#xff0c;传输过程中几乎没有噪…

使用java连接Libvirtd

基于springboot web 一、依赖 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId>&l…

图书管理信息系统分析与设计

一、系统开发的可行性分析 &#xff08;一&#xff09;系统背景.必要性及意义 随着社会经济的迅速发展和科学技术的全面进步&#xff0c;计算机事业的飞速发展&#xff0c;以计算机与通信技术为基础的信息系统正处于蓬勃发展的时期。随着经济文化水平的显著提高&#xff0c;人…

认识网线上的各种参数标号

最近工作需要&#xff0c;接触了很多不同类型的网线&#xff0c;为了能够区分不同型号的网线&#xff0c;特意做一篇笔记用来学习&#xff0c;如有记录有误之处&#xff0c;欢迎大家指正~初步认识网线 常用的网络电缆有三种&#xff1a;双绞线、同轴电缆和光纤电缆&#xff08…

thinkphp6 入门(4)--数据库操作 增删改查

一、设计数据库表 比如我新建了一个数据库表&#xff0c;名为test 二、配置数据库连接信息 本地测试 直接在.env中修改&#xff0c;不用去config/database.php中修改 正式环境 三、增删改查 引入Db库 use think\facade\Db; 假设新增的控制器路径为 app\test\control…

B+Tree 索引结构

文章目录 1. 数据库索引2. BTree 索引结构 1. 数据库索引 索引是为了提高数据的查询速度&#xff0c;相当于给数据进行编号&#xff0c;在查找数据的时候就可以通过编号快速找到对应的数据。索引用的是 B Tree 数据结构。 乱序插入数据&#xff0c;会自动按照 id 进行升序排…