From 13b58aeaa9339af3a6225a82ca758286630359da Mon Sep 17 00:00:00 2001 From: rick conroy Date: Mon, 6 Oct 2025 17:24:24 -0400 Subject: [PATCH] Swiping to close the sidebar (#678) * enabled drag gesture to close the sidebar view * removed extraneous onChanged block * Fix sidebar swipe gestures to work with ScrollView Improvements: - Use simultaneousGesture() instead of gesture() to work alongside ScrollView - Add horizontal-only detection (width > height * 1.5) to prevent interfering with vertical scrolling - Add visual feedback during drag with sidebarDragOffset updates - Add 20pt right edge zone for easier swipe-to-open activation (iOS-native behavior) - Lower threshold for edge swipe (-50pt instead of -100pt) Both swipe-to-open and swipe-to-close now work reliably: - Swipe left from anywhere (or from right edge) to open sidebar - Swipe right on sidebar to close it - Vertical scrolling unaffected * Fix sidebar drag offset direction Changed offset calculation from: showSidebar ? -dragOffset : width - dragOffset To: showSidebar ? dragOffset : width + dragOffset This fixes the issue where dragging right to close the sidebar would make it fly to the left side of the screen before closing. Now the sidebar correctly follows the finger during drag: - When closing: moves right (toward off-screen) - When opening: moves left (toward on-screen) * Optimize sidebar drag performance for smooth 60fps Performance improvements: - Remove .animation() modifier that was conflicting with drag updates - Use Transaction with disablesAnimations during drag for instant updates - Throttle state updates to only fire when offset changes >2pt - Always render sidebar (avoid conditional view creation overhead) - Only animate on gesture end, not during drag This eliminates the lag/jank during swipe by ensuring: 1. No animation conflicts during manual drag 2. Direct offset updates follow finger immediately 3. Stable view hierarchy (no conditional rendering) 4. Reduced state update frequency The sidebar now feels buttery smooth at 60fps. --------- Co-authored-by: jack --- bitchat/Views/ContentView.swift | 163 +++++++++++++++++++++++++------- 1 file changed, 130 insertions(+), 33 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index f962f594..f57e2176 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -129,6 +129,45 @@ struct ContentView: View { ) } + // Right edge swipe zone for easier sidebar opening (iOS-native behavior) + if !showSidebar { + HStack { + Spacer() + Color.clear + .frame(width: 20) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 5) + .onChanged { value in + let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width + if translationWidth < 0 { + let newOffset = max(translationWidth, -300) + if abs(newOffset - sidebarDragOffset) > 2 { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + sidebarDragOffset = newOffset + } + } + } + } + .onEnded { value in + let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width + let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width + withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + if translationWidth < -50 || velocity < -300 { + showSidebar = true + sidebarDragOffset = 0 + } else { + sidebarDragOffset = 0 + } + } + } + ) + } + .allowsHitTesting(true) + } + // Sidebar overlay HStack(spacing: 0) { // Tap to dismiss area @@ -141,31 +180,19 @@ struct ContentView: View { } } - // Only render sidebar content when it's visible or animating - if showSidebar || sidebarDragOffset != 0 { - sidebarView - #if os(macOS) - .frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4)) - #else - .frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7) - #endif - .transition(.move(edge: .trailing)) - } else { - // Empty placeholder when hidden - Color.clear - #if os(macOS) - .frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4)) - #else - .frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7) - #endif - } + // Always render sidebar to avoid layout recalculation during drag + sidebarView + #if os(macOS) + .frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4)) + #else + .frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7) + #endif } .offset(x: { let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width) - return showSidebar ? -dragOffset : width - dragOffset + return showSidebar ? dragOffset : width + dragOffset }()) - .animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar) } } #if os(macOS) @@ -921,9 +948,52 @@ struct ContentView: View { .id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined()) } - Spacer() - } - .background(backgroundColor) + Spacer() + } + .background(backgroundColor) + .simultaneousGesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width + let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height + + // Only handle horizontal drags on the sidebar + guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return } + + if translationWidth > 0 { + let newOffset = min(translationWidth, 300) + if abs(newOffset - sidebarDragOffset) > 2 { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + sidebarDragOffset = newOffset + } + } + } + } + .onEnded { value in + let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width + let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height + let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width + + // Only handle if drag is predominantly horizontal + guard abs(translationWidth) > abs(translationHeight) * 1.5 else { + sidebarDragOffset = 0 + return + } + + withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + if translationWidth > 100 || (translationWidth > 50 && velocity > 500) { + showSidebar = false + sidebarDragOffset = 0 + } else { + sidebarDragOffset = 0 + } + } + } + ) + + } } @@ -939,29 +1009,56 @@ struct ContentView: View { } .background(backgroundColor) .foregroundColor(textColor) - .gesture( - DragGesture() + .simultaneousGesture( + DragGesture(minimumDistance: 10) .onChanged { value in - let translation = value.translation.width.isNaN ? 0 : value.translation.width - if !showSidebar && translation < 0 { - sidebarDragOffset = max(translation, -300) - } else if showSidebar && translation > 0 { - sidebarDragOffset = min(-300 + translation, 0) + let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width + let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height + + // Only handle if drag is predominantly horizontal (prevents interfering with scroll) + guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return } + + if !showSidebar && translationWidth < 0 { + let newOffset = max(translationWidth, -300) + if abs(newOffset - sidebarDragOffset) > 2 { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + sidebarDragOffset = newOffset + } + } + } else if showSidebar && translationWidth > 0 { + let newOffset = min(-300 + translationWidth, 0) + if abs(newOffset - sidebarDragOffset) > 2 { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + sidebarDragOffset = newOffset + } + } } } .onEnded { value in - let translation = value.translation.width.isNaN ? 0 : value.translation.width + let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width + let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width + + // Only handle if drag is predominantly horizontal + guard abs(translationWidth) > abs(translationHeight) * 1.5 else { + sidebarDragOffset = 0 + return + } + withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { if !showSidebar { - if translation < -100 || (translation < -50 && velocity < -500) { + if translationWidth < -100 || (translationWidth < -50 && velocity < -500) { showSidebar = true sidebarDragOffset = 0 } else { sidebarDragOffset = 0 } } else { - if translation > 100 || (translation > 50 && velocity > 500) { + if translationWidth > 100 || (translationWidth > 50 && velocity > 500) { showSidebar = false sidebarDragOffset = 0 } else {