Putting a Gesture on Every Row, Then Merging Them Into One
Dragging folders to reorder them and to nest them inside one another. How I put a Pan gesture on every row, fought the scroll, and ended up merging it all into a single gesture wrapping the whole list.
Putting a Gesture on Every Row, Then Merging Them Into One
I added folders to Fecit. If labels are a lightweight way to sort things, folders are a tree. Tasks, memos, and templates can all be filed into folders.
Once you have folders, two things become inevitable: reordering and nesting. I wanted both done by dragging. Drag up or down to change the order; drop a folder onto another folder to put it inside. One finger, done.
Once I started building it, I was fighting the scroll again.
Why it’s hard, again
I’d been burned once before, building auto-scroll while dragging in the timetable. The lesson there was “don’t mix the JS thread and the UI thread.” This time it was a different kind of collision.
The folder list scrolls vertically. But dragging a folder is also vertical. A vertical drag inside a vertical scroll. When the finger moves up, who decides whether that’s a scroll or a drag? That question was the whole problem.
And the moment a drag begins, the scroll has to stop. Otherwise you lift a folder and the whole list comes up with it, and nothing works. “Turn off scrolling while dragging.” That one came back to bite me later.
First structure: a gesture on every row
The intuitive starting point was this: put a Pan gesture on each individual folder row.
If every row owns its gesture, you never have to figure out which row got grabbed — the one that got grabbed is the one that got grabbed. Press and hold (220ms) to arm, move after that to start the drag, and when it starts, turn off the scroll.
function FolderRow({folder, ...}) {
const rowGesture = useMemo(() => Gesture.Pan()
.manualActivation(true)
.simultaneousWithExternalGesture(scrollRef)
// arm at 220ms → activate → scrollEnabled = false
, [...]);
return <GestureDetector gesture={rowGesture}>...</GestureDetector>;
}
So far so good. Dragging worked, and nesting worked. But starting from the second drag, something was off.
After dragging once, you had to press twice to drag again. The first attempt was just swallowed; only the second one caught. And when you did drag, it flickered and stuttered.
Diagnosis: turning off the scroll kills your own gesture
The cause was hiding in “turn off scrolling while dragging.”
A row’s gesture lives inside the scroll view. When the drag starts and calls scrollEnabled = false, the scroll view re-arbitrates touch handling among its children. In that process, the very gesture that just activated gets cancelled too. You flip the switch and the switch electrocutes you.
A per-row pan simply couldn’t coexist with turning off the scroll. The gesture died once without the finger lifting, and the next touch caught a fresh one — which is exactly why it became “press twice to grab.”
Second structure: merge it all into one gesture
The fix was to move the gesture outside the list. I stripped the per-row gestures off entirely and merged them into a single Pan wrapping the whole ScrollView.
<GestureDetector gesture={rootPan}>
<ScrollView ref={scrollRef}>
{flatRows.map((it) => <FolderRow .../>)}
</ScrollView>
</GestureDetector>
Now turning off the child scroll mid-drag doesn’t break the gesture, because the gesture sits on the scroll view’s parent. (This is the structure react-native-draggable-flatlist uses, too.)
But you lose the one thing each row used to carry for free: which row got grabbed. With the gesture blanketing the whole list, I had to compute exactly which row the finger was over.
The gesture flow ended up like this:
onBegin: record the initial touch y, and start the 220ms arm timer.onTouchesMove: if armed, activate the gesture. If the finger moved more than 12px before arming, give up the drag and yield to the scroll.onStart: find the “grabbed row” from the recorded initial y. Turn off the scroll here.onUpdate: move the grabbed row with the finger, and compute where it would drop right now.onEnd: drop it.
The 220ms hold and the 12px yield are the heart of it. Hold still and it’s a drag; swipe right away and it’s a scroll. The user never thinks about it, but the finger sorts the two out on its own.
Where do you drop it
When each row had a gesture, the OS did the hit-testing. Now I had to do it myself.
First I flatten the visible tree and render it one row per line. That way the y coordinate each row’s onLayout reports is an absolute coordinate within the list content. I keep those coordinates in a map.
During a drag, the finger’s screen coordinate is converted to a list coordinate like this:
const contentY = absoluteY - listTop + scrollY;
Subtract the list’s top position from the absolute screen position, add the current scroll offset, and you get “the position within the content.” That value tells me which row the finger is over.
Then, within a single row, I split it into top / middle / bottom.
const rel = (contentY - row.y) / row.height;
const zone = rel < 0.28 ? "before" : rel > 0.72 ? "after" : "into";
The top 28% of a row’s height means “before this row,” the bottom 28% means “after this row,” and the middle 44% means “inside this folder.” I made the middle wide on purpose, because nesting is the more common action. And I block dropping onto yourself or your own descendant — a folder inside itself would break the tree.
And three subtle bugs
By here it worked. But three things still grated. All three were “one frame” problems.
One, on drop it went somewhere and came back. When you released a folder, it briefly slid to the wrong spot and snapped back — a flicker. The cause was timing. Resetting the translateY that had been following the finger, and committing the new order to the data, happened at different moments. For a frame or two in between, you saw a “new layout but old translateY” state. Binding the two into the same synchronous block, done in one frame, made it disappear.
Two, hovering over a folder pushed the neighboring rows. To signal “you can nest here,” I drew a border — but the border going from 0 to 1.5 made the row grow by that much and shoved everything around it. The fix is trivial. Always draw the border at 1.5, transparent normally, colored only when signaling. The width never changes, so neither does the layout.
borderWidth: 1.5, // always 1.5
borderColor: showInto ? PRIMARY500 : "transparent", // toggle color only
Three, after one drop, only some rows would drag. The most confusing one. When expanding or collapsing a folder, I cleared the coordinate map wholesale — but right after, the screen re-renders and each row’s onLayout refills its coordinate. The clear and the refill raced, and the fresh coordinates of still-live rows got wiped along with the rest. A row with no coordinate isn’t caught by the hit-test, hence “only some drag.” Instead of clearing the whole thing, I switched to deleting only the rows that went out of view, and it was fixed.
Bonus: Korean wouldn’t type
In the new-folder input, Korean wouldn’t type. English was fine; only Korean.
The drawer floats on a Portal, and inside that Portal I was floating the TextInput wrapped in yet another Portal, which broke the Korean IME. The drawer is already a floating layer, so there was no need to wrap the modal in a Portal again. Removing the Portal wrapping let Korean through.
What I learned
-
For a drag inside a scroll, put the gesture on the outside. A per-row gesture kills itself the moment it turns off the scroll. One gesture wrapping the whole list is the answer. You can do the hit-testing yourself.
-
“Hold still to drag, swipe to scroll.” A 220ms hold plus a small movement tolerance is enough to cleanly separate two vertical actions.
-
Change visual state atomically, within one frame. If resetting position and committing data happen at different times, it flickers. Bind them into one block.
-
Draw what must not change from the start. Don’t change a border’s width — change its color. Don’t touch the layout.
-
Don’t clear a cache wholesale — delete selectively. If clearing races with refilling, you lose the live entries too.
I wrote down the same lesson once before, and it came back in a different shape. The gap between threads and frames is always there.