HarmonyOS 应用开发进阶案例(十):制作电子相册

47 阅读5分钟

本案例介绍了如何实现一个简单的电子相册应用,主要功能包括:

  1. 实现首页顶部的轮播效果。
  2. 实现页面多种布局方式。
  3. 实现通过手势控制图片的放大、缩小、左右滑动查看细节等效果。

一、案例效果截图

二、案例运用到的知识点

  1. 核心知识点
  • 组合手势:手势识别组,多种手势组合为复合手势,支持连续识别、并行识别和互斥识别。
  • Swiper:滑块视图容器,提供子组件滑动轮播显示的能力。
  • Grid:网格容器,由“行”和“列”分割的单元格所组成,通过指定“项目”所在的单元格做出各种各样的布局。
  • Navigation:Navigation组件一般作为Page页面的根容器,通过属性设置来展示页面的标题、工具栏、菜单。
  • List:列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Param/AppStorageV2
  • 渲染控制:ForEach
  • 自定义组件和组件生命周期
  • 自定义构建函数@Builder
  • @Extend:定义扩展样式
  • Navigation导航组件与router路由导航
  • 内置组件:Swiper/Stack/Column/Row/Image/Grid/List
  • 常量与资源分类的访问
  • MVVM模式

三、代码结构

├──entry/src/main/ets                // 代码区
│  ├──common
│  │  ├──constansts
│  │  │  └──Constants.ets            // 常量类
│  │  └──utils
│  │     └──Logger.ets               // Logger公共类
│  ├──entryability
│  │  └──EntryAbility.ets            // 程序入口类
│  ├──pages
│  │  ├──DetailListPage.ets          // 图片详情页面
│  │  ├──DetailPage.ets              // 查看大图页面
│  │  ├──IndexPage.ets               // 电子相册主页面
│  │  └──ListPage.ets                // 图片列表页面
│  └──view
│     └──PhotoItem.ets               // 首页相册Item组件
└──entry/src/main/resources          // 资源文件

四、公共文件与资源

本案例涉及到的常量类和工具类代码如下:

  1. 通用常量类
// entry/src/main/ets/common/constants/Constants.ets
export default class Constants {

  /**
   * banner list
   */
  static readonly BANNER_IMG_LIST: Array<Resource> = [
    $r('app.media.ic_scene_1'),
    $r('app.media.ic_food_0'),
    $r('app.media.ic_life_0'),
    $r('app.media.ic_men_0')
  ]

  /**
   * scene list
   */
  static readonly SCENE_LIST: Array<Resource> = [
    $r('app.media.ic_scene_1'),
    $r('app.media.ic_scene_2'),
    $r('app.media.ic_scene_0')
  ]

  /**
   * men list
   */
  static readonly MEN_LIST: Array<Resource> = [
    $r('app.media.ic_men_0'),
    $r('app.media.ic_men_2'),
    $r('app.media.ic_men_3')
  ]

  /**
   * food list
   */
  static readonly FOOD_LIST: Array<Resource> = [
    $r('app.media.ic_food_1'),
    $r('app.media.ic_food_0'),
  ]

  /**
   * life list
   */
  static readonly LIFE_LIST: Array<Resource> = [
    $r('app.media.ic_life_1'),
    $r('app.media.ic_life_0'),
    $r('app.media.ic_life_2'),
    $r('app.media.ic_life_3'),
    $r('app.media.ic_life_4'),
    $r('app.media.ic_life_5')
  ]

  /**
   * index page img arr
   */
  static readonly IMG_ARR: Resource[][] = [
    new Array<Resource>().concat(Constants.SCENE_LIST, Constants.LIFE_LIST, Constants.MEN_LIST),
    new Array<Resource>().concat(Constants.MEN_LIST, Constants.LIFE_LIST, Constants.SCENE_LIST),
    new Array<Resource>().concat(Constants.FOOD_LIST, Constants.SCENE_LIST, Constants.SCENE_LIST),
    new Array<Resource>().concat(Constants.LIFE_LIST, Constants.FOOD_LIST, Constants.MEN_LIST)
  ]

  /**
   * title font weight
   */
  static readonly TITLE_FONT_WEIGHT: number = 500

  /**
   * aspect ratio
   */
  static readonly BANNER_ASPECT_RATIO: number = 1.5

  /**
   * animate duration
   */
  static readonly BANNER_ANIMATE_DURATION: number = 300

