9afce6f6创建于 2025年5月7日历史提交
/*
 * Copyright (c) 2024 Huawei Device Co., Ltd.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { AppInfo } from '../model/AppInfo';
import { APP_LIST_DATA, FIRST_APP_LIST_DATA, SECOND_APP_LIST_DATA } from '../model/MockData';
import { GridItemAddCtrl, GridItemDeletionCtrl, AddStatus, DeletionStatus } from '../model/GridItemDeletionCtrl';
import promptAction from '@ohos.promptAction';
import { logger } from '../utils/Logger';

const SHOW_TIME: number = 2000; // 弹窗展示时间
const ANIMATION_DURATION: number = 1000; // 动画总时长200ms

/**
 * 功能描述: 本示例直接进行交换和删除元素会给用户带来不好的体验效果,因此需要在此过程中注入一些特色的动画来提升体验效果,本案例通过Grid组件、
 * attributeModifier、以及animateTo函数实现了拖拽动画,删除动画和添加时的位移动画。
 *
 * 推荐场景: 在进行交换和删除元素时注入一些特色的动画来提升体验效果
 *
 * 核心组件:
 * 1. onAreaChange
 * 2. onTouch
 *
 * 实现步骤:
 * 1. 本示例主要通过attributeModifier、supportAnimation、animateTo等实现了删除动画,以及长按拖拽动画。
 * 2. attributeModifier绑定自定义属性对象,控制每个网格元素的属性更新。执行删除操作时,通过animateTo去更新offset值,以及opacity等属性,执行添加操作时,通过animateTo去更新translate偏移量和visibility等属性。
 * 3. supportAnimation设置为true,支持GridItem拖拽动画,在onItemDragStart开始拖拽网格元素时触发,onItemDragStart可以返回一个@Builder修饰的自定义组件,这样在拖拽的时候,能够显示目标元素。
 * 4. onItemDrop在网格元素内停止拖拽时触发。此时执行元素位置的切换功能。
 * 5. 通过数组appNameList控制应用类别1和应用类别2里的应用是否展示添加标识,在应用类别1和应用类别2被点击时,通过将appInfo添加到appInfoList实现添加功能, 通过transition实现应用被添加到首页应用时的动画效果。
 */

@Component
export struct GridExchangeComponent {
  @Provide isEdit: boolean = false;
  @Provide @Watch('monitoringData') appInfoList: AppInfo[] = APP_LIST_DATA;
  @Provide firstAppInfoList: AppInfo[] = FIRST_APP_LIST_DATA;
  @Provide secondAppInfoList: AppInfo[] = SECOND_APP_LIST_DATA;
  @State movedItem: AppInfo = new AppInfo();
  @Provide appNameList: Array<string> = []; // 存放首页应用的appName
  @Provide originalAppNameList: Array<string> = []; // 存放首页应用的appName初始值
  private originAppInfoList: AppInfo[] = [];
  @Provide GridItemDeletion: GridItemDeletionCtrl<AppInfo> =
    new GridItemDeletionCtrl<AppInfo>(this.appInfoList); // gridItem删除管理类
  @Provide FirstGridItemAdd: GridItemAddCtrl<AppInfo> =
    new GridItemAddCtrl<AppInfo>(this.firstAppInfoList); // gridItem添加管理类
  @Provide SecondGridItemAdd: GridItemAddCtrl<AppInfo> =
    new GridItemAddCtrl<AppInfo>(this.secondAppInfoList); // gridItem添加管理类
  @StorageLink('addStatus') addStatus: AddStatus = AddStatus.FINISH; // 向首页应用添加应用的动画状态
  @StorageLink('deletionStatus') deletionStatus: DeletionStatus = DeletionStatus.FINISH; // 首页应用删除应用的动画状态
  private itemAreaWidth: number = 0;
  private isChange: boolean = false;

  aboutToAppear(): void {
    this.appInfoList.forEach((item: AppInfo) => {
      this.appNameList.push(JSON.stringify(item.name));
    });
  }

