eleven / messenger Public

ios: smooth drawer flick + edge-open scroll lock (#127)

* ios: smooth drawer flick + lock list scroll during edge-open

Drive the server drawer from a single position value (drawerPos) so a
fast flick settles straight to its rest state instead of bouncing — the
old design summed a showServers base with the live drag and the two
updates landed on different ticks at release.

Also lock the chat list's scroll from touch-down (a zero-distance
sentinel gesture) while a finger is in the drawer's opening zone, so an
edge pull slides the drawer without the list scrolling underneath.
Locking on drag-engage was too late: the list's pan has already begun
and .scrollDisabled can't recall an in-flight scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: lock list scroll from touch-down without eating row taps

Replace the zero-distance SwiftUI DragGesture sentinel (which swallowed
the chat list's row taps) with a UIKit UILongPressGestureRecognizer
(minimumPressDuration 0, cancelsTouchesInView false) wrapped as
EdgeTouchReporter. It observes a finger landing in the left-edge zone
without consuming the touch, so the list scroll locks before the pan can
begin while taps and scrolling elsewhere stay intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 3b11c4795421e52d7c122ebb6033b657cc7e1658 parent dcfd2aa
3 files changed, +172 −27
  • ios/LChat/LChatApp.swift +78 −26
  • ios/LChat/Views/EdgeTouchReporter.swift +89 −0
  • ios/LChat/Views/ThreadListView.swift +5 −1
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 9893675..3c80026 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -74,30 +74,52 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var nav = NavModel()
@State private var path: [String] = [] // pushed thread ids
+ // Logical open state: drives hit-testing, accessibility, and external opens
+ // (the server button, a chat select, an invite link). Kept in step with
+ // `drawerPos` by onChange below.
@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?
+ // Where the drawer actually sits: 0 closed … 1 open. The single source of
+ // truth for the panel's position, so it can't glitch on a fast flick: a drag
+ // writes it live, and release animates it straight to a rest state. (The old
+ // design summed a `showServers ? 1 : 0` base with the live drag; at release
+ // those two updates landed on different ticks, so the drawer darted to one
+ // and snapped back to the other — the flick "bounce".)
+ @State private var drawerPos: CGFloat = 0
+ // The drawer position captured when the current drag began, or nil when no
+ // drag is in flight — the baseline the live translation is added onto.
+ @State private var dragBaseline: CGFloat?
+ // True only while a finger is down. @GestureState so a system-cancelled drag
+ // (the list's scroll recognizer wins) auto-clears it; we watch it to snap a
+ // stranded drawer back to rest instead of wedging the screen half-open.
+ @GestureState private var dragging = false
+ // True from the instant a finger lands in the drawer's opening zone (the left
+ // edge) until it lifts. We freeze the list's scroll on this, not on the drag
+ // engaging: the list's pan starts in the first few points of movement, and
+ // once it's scrolling `.scrollDisabled` can't call it back — so the lock has
+ // to be in place *before* the finger moves. Set from touch-down by a UIKit
+ // probe (EdgeTouchReporter) that observes the touch without consuming it; a
+ // SwiftUI zero-distance drag would do the same but swallow the list's taps.
+ @State private var edgeTouch = false
@State private var showAdd = false
// An invite link the OS opened us with (custom scheme or universal link),
// waiting to pre-fill the add sheet.
@State private var pendingAddLink = ""
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))
- }
+ // Slide-in / settle curve. easeOut decelerates into place and never
+ // overshoots, so there's no bounce by construction.
+ private let drawerSettle: Animation = .easeOut(duration: 0.22)
var body: some View {
NavigationStack(path: $path) {
Group {
if let store = nav.store {
- ThreadListView(store: store, path: $path, showServers: $showServers)
+ // Freeze the list's own scroll while a finger that could be
+ // opening the drawer is down at the edge, so a diagonal pull
+ // slides the drawer without the list behind scrolling under it
+ // (the drag is simultaneous, so the list keeps its recognizer).
+ ThreadListView(store: store, path: $path, showServers: $showServers,
+ lockScroll: edgeTouch)
} else {
noServers
}
@@ -105,6 +127,25 @@ struct RootView: View {
}
.overlay { drawer }
.simultaneousGesture(drawerDragGesture)
+ // Flips edgeTouch the moment a finger lands in the drawer's opening zone,
+ // so the list is already scroll-locked before it can move. A UIKit probe
+ // (not a SwiftUI gesture) so it observes the touch-down without stealing
+ // the list's row taps.
+ .background(EdgeTouchReporter(edgeWidth: 44) { edgeTouch = $0 })
+ // Any open/close that isn't a drag — the server button, a chat select, an
+ // invite link — just flips showServers; the drawer follows it here, so the
+ // slide is defined in exactly one place (the implicit .animation below).
+ .onChange(of: showServers) { _, open in drawerPos = open ? 1 : 0 }
+ // A drag the system cancelled fires no onEnded (a recognizer stole it),
+ // so dragBaseline is still set: snap the stranded panel to its nearest
+ // rest state rather than leaving it parked mid-screen.
+ .onChange(of: dragging) { _, active in
+ guard !active, dragBaseline != nil else { return }
+ dragBaseline = nil
+ let open = drawerPos > 0.5
+ showServers = open
+ drawerPos = open ? 1 : 0
+ }
.sheet(isPresented: $showAdd, onDismiss: { pendingAddLink = "" }) {
AddInstanceView(initialLink: pendingAddLink, onAdded: select)
// Keyed on the link: a fresh OS-opened link rebuilds (and
@@ -172,17 +213,17 @@ struct RootView: View {
// a chat worth hearing about, and (re)register this instance.
push.enablePush()
push.subscribeNewInstance(inst)
- withAnimation(.easeOut(duration: 0.2)) { showServers = false }
+ showServers = false
}
- // The drawer is always present, positioned by drawerProgress — that's what
+ // The drawer is always present, positioned by drawerPos — 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
+ let progress = drawerPos
ZStack(alignment: .leading) {
Color.black.opacity(0.35 * progress)
.ignoresSafeArea()
- .onTapGesture { withAnimation(.easeOut(duration: 0.2)) { showServers = false } }
+ .onTapGesture { 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.
@@ -204,8 +245,11 @@ struct RootView: View {
// Fully closed = gone: VoiceOver shouldn't focus an offscreen drawer.
.accessibilityHidden(progress == 0)
}
- // Snaps animate; while the finger is down the drawer tracks it raw.
- .animation(drawerDrag == nil ? .easeOut(duration: 0.22) : nil, value: progress)
+ // Snaps animate; while the finger is down (dragBaseline set) the drawer
+ // tracks it raw. Because progress is now a single value that the drag
+ // leaves exactly where the finger let go, this tween always runs from the
+ // release point straight to the rest state — nothing to bounce off.
+ .animation(dragBaseline == nil ? drawerSettle : 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)
@@ -216,20 +260,28 @@ struct RootView: View {
// 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 {
+ // Just a liveness flag; @GestureState auto-clears it on cancel so the
+ // onChange(dragging) recovery above can rescue a stranded drag.
+ .updating($dragging) { _, state, _ in state = true }
+ .onChanged { v in
+ if dragBaseline == 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 }
+ dragBaseline = drawerPos
}
- state = v.translation.width
+ guard let base = dragBaseline else { return }
+ drawerPos = min(1, max(0, base + v.translation.width / drawerWidth))
}
.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
- }
+ guard let base = dragBaseline else { return }
+ dragBaseline = nil
+ // Settle by where the flick was *headed*, not where the finger
+ // happened to lift — a quick flick commits even if it barely moved.
+ let predicted = min(1, max(0, base + v.predictedEndTranslation.width / drawerWidth))
+ let open = predicted > 0.5
+ showServers = open
+ drawerPos = open ? 1 : 0
}
}
diff --git a/ios/LChat/Views/EdgeTouchReporter.swift b/ios/LChat/Views/EdgeTouchReporter.swift
new file mode 100644
index 0000000..f8e7784
--- /dev/null
+++ b/ios/LChat/Views/EdgeTouchReporter.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+import UIKit
+
+// Reports, via `onChange`, whether a finger is currently pressing in the left
+// screen-edge zone — the strip where a drawer-open drag begins — from the moment
+// it lands until it lifts. RootView freezes the chat list's scroll on this, so an
+// edge pull slides the drawer in without the list scrolling out from under it.
+//
+// Why UIKit and not a SwiftUI `DragGesture(minimumDistance: 0)`: that gesture
+// recognises on touch-down and, even as a `.simultaneousGesture`, swallows the
+// list rows' taps. A `UILongPressGestureRecognizer` with `cancelsTouchesInView =
+// false` observes the touch-down without stealing it — we only need to *know* a
+// touch began at the edge, not consume it, so taps and scrolling stay intact.
+struct EdgeTouchReporter: UIViewRepresentable {
+ var edgeWidth: CGFloat = 44
+ var onChange: (Bool) -> Void
+
+ func makeCoordinator() -> Coordinator { Coordinator(edgeWidth: edgeWidth, onChange: onChange) }
+
+ func makeUIView(context: Context) -> UIView { Probe(coordinator: context.coordinator) }
+
+ func updateUIView(_ uiView: UIView, context: Context) {
+ context.coordinator.edgeWidth = edgeWidth
+ context.coordinator.onChange = onChange
+ }
+
+ final class Coordinator: NSObject, UIGestureRecognizerDelegate {
+ var edgeWidth: CGFloat
+ var onChange: (Bool) -> Void
+
+ init(edgeWidth: CGFloat, onChange: @escaping (Bool) -> Void) {
+ self.edgeWidth = edgeWidth
+ self.onChange = onChange
+ }
+
+ @objc func handle(_ g: UILongPressGestureRecognizer) {
+ switch g.state {
+ case .began:
+ onChange(g.location(in: g.view).x < edgeWidth)
+ case .ended, .cancelled, .failed:
+ onChange(false)
+ default:
+ break
+ }
+ }
+
+ // Coexist with everything — the scroll view, the drawer drag, row taps.
+ func gestureRecognizer(_ g: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
+ }
+
+ // A zero-size, non-interactive probe that hangs the recognizer on its
+ // superview — an ancestor of the list — so the recognizer sees the list's
+ // touches while the probe itself never appears in hit-testing.
+ final class Probe: UIView {
+ private let recognizer: UILongPressGestureRecognizer
+ private weak var host: UIView?
+
+ init(coordinator: Coordinator) {
+ recognizer = UILongPressGestureRecognizer(target: coordinator,
+ action: #selector(Coordinator.handle(_:)))
+ // Fire at touch-down, and don't bail when the finger then moves off to
+ // become a drag — we want the whole press, down to up.
+ recognizer.minimumPressDuration = 0
+ recognizer.allowableMovement = .greatestFiniteMagnitude
+ recognizer.cancelsTouchesInView = false
+ recognizer.delaysTouchesBegan = false
+ recognizer.delaysTouchesEnded = false
+ super.init(frame: .zero)
+ recognizer.delegate = coordinator
+ isUserInteractionEnabled = false
+ }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ guard window != nil, let host = superview, recognizer.view !== host else { return }
+ recognizer.view?.removeGestureRecognizer(recognizer)
+ host.addGestureRecognizer(recognizer)
+ self.host = host
+ }
+
+ override func removeFromSuperview() {
+ host?.removeGestureRecognizer(recognizer)
+ host = nil
+ super.removeFromSuperview()
+ }
+ }
+}
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 2274e79..41cea5f 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -9,6 +9,9 @@ struct ThreadListView: View {
@ObservedObject var store: ThreadStore
@Binding var path: [String]
@Binding var showServers: Bool
+ // Freezes this list's scroll while a finger that might be opening the drawer
+ // is down, so the panel slides in without the list scrolling under it.
+ var lockScroll: Bool = false
@State private var showNew = false
@State private var search = ""
@@ -26,6 +29,7 @@ struct ThreadListView: View {
}
}
.listStyle(.plain)
+ .scrollDisabled(lockScroll)
.searchable(text: $search, prompt: "Search chats")
.overlay { if store.loading && store.threads.isEmpty { ProgressView() } }
.overlay { if !store.loading && store.threads.isEmpty { empty } }
@@ -38,7 +42,7 @@ struct ThreadListView: View {
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
- Button { withAnimation(.easeOut(duration: 0.22)) { showServers = true } } label: {
+ Button { showServers = true } label: {
Avatar(name: store.instanceName.isEmpty ? store.instance.displayName : store.instanceName,
size: 30)
}