  /**
   * share delay
   */
  static readonly SHARE_TRANSITION_DELAY: number = 100

  /**
   * aspect ratio
   */
  static readonly STACK_IMG_RATIO: number = 0.7

  /**
   * item space
   */
  static readonly LIST_ITEM_SPACE: number = 2

  /**
   * cache size
   */
  static readonly CACHE_IMG_SIZE: number = 4

  /**
   * cache list
   */
  static readonly CACHE_IMG_LIST: string[] = ['', '', '', '']

  /**
   * title
   */
  static readonly PAGE_TITLE: string = '电子相册'

  /**
   *  router param
   */
  static readonly PARAM_PHOTO_ARR_KEY: string = 'photoArr'

  /**
   *  selected index
   */
  static readonly SELECTED_INDEX_KEY: string = 'selectedIndex'

  /**
   * grid column template
   */
  static readonly GRID_COLUMNS_TEMPLATE: string = '1fr 1fr 1fr 1fr'

  /**
   * index page columns template
   */
  static readonly INDEX_COLUMNS_TEMPLATE: string = '1fr 1fr'

  /**
   *  percent
   */
  static readonly FULL_PERCENT: string = '100%'

  /**
   * photo item percent
   */
  static readonly PHOTO_ITEM_PERCENT: string = '90%'

  /**
   * show count
   */
  static readonly SHOW_COUNT: number = 8

  /**
   * default width
   */
  static readonly DEFAULT_WIDTH: number = 360

  /**
   * padding
   */
  static readonly PHOTO_ITEM_PADDING: number = 8

  /**
   * offset
   */
  static readonly PHOTO_ITEM_OFFSET: number = 16

  /**
   * item opacity offset
   */
  static readonly ITEM_OPACITY_OFFSET: number = 0.2

  /**
   * double number
   */
  static readonly DOUBLE_NUMBER: number = 2

  /**
   * list page url
   */
  static readonly URL_LIST_PAGE: string = 'pages/ListPage'

  /**
   * detail list page url
   */
  static readonly URL_DETAIL_LIST_PAGE: string = 'pages/ListDetailPage'

  /**
   * detail page url
   */
  static readonly URL_DETAIL_PAGE: string = 'pages/DetailPage'

  /**
   * index page tag
   */
  static readonly TAG_INDEX_PAGE: string = 'IndexPage push error '

  /**
   * list page tag
   */
  static readonly TAG_LIST_PAGE: string = 'ListPage push error '

  /**
   * detail list page tag
   */
  static readonly TAG_DETAIL_PAGE: string = 'DetailListPage push error '

}

2. 公共日志类

// entry/src/main/ets/common/utils/Logger.ets
import { hilog } from '@kit.PerformanceAnalysisKit'

const LOGGER_PREFIX: string = 'Electronic Album'

class Logger {
  private domain: number
  private prefix: string

  private format: string = '%{public}s, %{public}s'

  constructor(prefix: string = '', domain: number = 0xFF00) {
    this.prefix = prefix
    this.domain = domain
  }

  debug(...args: string[]): void {
    hilog.debug(this.domain, this.prefix, this.format, args)
  }

  info(...args: string[]): void {
    hilog.info(this.domain, this.prefix, this.format, args)
  }

  warn(...args: string[]): void {
    hilog.warn(this.domain, this.prefix, this.format, args)
  }

  error(...args: string[]): void {
    hilog.error(this.domain, this.prefix, this.format, args)
  }
}

export default new Logger(LOGGER_PREFIX)

本案例涉及到的资源文件如下:

  1. string.json
// entry/src/main/resources/base/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "模块描述"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "电子相册"
    }
  ]
}

2. color.json

// entry/src/main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "detail_background",
      "value": "#000000"
    }
  ]
}

3. float.json

// entry/src/main/resources/base/element/float.json
{
  "float": [
    {
      "name": "detail_list_margin",
      "value": "20vp"
    },
    {
      "name": "img_border_radius",
      "value": "12vp"
    },
    {
      "name": "grid_padding",
      "value": "12vp"
    },
    {
      "name": "title_padding",
      "value": "24vp"
    },
    {
      "name": "title_font_size",
      "value": "30fp"
    },
    {
      "name": "navi_bar_height",
      "value": "56vp"
    }
  ]
}

其他资源请到源码中获取。

五、应用首页

