eleven / messenger Public

ios: interactive edge-drag for the server drawer (#75)

The drawer is now positioned by a progress value rather than toggled: drag
from the left edge and it slides in under your finger, with a velocity-aware
snap on release; drag or tap the scrim to close. @GestureState (not @State)
carries the live offset so a system-cancelled drag can never strand the drawer
half-open with hit-testing wedged, and hit-testing never depends on the live
drag (flipping it mid-gesture cancels the gesture doing the closing). Closed
drawer is hidden from VoiceOver.

UI test drives the drag both ways; asserts use hittability — offscreen
elements never leave the XCUITest tree, so exists-checks lie.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 695dec56611ee64fe568f5a109730f9fb67b4d68 parent 57cbdc6
3 files changed, +96 −16
  • ios/LChat/LChatApp.swift +73 −16
  • ios/LChat/Views/InstanceSidebar.swift +1 −0
  • ios/LChatUITests/JoinAndChatTests.swift +22 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 8d7ec5c..b5970e0 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -74,8 +74,21 @@ struct RootView: View {
@StateObject private var nav = NavModel()
@State private var path: [String] = [] // pushed thread ids
@State private var showServers = false
+ // @GestureState resets itself when the system cancels the drag (e.g. the
+ // list's scroll recognizer wins) — a plain @State would strand the drawer
+ // mid-flight with hit-testing disabled, wedging the whole screen.
+ @GestureState private var drawerDrag: CGFloat?
@State private var showAdd = false
+ private let drawerWidth: CGFloat = 300
+
+ // 0 closed … 1 open, tracking the finger mid-drag so the drawer slides with it.
+ private var drawerProgress: CGFloat {
+ let base: CGFloat = showServers ? 1 : 0
+ guard let drawerDrag else { return base }
+ return min(1, max(0, base + drawerDrag / drawerWidth))
+ }
+
var body: some View {
NavigationStack(path: $path) {
Group {
@@ -87,6 +100,7 @@ struct RootView: View {
}
}
.overlay { drawer }
+ .simultaneousGesture(drawerDragGesture)
.sheet(isPresented: $showAdd) {
AddInstanceView(onAdded: select)
}
@@ -121,25 +135,68 @@ struct RootView: View {
withAnimation(.easeOut(duration: 0.2)) { showServers = false }
}
+ // The drawer is always present, positioned by drawerProgress — that's what
+ // lets a drag slide it in under the finger rather than just toggling it.
@ViewBuilder private var drawer: some View {
+ let progress = drawerProgress
ZStack(alignment: .leading) {
- if showServers {
- Color.black.opacity(0.35)
- .ignoresSafeArea()
- .onTapGesture { withAnimation(.easeOut(duration: 0.2)) { showServers = false } }
- .transition(.opacity)
- ServerDrawer(
- activeID: nav.instanceID,
- onSelect: select,
- onAdd: {
- showServers = false
- showAdd = true
- }
- )
- .transition(.move(edge: .leading))
- }
+ Color.black.opacity(0.35 * progress)
+ .ignoresSafeArea()
+ .onTapGesture { withAnimation(.easeOut(duration: 0.2)) { showServers = false } }
+ // The scrim carries its own copy of the drag: gestures attached
+ // right where the touch lands are the only ones that reliably
+ // beat the UIKit recognizers layered through this hierarchy.
+ .gesture(drawerDragGesture)
+ // Hit-testing must NOT depend on the live drag: flipping it
+ // mid-gesture pulls the surface out from under the finger and
+ // cancels the very drag that's closing the drawer.
+ .allowsHitTesting(showServers)
+ ServerDrawer(
+ activeID: nav.instanceID,
+ onSelect: select,
+ onAdd: {
+ showServers = false
+ showAdd = true
+ }
+ )
+ .offset(x: (progress - 1) * drawerWidth)
+ .allowsHitTesting(showServers)
+ // Fully closed = gone: VoiceOver shouldn't focus an offscreen drawer.
+ .accessibilityHidden(progress == 0)
}
- .animation(.easeOut(duration: 0.22), value: showServers)
+ // Snaps animate; while the finger is down the drawer tracks it raw.
+ .animation(drawerDrag == nil ? .easeOut(duration: 0.22) : nil, value: progress)
+ // The overlay owns hit-testing while open, so it must carry the drag
+ // gesture too — the root's copy only ever sees the edge-open.
+ .simultaneousGesture(drawerDragGesture)
+ }
+
+ // Drag from the left edge to pull the drawer in (or drag it back closed from
+ // anywhere while open). Root screen only — in a pushed chat the left edge
+ // belongs to back-swipe, and the feed owns its own horizontal gestures.
+ private var drawerDragGesture: some Gesture {
+ DragGesture(minimumDistance: 15)
+ .updating($drawerDrag) { v, state, _ in
+ if state == nil {
+ // Engage only for a horizontal pull — from the edge to open,
+ // from anywhere to close — so list scrolling stays untouched.
+ guard engages(v) else { return }
+ }
+ state = v.translation.width
+ }
+ .onEnded { v in
+ guard engages(v) else { return }
+ let predicted = (showServers ? drawerWidth : 0) + v.predictedEndTranslation.width
+ withAnimation(.easeOut(duration: 0.22)) {
+ showServers = predicted > drawerWidth / 2
+ }
+ }
+ }
+
+ private func engages(_ v: DragGesture.Value) -> Bool {
+ guard path.isEmpty else { return false }
+ guard abs(v.translation.width) > abs(v.translation.height) else { return false }
+ return showServers || v.startLocation.x < 44
}
private var noServers: some View {
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
index 235c462..74d1786 100644
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -13,6 +13,7 @@ struct ServerDrawer: View {
VStack(alignment: .leading, spacing: 0) {
Text("Servers")
.font(.title2.weight(.semibold))
+ .accessibilityIdentifier("server-drawer-title") // disambiguates from the toolbar button's label in UI tests
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 10)
diff --git a/ios/LChatUITests/JoinAndChatTests.swift b/ios/LChatUITests/JoinAndChatTests.swift
index ed0c298..4b7cb6f 100644
--- a/ios/LChatUITests/JoinAndChatTests.swift
+++ b/ios/LChatUITests/JoinAndChatTests.swift
@@ -92,6 +92,28 @@ final class JoinAndChatTests: XCTestCase {
app.launch()
let row = app.staticTexts["Paula"].firstMatch
XCTAssertTrue(row.waitForExistence(timeout: 15), "no Paula chat on this install")
+
+ // Edge-drag pulls the server drawer in under the finger; drag it back shut.
+ let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
+ let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.5))
+ edge.press(forDuration: 0.05, thenDragTo: middle, withVelocity: .slow, thenHoldForDuration: 0.1)
+ let serversTitle = app.staticTexts["server-drawer-title"].firstMatch
+ XCTAssertTrue(serversTitle.waitForExistence(timeout: 3) && serversTitle.isHittable,
+ "drawer didn't open from edge drag")
+ shoot(app, "15-drawer-dragged")
+ // Close by dragging from the scrim (the drawer panel's scroll view owns
+ // gestures that start inside it; the scrim also closes on a plain tap).
+ let scrim = app.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5))
+ scrim.press(forDuration: 0.2, thenDragTo: edge, withVelocity: .slow, thenHoldForDuration: 0.2)
+ sleep(1)
+ shoot(app, "16-drawer-closed")
+ // Offscreen elements never leave the XCUITest tree — `exists` stays true
+ // forever; hittability is what actually flips when the drawer slides out.
+ let hidden = NSPredicate(format: "isHittable == false")
+ let closed = XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: hidden, object: serversTitle)],
+ timeout: 3) == .completed
+ XCTAssertTrue(closed, "drawer didn't close from drag")
+
row.tap()
let composer = app.textViews.firstMatch
XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer")