鸿蒙开发长截图分享功能实现方案完整代码

一键截图

场景描述

一键截图将组件数据从顶部截取到底部,在截图过程中用户看不到界面的滚动,做到无感知滚动截图。这种方案一般用于分享截图、保存数据量较少的场景。

实现效果

点击“一键截图”,会生成整个列表的长截图。

 

开发流程

  1. 给List绑定滚动控制器,添加监听事件。

    1.1 为List滚动组件绑定Scroller控制器,以控制其滚动行为,并给List组件绑定自定义的id。

    1.2 通过onDidScroll()方法实时监听并获取滚动偏移量,确保截图拼接位置的准确性。

    1.3 同时,利用onAreaChange()事件获取List组件的尺寸,以便精确计算截图区域的大小。

  2. 给List添加遮罩图,初始化滚动位置。

    “一键截图”功能确保在滚动截图过程中用户不会察觉到页面的滚动。通过截取当前屏幕生成遮罩图覆盖列表,并记录此时的滚动偏移量(yOffsetBefore),便于后续完成滚动截图之后,恢复到之前记录的偏移量,使用户无感知页面变化。

    为保证截图的完整性,设置完遮罩图后,同样利用scrollTo()方法将列表暂时滚至顶部,确保截图从最顶端开始。

  3. 循环滚动截图,裁剪和缓存截图数据。

    3.1 记录每次滚动的位置到数组scrollYOffsets中,并使用componentSnapshot.get(LIST_ID) 方法获取当前画面的截图。

    3.2 如果非首次截图,则使用crop方法截取从底部滚动进来的区域,然后调用pixmap.readPixelsSync(area)方法将截图数据读取到缓冲区域area中,并将area通过集合进行保存,用于后续截图拼接。

    3.3 如果页面没有滚动到底部,继续滚动,继续递归调用snapAndMerge()方法进行截图;如果到达底部,则调用mergeImage()方法拼接所有收集到的图像片段,生成完整的长截图;同时还需限制截图的高度,以防过大的截图占用过多内存,影响应用性能,例如这里设置截长截图高度不超过5000。

  4. 拼接截图片段。

    使用image.createPixelMapSync()方法创建长截图longPixelMap,并遍历之前保存的图像片段数据 (this.areaArray),构建image.PositionArea对象area,然后调用longPixelMap.writePixelsSync(area) 方法将这些片段逐个写入到正确的位置,从而拼接成一个完整的长截图。

  5. 恢复到截图前的状态,滚动到截图前的位置。
  6. 使用安全控件SaveButton保存截图相册。

    通过安全控件SaveButton结合photoAccessHelper模块保存截图到相册。

    完整代码
import { image } from '@kit.ImageKit';
import { componentSnapshot, router } from '@kit.ArkUI';
import { LogUtils, NavCom, RouterName, UserDao } from '@wear/basic';
import Dayjs from 'dayjs';
import { fileIo } from '@kit.CoreFileKit';
import { fileUri } from '@kit.CoreFileKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { systemShare } from '@kit.ShareKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

const LIST_ID = 'LIST_ID';
const TAG = 'SportShare';
class PopupUtils {
  static calcPopupCenter(displayWidth: number, displayHeight: number, snapPopupWidth: number,
    snapPopupHeight: number): Position {
    return {
      x: (displayWidth - snapPopupWidth) / 2,
      y: (displayHeight - snapPopupHeight) / 2
    };
  }

