做阿里网站,包装建设网站,营销型网站建设公司易网拓,北京市保障房建设投资中心网站一、介绍 溪村小镇是一款展示溪流背坡村园区风貌的应用#xff0c;包括园区内的导航功能#xff0c;小火车行车状态查看#xff0c;以及各区域的风景展览介绍#xff0c;主要用于展示HarmonyOS的ArkUI能力和动画效果。具体包括如下功能#xff1a; 打开应用时进入启动页包括园区内的导航功能小火车行车状态查看以及各区域的风景展览介绍主要用于展示HarmonyOS的ArkUI能力和动画效果。具体包括如下功能 打开应用时进入启动页启动页轮播展示溪村小镇风景图之后进入应用首页。在首页的“地图浏览”标签页可以拖动和缩放查看地图并查找相应地标建筑。在首页的“区域导览”标签页可以上下滑动查看溪村小镇不同区域的卡片点击卡片可以进入对应的区域详情页并查看区域的详细介绍和高清建筑风景图。在首页的“小火车”标签页可以查看溪村小火车的运行路线图。 相关概念 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。 环境搭建 安装DevEco Studio详情请参考下载和安装软件。设置DevEco Studio开发环境DevEco Studio开发环境需要依赖于网络环境需要连接上网络才能确保工具的正常使用可以根据如下两种情况来配置开发环境如果可以直接访问Internet只需进行下载HarmonyOS SDK操作。 如果网络不能直接访问Internet需要通过代理服务器才可以访问请参考配置开发环境。开发者可以参考以下链接完成设备调试的相关配置使用真机进行调试 使用模拟器进行调试 三、代码结构解读 本篇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 abilityhilog.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组件的indexState 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)}
}五、地图浏览 地图浏览模块提供了“溪村小镇”的全景地图方便用户了解“溪村小镇”内部的地理概况。包含以下功能 搜索指定类型地标并在地图上展示。通过手势对地图进行操作包括放大、缩小和拖拽。 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: ArrayPositionItem 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: ArrayGeoCoordinates, 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: ArrayGeoCoordinates): ArrayLocation {let locations: ArrayLocation [];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: Arraynumber [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)}...}
}七、小火车 小火车模块提供了溪村小镇小火车的相关信息主要功能如下 查看溪村小火车三条路线的概览图。点击对应路线展示当前时间每条路线上小火车运营状态、时刻表、所在的位置、运动方向、及实时运动轨迹。效果如图所示 注意非运营时间内不展示路线图。 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[index1].x,endY index length - 1 ? data[0].y : data[index1].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的学习并了解到以下知识点 实现页面跳转和自定义弹窗。识别用户操作手势并触发相应事件。添加页面动画效果。使用画布组件自定义绘制图形。