应用首页用Column组件来实现纵向布局,从上到下依次是标题组件Text、轮播图Swiper、相册列表Grid。标题和轮播图均设置固定高度,底部相册列表通过layoutWeight属性实现自适应布局占满剩余空间。

// entry/src/main/ets/pages/Index.ets
import { router } from '@kit.ArkUI'
import Constants from '../common/constants/Constants'
import PhotoItem from '../view/PhotoItem'

@Entry
@ComponentV2
struct IndexPage {
  swiperController: SwiperController = new SwiperController()
  scroller: Scroller = new Scroller()
  @Local currentIndex: number = 0
  @Local angle: number = 0

  build() {
    Column() {
      Row() {
        Text($r('app.string.EntryAbility_label'))
          .fontSize($r('app.float.title_font_size'))
          .fontWeight(Constants.TITLE_FONT_WEIGHT)
      }
      .height($r('app.float.navi_bar_height'))
      .alignItems(VerticalAlign.Center)
      .justifyContent(FlexAlign.Start)
      .margin({ top: $r('app.float.grid_padding') })
      .padding({ left: $r('app.float.title_padding') })
      .width(Constants.FULL_PERCENT)

      Swiper(this.swiperController) {
        ForEach(Constants.BANNER_IMG_LIST, (item: Resource) => {
          Row() {
            Image(item)
              .width(Constants.FULL_PERCENT)
              .height(Constants.FULL_PERCENT)
          }
          .width(Constants.FULL_PERCENT)
          .aspectRatio(Constants.BANNER_ASPECT_RATIO)
        }, (item: Resource, index?: number) => JSON.stringify(item) + index)
      }
      .autoPlay(true)
      .loop(true)
      .margin($r('app.float.grid_padding'))
      .borderRadius($r('app.float.img_border_radius'))
      .clip(true)
      .duration(Constants.BANNER_ANIMATE_DURATION)
      .indicator(false)

      Grid() {
        ForEach(Constants.IMG_ARR, (photoArr: Array<Resource>) => {
          GridItem() {
            PhotoItem({ photoArr })
          }
          .width(Constants.FULL_PERCENT)
          .aspectRatio(Constants.STACK_IMG_RATIO)
          .onClick(() => {
            router.pushUrl({
              url: Constants.URL_LIST_PAGE,
              params: { photoArr: photoArr }
            })
          })
        }, (item: Resource, index?: number) => JSON.stringify(item) + index)
      }
      .scrollBar(BarState.Off)
      .columnsTemplate(Constants.INDEX_COLUMNS_TEMPLATE)
      .columnsGap($r('app.float.grid_padding'))
      .rowsGap($r('app.float.grid_padding'))
      .padding({ 
        left: $r('app.float.grid_padding'), 
        right: $r('app.float.grid_padding')
      })
      .width(Constants.FULL_PERCENT)
      .layoutWeight(1)
    }
    .width(Constants.FULL_PERCENT)
    .height(Constants.FULL_PERCENT)
  }
}

PhotoItem视图用来渲染相册图片:

// entry/src/main/ets/view/PhotoItem.ets
import Constants from '../common/constants/Constants'

@ComponentV2
export default struct PhotoItem {
  @Param photoArr: Array<Resource> = []
  @Local currentIndex: number = 0
  private showCount: number = Constants.SHOW_COUNT / Constants.DOUBLE_NUMBER

  @Builder albumPicBuilder(img: Resource, index: number) {
    Column() {
      Image(img)
        .width(Constants.FULL_PERCENT)
        .height(Constants.FULL_PERCENT)
        .borderRadius($r('app.float.img_border_radius'))
        .opacity(1 - (this.showCount - index - 1) 
                 * Constants.ITEM_OPACITY_OFFSET)
    }
    .padding((this.showCount - index - 1) * Constants.PHOTO_ITEM_PADDING)
    .offset({ y: (this.showCount - index - 1) * Constants.PHOTO_ITEM_OFFSET })
    .height(Constants.PHOTO_ITEM_PERCENT)
    .width(Constants.FULL_PERCENT)
  }

  build() {
    Stack({ alignContent: Alignment.Top }) {
      ForEach(Constants.CACHE_IMG_LIST, (image: string, index?: number) => {
        if (index) {
          this.albumPicBuilder(this.photoArr[this.showCount - index - 1], index)
        }
      }, (item: string, index?: number) => JSON.stringify(item) + index)
    }
    .width(Constants.FULL_PERCENT)
    .height(Constants.FULL_PERCENT)
  }
}