  static calcPopupBottomLeft(displayHeight: number, snapPopupHeight: number): Position {
    return {
      x: 20,
      y: displayHeight - snapPopupHeight - 40
    };
  }
}
class CommonUtils {
  static sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  static scrollAnimation(scroller: Scroller, duration: number, scrollHeight: number): void {
    scroller.scrollTo({
      xOffset: 0,
      yOffset: (scroller.currentOffset().yOffset + scrollHeight),
      animation: {
        duration: duration,
        curve: Curve.Smooth,
        canOverScroll: false
      }
    });
  }
}
class ImageUtils {
  /**
   * Read the screenshot PixelMap object into the buffer area
   * @param {PixelMap} pixelMap - Screenshot PixelMap
   * @param {number[]} scrollYOffsets - Component scrolls an array of y-axis offsets
   * @param {number} listWidth - List component width
   * @param {number} listHeight - List component height
   * @returns {image.PositionArea} Picture buffer area
   */
  static async getSnapshotArea(pixelMap: PixelMap, scrollYOffsets: number[], listWidth: number,
    listHeight: number): Promise<image.PositionArea> {
    // Gets the number of bytes per line of image pixels.
    let stride = pixelMap.getBytesNumberPerRow();
    // Get the total number of bytes of image pixels.
    let bytesNumber = pixelMap.getPixelBytesNumber();
    let buffer: ArrayBuffer = new ArrayBuffer(bytesNumber);
    // 	Region size, read based on region.   PositionArea represents the data within the specified area of the image.
    let len = scrollYOffsets.length;

    // // Except for the first screenshot, you don't need to crop it, and you need to crop the new parts
    if (scrollYOffsets.length >= 2) {
      // Realistic roll distance
      let realScrollHeight = scrollYOffsets[len-1] - scrollYOffsets[len-2];
      if (listHeight - realScrollHeight > 0) {
        let cropRegion: image.Region = {
          x: 0,
          y: vp2px(listHeight - realScrollHeight),
          size: {
            height: vp2px(realScrollHeight),
            width: vp2px(listWidth)
          }
        };
        // Crop roll area
        await pixelMap.crop(cropRegion);
      }
    }

    let imgInfo = pixelMap.getImageInfoSync();
    // Region size, read based on region. PositionArea represents the data within the specified area of the image.
    let area: image.PositionArea = {
      pixels: buffer,
      offset: 0,
      stride: stride,
      region: {
        size: {
          width: imgInfo.size.width,
          height: imgInfo.size.height
        },
        x: 0,
        y: 0
      }
    }
    // Write data to a specified area
    pixelMap.readPixelsSync(area);
    return area;
  }

  /**
   * Merge image area array into long screenshots
   * @param {image.PositionArea[]} areaArray - screenshot area
   * @param {number} lastOffsetY - The offset Y of the last screenshot
   * @param {number} listWidth - List component width
   * @param {number} listHeight - List component height
   * @returns {PixelMap} Long image after merge
   */
  static async mergeImage(areaArray: image.PositionArea[], lastOffsetY: number, listWidth: number,
    listHeight: number): Promise<PixelMap> {
    // Create a long screenshot PixelMap
    let opts: image.InitializationOptions = {
      editable: true,
      pixelFormat: 4,
      size: {
        width: vp2px(listWidth),
        height: vp2px(lastOffsetY + listHeight)
      }
    };
    let longPixelMap = image.createPixelMapSync(opts);
    let imgPosition: number = 0;

    for (let i = 0; i < areaArray.length; i++) {
      let readArea = areaArray[i];
      let area: image.PositionArea = {
        pixels: readArea.pixels,
        offset: 0,
        stride: readArea.stride,
        region: {
          size: {
            width: readArea.region.size.width,
            height: readArea.region.size.height
          },
          x: 0,
          y: imgPosition
        }
      }
      imgPosition += readArea.region.size.height;
      longPixelMap.writePixelsSync(area);
    }
    return longPixelMap;
  }
}

@Entry({ routeName: RouterName.SPORT_SHARE })
@Component
export struct Share {
  @StorageLink('screenWidth') screenWidth: number = 0;
  @StorageLink('screenHeight') screenHeight: number = 0;
  // The component is overwritten during the screenshot process
  @State componentMaskImage: PixelMap | undefined = undefined;
  @State snapPopupPosition: Position = { x: 0, y: 0 };
  // Whether to display the preview window
  @State isShowPreview: boolean = false;
  // Displays a long screenshot to preview a larger image
  @State isLargePreview: boolean = false;
  // Long picture after stitching
  @State mergedImage: PixelMap | undefined = undefined;
  @State isEnableScroll: boolean = true;
  private listComponentWidth: number = 0;
  private listComponentHeight: number = 0;
  // The current offset of the List component
  private curYOffset: number = 0;
  // The location of the component before backing up the screenshot
  private yOffsetBefore: number = 0;
  // Scroll controller
  private scroller: Scroller = new Scroller();
  // One-click screenshot scrolling process caching
  private areaArray: image.PositionArea[] = [];
  // The y-direction offset of the List component on each page during the screenshot scrolling process, in the unit of vp
  private scrollYOffsets: number[] = [];
  // is click to stop scroll
  private isClickStop: boolean = false;
  private scrollHeight: number = 0;
  @Provide title: string = '分享';
  @Provide sportData: RunningOutdoorsData = {} as RunningOutdoorsData
  @State pixmap: string = '';
  @State userName:string = UserDao.getUser().nickname
  @State avatar:string = UserDao.getUser().avatar
  @Provide unit:number = UserDao.getUser().unit
  @State mapImage: PixelMap | string = ''
  aboutToAppear(): void {
    const params: routerParams = router.getParams() as routerParams
    this.title = params.title
    this.sportData = params.sportData
    this.mapImage = params.mapImage
  }
  /**
   * One-click screenshot
   */
  async onceSnapshot() {
    this.isLoading = true
    await this.beforeSnapshot();
    await this.snapAndMerge();
    await this.afterSnapshot();
    this.afterGeneratorImage();
  }