  /**
   * 拖拽过程中展示的样式
   */
  @Builder
  pixelMapBuilder() {
    IconWithNameView({ app: this.movedItem })
      .width($r('app.string.grid_exchange_builder_width'))
  }

  /**
   * 应用类别里每一个应用和位移动画过程中的展示样式
   * @param data app信息和首页应用里的appName
   */
  @Builder
  appItemWithNameView(data: AddedIconWithNameViewMode) {
    Stack({ alignContent: Alignment.TopEnd }) {
      Image(data.app.icon)
        .width($r('app.string.grid_exchange_icon_size'))
        .height($r('app.string.grid_exchange_icon_size'))
        .interpolation(ImageInterpolation.High)
        .syncLoad(true)
        .draggable(false) // TODO:知识点:保持默认值true时,图片有默认拖拽效果,会影响Grid子组件拖拽判断,所以修改为false
      // 不在首页应用里的app才会展示添加标识
      if (this.isEdit && !data.homeAppNames.includes(JSON.stringify(data.app.name))) {
        Image($r('app.media.ic_public_list_add_light'))
          .width($r('app.string.grid_exchange_remove_icon_size'))
          .height($r('app.string.grid_exchange_remove_icon_size'))
          .markAnchor({
            x: $r('app.string.grid_exchange_add_sign_x_size'),
            y: $r('app.string.grid_exchange_add_sign_y_size')
          })
          .id('add_button')
      }
    }

    Text(data.app.name)
      .width($r('app.string.grid_exchange_app_name_width'))
      .fontSize($r('app.string.grid_exchange_app_name_font_size'))
      .maxLines(1)
      .fontColor(Color.Black)
      .textAlign(TextAlign.Center)
      .margin({ top: $r('app.string.grid_exchange_app_name_margin_top_size') })
  }

  /**
   * 应用类别中每一个应用模块
   * @param data app信息和首页应用里的appName
   */
  @Builder
  addedIconWithNameView(data: AddedIconWithNameViewMode) {
    Column() {
      this.appItemWithNameView({ app: data.app, homeAppNames: data.homeAppNames });
    }
    .id(data.app.name.toString()) // 绑定id信息,可通过id获取组件坐标
    .width($r('app.string.grid_exchange_grid_item_width'))
    .height($r('app.string.grid_exchange_grid_item_height'))
    .justifyContent(FlexAlign.Center)
  }

  /**
   * 用于位移动画的应用样式
   * @param data app信息,首页应用里的appName和gridItem添加管理类
   */
  @Builder
  translateIconWithNameView(data: TranslateItemWithNameViewMode) {
    Column() {
      this.appItemWithNameView({ app: data.app, homeAppNames: data.homeAppNames });
    }
    // TODO:知识点:动态绑定属性信息
    .attributeModifier(data.translateItemModifier.getModifier(data.app))
    .width($r('app.string.grid_exchange_grid_item_width'))
    .height($r('app.string.grid_exchange_grid_item_height'))
    .justifyContent(FlexAlign.Center)
  }