六、图片列表页

图片列表页是网格状展开的图片列表,主要使用Grid组件和GridItem组件,GridItem高度通过aspectRatio属性设置为跟宽度一致。

// entry/src/main/ets/pages/ListPage.ets
import { AppStorageV2, router } from '@kit.ArkUI'
import Constants from '../common/constants/Constants'
import { ListPageStorage } from '../model/ListPageModel'

@Entry
@ComponentV2
struct ListPage {
  @Local storage: ListPageStorage = AppStorageV2.connect(
    ListPageStorage, 
    'storage', 
    () => new ListPageStorage()
  )!
  photoArr: Array<Resource> = (router.getParams() as
    Record<string, Array<Resource>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`]
  build() {
    Navigation() {
      Grid() {
        ForEach(this.photoArr, (img: Resource, index?: number) => {
          GridItem() {
            Image(img)
              .height(Constants.FULL_PERCENT)
              .width(Constants.FULL_PERCENT)
              .objectFit(ImageFit.Cover)
              .onClick(() => {
                if (!index) {
                  index = 0
                }
                this.storage.selectedIndex = index
                router.pushUrl({
                  url: Constants.URL_DETAIL_LIST_PAGE,
                  params: {
                    photoArr: this.photoArr,
                  }
                })
              })
          }
          .width(Constants.FULL_PERCENT)
          .aspectRatio(1)
        }, (item: Resource) => JSON.stringify(item))
      }
      .scrollBar(BarState.Off)
      .columnsTemplate(Constants.GRID_COLUMNS_TEMPLATE)
      .rowsGap(Constants.LIST_ITEM_SPACE)
      .columnsGap(Constants.LIST_ITEM_SPACE)
      .layoutWeight(1)
    }
    .title(Constants.PAGE_TITLE)
    .hideBackButton(false)
    .titleMode(NavigationTitleMode.Mini)
  }
}

上述代码中storage需要构建一个AppStorageV2的数据:

// entry/src/main/ets/model/ListPageModel.ets
@ObservedV2
export class ListPageStorage {
  @Trace selectedIndex: number = 0
}

七、图片详情页

图片详情页由上下两个横向滚动的List组件完成整体布局,两个组件之间有联动的效果。上边展示的大图始终是底部List处于屏幕中间位置的图片。滚动或者点击底部的List,上边展示的大图会随着改变,同样左右滑动上边的图片时,底部List组件也会随之进行滚动。

界面跳转动画是通过共享元素实现的,通过给Image添加sharedTransition属性来实现共享元素转场动画。两个页面的组件配置为同一个shareId,当shareId被配置为空字符串时不会有共享元素转场效果。

// entry/src/main/ets/pages/ListDetailPage.ets
import { router,display, AppStorageV2 } from '@kit.ArkUI'
import Constants from '../common/constants/Constants'
import { ListPageStorage } from '../model/ListPageModel'

enum scrollTypeEnum {
  STOP = 'onScrollStop',
  SCROLL = 'onScroll'
}

@Entry
@ComponentV2
struct DetailListPage {
  private smallScroller: Scroller = new Scroller()
  private bigScroller: Scroller = new Scroller()
  @Local deviceWidth: number = Constants.DEFAULT_WIDTH
  @Local smallImgWidth: number = (this.deviceWidth - Constants.LIST_ITEM_SPACE 
                        * (Constants.SHOW_COUNT - 1)) / Constants.SHOW_COUNT
  @Local imageWidth: number = this.deviceWidth + this.smallImgWidth
  private photoArr: Array<Resource | string> = (router.getParams() as 
  Record<string, Array<Resource | string>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`]
  private smallPhotoArr: Array<Resource | string> 
  = new Array<Resource | string>().concat(
    Constants.CACHE_IMG_LIST,
    (router.getParams() as Record<string, Array<Resource | string>>
    )[`${Constants.PARAM_PHOTO_ARR_KEY}`],
    Constants.CACHE_IMG_LIST)
  @Local storage: ListPageStorage 
    = AppStorageV2.connect(
      ListPageStorage, 'storage', () => new ListPageStorage())!

  @Builder SmallImgItemBuilder(img: Resource, index?: number) {
    if (index && index > (Constants.CACHE_IMG_SIZE - 1) 
        && index < (this.smallPhotoArr.length - Constants.CACHE_IMG_SIZE)) {
      Image(img)
        .onClick(() => this.smallImgClickAction(index))
    }
  }

