This feature has been deployed in the latest release of QuickieSticker — feel free to download and try it out!
Preface
When I was first asked to implement multi-selection for stickers, I flat-out refused. I had barely used Grid
, didn’t know how to adapt multi-selection for it, and besides, the app’s existing functionality conflicted with Grid’s built-in multi-select, so I just deleted it altogether. As a result, sticker multi-selection had always felt like a technical challenge I’d been avoiding. It wasn’t until I was filming the promo video and needed to batch edit sticker info that I realized multi-selection was actually a very real need.
After I finished the basic multi-select, I hit another problem: tapping one by one was still too tedious. Could we make it more like the photo album app — support drag selection and select all?
Due to personal coding habits, I’d been using a Set<string>
to track selected sticker UIDs and leaving visual toggle state to the Toggle
component. Luckily, ArkUI’s State Management V2 supports reactive tracking of Set
and Map
, so my existing code migrated seamlessly into a stateful structure. Once the state system is in place, a lot of more advanced logic becomes possible.
Final Result
Let’s get to the point — the goal is to implement photo-gallery-style sliding multi-select, as shown in the GIF below:
(unfortunately the gif was always failed to upload so only static img here)
Specifically, we aim to achieve the following:
- A white overlay to indicate selected state
- Drag gesture to select/deselect stickers
- Auto-scroll when dragging to the top/bottom edge of the screen
Core Principle
This implementation does not use Grid's built-in multi-selection. Grid is used purely for the layout — all functionality is custom.
Each grid item is structured as follows:
@ObservedV2 class ViewModel { uid: string @Trace img: string } @Builder singleItemBuilder(item: ViewModel, index: number) { Image(item.img) .aspectRatio(1) }
Selection State
Selection overlay is simple: stack a semi-transparent white layer over the image, along with a Toggle
component. We use a Set<string>
to keep track of selected UIDs, made reactive via ArkUI's state system:
@Local selectedImgs: Set<string> = new Set() @Builder singleItemBuilder(item: ViewModel, index: number) { Stack({alignContent: Alignment.BottomEnd}) { Image(item.img) .aspectRatio(1) if (this.isMultiSelecting) { // Semi-transparent overlay Column() {}.width('100%').height('100%') .backgroundColor(this.selectedImgs.has(item.uid) ? '#33ffffff' : undefined) .onClick(() => { if (!this.selectedImgs.has(item.uid)) this.selectedImgs.add(item.uid) else this.selectedImgs.delete(item.uid) }) // Toggle checkbox Toggle({isOn: this.selectedImgs.has(item.uid)}, type: ToggleType.Checkbox) .onChange((isOn) => { if (!isOn) this.selectedImgs.add(item.uid) else this.selectedImgs.delete(item.uid) }).margin({right: 8, bottom: 8}) } } }
Sliding Multi-Selection
Problem: How do we determine which item is touched?
Options:
-
onTouch
orPanGesture
on each GridItem: only tracks gestures on that item; doesn't work if dragging out of bounds. -
PanGesture
on the Grid parent container: doesn’t know which item is being touched — only gives coordinates.
We go with option 3 and manually map global touch coordinates to the grid item.
Each item's Area
is captured via onAreaChange
, and stored in its ViewModel:
@ObservedV2 class ViewModel { uid: string @Trace img: string inGridArea: Area | undefine }
Grid() { ForEach(..., (val: ViewModel, index) => { GridItem() { this.singleItemBuilder(val, index) }.aspectRatio(1) .onAreaChange((_, nw) => { val.inGridArea = nw }) }) }
Touch Coordinate to Grid Item Index
private checkIsInArea(x: number, y: number, area: Area) { let lx = area.globalPosition.x!.valueOf(), rx = lx + area.width!.valueOf(); let uy = area.globalPosition.y!.valueOf(), dy = uy + area.height!.valueOf(); return lx <= x && x <= rx && uy <= y && y <= dy; } private findInGridIndex(x: number, y: number) { return this.itemDataArray.findIndex((val) => { return this.checkIsInArea(x, y, val.inGridArea!) }) }
Sliding State ViewModel
@ObservedV2 class GridSlideSelectInfo { @Trace slideState: boolean = false @Trace slideIsAdd: boolean = false @Trace firstCellIndex: number = -1 @Trace lastCellIndex: number = -1 judgeCellIsActive(index: number) { return (this.firstCellIndex <= index && index <= this.lastCellIndex) || (this.firstCellIndex >= index && index >= this.lastCellIndex) } get first() { return Math.min(this.firstCellIndex, this.lastCellIndex) } get last() { return Math.max(this.firstCellIndex, this.lastCellIndex) } }
Gesture Lifecycle: Start
PanGesture().onActionStart((event) => { if (!this.isMultiSelecting || event.fingerList.length !== 1) return this.slideState.slideState = true let vx = event.fingerList[0].globalX, vy = event.fingerList[0].globalY let firstCellIndex = this.findInGridIndex(vx, vy) if (firstCellIndex === -1) return this.slideState.firstCellIndex = this.slideState.lastCellIndex = firstCellIndex this.slideState.slideIsAdd = !this.selectedImgUids.has(this.stickersData[firstCellIndex].uid) })
Gesture Move
.onActionUpdate((event) => { if (!this.isMultiSelecting || event.fingerList.length !== 1) return let vx = event.fingerList[0].globalX, vy = event.fingerList[0].globalY this.slideState.lastCellIndex = this.findInGridIndex(vx, vy) })
Is Item Selected?
private judgeIsSelected(uid: string, index: number) { if (!this.slideState.slideState) return this.selectedImgUids.has(uid) if (this.slideState.slideIsAdd) { return this.selectedImgUids.has(uid) || this.slideState.judgeCellIsActive(index) } else { if (this.slideState.judgeCellIsActive(index)) return false return this.selectedImgUids.has(uid) } }
Update your builder:
@Builder singleItemBuilder(item: ViewModel, index: number) { Stack({alignContent: Alignment.BottomEnd}) { Image(item.img).aspectRatio(1) if (this.isMultiSelecting) { Column().width('100%').height('100%') .backgroundColor(this.judgeIsSelected(item.uid,index) ? '#33ffffff' : undefined) .onClick(() => { if (!this.selectedImgs.has(item.uid)) this.selectedImgs.add(item.uid) else this.selectedImgs.delete(item.uid) }) Toggle({ isOn: this.judgeIsSelected(item.uid,index), type: ToggleType.Checkbox }) .margin({right: 8, bottom: 8}) .hitTestBehavior(HitTestMode.None) } } }
Gesture End
.onActionEnd((event) => { if (!this.isMultiSelecting || event.fingerList.length !== 1) return let vx = event.fingerList[0].globalX, vy = event.fingerList[0].globalY this.slideState.lastCellIndex = this.findInGridIndex(vx, vy) let first = this.slideState.first, last = this.slideState.last let adds = this.stickersData.slice(first, last + 1).map(val => val.uid) for (let uid of adds) { if (this.slideState.slideIsAdd) { this.selectedImgUids.add(uid) } else { this.selectedImgUids.delete(uid) } } this.slideState = new GridSlideSelectInfo() })
Auto Scroll at Edge
To auto-scroll, detect whether the finger is near the top or bottom edge of the screen. Then use scroller.scrollEdge()
with a dynamic velocity based on distance to the edge.
private checkIsInTopSlideArea(x: number, y: number) { return (y <= this.EDGE_SLIDE_HEIGHT) } private checkIsInBottomSlideArea(x: number, y: number) { let h = this.pageArea!.height!.valueOf() return (y >= h - this.EDGE_SLIDE_HEIGHT) } private getSlideVelocity(x: number, y: number) { let h = this.pageArea!.height!.valueOf() let dist = Math.min(y, h - y) if (dist <= 25) return 700 if (dist <= 50) return 400 if (dist <= 70) return 320 if (dist <= 90) return 200 return 100 }
In onActionUpdate
:
if (this.checkIsInTopSlideArea(vx, vy)) { this.scroller.scrollEdge(Edge.Top, { velocity: this.getSlideVelocity(vx, vy), }) } else if (this.checkIsInBottomSlideArea(vx, vy)) { this.scroller.scrollEdge(Edge.Bottom, { velocity: this.getSlideVelocity(vx, vy), }) } else { this.scroller.scrollBy(0, 0) }
Don’t forget to scrollBy(0, 0)
in onActionEnd
to stop scrolling.
Conclusion
This wraps up the entire feature! It was a milestone for me — implementing a gesture-heavy UX without any reference code, just based on understanding. I also wanted to share this because HarmonyOS still lacks a lot of community content — hope this helps others on similar paths.
Reference: https://www.jianshu.com/p/c73597d91fab
Top comments (0)