DEV Community

Studotie Handwer
Studotie Handwer

Posted on

[Dev Notes] Implementing Photo-Gallery-Style Sliding Multi-Select with Custom Gestures

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:

Image description

Image description

(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) } 
Enter fullscreen mode Exit fullscreen mode

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}) } } } 
Enter fullscreen mode Exit fullscreen mode

Sliding Multi-Selection

Problem: How do we determine which item is touched?

Options:

  • onTouch or PanGesture 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 } 
Enter fullscreen mode Exit fullscreen mode
Grid() { ForEach(..., (val: ViewModel, index) => { GridItem() { this.singleItemBuilder(val, index) }.aspectRatio(1) .onAreaChange((_, nw) => { val.inGridArea = nw }) }) } 
Enter fullscreen mode Exit fullscreen mode

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!) }) } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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) }) 
Enter fullscreen mode Exit fullscreen mode

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) }) 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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) } } } 
Enter fullscreen mode Exit fullscreen mode

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() }) 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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)