  /**
   * 用于展示应用类别1和应用类别2
   * @param data app标题和appInfo所在数组
   */
  @Builder
  sortIconWithNameView(data: SortIconWithNameView) {
    Column() {
      Text($r(data.title))
        .textAlign(TextAlign.Start)
        .width($r('app.string.grid_exchange_container_size'))
        .height($r('app.string.grid_exchange_title_height'))
        .padding({
          left: $r('app.string.ohos_id_card_padding_start'),
          right: $r('app.string.ohos_id_card_padding_start')
        })
      // TODO:性能知识点: 使用了flex布局会对应用功耗产生较大影响,请结合实际情况谨慎使用。此处使用flex布局是因为位移动画所需。
      Flex() {
        // TODO:性能知识点:动态加载数据场景可以使用LazyForEach遍历数据。https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-rendering-control-lazyforeach-0000001524417213-V3
        ForEach(data.appInfoList, (item: AppInfo) => {
          Stack() {
            this.translateIconWithNameView({
              app: item,
              homeAppNames: this.appNameList,
              translateItemModifier: data.translateItemModifier
            });
            this.addedIconWithNameView({ app: item, homeAppNames: this.appNameList });
          }
          .width($r('app.string.grid_exchange_sort_item_width'))
          .onClick(() => {
            if (!this.isEdit) {
              return;
            }
            if (this.appNameList.includes(JSON.stringify(item.name))) {
              promptAction.showToast({
                message: $r('app.string.grid_exchange_repeat_app_message'),
                duration: SHOW_TIME,
              })
              return;
            }
            // 上一个动画处于完成状态,再去执行下一个动画
            if (this.addStatus === AddStatus.FINISH) {
              this.addStatus = AddStatus.IDLE;
              this.appNameList.push(JSON.stringify(item.name));
              data.translateItemModifier.addGridItem(item, this.appInfoList);

              // 动画执行结束后向首页应用里添加被点击应用
              setTimeout(() => {
                this.appInfoList.push(item);
              }, ANIMATION_DURATION)
            }
          })
        }, (item: AppInfo) => JSON.stringify(item))
      }
      .width($r('app.string.grid_exchange_container_size'))
      .layoutWeight(1)
    }
    .id('flexContainer')
    .height($r('app.string.grid_exchange_first_app_container_height'))
    .borderRadius($r('app.string.grid_exchange_app_container_border_radius'))
    .margin({
      left: $r('app.string.grid_exchange_app_container_margin_size'),
      right: $r('app.string.grid_exchange_app_container_margin_size'),
      top: $r('app.string.grid_exchange_app_container_margin_top_size')
    })
    .backgroundColor(Color.White)
  }

  /**
   * 交换应用位置函数
   * @param itemIndex 目标网格元素的index
   * @param insertIndex 被切换网格元素的index
   */
  changeIndex(itemIndex: number, insertIndex: number): void {
    this.appInfoList.splice(insertIndex, 0, this.appInfoList.splice(itemIndex, 1)[0]);
  }

  // 监听数据变化函数
  monitoringData(): void {
    this.isChange = true;
    this.GridItemDeletion = new GridItemDeletionCtrl<AppInfo>(this.appInfoList);
  }

  build() {
    Column() {
      Row() {
        Text($r('app.string.grid_exchange_cancel'))
          .fontColor($r('app.string.grid_exchange_color1'))
          .onClick(() => {
            if (!this.isEdit) {
              return;
            }
            if (!this.isChange) {
              this.isEdit = false;
              return;
            }
            promptAction.showDialog({
              message: $r('app.string.grid_exchange_toast_message'),
              alignment: DialogAlignment.Center,
              buttons: [
                {
                  text: $r('app.string.grid_exchange_cancel'),
                  color: $r('app.string.grid_exchange_color1'),
                },
                {
                  text: $r('app.string.grid_exchange_save'),
                  color: $r('app.string.grid_exchange_color1'),
                }
              ],
            })
              .then(data => {
                if (data.index === 0) {
                  this.appInfoList = [...this.originAppInfoList];
                  // 将appName初始值数组赋值给appNameList
                  this.appNameList = [...this.originalAppNameList];
                  this.GridItemDeletion = new GridItemDeletionCtrl(this.appInfoList);
                  this.isEdit = false;
                  this.isChange = false;
                } else {
                  this.isEdit = false;
                  this.isChange = false;
                }
              })
              .catch((err: Error) => {
                logger.info(`showDialog error: ${JSON.stringify(err)}`);
              })
          })

        Text($r('app.string.grid_exchange_management_applications'))
          .fontSize($r('app.string.grid_exchange_title_name_font_size'))

        Button({ type: ButtonType.Normal }) {
          Text(this.isEdit ? $r('app.string.grid_exchange_save') : $r('app.string.grid_exchange_edit'))
            .fontColor(Color.White)
        }
        .width($r('app.string.grid_exchange_button_width'))
        .height($r('app.string.grid_exchange_button_height'))
        .borderRadius($r('app.string.grid_exchange_button_border_radius'))
        .backgroundColor($r('app.string.grid_exchange_color1'))
        .onClick(() => {
          this.isEdit = !this.isEdit;
          // 编辑和保存的情况下都会重新赋值初始值所在数组
          this.originAppInfoList = [...this.appInfoList];
          this.originalAppNameList = [...this.appNameList];
        })
      }
      .padding({
        left: $r('app.string.grid_exchange_title_bar_padding_left'),
        right: $r('app.string.grid_exchange_title_bar_padding_right'),
        top: $r('app.string.grid_exchange_title_bar_padding_top')
      })
      .width($r('app.string.grid_exchange_container_size'))
      .alignItems(VerticalAlign.Center)
      .justifyContent(FlexAlign.SpaceBetween)

      Text(this.isEdit ? $r('app.string.grid_exchange_operation_message') : $r('app.string.grid_exchange_edit_message'))
        .fontColor(Color.Grey)
        .fontSize($r('app.string.grid_exchange_edit_message_font_size'))
        .margin({
          top: $r('app.string.ohos_id_elements_margin_vertical_l'),
          bottom: $r('app.string.ohos_id_elements_margin_vertical_m')
        })

      Column() {
        Text($r('app.string.grid_exchange_title_message'))
          .textAlign(TextAlign.Start)
          .width($r('app.string.grid_exchange_container_size'))
          .textAlign(TextAlign.Start)
          .height($r('app.string.grid_exchange_title_height'))
          .padding({
            left: $r('app.string.ohos_id_card_padding_start'),
            right: $r('app.string.ohos_id_card_padding_start')
          })
        Grid() {
          // 性能知识点:动态加载数据场景可以使用LazyForEach遍历数据。https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-rendering-control-lazyforeach-0000001524417213-V3
          ForEach(this.appInfoList, (item: AppInfo, index: number) => {
            GridItem() {
              IconWithNameView({ app: item })
            }
            .id(`${item.name.toString()}InHome`) // 绑定id信息,可通过id获取组件坐标
            /**
             * 性能知识点:此函数在区域发生大小变化的时候会进行调用,由于删除操作或者网格元素的交互都能够触发区域函数的使用,操作频繁,
             * 建议此处减少日志的打印、复用函数逻辑来降低性能的内耗。
             * 参考链接:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V2/ts-universal-component-area-change-event-0000001478061665-V2
             */
            .onAreaChange((oldValue: Area, newValue: Area) => {
              this.itemAreaWidth = Number(newValue.width);
            })
            /**
             * 性能知识点:此函数进行手势操作的时候会进行多次调用,建议此处减少日志的打印、复用函数逻辑来降低性能的内耗。
             * 参考链接:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V2/ts-universal-events-touch-0000001427902424-V2
             */
            .onTouch((event: TouchEvent) => {
              if (event.type === TouchType.Down) {
                this.movedItem = this.appInfoList[index];
              }
            })
            // TODO:知识点:动态绑定属性信息
            .attributeModifier(this.GridItemDeletion.getModifier(item))
            .onClick(() => {
              if (!this.isEdit) {
                return;
              }
              // 上一个动画处于完成状态,再去执行下一个动画
              if (this.deletionStatus === DeletionStatus.FINISH) {
                this.deletionStatus = DeletionStatus.IDLE;
                this.GridItemDeletion.deleteGridItem(item, this.itemAreaWidth);
                this.appNameList.splice(this.appNameList.indexOf(JSON.stringify(item.name)), 1);
              }
            })
          }, (item: AppInfo) => JSON.stringify(item))
        }
        .columnsTemplate('1fr 1fr 1fr 1fr 1fr')
        .width($r('app.string.grid_exchange_container_size'))
        .layoutWeight(1)
        // TODO:知识点:支持GridItem拖拽动画。
        .supportAnimation(true)
        .editMode(this.isEdit)
        .onItemDragStart((event: ItemDragInfo, itemIndex: number) => {
          // TODO:知识点:在onItemDragStart函数返回自定义组件,可在拖拽过程中显示此自定义组件。
          return this.pixelMapBuilder();
        })
        .onItemDrop((event: ItemDragInfo, itemIndex: number, insertIndex: number, isSuccess: boolean) => {
          // TODO:知识点:执行gridItem切换操作
          if (isSuccess && insertIndex < this.appInfoList.length) {
            this.changeIndex(itemIndex, insertIndex);
          }
        })
      }
      .id('gridContainer')
      .height($r('app.string.grid_exchange_app_container_height'))
      .borderRadius($r('app.string.grid_exchange_app_container_border_radius'))
      .margin({
        left: $r('app.string.grid_exchange_app_container_margin_size'),
        right: $r('app.string.grid_exchange_app_container_margin_size')
      })
      .backgroundColor(Color.White)

      this.sortIconWithNameView({
        title: 'app.string.grid_exchange_first_title_message',
        appInfoList: this.firstAppInfoList,
        translateItemModifier: this.FirstGridItemAdd
      });

      this.sortIconWithNameView({
        title: 'app.string.grid_exchange_second_title_message',
        appInfoList: this.secondAppInfoList,
        translateItemModifier: this.SecondGridItemAdd
      });
    }
    .height($r('app.string.grid_exchange_container_size'))
    .backgroundColor($r('app.color.grid_exchange_grid_background_color'))
    .alignItems(HorizontalAlign.Center)
  }
}