  /**
   * One click screenshot loop traversal screenshot and merge
   */
  async snapAndMerge() {
    this.scrollYOffsets.push(this.curYOffset);
    // Call the component screenshot interface to obtain the current screenshot
    const pixelMap = await componentSnapshot.get(LIST_ID);
    // Gets the number of bytes per line of image pixels.
    let area: image.PositionArea =
      await ImageUtils.getSnapshotArea(pixelMap, this.scrollYOffsets, this.listComponentWidth,
        this.listComponentHeight);
    this.areaArray.push(area);
    // Determine whether the bottom has been reached during the loop process
    if (!this.scroller.isAtEnd()) {
      CommonUtils.scrollAnimation(this.scroller, 200, this.scrollHeight);
      await CommonUtils.sleep(200)
      await this.snapAndMerge();
    } else {
      this.mergedImage =
        await ImageUtils.mergeImage(this.areaArray, this.scrollYOffsets[this.scrollYOffsets.length - 1],
          this.listComponentWidth, this.listComponentHeight);
    }
  }

  /**
   * Rolling screenshots, looping through screenshots, and merge them
   */
  async scrollSnapAndMerge() {
    // Record an array of scrolls
    this.scrollYOffsets.push(this.curYOffset - this.yOffsetBefore);
    // Call the API for taking screenshots to obtain the current screenshots
    const pixelMap = await componentSnapshot.get(LIST_ID);
    // Gets the number of bytes per line of image pixels.
    let area: image.PositionArea =
      await ImageUtils.getSnapshotArea(pixelMap, this.scrollYOffsets, this.listComponentWidth, this.listComponentHeight)
    this.areaArray.push(area);

    // During the loop, it is determined whether the bottom is reached, and the user does not stop taking screenshots
    if (!this.scroller.isAtEnd() && !this.isClickStop) {
      // Scroll to the next page without scrolling to the end
      CommonUtils.scrollAnimation(this.scroller, 1000, this.scrollHeight);
      await CommonUtils.sleep(1500);
      await this.scrollSnapAndMerge();
    } else {
      // After scrolling to the bottom, the buffer obtained by each round of scrolling is spliced
      // to generate a long screenshot
      this.mergedImage =
        await ImageUtils.mergeImage(this.areaArray, this.scrollYOffsets[this.scrollYOffsets.length - 1],
          this.listComponentWidth, this.listComponentHeight);
    }
  }

  async beforeSnapshot() {
    this.yOffsetBefore = this.curYOffset;
    // Take a screenshot of the loaded List component as a cover image for the List component
    this.componentMaskImage = await componentSnapshot.get(LIST_ID);
    this.scroller.scrollTo({
      xOffset: 0,
      yOffset: 0,
      animation:
      {
        duration: 200
      }
    });
    this.snapPopupPosition = PopupUtils.calcPopupCenter(this.screenWidth, this.screenHeight, 100, 200);
    this.isShowPreview = true;
    // Delay ensures that the scroll has reached the top
    await CommonUtils.sleep(200);
  }

  async afterSnapshot() {
    this.scroller.scrollTo({
      xOffset: 0,
      yOffset: this.yOffsetBefore,
      animation: {
        duration: 200
      }
    });
    await CommonUtils.sleep(200);
  }

