HarmonyOS APP开发---「冰箱贴集」冰箱贴收集App,需要用到这个API

3 阅读11分钟

如果你想做一个冰箱贴收集App,需要用到这个API

想要亲自体验"冰箱贴集"这款App的功能?打开你的鸿蒙手机,前往华为应用市场,搜索"冰箱贴集"即可下载安装,把旅行收集的冰箱贴都记在上面吧。


写在前面

冰箱贴这个东西,说起来是一种很特别的旅行纪念品。每次去一个新城市,看到好看的冰箱贴就想买一个。时间一长,冰箱上贴满了,但是到底去了哪些城市、每个冰箱贴长什么样、啥时候买的,全记不清了。

所以我就想做一个"冰箱贴集"App,把这些冰箱贴都拍照记录下来,还能在一个虚拟冰箱上排版展示,像真正的冰箱一样随便贴。这个App用到的核心技术就是图片处理和Canvas绘制,再加上拖拽手势来实现冰箱贴的移动和排版。

这篇文章,我会带你一步步做出来。先看Web端的React实现,然后再转到鸿蒙的ArkTS。

这篇文章聊什么

咱们今天要搞明白三件事:

  1. 图片加载和缩放 -- 怎么把用户选择的图片缩放到合适的大小,在有限的冰箱空间里展示
  2. 网格布局计算 -- 虚拟冰箱是一个固定大小的区域,怎么让冰箱贴按网格排列,看起来整齐又不呆板
  3. 拖拽排序 -- 用手势拖拽冰箱贴到任意位置,怎么实现流畅的拖拽体验

先看整体流程:

flowchart TD
    A[用户打开App] --> B[加载冰箱贴列表]
    B --> C{用户操作}
    C -->|添加冰箱贴| D[选择图片]
    D --> E[缩放适配]
    E --> F[添加到冰箱]
    C -->|拖拽冰箱贴| G[PanGesture触发]
    G --> H[计算新位置]
    H --> I[更新布局]
    C -->|查看详情| J[弹出详情面板]
    J --> K[显示地点和日期]

第一步:React版 -- 用Canvas画冰箱

Web端的做法是用一个Canvas元素当虚拟冰箱,冰箱贴就是Canvas上的图片对象。先看代码:

import React, { useState, useRef, useCallback, useEffect } from 'react';

