Skip to content

Commit 96e23f0

Browse files
Ripwordsclaude
andcommitted
feat(expo): AssistiveTouch-style edge-snap drag for the launcher
Users were asking to drag the launcher anywhere along the edge of the screen, not just to one of four corners. Switch to iOS AssistiveTouch behavior: free movement during drag, then snap on release to whichever of the four screen edges (left/right/top/bottom) is nearest, preserving the position along that edge. The launcher can never end up parked mid-screen. - Extract pure geometry to launcher-geometry.ts (anchor types, bounds, edge-snap math) so it's unit-testable without RN/Animated/AsyncStorage. Tests cover anchor parsing, bound computation, anchor → center projection, drop-point → nearest-edge resolution, clamping, and tie-break determinism. - launcher.tsx now drives an Animated.ValueXY for absolute top-left position and springs to the resolved edge anchor on drag end. A JS-side ref mirrors the animated value so onStart can read the current position without touching Animated's private _value field. Window-resize listener recomputes bounds on rotation. - Persistence migrates @reprojs/expo/launcher-corner/v1 (Corner string) → @reprojs/expo/launcher-edge/v2 ({edge, along} JSON). The v1 key is read once as a fallback so users on the previous SDK keep their preferred side after upgrade. - `position` prop still accepts the four Corner values for back-compat as the initial position before persistence kicks in. No public API break. - Update docs/guide/expo.md to describe the new behavior. No standalone tests for the React component itself — covering it properly would require mocking RN gesture-handler, Animated, AsyncStorage, and Dimensions, which is out of scope. The geometry module owns the non-trivial logic and is fully tested. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9c45ba commit 96e23f0

4 files changed

Lines changed: 350 additions & 66 deletions

File tree

docs/guide/expo.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ The report lands in your dashboard's inbox with a Mobile / iOS / Android platfor
104104

105105
## Draggable launcher
106106

107-
The launcher is draggable to any of the four corners. Drag anywhere on screen, release, and it snaps (spring-animated) to the nearest corner. The choice persists across app restarts via AsyncStorage. Disable the behavior if you want a fixed position:
107+
The launcher drags AssistiveTouch-style: free movement during the drag, and on release it springs to the nearest screen edge (left, right, top, or bottom) with the position along that edge preserved. It can never end up parked in the middle of the screen. The chosen edge + along-axis position persists across app restarts via AsyncStorage.
108+
109+
Pass `position` as the initial corner before the user has dragged it; after the first drag, the persisted position takes over. Disable dragging entirely with `draggable={false}` to pin the launcher to `position`:
108110