  async afterGeneratorImage() {
    // Delay for transition animation
    await CommonUtils.sleep(200);
    this.snapPopupPosition = PopupUtils.calcPopupBottomLeft(this.screenHeight, 200);
    this.componentMaskImage = undefined;
    this.scrollYOffsets.length = 0;
    this.areaArray.length = 0;
    this.share()
  }
  @State isLoading: boolean = false;
async share() {
  const imgBuffer = await this.transferPixelMap2Buffer(this.mergedImage as image.PixelMap);
  const uniqueName = this.userName +"_"+this.title +"_"+ Dayjs(this.sportData.time * 1000).format('YYYYMDDHHmmss') + ".jpg"
  const filesDir = getContext(this).filesDir
  const file = fileIo.openSync(filesDir +'/' +uniqueName, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE)
  const fileAll = fileUri.getUriFromPath(filesDir+'/'+uniqueName)
  this.pixmap = fileAll
  const res = await fileIo.write(file.fd, imgBuffer);
  await fileIo.close(file.fd);

  // 获取精准的utd类型
  let utdTypeId = utd.getUniformDataTypeByFilenameExtension('.jpg', utd.UniformDataType.IMAGE);
  let shareData: systemShare.SharedData = new systemShare.SharedData({
    utd: utdTypeId,
    uri: fileAll
    // title: '图片标题', // 不传title字段时,显示图片文件名
    // description: '图片描述', // 不传description字段时,显示图片大小
    // thumbnail: new Uint8Array() // 优先使用传递的缩略图预览  不传则默认使用原图做预览图
  });
  this.isLoading = false
  // 进行分享面板显示
  let controller: systemShare.ShareController = new systemShare.ShareController(shareData);
  let context = getContext(this) as common.UIAbilityContext;
  controller.show(context, {
    selectionMode: systemShare.SelectionMode.SINGLE,
    previewMode: systemShare.SharePreviewMode.DETAIL,
  }).then(() => {
    LogUtils.i(TAG, 'ShareController show success.');
  }).catch((error: BusinessError) => {
    LogUtils.e(TAG, `ShareController show error. code: ${error.code}, message: ${error.message}`);
  });

}

// 将pixelMap转成图片格式
transferPixelMap2Buffer(pixelMap: image.PixelMap): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    /**
     * 设置打包参数
     * format:图片打包格式,只支持 jpg 和 webp
     * quality:JPEG 编码输出图片质量
     * bufferSize:图片大小,默认 10M
     */
    let packOpts: image.PackingOption = { format: 'image/jpeg', quality: 98 };
    // 创建ImagePacker实例
    const imagePackerApi = image.createImagePacker();
    imagePackerApi.packing(pixelMap, packOpts).then((buffer: ArrayBuffer) => {
      resolve(buffer);
    }).catch((err: BusinessError) => {
      reject(err);
    })
  })
}

  build() {
    Stack(){
      Column() {
        NavCom({
          title: '分享',
          firstButtonFlag: true,
          firstButton: $r('app.media.ic_sport_share'),
          firstButtonTask: () => {
            this.onceSnapshot()
          }
        }).padding({ top: Number(AppStorage.get("safeBottom")) || 0 })

        Stack({ alignContent: Alignment.Top }) {
          Scroll(this.scroller) {
            Column({space: 20}) {
              Row({space:10}) {
                Image(this.avatar)
                  .height(40)
                  .width(40)
                  .borderRadius(50)
                Text(this.userName)
              }
              .borderRadius(20)
              .backgroundColor(Color.White)
              .padding({top:2,left:2,bottom:2, right:5})

              Row() {
                Text('内容区域...')
              }.margin({top: this.mapImage ? 300 : 0})

              Row({space:20}) {
                Image($r('app.media.ic_wearfitlogo_l'))
                  .height(40)
                  .width(40)
                Text('Wearfit')
              }
            }
            .padding(20)
            .backgroundColor("#f6f6f6")
            .backgroundImage(this.mapImage)
            .backgroundImageSize({ width: '100%'})
            .alignItems(HorizontalAlign.Start)
            .border({
              width: 1,
              color: '#cccccc',
            })
            .borderRadius(12)
          }
          .id(LIST_ID)
          .align(Alignment.Top)
          .scrollBar(BarState.Off)
          .width('100%')
          .height('100%')
          .backgroundColor('#f6f6f6')
          .enableScrollInteraction(this.isEnableScroll)
          .onDidScroll(() => {
            this.curYOffset = this.scroller.currentOffset().yOffset;
          })
          .onAreaChange((oldValue, newValue) => {
            this.listComponentWidth = newValue.width as number;
            this.listComponentHeight = newValue.height as number;
            this.scrollHeight = this.listComponentHeight;
          })
          .onClick(() => {
            // Click on the list to stop scrolling
            if (!this.isEnableScroll) {
              this.scroller.scrollBy(0, 0);
              this.isClickStop = true;
            }
          })
          if (this.componentMaskImage) {
            Image(this.componentMaskImage)
          }
        }
        .padding({ top: 20, bottom: 100,left: 20,right:20 })
      }
      .backgroundColor("#f6f6f6")
      .height("100%")
      if (this.isLoading) {
        Column() {
          LoadingProgress()
            .color(Color.White)
            .width(80).height(80)
          Text('请稍等...')
            .fontSize(16)
            .fontColor(Color.White)
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#40000000')
        .justifyContent(FlexAlign.Center)
      }
    }
  }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值