鸿蒙学习实战之路:HarmonyOS Grid 网格元素拖拽交换实现

HarmonyOS Grid 网格元素拖拽交换实现

官方文档永远是你的好伙伴,请收藏!

华为开发者联盟 - Grid 组件参考文档
华为开发者联盟 - GridItem 组件参考文档

关于本文

本文主要介绍在 HarmonyOS 中如何实现 Grid 网格元素的拖拽交换功能,包含相同大小和不同大小网格元素的实现方法

  • 本文并不能代替官方文档,所有内容基于官方文档+实践记录
  • 所有代码示例都有详细注释,建议自己动手尝试
  • 基本所有关键功能都会附上对应的文档链接,强烈建议你点看看看

概述

Grid 网格元素拖拽交换功能在应用中经常会被使用,比如编辑九宫格图片时需要拖拽图片改变排序。当网格中图片进行拖拽交换时,元素排列会跟随图片拖拽的位置而变化,并有对应的动画效果,提供良好的用户体验。

先来看看最终效果:

Grid网格元素拖拽交换效果图

实现原理

关键技术

Grid 网格元素拖拽交换功能主要通过三个部分实现:

  1. Grid 容器组件 - 用来构建网格布局
  2. 组合手势 - 实现元素的拖拽操作
  3. animateTo 动画 - 提供流畅的视觉效果

重要提醒!
Grid 组件的支持 animation 功能有一些限制:

  • 必须设置 supportAnimation 为 true
  • 仅在滚动模式下(设置 rowsTemplate 或 columnsTemplate)支持
  • 只在大小规则的 Grid 中支持拖拽动画,跨行或跨列场景不支持
  • 跨行跨列场景需要自定义 Grid 布局、手势和动画

开发流程

实现拖拽交换的基本步骤:

  1. 实现 Grid 布局,设置 editMode=true 开启编辑模式
  2. 给 GridItem 组件绑定长按、拖拽等手势
  3. 使用 animateTo 添加动画效果

华为开发者联盟 - 组合手势参考文档
华为开发者联盟 - animateTo 参考文档

相同大小网格元素,长按拖拽

场景描述

最常见的场景就是编辑九宫格图片,长按图片可以拖拽交换排序,拖拽时旁边的图片会即时移动,形成新的布局。

看看实际效果:

相同大小网格元素拖拽效果图

开发步骤

1. 创建 Grid 布局

首先创建一个基本的 Grid 布局,包含相同大小的 GridItem:

Grid() {
  // 使用ForEach遍历数据生成GridItem
  ForEach(this.numbers, (item: number) => {
    GridItem() {
      Image($r(`app.media.image${item}`)) // 根据item加载对应的图片资源
        .width('100%') // 宽度占满GridItem
        .height(this.curBp === 'md' ? 131 : 105) // 根据屏幕尺寸设置高度
        .draggable(false) // 禁止图片自身的拖拽
        .animation({ curve: Curve.Sharp, duration: 300 }) // 设置动画效果
    }
  }, (item: number) => item.toString()) // 设置唯一的key值
}
.width(this.curBp === 'md' ? '66%' : '100%') // 根据屏幕尺寸设置宽度
.scrollBar(BarState.Off) // 不显示滚动条
.columnsTemplate('1fr 1fr 1fr') // 设置为3列布局
.columnsGap(this.curBp === 'md' ? 6 : 4) // 设置列间距
.rowsGap(this.curBp === 'md' ? 6 : 4) // 设置行间距
.height(this.curBp === 'md' ? 406 : 323) // 根据屏幕尺寸设置高度

2. 开启编辑模式和动画

给 Grid 组件添加两个重要属性:

.editMode(true) // 开启编辑模式,允许拖拽
.supportAnimation(true) // 开启动画效果

3. 实现数组交换逻辑

定义一个函数来处理数组元素交换:

changeIndex(index1: number, index2: number) {
  // 从数组中删除第一个索引的元素,并返回被删除的元素
  let tmp = this.numbers.splice(index1, 1);
  // 在第二个索引位置插入被删除的元素
  this.numbers.splice(index2, 0, tmp[0])
}

4. 绑定拖拽事件

给 Grid 组件绑定拖拽相关的事件:

// 拖拽开始时触发
.onItemDragStart((_, itemIndex: number) => {
  // 记录当前拖拽的图片编号
  this.imageNum = this.numbers[itemIndex];
  // 返回拖拽时显示的像素图
  return this.pixelMapBuilder();
})
// 拖拽结束时触发
.onItemDrop((_, itemIndex: number, insertIndex: number, isSuccess: boolean) => {
  // 检查拖拽是否成功,并且插入索引是否有效
  if (!isSuccess || insertIndex >= this.numbers.length) {
    return;
  }
  // 执行数组元素交换
  this.changeIndex(itemIndex, insertIndex);
})

// pixelMapBuilder()方法用于创建拖拽时显示的图片

## 不同大小网格元素,长按拖拽

### 场景描述

在展示设备等场景中,我们经常需要混合使用不同大小的网格元素。这时候拖拽交换就会更复杂一些。

看看效果:

![不同大小网格元素拖拽效果图](https://i-blog.csdnimg.cn/img_convert/e0b509710bce013d28119d9d821c36d5.gif)

> 注意:当前方案仅适用于页面包含一个较大网格元素的布局

### 开发步骤

#### 1. 创建Grid布局

首先创建一个包含不同大小GridItem的Grid布局:

```typescript
Grid() {
  ForEach(this.numbers, (item: number) => {
    GridItem() {
      Stack({ alignContent: Alignment.TopEnd }) {
        Image(this.changeImage(item))
          .width('100%')
          .borderRadius(16)
          .objectFit(this.curBp === 'md' ? ImageFit.Fill : ImageFit.Cover)
          .draggable(false)
          .animation({ curve: Curve.Sharp, duration: 300 })
      }
    }
    .rowStart(0)
    .rowEnd(this.getRowEnd(item)) // 根据item决定元素占据的行数
    .scale({ x: this.scaleItem === item ? 1.02 : 1, y: this.scaleItem === item ? 1.02 : 1 }) // 拖拽时放大效果
    .zIndex(this.dragItem === item ? 1 : 0) // 拖拽元素置于顶层
    .translate(this.dragItem === item ? { x: this.offsetX, y: this.offsetY } : { x: 0, y: 0 }) // 拖拽位移
    .hitTestBehavior(this.isDraggable(this.numbers.indexOf(item)) ? HitTestMode.Default : HitTestMode.None) // 控制点击行为
    // ...
  }, (item: number) => item.toString())
}
.width('100%')
.height('100%')
.editMode(true)
.scrollBar(BarState.Off)
.columnsTemplate('1fr 1fr') // 2列布局
.supportAnimation(true)
.columnsGap(12)
.rowsGap(12)
.enableScrollInteraction(true)

2. 定义元素移动相关计算函数

实现元素交换重新排序的核心逻辑:

itemMove(index: number, newIndex: number): void {
  if (!this.isDraggable(newIndex)) {
    return;
  }
  let tmp = this.numbers.splice(index, 1);
  this.numbers.splice(newIndex, 0, tmp[0]);
  this.bigItemIndex = this.numbers.findIndex((item) => item === 0);
}

isInLeft(index: number) {
  if (index === this.bigItemIndex) {
    return index % 2 == 0;
  }
  if (this.bigItemIndex % 2 === 0) {
    if (index - this.bigItemIndex === 2 || index - this.bigItemIndex === 1) {
      return false;
    }
  } else {
    if (index - this.bigItemIndex === 1) {
      return false;
    }
  }
  if (index > this.bigItemIndex) {
    return index % 2 == 1;
  } else {
    return index % 2 == 0;
  }
}

// 上下左右移动的方法
down(index: number): void {
  if ([this.numbers.length - 1, this.numbers.length - 2].includes(index)) {
    return;
  }
  if (this.bigItemIndex - index === 1) {
    return;
  }
  if ([14, 15].includes(this.bigItemIndex) && this.bigItemIndex === index) {
    return;
  }
  this.offsetY -= this.FIX_VP_Y;
  this.dragRefOffSetY += this.FIX_VP_Y;
  if (index - 1 === this.bigItemIndex) {
    this.itemMove(index, index + 1);
  } else {
    this.itemMove(index, index + 2);
  }
}

up(index: number): void {
  if (!this.isDraggable(index - 2)) {
    return;
  }
  if (index - this.bigItemIndex === 3) {
    return;
  }
  this.offsetY += this.FIX_VP_Y;
  this.dragRefOffSetY -= this.FIX_VP_Y;
  if (this.bigItemIndex === index) {
    this.itemMove(index, index - 2);
  } else {
    if (index - 2 === this.bigItemIndex) {
      this.itemMove(index, index - 1);
    } else {
      this.itemMove(index, index - 2);
    }
  }
}

left(index: number): void {
  if (this.bigItemIndex % 2 === 0) {
    if (index - this.bigItemIndex === 2) {
      return;
    }
  }
  if (this.isInLeft(index)) {
    return;
  }
  if (!this.isDraggable(index - 1)) {
    return;
  }
  this.offsetX += this.FIX_VP_X;
  this.dragRefOffSetX -= this.FIX_VP_X;
  this.itemMove(index, index - 1)
}

right(index: number): void {
  if (this.bigItemIndex % 2 === 1) {
    if (index - this.bigItemIndex === 1) {
      return;
    }
  }
  if (!this.isInLeft(index)) {
    return;
  }
  if (!this.isDraggable(index + 1)) {
    return;
  }
  this.offsetX -= this.FIX_VP_X;
  this.dragRefOffSetX += this.FIX_VP_X;
  this.itemMove(index, index + 1)
}

isDraggable(index: number): boolean {
  return index >= 0;
}

3. 绑定组合手势

给 GridItem 绑定长按和拖拽手势,并设置显式动画:

注意事项

开发时一定要注意这些细节!

  1. Grid 拖拽基础设置

    • editMode 必须设置为 true 才能启用拖拽
    • supportAnimation 设置为 true 才能有动画效果
    • 内置动画仅支持大小规则的网格
  2. 不规则网格的特殊处理

    • 跨行跨列场景需要自定义实现
    • 要特别注意元素位置关系的计算
    • 大元素会影响周围小元素的排列,需要特殊处理
  3. 性能优化

    • 拖拽过程中的计算要尽量高效
    • 避免不必要的重排重绘
    • 动画持续时间不宜过长

总结

通过本文的学习,我们掌握了在 HarmonyOS 中实现 Grid 网格元素拖拽交换的方法:

  1. 基础实现

    • 使用 Grid 和 GridItem 构建布局
    • 设置 editMode 和 supportAnimation 属性
    • 实现数组交换逻辑
    • 绑定拖拽事件
  2. 进阶实现

    • 处理不同大小元素的布局
    • 实现复杂的位置计算和移动逻辑
    • 自定义手势和动画效果

Grid 网格拖拽交换是提升用户体验的重要功能,合理使用可以让你的应用交互更加直观和友好。

再次提醒:官方文档是最好的学习资源!

参考资料

posted @ 2025-12-15 20:48  时间煮鱼  阅读(0)  评论(0)    收藏  举报