/**
 * App自定义组件
 */
@Component
struct IconWithNameView {
  private app: AppInfo = new AppInfo();
  @Consume isEdit: boolean;

  build() {
    Column() {
      Stack({ alignContent: Alignment.TopEnd }) {
        Image(this.app.icon)
          .width($r('app.string.grid_exchange_icon_size'))
          .height($r('app.string.grid_exchange_icon_size'))
          .interpolation(ImageInterpolation.High)
          .syncLoad(true)
          .draggable(false) // TODO:知识点:保持默认值true时,图片有默认拖拽效果,会影响Grid子组件拖拽判断,所以修改为false
        if (this.isEdit) {
          Image($r('app.media.ic_public_remove_filled'))
            .width($r('app.string.grid_exchange_remove_icon_size'))
            .height($r('app.string.grid_exchange_remove_icon_size'))
            .markAnchor({ x: '-40%', y: '40%' })
            .draggable(false)
            .id('delete_button')
        }
      }

      Text(this.app.name)
        .width($r('app.string.grid_exchange_app_name_width'))
        .fontSize($r('app.string.grid_exchange_app_name_font_size'))
        .maxLines(1)
        .fontColor(Color.Black)
        .textAlign(TextAlign.Center)
        .margin({ top: 1 })
    }
    .width($r('app.string.grid_exchange_grid_item_width'))
    .height($r('app.string.grid_exchange_grid_item_height'))
    .justifyContent(FlexAlign.Center)
  }
}

/**
 * 应用类别中每一个GridItem元素模块传参所用类型
 */
interface AddedIconWithNameViewMode {
  // app信息
  app: AppInfo,

  // 首页应用里的app名所在数组
  homeAppNames: Array<string>,
}

/**
 * 用于位移动画的每一个GridItem元素传参所用类型
 */
interface TranslateItemWithNameViewMode {
  // app信息
  app: AppInfo,

  // 首页应用里的app名所在数组
  homeAppNames: Array<string>,

  // gridItem添加管理类
  translateItemModifier: GridItemAddCtrl<AppInfo>
}

/**
 * 应用类别模块传参所用类型
 */
interface SortIconWithNameView {
  // 标题
  title: string,

  // appInfo所在数组
  appInfoList: Array<AppInfo>,

  // gridItem添加管理类
  translateItemModifier: GridItemAddCtrl<AppInfo>
}