// 冰箱贴数据结构
// position是相对于冰箱左上角的坐标
// size是缩放后的尺寸
const MagnetItem = ({ magnet, onSelect, onDrag }) => {
  const imgRef = useRef(null);

  const handleMouseDown = (e) => {
    // 记录鼠标按下时的位置,用于计算拖拽偏移
    const startX = e.clientX;
    const startY = e.clientY;
    const offsetX = magnet.x;
    const offsetY = magnet.y;

    const handleMouseMove = (moveEvent) => {
      const deltaX = moveEvent.clientX - startX;
      const deltaY = moveEvent.clientY - startY;
      // 实时更新位置
      onDrag(magnet.id, offsetX + deltaX, offsetY + deltaY);
    };

    const handleMouseUp = () => {
      // 鼠标松开时移除事件监听
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  return (
    <div
      style={{
        position: 'absolute',
        left: magnet.x,
        top: magnet.y,
        width: magnet.size,
        height: magnet.size,
        cursor: 'grab',
      }}
      onMouseDown={handleMouseDown}
      onClick={() => onSelect(magnet)}
    >
      <img
        ref={imgRef}
        src={magnet.imageUrl}
        alt={magnet.name}
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
        draggable={false}
      />
      <span style={{
        position: 'absolute',
        bottom: 2,
        left: 0,
        right: 0,
        textAlign: 'center',
        fontSize: 10,
        color: '#fff',
        textShadow: '0 0 3px rgba(0,0,0,0.8)',
      }}>
        {magnet.city}
      </span>
    </div>
  );
};

function FridgePage() {
  // 冰箱贴列表,每个冰箱贴有位置、大小、图片、城市等信息
  const [magnets, setMagnets] = useState([
    { id: 1, name: '长城冰箱贴', city: '北京', imageUrl: '/fridge/1.jpg', x: 20, y: 20, size: 80, date: '2025-03-15' },
    { id: 2, name: '东方明珠冰箱贴', city: '上海', imageUrl: '/fridge/2.jpg', x: 120, y: 30, size: 80, date: '2025-05-20' },
    { id: 3, name: '兵马俑冰箱贴', city: '西安', imageUrl: '/fridge/3.jpg', x: 50, y: 130, size: 80, date: '2025-08-10' },
  ]);
  const [selectedMagnet, setSelectedMagnet] = useState(null);

  const handleDrag = useCallback((id, newX, newY) => {
    // 限制在冰箱范围内
    const maxX = 320; // 冰箱宽度减去冰箱贴大小
    const maxY = 400; // 冰箱高度减去冰箱贴大小
    setMagnets(prev => prev.map(m =>
      m.id === id ? { ...m, x: Math.max(0, Math.min(newX, maxX)), y: Math.max(0, Math.min(newY, maxY)) } : m
    ));
  }, []);

  const handleAdd = () => {
    // 添加新冰箱贴,随机放在冰箱上
    const newMagnet = {
      id: Date.now(),
      name: '新冰箱贴',
      city: '待填写',
      imageUrl: '/fridge/new.jpg',
      x: Math.random() * 200,
      y: Math.random() * 300,
      size: 80,
      date: new Date().toISOString().split('T')[0],
    };
    setMagnets(prev => [...prev, newMagnet]);
  };

  return (
    <div style={{ padding: 20 }}>
      <h1>冰箱贴集</h1>
      <button onClick={handleAdd}>添加冰箱贴</button>
      {/* 虚拟冰箱容器 */}
      <div style={{
        position: 'relative',
        width: 400,
        height: 500,
        backgroundColor: '#E8E8E8',
        border: '3px solid #999',
        borderRadius: 8,
        margin: '20px auto',
        // 磁铁质感背景
        backgroundImage: 'linear-gradient(135deg, #ddd 25%, #eee 25%, #eee 50%, #ddd 50%, #ddd 75%, #eee 75%)',
        backgroundSize: '20px 20px',
      }}>
        {magnets.map(magnet => (
          <MagnetItem
            key={magnet.id}
            magnet={magnet}
            onSelect={setSelectedMagnet}
            onDrag={handleDrag}
          />
        ))}
      </div>
      {selectedMagnet && (
        <div style={{ padding: 10, backgroundColor: '#f0f0f0', borderRadius: 8 }}>
          <p>名称: {selectedMagnet.name}</p>
          <p>城市: {selectedMagnet.city}</p>
          <p>日期: {selectedMagnet.date}</p>
        </div>
      )}
    </div>
  );
}

export default FridgePage;

这段React代码的思路很简单:用一个相对定位的div当冰箱,每个冰箱贴用绝对定位放在里面,拖拽的时候通过mousedown/mousemove/mouseup事件来更新位置。

但有个问题 -- React这种方式在高频拖拽时性能不太好,因为每次mousemove都会触发setState,导致整个组件重新渲染。在冰箱贴数量少的时候感觉不明显,但如果冰箱贴多了就会卡。鸿蒙的ArkTS在这方面做得更好,因为有手势系统的原生支持。


第二步:ArkTS版 -- 用手势系统和Canvas实现

现在转到鸿蒙。ArkTS里我们用PanGesture(拖拽手势)来实现冰箱贴的拖拽,用Canvas来绘制冰箱的背景。

import { image } from '@kit.MediaLibraryKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { promptAction } from '@kit.ArkUI';

// 冰箱贴数据结构
interface MagnetData {
  id: number;
  name: string;
  city: string;
  imagePath: string;     // 本地图片路径
  x: number;             // 在冰箱上的x坐标
  y: number;             // 在冰箱上的y坐标
  size: number;          // 缩放后的尺寸
  date: string;          // 收集日期
}

// 冰箱常量
// 为什么用常量?因为这些值在多个地方用到,写死的话改起来很麻烦
const FRIDGE_WIDTH = 350;
const FRIDGE_HEIGHT = 500;
const MAGNET_DEFAULT_SIZE = 80;
const GRID_COLUMNS = 4;     // 网格列数
const GRID_ROWS = 6;        // 网格行数

@Entry
@Component
struct FridgePage {
  @State magnets: MagnetData[] = [];
  @State selectedMagnetId: number = -1;
  @State isAdding: boolean = false;
  // 当前正在拖拽的冰箱贴ID
  private draggingId: number = -1;
  // 拖拽起始位置
  private dragStartX: number = 0;
  private dragStartY: number = 0;
  // 冰箱贴原始位置
  private magnetStartX: number = 0;
  private magnetStartY: number = 0;

  aboutToAppear() {
    // 初始化一些示例冰箱贴数据
    this.magnets = [
      { id: 1, name: '长城冰箱贴', city: '北京', imagePath: 'fridge/greatwall.jpg', x: 20, y: 20, size: MAGNET_DEFAULT_SIZE, date: '2025-03-15' },
      { id: 2, name: '东方明珠', city: '上海', imagePath: 'fridge/pearl.jpg', x: 110, y: 20, size: MAGNET_DEFAULT_SIZE, date: '2025-05-20' },
      { id: 3, name: '兵马俑', city: '西安', imagePath: 'fridge/terracotta.jpg', x: 200, y: 20, size: MAGNET_DEFAULT_SIZE, date: '2025-08-10' },
      { id: 4, name: '西湖', city: '杭州', imagePath: 'fridge/westlake.jpg', x: 20, y: 120, size: MAGNET_DEFAULT_SIZE, date: '2025-10-01' },
      { id: 5, name: '熊猫基地', city: '成都', imagePath: 'fridge/panda.jpg', x: 110, y: 120, size: MAGNET_DEFAULT_SIZE, date: '2026-01-15' },
    ];
  }

  // ==================== 图片缩放适配 ====================
  // 这个方法把原始图片缩放到适合冰箱贴展示的大小
  // 为什么需要缩放?因为用户拍的照片可能有几MB,直接加载太大了
  // 而且冰箱贴的展示区域就80x80像素,加载原图完全是浪费内存
  async resizeImage(sourcePath: string, targetSize: number): Promise<PixelMap | undefined> {
    try {
      const imageSource = image.createImageSource(sourcePath);
      // 先获取图片的原始尺寸,然后按比例缩放
      const imageInfo = await imageSource.getImageInfo();
      const originalWidth = imageInfo.size.width;
      const originalHeight = imageInfo.size.height;

      // 计算缩放比例 -- 取宽高中较大的那个做等比缩放
      // 为什么取较大的?因为我们要保证图片完整显示在正方形区域内
      const scale = targetSize / Math.max(originalWidth, originalHeight);
      const targetWidth = Math.floor(originalWidth * scale);
      const targetHeight = Math.floor(originalHeight * scale);

      // 解码选项:设置目标尺寸
      const decodingOptions: image.DecodingOptions = {
        editable: true,
        desiredSize: { width: targetWidth, height: targetHeight },
      };

      const pixelMap = await imageSource.createPixelMap(decodingOptions);
      imageSource.release(); // 记得释放资源
      return pixelMap;
    } catch (err) {
      console.error('Resize image failed: ' + JSON.stringify(err));
      return undefined;
    }
  }

  // ==================== 网格布局计算 ====================
  // 计算冰箱贴在网格中的位置
  // 为什么需要这个方法?因为用户可以点击"自动排列"按钮,让冰箱贴整齐排列
  calculateGridPosition(index: number): { x: number; y: number } {
    // 每个格子的间距
    const gap = 10;
    // 冰箱可用宽度 = 冰箱宽度 - 左右边距
    const availableWidth = FRIDGE_WIDTH - 20; // 左右各10px边距
    // 每个格子的实际宽度
    const cellWidth = (availableWidth - gap * (GRID_COLUMNS - 1)) / GRID_COLUMNS;
    // 列号和行号
    const col = index % GRID_COLUMNS;
    const row = Math.floor(index / GRID_COLUMNS);
    return {
      x: 10 + col * (cellWidth + gap),
      y: 10 + row * (cellWidth + gap),
    };
  }

  // 自动排列所有冰箱贴
  autoArrange() {
    this.magnets = this.magnets.map((magnet, index) => {
      const pos = this.calculateGridPosition(index);
      return { ...magnet, x: pos.x, y: pos.y, size: 80 };
    });
    promptAction.showToast({ message: '已自动排列', duration: 1000 });
  }

  // ==================== 从相册选择图片 ====================
  async pickImage(): Promise<string> {
    try {
      const picker = new photoAccessHelper.PhotoViewPicker();
      const options = new photoAccessHelper.PhotoSelectOptions();
      options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      options.maxSelectNumber = 1; // 每次只选一张
      const result = await picker.select(options);
      if (result.photoUris && result.photoUris.length > 0) {
        return result.photoUris[0];
      }
    } catch (err) {
      console.error('Pick image failed: ' + JSON.stringify(err));
    }
    return '';
  }

  // 添加冰箱贴
  async addMagnet() {
    const imagePath = await this.pickImage();
    if (!imagePath) return;

    // 计算新冰箱贴的位置 -- 放在最后面
    const newIndex = this.magnets.length;
    const pos = this.calculateGridPosition(newIndex);

    const newMagnet: MagnetData = {
      id: Date.now(),
      name: '新冰箱贴',
      city: '',
      imagePath: imagePath,
      x: pos.x,
      y: pos.y,
      size: MAGNET_DEFAULT_SIZE,
      date: new Date().toISOString().split('T')[0],
    };

    this.magnets = [...this.magnets, newMagnet];
    promptAction.showToast({ message: '冰箱贴已添加', duration: 1000 });
  }

  // 删除冰箱贴
  deleteMagnet(id: number) {
    this.magnets = this.magnets.filter(m => m.id !== id);
    if (this.selectedMagnetId === id) {
      this.selectedMagnetId = -1;
    }
    promptAction.showToast({ message: '已删除', duration: 1000 });
  }

  // ==================== UI构建 ====================
  build() {
    Column() {
      // 顶部标题
      Row() {
        Text('冰箱贴集')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
        Text(`共 ${this.magnets.length} 个`)
          .fontSize(14)
          .fontColor('#999999')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16 })

      // 操作按钮行
      Row({ space: 10 }) {
        Button('添加冰箱贴')
          .fontSize(14)
          .height(36)
          .backgroundColor('#4ECDC4')
          .onClick(() => this.addMagnet())
        Button('自动排列')
          .fontSize(14)
          .height(36)
          .backgroundColor('#95E1D3')
          .onClick(() => this.autoArrange())
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8 })

      // 虚拟冰箱区域
      // 这里用Stack组件,因为它支持子组件的绝对定位
      // Stack就好比React里的position:relative容器
      Stack() {
        // 冰箱背景 -- 用Canvas绘制一个金属质感的背景
        Canvas(this.fridgeCanvas)
          .width(FRIDGE_WIDTH)
          .height(FRIDGE_HEIGHT)
          .backgroundColor('#D4D4D4')
          .borderRadius(12)
          .border({ width: 3, color: '#888888' })

        // 冰箱贴列表
        ForEach(this.magnets, (magnet: MagnetData) => {
          // 每个冰箱贴是一个Image组件,用position属性定位
          Image(magnet.imagePath)
            .width(magnet.size)
            .height(magnet.size)
            .objectFit(ImageFit.Cover)
            .borderRadius(8)
            .border({ width: 2, color: this.selectedMagnetId === magnet.id ? '#FF6B6B' : '#FFFFFF' })
            .shadow({ radius: 4, color: 'rgba(0,0,0,0.3)', offsetY: 2 })
            .position({ x: magnet.x, y: magnet.y })
            // 关键:绑定拖拽手势
            .gesture(
              // PanGesture是平移手势,手指在屏幕上滑动时触发
              // direction设为All表示支持所有方向
              PanGesture({ direction: PanDirection.All })
                .onActionStart((event: GestureEvent) => {
                  // 拖拽开始
                  this.draggingId = magnet.id;
                  this.dragStartX = event.fingers[0].localX;
                  this.dragStartY = event.fingers[0].localY;
                  this.magnetStartX = magnet.x;
                  this.magnetStartY = magnet.y;
                  // 选中这个冰箱贴
                  this.selectedMagnetId = magnet.id;
                })
                .onActionUpdate((event: GestureEvent) => {
                  // 拖拽过程中实时更新位置
                  if (this.draggingId === magnet.id) {
                    const deltaX = event.fingers[0].localX - this.dragStartX;
                    const deltaY = event.fingers[0].localY - this.dragStartY;
                    // 计算新位置,并限制在冰箱范围内
                    const newX = Math.max(0, Math.min(this.magnetStartX + deltaX, FRIDGE_WIDTH - magnet.size));
                    const newY = Math.max(0, Math.min(this.magnetStartY + deltaY, FRIDGE_HEIGHT - magnet.size));
                    // 更新位置
                    const index = this.magnets.findIndex(m => m.id === magnet.id);
                    if (index >= 0) {
                      this.magnets[index].x = newX;
                      this.magnets[index].y = newY;
                    }
                  }
                })
                .onActionEnd(() => {
                  // 拖拽结束
                  this.draggingId = -1;
                })
            )
            .onClick(() => {
              // 点击选中/取消选中
              this.selectedMagnetId = this.selectedMagnetId === magnet.id ? -1 : magnet.id;
            })
        })
      }
      .width(FRIDGE_WIDTH)
      .height(FRIDGE_HEIGHT)
      .alignContent(Alignment.TopStart)
      .margin({ top: 12 })

      // 冰箱贴信息面板
      // 选中冰箱贴后显示详细信息
      if (this.selectedMagnetId > 0) {
        Column() {
          // 找到选中的冰箱贴数据
          // 为什么用find而不是用index?因为find更语义化
          const selectedMagnet = this.magnets.find(m => m.id === this.selectedMagnetId);
          if (selectedMagnet) {
            Row() {
              Image(selectedMagnet.imagePath)
                .width(60)
                .height(60)
                .objectFit(ImageFit.Cover)
                .borderRadius(8)
              Column({ space: 4 }) {
                Text(selectedMagnet.name)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                Text(`城市: ${selectedMagnet.city || '未填写'}`)
                  .fontSize(14)
                  .fontColor('#666666')
                Text(`收集日期: ${selectedMagnet.date}`)
                  .fontSize(14)
                  .fontColor('#666666')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })
            }
            .width('100%')
            .padding(12)
            .backgroundColor('#F5F5F5')
            .borderRadius(8)

            Button('删除此冰箱贴')
              .width('100%')
              .height(40)
              .fontSize(14)
              .backgroundColor('#FF6B6B')
              .margin({ top: 8 })
              .onClick(() => this.deleteMagnet(selectedMagnet.id))
          }
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12 })
      }

      // 空状态提示
      if (this.magnets.length === 0) {
        Column() {
          Text('还没有冰箱贴')
            .fontSize(16)
            .fontColor('#999999')
          Text('点击上方"添加冰箱贴"开始收集吧')
            .fontSize(14)
            .fontColor('#BBBBBB')
            .margin({ top: 4 })
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAFAFA')
  }

  // Canvas绘制冰箱背景
  private fridgeCanvas: CanvasRenderingContext2D = new CanvasRenderingContext2D(new CanvasRenderingContext2DSettings());
}

这段代码有几个关键点要讲:

Stack组件的使用

为什么用Stack而不是Column或者Row?因为冰箱贴需要绝对定位 -- 每个冰箱贴可以在冰箱上的任意位置。Stack组件就是干这个的,它允许子组件通过position属性设置偏移位置,类似于Web里的absolute定位。

PanGesture手势系统

PanGesture是鸿蒙手势系统里的一种,专门处理拖拽。它有三个关键回调:

  • onActionStart:手指按下时触发,我们在这里记录起始位置
  • onActionUpdate:手指移动时持续触发,我们在这里计算偏移量并更新位置
  • onActionEnd:手指抬起时触发,我们在这里清理拖拽状态

这个比React的mousedown/mousemove/mouseup好用多了,因为鸿蒙的手势系统原生处理了触摸事件,在手机上体验更好。

图片缩放

resizeImage方法用image.createImageSource来创建图片源,然后通过DecodingOptions设置目标尺寸来缩放。为什么要这样做?因为用户从相册选的照片可能是4000x3000像素的,直接加载到80x80的展示区域不仅浪费内存,渲染也很慢。先缩放再显示,性能会好很多。


第三步:冰箱背景的Canvas绘制

上面的代码里我们只是用了一个灰色背景,现在给冰箱画一个更有质感的金属背景:

// 在组件里添加一个方法来绘制冰箱背景
private drawFridgeBackground(ctx: CanvasRenderingContext2D) {
  // 冰箱底色
  ctx.fillStyle = '#C0C0C0';
  ctx.fillRect(0, 0, FRIDGE_WIDTH, FRIDGE_HEIGHT);

  // 金属拉丝效果 -- 画一些水平细线
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
  ctx.lineWidth = 1;
  for (let y = 0; y < FRIDGE_HEIGHT; y += 3) {
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(FRIDGE_WIDTH, y);
    ctx.stroke();
  }

  // 冰箱把手装饰
  ctx.fillStyle = '#888888';
  ctx.fillRect(FRIDGE_WIDTH - 20, FRIDGE_HEIGHT * 0.3, 15, FRIDGE_HEIGHT * 0.4);
  ctx.fillStyle = '#AAAAAA';
  ctx.fillRect(FRIDGE_WIDTH - 18, FRIDGE_HEIGHT * 0.3 + 2, 11, FRIDGE_HEIGHT * 0.4 - 4);

  // 品牌logo区域
  ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
  ctx.font = '16px sans-serif';
  ctx.fillText('Fridge', 10, FRIDGE_HEIGHT - 15);
}

Canvas绘制这块我多说两句。为什么用Canvas而不是直接用CSS/样式来做背景?因为Canvas可以做更精细的绘制控制。比如金属拉丝效果就是画一系列半透明的水平细线,这个用CSS很难实现。

但在实际项目中,如果背景效果不复杂,用渐变或者图片背景会更简单。Canvas适合那种需要动态绘制的场景。


流程图

flowchart TD
    A[打开冰箱贴集App] --> B[加载冰箱贴数据]
    B --> C[渲染虚拟冰箱]
    C --> D{用户操作}
    D -->|添加| E[PhotoViewPicker选择图片]
    E --> F[缩放图片]
    F --> G[计算网格位置]
    G --> H[添加到magnets数组]
    D -->|拖拽| I[PanGesture.onActionStart]
    I --> J[记录起始坐标]
    J --> K[PanGesture.onActionUpdate]
    K --> L[计算偏移并更新位置]
    L --> M[PanGesture.onActionEnd]
    D -->|自动排列| N[calculateGridPosition]
    N --> O[批量更新所有位置]
    D -->|点击| P[显示/隐藏详情面板]
    D -->|删除| Q[从数组中移除]
    Q --> R[重新渲染]

React vs ArkTS 对比表

功能点React (Web)ArkTS (鸿蒙)
绝对定位容器position: relative/absoluteStack 组件
图片展示<img> 标签Image 组件
拖拽实现mouse事件 + addEventListenerPanGesture 手势绑定
图片缩放CSS width/height + objectFitimage.createImageSource + DecodingOptions
相册选择<input type="file">PhotoViewPicker API
列表渲染array.map()ForEach 组件
条件渲染&& 或三元运算符if 语句(在build里)
Canvas绘制<canvas> + getContext('2d')Canvas 组件 + CanvasRenderingContext2D

最大的区别在拖拽的实现上。Web端用的是鼠标事件,要自己管理事件监听器的添加和移除,还要考虑边界情况。鸿蒙直接给你一个PanGesture,把复杂的触摸事件处理封装好了,你只需要关心位移计算就行。


权限声明说明

冰箱贴集这个App的权限需求非常轻:

{
  "module": {
    "reqPermissions": []
  }
}

你没看错,不需要任何权限声明。为什么?因为我们用的是PhotoViewPicker来选择图片,这个API不需要相册读取权限。鸿蒙在设计PhotoViewPicker的时候就考虑到了隐私问题,用户主动选择的图片不需要额外的权限授权。

如果你需要直接读取相册里所有的图片(而不是让用户选择),那才需要ohos.permission.READ_MEDIA权限。但我们的场景是用户主动选择,所以完全不需要。


总结

这篇文章我们用"冰箱贴集"这个App讲清楚了三件事:

  1. 图片缩放适配 -- 用image.createImageSource的DecodingOptions来缩放原图,减少内存占用
  2. 网格布局计算 -- 根据冰箱的尺寸和列数,自动计算每个冰箱贴应该放在哪个格子
  3. 拖拽排序 -- PanGesture三步走:onActionStart记录起点,onActionUpdate计算偏移,onActionEnd清理状态

核心要点是PanGesture的使用,这是鸿蒙手势系统里最常用的手势之一。记住三个回调的职责分工,拖拽功能就能轻松搞定。

好,去华为应用市场搜索"冰箱贴集"下载体验一下吧,把你的冰箱贴都记录起来。