  aboutToAppear() {
    let displayClass: display.Display = display.getDefaultDisplaySync()
    let width = displayClass?.width / displayClass.densityPixels 
      ?? Constants.DEFAULT_WIDTH
    this.deviceWidth = width
    this.smallImgWidth = (width - Constants.LIST_ITEM_SPACE 
                          * (Constants.SHOW_COUNT - 1)) / Constants.SHOW_COUNT
    this.imageWidth = this.deviceWidth + this.smallImgWidth
  }

  onPageShow() {
    this.smallScroller.scrollToIndex(this.storage.selectedIndex)
    this.bigScroller.scrollToIndex(this.storage.selectedIndex)
  }

  goDetailPage(): void {
    router.pushUrl({
      url: Constants.URL_DETAIL_PAGE,
      params: { photoArr: this.photoArr }
    })
  }

  smallImgClickAction(index: number): void {
    this.storage.selectedIndex = index - Constants.CACHE_IMG_SIZE
    this.smallScroller.scrollToIndex(this.storage.selectedIndex)
    this.bigScroller.scrollToIndex(this.storage.selectedIndex)
  }

  smallScrollAction(type: scrollTypeEnum): void {
    this.storage.selectedIndex = Math.round(((
      this.smallScroller.currentOffset().xOffset as number) 
      + this.smallImgWidth / Constants.DOUBLE_NUMBER) 
        / (this.smallImgWidth + Constants.LIST_ITEM_SPACE))
    if (type === scrollTypeEnum.SCROLL) {
      this.bigScroller.scrollTo({
        xOffset: this.storage.selectedIndex * this.imageWidth, yOffset: 0
      })
    } else {
      this.smallScroller.scrollTo({
        xOffset: this.storage.selectedIndex * this.smallImgWidth, yOffset: 0
      })
    }
  }

  bigScrollAction(type: scrollTypeEnum): void {
    let smallWidth = this.smallImgWidth + Constants.LIST_ITEM_SPACE
    this.storage.selectedIndex = Math.round(((
      this.bigScroller.currentOffset().xOffset as number) +
      smallWidth / Constants.DOUBLE_NUMBER) / this.imageWidth)
    if (type === scrollTypeEnum.SCROLL) {
      this.smallScroller.scrollTo({
        xOffset: this.storage.selectedIndex * smallWidth, yOffset: 0 })
    } else {
      this.bigScroller.scrollTo({
        xOffset: this.storage.selectedIndex * this.imageWidth, yOffset: 0 })
    }
  }

  build() {
    Navigation() {
      Stack({ alignContent: Alignment.Bottom }) {
        List({
          scroller: this.bigScroller, 
          initialIndex: this.storage.selectedIndex
        }) {
          ForEach(this.photoArr, (img: Resource) => {
            ListItem() {
              Image(img)
                .height(Constants.FULL_PERCENT)
                .width(Constants.FULL_PERCENT)
                .objectFit(ImageFit.Contain)
                .gesture(PinchGesture({ fingers: Constants.DOUBLE_NUMBER })
                  .onActionStart(() => this.goDetailPage()))
                .onClick(() => this.goDetailPage())
            }
            .padding({
              left: this.smallImgWidth / Constants.DOUBLE_NUMBER,
              right: this.smallImgWidth / Constants.DOUBLE_NUMBER
            })
            .width(this.imageWidth)
          }, (item: Resource) => JSON.stringify(item))
        }
        .onDidScroll((scrollOffset, scrollState) => {
          if (scrollState === ScrollState.Fling) {
            this.bigScrollAction(scrollTypeEnum.SCROLL)
          }
        })
        .scrollBar(BarState.Off)
        .onScrollStop(() => this.bigScrollAction(scrollTypeEnum.STOP))
        .width(Constants.FULL_PERCENT)
        .height(Constants.FULL_PERCENT)
        .padding({ bottom: this.smallImgWidth * Constants.DOUBLE_NUMBER })
        .listDirection(Axis.Horizontal)

        List({
          scroller: this.smallScroller,
          space: Constants.LIST_ITEM_SPACE,
          initialIndex: this.storage.selectedIndex
        }) {
          ForEach(this.smallPhotoArr, (img: Resource, index?: number) => {
            ListItem() {
              this.SmallImgItemBuilder(img, index)
            }
            .width(this.smallImgWidth)
            .aspectRatio(1)
          }, (item: Resource) => JSON.stringify(item))
        }
        .listDirection(Axis.Horizontal)
        .onDidScroll((scrollOffset, scrollState) => {
          if (scrollState === ScrollState.Fling) {
            this.smallScrollAction(scrollTypeEnum.SCROLL)
          }
        })
        .scrollBar(BarState.Off)
        .onScrollStop(() => this.smallScrollAction(scrollTypeEnum.STOP))
        .margin({
          top: $r('app.float.detail_list_margin'), 
          bottom: $r('app.float.detail_list_margin')
        })
        .height(this.smallImgWidth)
        .width(Constants.FULL_PERCENT)
      }
      .width(this.imageWidth)
      .height(Constants.FULL_PERCENT)
    }
    .title(Constants.PAGE_TITLE)
    .hideBackButton(false)
    .titleMode(NavigationTitleMode.Mini)
  }
}

