| 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 |
| } |
| } |
| |