From 73606041366265e81215a14455e284892f6df22c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 22:46:25 -0700 Subject: [PATCH] ios: swipe to create new conversation --- .../Sybil/Sources/Sybil/SplitView.swift | 6 +- .../Sources/Sybil/SybilPhoneShellView.swift | 28 +- .../Sources/Sybil/SybilWorkspaceView.swift | 537 +++++++++++++++++- .../Sybil/Tests/SybilTests/SybilTests.swift | 19 + 4 files changed, 568 insertions(+), 22 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index f52ac5e..c46ad29 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -5,6 +5,7 @@ public struct SplitView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.scenePhase) private var scenePhase @State private var shouldRefreshOnForeground = false + @State private var composerFocusRequest = 0 @MainActor public init() { SybilFontRegistry.registerIfNeeded() @@ -29,7 +30,10 @@ public struct SplitView: View { NavigationSplitView { SybilSidebarView(viewModel: viewModel) } detail: { - SybilWorkspaceView(viewModel: viewModel) + SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) { + viewModel.startNewChat() + composerFocusRequest += 1 + } } .navigationSplitViewStyle(.balanced) .tint(SybilTheme.primary) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index 76fc8b0..7e5ae7f 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -25,6 +25,7 @@ struct SybilPhoneShellView: View { @State private var path: [PhoneRoute] = [] @Environment(\.scenePhase) private var scenePhase @State private var shouldRefreshOnForeground = false + @State private var composerFocusRequest = 0 var body: some View { NavigationStack(path: $path) { @@ -37,7 +38,12 @@ struct SybilPhoneShellView: View { } } .navigationDestination(for: PhoneRoute.self) { route in - SybilPhoneDestinationView(viewModel: viewModel, route: route) + SybilPhoneDestinationView( + viewModel: viewModel, + path: $path, + composerFocusRequest: $composerFocusRequest, + route: route + ) } } .tint(SybilTheme.primary) @@ -248,15 +254,25 @@ private struct SybilPhoneSidebarRow: View { private struct SybilPhoneDestinationView: View { @Bindable var viewModel: SybilViewModel + @Binding var path: [PhoneRoute] + @Binding var composerFocusRequest: Int let route: PhoneRoute var body: some View { - SybilWorkspaceView(viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .navigationBarTitleDisplayMode(.inline) - .task(id: route) { - applyRoute() + SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) { + viewModel.startNewChat() + composerFocusRequest += 1 + if path.isEmpty { + path = [.draftChat] + } else { + path[path.index(before: path.endIndex)] = .draftChat } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .navigationBarTitleDisplayMode(.inline) + .task(id: route) { + applyRoute() + } } private func applyRoute() { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 9d7cf4c..b1cb5ba 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -6,12 +6,20 @@ import UIKit struct SybilWorkspaceView: View { @Bindable var viewModel: SybilViewModel + var composerFocusRequest: Int = 0 + var onRequestNewChat: (() -> Void)? = nil @FocusState private var composerFocused: Bool @State private var isShowingAttachmentOptions = false @State private var isShowingFileImporter = false @State private var isShowingPhotoPicker = false @State private var photoPickerItems: [PhotosPickerItem] = [] @State private var isComposerDropTargeted = false + @State private var newChatSwipeOffset: CGFloat = 0 + @State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth + @State private var newChatSwipeIsActive = false + @State private var newChatSwipeHasLatched = false + @State private var newChatSwipeDidTriggerHaptic = false + @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator? private var isSettingsSelected: Bool { if case .settings = viewModel.selectedItem { @@ -34,7 +42,64 @@ struct SybilWorkspaceView: View { return "chat:none" } + private var canSwipeToCreateChat: Bool { + guard onRequestNewChat != nil else { + return false + } + guard !viewModel.isSending, viewModel.draftKind == nil else { + return false + } + guard case .chat = viewModel.selectedItem else { + return false + } + return true + } + var body: some View { + ZStack(alignment: .trailing) { + if canSwipeToCreateChat { + NewChatSwipeBackdrop( + progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth), + hasLatched: newChatSwipeHasLatched + ) + .padding(.trailing, 18) + .padding(.vertical, 20) + .allowsHitTesting(false) + } + + workspaceContent + .compositingGroup() + .offset(x: newChatSwipeOffset) + .blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth)) + } + .background(SybilTheme.background) + .navigationTitle(viewModel.selectedTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbarRole(.editor) + .toolbar { + if !isSettingsSelected { + ToolbarItem(placement: .topBarTrailing) { + if viewModel.isSearchMode { + searchModeChip + } else { + providerModelMenu + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: canSwipeToCreateChat) { _, isEnabled in + guard !isEnabled else { + return + } + resetNewChatSwipe(animated: false) + } + .task(id: composerFocusRequest) { + await focusComposerIfRequested() + } + } + + private var workspaceContent: some View { VStack(spacing: 0) { if showsHeader { header @@ -67,6 +132,24 @@ struct SybilWorkspaceView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + NewChatSwipePanInstaller( + isEnabled: canSwipeToCreateChat, + onBegan: { width in + beginNewChatSwipe(containerWidth: width) + }, + onChanged: { translationX, width in + updateNewChatSwipe(with: translationX, containerWidth: width) + }, + onEnded: { translationX, width, didFinish in + finishNewChatSwipe( + translationX: translationX, + containerWidth: width, + didFinish: didFinish + ) + } + ) + } if viewModel.showsComposer { Divider() @@ -74,22 +157,96 @@ struct SybilWorkspaceView: View { composerBar } } - .navigationTitle(viewModel.selectedTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbarRole(.editor) - .toolbar { - if !isSettingsSelected { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.isSearchMode { - searchModeChip - } else { - providerModelMenu - } - } - } + } + + private func beginNewChatSwipe(containerWidth: CGFloat) { + let update = { + newChatSwipeContainerWidth = max(containerWidth, 1) + newChatSwipeIsActive = true + newChatSwipeHasLatched = false + newChatSwipeDidTriggerHaptic = false } - .background(SybilTheme.background) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction, update) + + if newChatSwipeFeedbackGenerator == nil { + newChatSwipeFeedbackGenerator = UIImpactFeedbackGenerator(style: .rigid) + } + newChatSwipeFeedbackGenerator?.prepare() + } + + private func updateNewChatSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) { + let nextOffset = NewChatSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth) + let wasLatched = newChatSwipeHasLatched + let nextLatched = NewChatSwipeMetrics.isLatched( + offset: nextOffset, + width: containerWidth, + isCurrentlyLatched: newChatSwipeHasLatched + ) + + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + newChatSwipeContainerWidth = max(containerWidth, 1) + newChatSwipeOffset = nextOffset + newChatSwipeHasLatched = nextLatched + } + + if nextLatched && !wasLatched && !newChatSwipeDidTriggerHaptic { + newChatSwipeFeedbackGenerator?.impactOccurred(intensity: 0.95) + newChatSwipeDidTriggerHaptic = true + } + } + + private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) { + guard newChatSwipeIsActive else { + resetNewChatSwipe(animated: false) + return + } + + updateNewChatSwipe(with: translationX, containerWidth: containerWidth) + + if didFinish && newChatSwipeHasLatched { + onRequestNewChat?() + } + + resetNewChatSwipe(animated: true) + } + + private func resetNewChatSwipe(animated: Bool) { + let reset = { + newChatSwipeOffset = 0 + newChatSwipeIsActive = false + newChatSwipeHasLatched = false + newChatSwipeDidTriggerHaptic = false + } + + if animated { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { + reset() + } + } else { + reset() + } + + newChatSwipeFeedbackGenerator = nil + } + + @MainActor + private func focusComposerIfRequested() async { + guard composerFocusRequest > 0 else { + return + } + + await Task.yield() + try? await Task.sleep(for: .milliseconds(80)) + + guard viewModel.showsComposer, !viewModel.isSearchMode else { + return + } + composerFocused = true } private var header: some View { @@ -409,3 +566,353 @@ struct SybilWorkspaceView: View { } } } + +enum NewChatSwipeMetrics { + static let referenceWidth: CGFloat = 390 + static let horizontalActivationDistance: CGFloat = 18 + static let directionDominanceRatio: CGFloat = 1.22 + static let minimumLeftwardVelocity: CGFloat = 55 + static let latchHysteresis: CGFloat = 32 + + static func maxTravel(for width: CGFloat) -> CGFloat { + min(max(width * 0.46, 156), 240) + } + + static func latchDistance(for width: CGFloat) -> CGFloat { + min(max(width * 0.28, 112), 152) + } + + static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat { + max(min(rawTranslation, 0), -maxTravel(for: width)) + } + + static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat { + let travel = maxTravel(for: width) + guard travel > 0 else { + return 0 + } + return min(max(abs(offset) / travel, 0), 1) + } + + static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat { + progress(for: offset, width: width) * 10 + } + + static func shouldBeginPan( + leftwardTravel: CGFloat, + verticalTravel: CGFloat, + leftwardVelocity: CGFloat, + verticalVelocity: CGFloat + ) -> Bool { + guard leftwardTravel > 0 || leftwardVelocity > 0 else { + return false + } + + if leftwardTravel >= horizontalActivationDistance, + leftwardTravel >= verticalTravel * directionDominanceRatio { + return true + } + + return leftwardVelocity >= minimumLeftwardVelocity && + leftwardVelocity >= verticalVelocity * directionDominanceRatio + } + + static func latchReleaseDistance(for width: CGFloat) -> CGFloat { + max(latchDistance(for: width) - latchHysteresis, horizontalActivationDistance) + } + + static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool { + let distance = abs(offset) + if isCurrentlyLatched { + return distance >= latchReleaseDistance(for: width) + } + return distance >= latchDistance(for: width) + } +} + +private struct NewChatSwipePanInstaller: UIViewRepresentable { + var isEnabled: Bool + var onBegan: (CGFloat) -> Void + var onChanged: (CGFloat, CGFloat) -> Void + var onEnded: (CGFloat, CGFloat, Bool) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.isUserInteractionEnabled = false + view.coordinator = context.coordinator + context.coordinator.markerView = view + return view + } + + func updateUIView(_ uiView: InstallerView, context: Context) { + context.coordinator.update( + isEnabled: isEnabled, + onBegan: onBegan, + onChanged: onChanged, + onEnded: onEnded + ) + context.coordinator.markerView = uiView + context.coordinator.installIfPossible() + } + + static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) { + coordinator.detach() + } + + final class InstallerView: UIView { + weak var coordinator: Coordinator? + + override func didMoveToWindow() { + super.didMoveToWindow() + coordinator?.markerView = self + coordinator?.installIfPossible() + } + + override func layoutSubviews() { + super.layoutSubviews() + coordinator?.configureScrollViewFailureRequirements() + } + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + weak var markerView: UIView? + private weak var installedWindow: UIWindow? + private let panGesture = UIPanGestureRecognizer() + private var preparedScrollRecognizers: Set = [] + + private var isEnabled = false + private var onBegan: (CGFloat) -> Void = { _ in } + private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in } + private var onEnded: (CGFloat, CGFloat, Bool) -> Void = { _, _, _ in } + + override init() { + super.init() + panGesture.addTarget(self, action: #selector(handlePan(_:))) + panGesture.cancelsTouchesInView = true + panGesture.delaysTouchesBegan = false + panGesture.delaysTouchesEnded = false + panGesture.delegate = self + } + + func update( + isEnabled: Bool, + onBegan: @escaping (CGFloat) -> Void, + onChanged: @escaping (CGFloat, CGFloat) -> Void, + onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void + ) { + self.isEnabled = isEnabled + self.onBegan = onBegan + self.onChanged = onChanged + self.onEnded = onEnded + panGesture.isEnabled = isEnabled + configureScrollViewFailureRequirements() + } + + func installIfPossible() { + guard let window = markerView?.window else { + detach() + return + } + + guard installedWindow !== window else { + configureScrollViewFailureRequirements() + return + } + + installedWindow?.removeGestureRecognizer(panGesture) + window.addGestureRecognizer(panGesture) + installedWindow = window + configureScrollViewFailureRequirements() + } + + func detach() { + installedWindow?.removeGestureRecognizer(panGesture) + installedWindow = nil + preparedScrollRecognizers = [] + } + + func configureScrollViewFailureRequirements() { + guard isEnabled, let markerView, let window = markerView.window else { + return + } + + let markerFrame = markerView.convert(markerView.bounds, to: window) + for scrollView in window.sybilDescendantScrollViews { + let recognizerID = ObjectIdentifier(scrollView.panGestureRecognizer) + guard !preparedScrollRecognizers.contains(recognizerID) else { + continue + } + + let scrollFrame = scrollView.convert(scrollView.bounds, to: window) + if scrollFrame.intersects(markerFrame) { + scrollView.panGestureRecognizer.require(toFail: panGesture) + preparedScrollRecognizers.insert(recognizerID) + } + } + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + guard let markerView else { + return + } + + let width = max(markerView.bounds.width, 1) + let translationX = recognizer.translation(in: markerView).x + + switch recognizer.state { + case .began: + onBegan(width) + onChanged(translationX, width) + + case .changed: + onChanged(translationX, width) + + case .ended: + onEnded(translationX, width, true) + + case .cancelled, .failed: + onEnded(translationX, width, false) + + case .possible: + break + + @unknown default: + onEnded(translationX, width, false) + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard isEnabled, gestureRecognizer === panGesture, let markerView else { + return false + } + + return markerView.bounds.contains(touch.location(in: markerView)) + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard isEnabled, gestureRecognizer === panGesture, let markerView else { + return false + } + + let translation = panGesture.translation(in: markerView) + let velocity = panGesture.velocity(in: markerView) + return NewChatSwipeMetrics.shouldBeginPan( + leftwardTravel: max(-translation.x, 0), + verticalTravel: abs(translation.y), + leftwardVelocity: max(-velocity.x, 0), + verticalVelocity: abs(velocity.y) + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + false + } + } +} + +private extension UIView { + var sybilDescendantScrollViews: [UIScrollView] { + var scrollViews: [UIScrollView] = [] + collectSybilScrollViews(into: &scrollViews) + return scrollViews + } + + func collectSybilScrollViews(into scrollViews: inout [UIScrollView]) { + if let scrollView = self as? UIScrollView { + scrollViews.append(scrollView) + } + + for subview in subviews { + subview.collectSybilScrollViews(into: &scrollViews) + } + } +} + +private struct NewChatSwipeBackdrop: View { + var progress: CGFloat + var hasLatched: Bool + + private var clampedProgress: CGFloat { + min(max(progress, 0), 1) + } + + var body: some View { + ZStack(alignment: .trailing) { + Circle() + .fill((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.16 + (0.18 * clampedProgress))) + .frame(width: 176, height: 176) + .blur(radius: 44) + .offset(x: 38, y: 18) + + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.28), + SybilTheme.surface.opacity(0.78) + ], + center: .topLeading, + startRadius: 8, + endRadius: 58 + ) + ) + .overlay( + Circle() + .stroke( + (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.44 + (0.24 * clampedProgress)), + lineWidth: 1 + ) + ) + .shadow( + color: (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.24 + (0.20 * clampedProgress)), + radius: 24, + x: 0, + y: 0 + ) + + Circle() + .fill( + AngularGradient( + colors: [ + (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.20), + Color.clear, + (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.34) + ], + center: .center + ) + ) + .frame(width: 72, height: 72) + .blur(radius: 10) + + Image(systemName: hasLatched ? "checkmark" : "plus") + .font(.system(size: 31, weight: .bold, design: .rounded)) + .foregroundStyle(SybilTheme.text) + .symbolEffect(.bounce, value: hasLatched) + + Image(systemName: "sparkle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90)) + .offset(x: -26, y: -25) + } + .frame(width: 92, height: 92) + .background( + Circle() + .fill(SybilTheme.surface.opacity(0.42)) + .blur(radius: 16) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + .opacity(clampedProgress) + .offset(x: (1 - clampedProgress) * 28) + .animation(.easeOut(duration: 0.16), value: hasLatched) + .accessibilityHidden(true) + } +} diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 941e16b..9bb6634 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Foundation import Testing @testable import Sybil @@ -265,3 +266,21 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD #expect(snapshot.getSearch == 1) #expect(viewModel.selectedSearch?.answerText == "fresh answer") } + +@Test func newChatSwipeMetricsClampProgressAndLatch() async throws { + let width: CGFloat = 390 + let maxTravel = NewChatSwipeMetrics.maxTravel(for: width) + let latchDistance = NewChatSwipeMetrics.latchDistance(for: width) + + #expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel) + #expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5) + #expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10) + #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width)) + #expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width)) + #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true)) + #expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true)) + #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0)) + #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30)) + #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140)) + #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90)) +}