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)