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:
-
onTouchorPanGestureon each GridItem: only tracks gestures on that item; doesn't work if dragging out of bounds. -
PanGestureon 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)