八、查看大图页

查看大图页面由一个横向滚动的List组件来实现图片左右滑动时切换图片的功能,和一个Row组件实现图片的缩放和拖动查看细节功能。对图片进行缩放时会从List组件切换成Row组件来实现对单张图片的操作,对单张图片进行滑动操作时,也会由Row组件转换为List组件来实现图片的切换功能。

界面跳转动画是通过共享元素实现的,通过给Image添加sharedTransition属性来实现共享元素转场动画。两个页面的组件配置为同一个shareId,当shareId被配置为空字符串时不会有共享元素转场效果。

大图浏览界面双指捏合时通过改变Image组件的scale来控制图片的缩放,单手拖动时通过改变Image的偏移量来控制图片的位置,手势操作调用组合手势GestureGroup实现。其中PinchGesture实现双指缩放手势,PanGesture实现单指拖动手势。

// entry/src/main/ets/pages/DetailPage.ets
import { router, display, AppStorageV2 } from '@kit.ArkUI'
import Constants from '../common/constants/Constants'
import { ListPageStorage } from '../model/ListPageModel'

@Entry
@ComponentV2
struct DetailPage {
  scroller: Scroller = new Scroller()
  photoArr: Array<Resource> = (router.getParams() as 
    Record<string, Array<Resource>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`]
  preOffsetX: number = 0
  preOffsetY: number = 0
  currentScale: number = 1
  @Local deviceWidth: number = Constants.DEFAULT_WIDTH
  @Local smallImgWidth: number = (Constants.DEFAULT_WIDTH - Constants.LIST_ITEM_SPACE 
                                  * (Constants.SHOW_COUNT - 1)) /
  Constants.SHOW_COUNT
  @Local imageWidth: number = this.deviceWidth + this.smallImgWidth
  @Local isScaling: boolean = true
  @Local imgScale: number = 1
  @Local imgOffSetX: number = 0
  @Local imgOffSetY: number = 0
  @Local bgOpacity: number = 0
  @Local storage: ListPageStorage = AppStorageV2.connect(
    ListPageStorage, 
    'storage', 
    () => new ListPageStorage()
  )!

  aboutToAppear() {
    let displayClass: display.Display = display.getDefaultDisplaySync()
    let width = displayClass?.width / displayClass.densityPixels 
      ?? Constants.DEFAULT_WIDTH
    this.deviceWidth = width
    this.smallImgWidth = (width - Constants.LIST_ITEM_SPACE 
                          * (Constants.SHOW_COUNT - 1)) / Constants.SHOW_COUNT
    this.imageWidth = this.deviceWidth + this.smallImgWidth
  }

  resetImg(): void {
    this.imgScale = 1
    this.currentScale = 1
    this.preOffsetX = 0
    this.preOffsetY = 0
  }

  handlePanEnd(): void {
    let initOffsetX = (this.imgScale - 1) * this.imageWidth 
      + this.smallImgWidth
    if (Math.abs(this.imgOffSetX) > initOffsetX) {
      if (this.imgOffSetX > initOffsetX && this.storage.selectedIndex > 0) {
        this.storage.selectedIndex -= 1
      } else if (this.imgOffSetX < -initOffsetX 
                 && this.storage.selectedIndex < (this.photoArr.length - 1)) {
        this.storage.selectedIndex += 1
      }
      this.isScaling = false
      this.resetImg()
      this.scroller.scrollTo({ xOffset: this.storage.selectedIndex 
        * this.imageWidth, yOffset: 0 })
    }
  }

  build() {
    Stack() {
      List({
        scroller: this.scroller, 
        initialIndex: this.storage.selectedIndex
      }) {
        ForEach(this.photoArr, (img: Resource) => {
          ListItem() {
            Image(img)
              .objectFit(ImageFit.Contain)
              .onClick(() => router.back())
          }
          .gesture(GestureGroup(GestureMode.Exclusive,
            PinchGesture({ fingers: Constants.DOUBLE_NUMBER })
              .onActionStart(() => {
                this.resetImg()
                this.isScaling = true
                this.imgOffSetX = 0
                this.imgOffSetY = 0
              })
              .onActionUpdate((event?: GestureEvent) => {
                if (event) {
                  this.imgScale = this.currentScale * event.scale
                }
              })
              .onActionEnd(() => {
                if (this.imgScale < 1) {
                  this.resetImg()
                  this.imgOffSetX = 0
                  this.imgOffSetY = 0
                } else {
                  this.currentScale = this.imgScale
                }
              }), PanGesture()
              .onActionStart(() => {
                this.resetImg()
                this.isScaling = true
              })
              .onActionUpdate((event?: GestureEvent) => {
                if (event) {
                  this.imgOffSetX = this.preOffsetX + event.offsetX
                  this.imgOffSetY = this.preOffsetY + event.offsetY
                }
              })
          ))
          .padding({
            left: this.smallImgWidth / Constants.DOUBLE_NUMBER,
            right: this.smallImgWidth / Constants.DOUBLE_NUMBER
          })
          .width(this.imageWidth)
        }, (item: Resource) => JSON.stringify(item))
      }
      .onScrollStop(() => {
        let currentIndex 
          = Math.round(((this.scroller.currentOffset().xOffset as number) +
          (this.imageWidth / Constants.DOUBLE_NUMBER)) / this.imageWidth)
        this.storage.selectedIndex = currentIndex
        this.scroller.scrollTo({ xOffset: currentIndex 
          * this.imageWidth, yOffset: 0 })
      })
      .width(Constants.FULL_PERCENT)
      .height(Constants.FULL_PERCENT)
      .listDirection(Axis.Horizontal)
      .visibility(this.isScaling ? Visibility.Hidden : Visibility.Visible)

      Row() {
        Image(this.photoArr[this.storage.selectedIndex])
          .position({ x: this.imgOffSetX, y: this.imgOffSetY })
          .scale({ x: this.imgScale, y: this.imgScale })
          .objectFit(ImageFit.Contain)
          .onClick(() => router.back())
      }
      .gesture(GestureGroup(GestureMode.Exclusive,
        PinchGesture({ fingers: Constants.DOUBLE_NUMBER })
          .onActionUpdate((event?: GestureEvent) => {
            if (event) {
              this.imgScale = this.currentScale * event.scale
            }
          })
          .onActionEnd(() => {
            if (this.imgScale < 1) {
              this.resetImg()
              this.imgOffSetX = 0
              this.imgOffSetY = 0
            } else {
              this.currentScale = this.imgScale
            }
          }),
        PanGesture()
          .onActionStart(() => {
            this.preOffsetX = this.imgOffSetX
            this.preOffsetY = this.imgOffSetY
          })
          .onActionUpdate((event?: GestureEvent) => {
            if (event) {
              this.imgOffSetX = this.preOffsetX + event.offsetX
              this.imgOffSetY = this.preOffsetY + event.offsetY
            }
          })
          .onActionEnd(() => this.handlePanEnd())
      ))
      .padding({
        left: this.smallImgWidth / Constants.DOUBLE_NUMBER,
        right: this.smallImgWidth / Constants.DOUBLE_NUMBER
      })
      .width(this.imageWidth)
      .height(Constants.FULL_PERCENT)
      .visibility(this.isScaling ? Visibility.Visible : Visibility.Hidden)
    }
    .offset({ x: -(this.smallImgWidth / Constants.DOUBLE_NUMBER) })
    .width(this.imageWidth)
    .height(Constants.FULL_PERCENT)
    .backgroundColor($r('app.color.detail_background'))
  }
}

九、路由页面配置

页面间通过路由跳转时,需要配置每个页面的源路径。

{
  "src": [
    "pages/IndexPage",
    "pages/DetailPage",
    "pages/ListPage",
    "pages/ListDetailPage"
  ]
}

✋ 需要参加鸿蒙认证的请点击 鸿蒙认证链接