109111
```tsx
110112
<ReproLauncher draggable={false} position="top-right" />
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
anchorToCenter,
4+
computeBounds,
5+
cornerToAnchor,
6+
isAnchor,
7+
nearestEdgeAnchor,
8+
} from "./launcher-geometry"
9+
10+
const SIZE = 52
11+
const MARGIN = 24
12+
const WIN = { width: 400, height: 800 }
13+
const OFFSET = {} as const
14+
15+
describe("cornerToAnchor", () => {
16+
test("bottom-right → right edge bottom", () => {
17+
expect(cornerToAnchor("bottom-right")).toEqual({ edge: "right", along: 1 })
18+
})
19+
test("bottom-left → left edge bottom", () => {
20+
expect(cornerToAnchor("bottom-left")).toEqual({ edge: "left", along: 1 })
21+
})
22+
test("top-right → right edge top", () => {
23+
expect(cornerToAnchor("top-right")).toEqual({ edge: "right", along: 0 })
24+
})
25+
test("top-left → left edge top", () => {
26+
expect(cornerToAnchor("top-left")).toEqual({ edge: "left", along: 0 })
27+
})
28+
})
29+
30+
describe("isAnchor", () => {
31+
test("accepts a well-formed anchor", () => {
32+
expect(isAnchor({ edge: "left", along: 0.5 })).toBe(true)
33+
expect(isAnchor({ edge: "right", along: 0 })).toBe(true)
34+
expect(isAnchor({ edge: "top", along: 1 })).toBe(true)
35+
expect(isAnchor({ edge: "bottom", along: 0.25 })).toBe(true)
36+
})
37+
test("rejects unknown edges", () => {
38+
expect(isAnchor({ edge: "diagonal", along: 0.5 })).toBe(false)
39+
})
40+
test("rejects out-of-range along", () => {
41+
expect(isAnchor({ edge: "left", along: -0.1 })).toBe(false)
42+
expect(isAnchor({ edge: "left", along: 1.1 })).toBe(false)
43+
})
44+
test("rejects missing fields and non-objects", () => {
45+
expect(isAnchor({ edge: "left" })).toBe(false)
46+
expect(isAnchor({ along: 0.5 })).toBe(false)
47+
expect(isAnchor(null)).toBe(false)
48+
expect(isAnchor("left")).toBe(false)
49+
expect(isAnchor(undefined)).toBe(false)
50+
})
51+
})
52+
53+
describe("computeBounds", () => {
54+
test("uses DEFAULT_MARGIN + half-size to keep the launcher fully on-screen", () => {
55+
const b = computeBounds(OFFSET, WIN)
56+
expect(b.minX).toBe(MARGIN + SIZE / 2)
57+
expect(b.maxX).toBe(WIN.width - MARGIN - SIZE / 2)
58+
expect(b.minY).toBe(MARGIN + SIZE / 2)
59+
expect(b.maxY).toBe(WIN.height - MARGIN - SIZE / 2)
60+
})
61+
test("respects explicit per-side offsets", () => {
62+
const b = computeBounds({ top: 60, bottom: 30, left: 10, right: 40 }, WIN)
63+
expect(b.minX).toBe(10 + SIZE / 2)
64+
expect(b.maxX).toBe(WIN.width - 40 - SIZE / 2)
65+
expect(b.minY).toBe(60 + SIZE / 2)
66+
expect(b.maxY).toBe(WIN.height - 30 - SIZE / 2)
67+
})
68+
})
69+
70+
describe("anchorToCenter", () => {
71+
const b = computeBounds(OFFSET, WIN)
72+
test("left edge along=0 → top-left bound center", () => {
73+
expect(anchorToCenter({ edge: "left", along: 0 }, b)).toEqual({ x: b.minX, y: b.minY })
74+
})
75+
test("left edge along=1 → bottom-left bound center", () => {
76+
expect(anchorToCenter({ edge: "left", along: 1 }, b)).toEqual({ x: b.minX, y: b.maxY })
77+
})
78+
test("right edge along=0.5 → middle-right", () => {
79+
expect(anchorToCenter({ edge: "right", along: 0.5 }, b)).toEqual({
80+
x: b.maxX,
81+
y: b.minY + (b.maxY - b.minY) / 2,
82+
})
83+
})
84+
test("top edge along=0.5 → middle-top", () => {
85+
expect(anchorToCenter({ edge: "top", along: 0.5 }, b)).toEqual({
86+
x: b.minX + (b.maxX - b.minX) / 2,
87+
y: b.minY,
88+
})
89+
})
90+
test("bottom edge along=1 → bottom-right bound center", () => {
91+
expect(anchorToCenter({ edge: "bottom", along: 1 }, b)).toEqual({ x: b.maxX, y: b.maxY })
92+
})
93+
test("clamps along to [0, 1]", () => {
94+
expect(anchorToCenter({ edge: "left", along: -1 }, b)).toEqual({ x: b.minX, y: b.minY })
95+
expect(anchorToCenter({ edge: "left", along: 2 }, b)).toEqual({ x: b.minX, y: b.maxY })
96+
})
97+
})
98+
99+
describe("nearestEdgeAnchor", () => {
100+
const b = computeBounds(OFFSET, WIN)
101+
test("point near the left edge → left", () => {
102+
const r = nearestEdgeAnchor({ x: b.minX + 5, y: WIN.height / 2 }, b)
103+
expect(r.edge).toBe("left")
104+
expect(r.along).toBeGreaterThan(0.4)
105+
expect(r.along).toBeLessThan(0.6)
106+
})
107+
test("point near the right edge mid-height → right with along ~0.5", () => {
108+
const r = nearestEdgeAnchor({ x: b.maxX - 5, y: WIN.height / 2 }, b)
109+
expect(r.edge).toBe("right")
110+
expect(r.along).toBeGreaterThan(0.4)
111+
expect(r.along).toBeLessThan(0.6)
112+
})
113+
test("point near the top edge → top", () => {
114+
const r = nearestEdgeAnchor({ x: WIN.width / 2, y: b.minY + 5 }, b)
115+
expect(r.edge).toBe("top")
116+
})
117+
test("point near the bottom edge → bottom", () => {
118+
const r = nearestEdgeAnchor({ x: WIN.width / 2, y: b.maxY - 5 }, b)
119+
expect(r.edge).toBe("bottom")
120+
})
121+
test("clamps along to [0, 1] when drop is outside the bounding rect", () => {
122+
const r = nearestEdgeAnchor({ x: -50, y: -50 }, b)
123+
expect(r.edge).toBe("left")
124+
expect(r.along).toBe(0)
125+
})
126+
test("ties resolve deterministically (left wins over top)", () => {
127+
// A point exactly equidistant from left and top edges — chosen so dLeft == dTop.
128+
const r = nearestEdgeAnchor({ x: b.minX + 10, y: b.minY + 10 }, b)
129+
expect(r.edge).toBe("left")
130+
})
131+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Pure geometry helpers for the floating launcher. Extracted from launcher.tsx
2+
// so the math (edge snapping, bound computation, drop-point → anchor) can be
3+
// unit-tested without spinning up React Native, AsyncStorage, or gesture
4+
// handlers.
5+
6+
export type Edge = "left" | "right" | "top" | "bottom"
7+
export type Corner = "bottom-right" | "bottom-left" | "top-right" | "top-left"
8+
9+
export interface Anchor {
10+
edge: Edge
11+
along: number
12+
}
13+
14+
export interface OffsetInput {
15+
top?: number
16+
bottom?: number
17+
left?: number
18+
right?: number
19+
}
20+
21+
export interface WindowSize {
22+
width: number
23+
height: number
24+
}
25+
26+
export interface Bounds {
27+
minX: number
28+
maxX: number
29+
minY: number
30+
maxY: number
31+
}
32+
33+
export const LAUNCHER_SIZE = 52
34+
export const DEFAULT_MARGIN = 24
35+
36+
const EDGES = new Set<Edge>(["left", "right", "top", "bottom"])
37+
38+
function clamp01(n: number): number {
39+
if (n < 0) return 0
40+
if (n > 1) return 1
41+
return n
42+
}
43+
44+
export function cornerToAnchor(corner: Corner): Anchor {
45+
const edge: Edge = corner.endsWith("right") ? "right" : "left"
46+
const along = corner.startsWith("bottom") ? 1 : 0
47+
return { edge, along }
48+
}
49+
50+
export function isAnchor(v: unknown): v is Anchor {
51+
if (typeof v !== "object" || v === null) return false
52+
const a = v as { edge?: unknown; along?: unknown }
53+
if (typeof a.edge !== "string" || !EDGES.has(a.edge as Edge)) return false
54+
if (typeof a.along !== "number" || a.along < 0 || a.along > 1) return false
55+
return true
56+
}
57+
58+
export function computeBounds(offset: OffsetInput, win: WindowSize): Bounds {
59+
const top = offset.top ?? DEFAULT_MARGIN
60+
const bottom = offset.bottom ?? DEFAULT_MARGIN
61+
const left = offset.left ?? DEFAULT_MARGIN
62+
const right = offset.right ?? DEFAULT_MARGIN
63+
return {
64+
minX: left + LAUNCHER_SIZE / 2,
65+
maxX: win.width - right - LAUNCHER_SIZE / 2,
66+
minY: top + LAUNCHER_SIZE / 2,
67+
maxY: win.height - bottom - LAUNCHER_SIZE / 2,
68+
}
69+
}
70+
71+
export function anchorToCenter(anchor: Anchor, b: Bounds): { x: number; y: number } {
72+
const a = clamp01(anchor.along)
73+
const xRange = b.maxX - b.minX
74+
const yRange = b.maxY - b.minY
75+
switch (anchor.edge) {
76+
case "left":
77+
return { x: b.minX, y: b.minY + a * yRange }
78+
case "right":
79+
return { x: b.maxX, y: b.minY + a * yRange }
80+
case "top":
81+
return { x: b.minX + a * xRange, y: b.minY }
82+
case "bottom":
83+
return { x: b.minX + a * xRange, y: b.maxY }
84+
}
85+
}
86+
87+
export function nearestEdgeAnchor(point: { x: number; y: number }, b: Bounds): Anchor {
88+
const dLeft = Math.abs(point.x - b.minX)
89+
const dRight = Math.abs(point.x - b.maxX)
90+
const dTop = Math.abs(point.y - b.minY)
91+
const dBottom = Math.abs(point.y - b.maxY)
92+
const xRange = b.maxX - b.minX
93+
const yRange = b.maxY - b.minY
94+
const alongY = yRange === 0 ? 0 : clamp01((point.y - b.minY) / yRange)
95+
const alongX = xRange === 0 ? 0 : clamp01((point.x - b.minX) / xRange)
96+
const min = Math.min(dLeft, dRight, dTop, dBottom)
97+
if (min === dLeft) return { edge: "left", along: alongY }
98+
if (min === dRight) return { edge: "right", along: alongY }
99+
if (min === dTop) return { edge: "top", along: alongX }
100+
return { edge: "bottom", along: alongX }
101+
}

0 commit comments

Comments
 (0)