一键截图
场景描述
一键截图将组件数据从顶部截取到底部,在截图过程中用户看不到界面的滚动,做到无感知滚动截图。这种方案一般用于分享截图、保存数据量较少的场景。
实现效果
点击“一键截图”,会生成整个列表的长截图。
开发流程
- 给List绑定滚动控制器,添加监听事件。
1.1 为List滚动组件绑定Scroller控制器,以控制其滚动行为,并给List组件绑定自定义的id。
1.2 通过onDidScroll()方法实时监听并获取滚动偏移量,确保截图拼接位置的准确性。
1.3 同时,利用onAreaChange()事件获取List组件的尺寸,以便精确计算截图区域的大小。
- 给List添加遮罩图,初始化滚动位置。
“一键截图”功能确保在滚动截图过程中用户不会察觉到页面的滚动。通过截取当前屏幕生成遮罩图覆盖列表,并记录此时的滚动偏移量(yOffsetBefore),便于后续完成滚动截图之后,恢复到之前记录的偏移量,使用户无感知页面变化。
为保证截图的完整性,设置完遮罩图后,同样利用scrollTo()方法将列表暂时滚至顶部,确保截图从最顶端开始。
- 循环滚动截图,裁剪和缓存截图数据。
3.1 记录每次滚动的位置到数组scrollYOffsets中,并使用componentSnapshot.get(LIST_ID) 方法获取当前画面的截图。
3.2 如果非首次截图,则使用crop方法截取从底部滚动进来的区域,然后调用pixmap.readPixelsSync(area)方法将截图数据读取到缓冲区域area中,并将area通过集合进行保存,用于后续截图拼接。
3.3 如果页面没有滚动到底部,继续滚动,继续递归调用snapAndMerge()方法进行截图;如果到达底部,则调用mergeImage()方法拼接所有收集到的图像片段,生成完整的长截图;同时还需限制截图的高度,以防过大的截图占用过多内存,影响应用性能,例如这里设置截长截图高度不超过5000。
- 拼接截图片段。
使用image.createPixelMapSync()方法创建长截图longPixelMap,并遍历之前保存的图像片段数据 (this.areaArray),构建image.PositionArea对象area,然后调用longPixelMap.writePixelsSync(area) 方法将这些片段逐个写入到正确的位置,从而拼接成一个完整的长截图。
- 恢复到截图前的状态,滚动到截图前的位置。
- 使用安全控件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)
}
}
}
}