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