From 91ef28bf293b20945a228b598b6f286e79b25124 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 3 May 2026 23:06:39 -0700 Subject: [PATCH] ios: more ambitious gestures / navigation --- ios/Apps/Sybil/project.yml | 4 +- .../Sources/Sybil/SybilPhoneShellView.swift | 301 ++++++++++++++-- .../Sources/Sybil/SybilSidebarView.swift | 6 + .../Sybil/Sources/Sybil/SybilViewModel.swift | 2 +- .../Sources/Sybil/SybilWorkspaceView.swift | 324 +++++++++++++++--- .../Sybil/Tests/SybilTests/SybilTests.swift | 10 + 6 files changed, 579 insertions(+), 68 deletions(-) diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml index ddafd59..af81793 100644 --- a/ios/Apps/Sybil/project.yml +++ b/ios/Apps/Sybil/project.yml @@ -23,8 +23,8 @@ targets: TARGETED_DEVICE_FAMILY: "1,2,6" GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.4 - CURRENT_PROJECT_VERSION: 5 + MARKETING_VERSION: 1.5 + CURRENT_PROJECT_VERSION: 6 INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index 4957767..9fca46e 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -26,27 +26,87 @@ struct SybilPhoneShellView: View { @Environment(\.scenePhase) private var scenePhase @State private var shouldRefreshOnForeground = false @State private var composerFocusRequest = 0 + @State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth + @State private var backSwipeOffset: CGFloat = 0 + @State private var backSwipeCompletionOffset: CGFloat = 0 + @State private var backSwipeIsActive = false + @State private var backSwipeIsCompleting = false + @State private var backSwipeHasLatched = false + + private var canRecognizeBackSwipe: Bool { + !path.isEmpty && !backSwipeIsCompleting + } + + private var backSwipeVisualOffset: CGFloat { + backSwipeOffset + backSwipeCompletionOffset + } var body: some View { - NavigationStack(path: $path) { - SybilPhoneSidebarRoot(viewModel: viewModel, path: $path) - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - SybilWordmark(size: 21) + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + SybilPhoneSidebarRoot(viewModel: viewModel, path: $path) + .safeAreaInset(edge: .top, spacing: 0) { + phoneRootTopBar } - } - .navigationDestination(for: PhoneRoute.self) { route in + .zIndex(0) + + if let route = path.last { SybilPhoneDestinationView( viewModel: viewModel, - path: $path, composerFocusRequest: $composerFocusRequest, - route: route + route: route, + onRequestBack: requestBack, + onRequestNewChat: startNewChatFromDestination ) + .background(SybilTheme.background) + .offset(x: backSwipeVisualOffset) + .shadow( + color: backSwipeVisualOffset > 0 ? Color.black.opacity(0.34) : Color.clear, + radius: backSwipeVisualOffset > 0 ? 18 : 0, + x: -8, + y: 0 + ) + .transition(.move(edge: .trailing)) + .zIndex(1) + .background { + WorkspaceSwipePanInstaller( + direction: .right, + isEnabled: canRecognizeBackSwipe, + onBegan: { width in + beginBackSwipe(containerWidth: width) + }, + onChanged: { translationX, width in + updateBackSwipe(with: translationX, containerWidth: width) + }, + onEnded: { translationX, width, velocityX, didFinish in + finishBackSwipe( + translationX: translationX, + containerWidth: width, + velocityX: velocityX, + didFinish: didFinish + ) + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { + updatePhoneStackWidth(proxy.size.width) + } + .onChange(of: proxy.size.width) { _, width in + updatePhoneStackWidth(width) + } } .tint(SybilTheme.primary) + .animation(.easeOut(duration: 0.22), value: path.last) + .onChange(of: path) { _, nextPath in + guard nextPath.isEmpty else { + return + } + resetBackSwipe(animated: false) + } .onChange(of: scenePhase) { _, nextPhase in switch nextPhase { case .background: @@ -72,6 +132,190 @@ struct SybilPhoneShellView: View { } } } + + private var phoneRootTopBar: some View { + HStack { + SybilWordmark(size: 21) + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 12) + .background { + SybilTheme.panelGradient + .ignoresSafeArea(edges: .top) + } + } + + private func updatePhoneStackWidth(_ width: CGFloat) { + phoneStackWidth = max(width, 1) + } + + private func requestBack(animateNavigation: Bool = true) { + guard !path.isEmpty, !backSwipeIsCompleting else { + return + } + + if animateNavigation { + Task { + await completeBackSwipe(containerWidth: phoneStackWidth, releaseVelocityX: 0) + } + } else { + popRoute(disablesAnimations: true) + resetBackSwipe(animated: false) + } + } + + private func startNewChatFromDestination() { + viewModel.startNewChat() + composerFocusRequest += 1 + replaceTopRoute(with: .draftChat) + } + + private func replaceTopRoute(with route: PhoneRoute) { + if path.isEmpty { + withAnimation(.easeOut(duration: 0.22)) { + path = [route] + } + } else { + path[path.index(before: path.endIndex)] = route + } + } + + private func popRoute(disablesAnimations: Bool) { + let pop = { + guard !path.isEmpty else { + return + } + _ = path.removeLast() + } + + if disablesAnimations { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + pop() + } + } else { + withAnimation(.easeOut(duration: 0.22)) { + pop() + } + } + } + + private func beginBackSwipe(containerWidth: CGFloat) { + let update = { + backSwipeIsActive = true + backSwipeHasLatched = false + } + + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction, update) + } + + private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) { + let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth) + let nextLatched = BackSwipeMetrics.isLatched( + offset: nextOffset, + width: containerWidth, + isCurrentlyLatched: backSwipeHasLatched + ) + + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + backSwipeOffset = nextOffset + backSwipeHasLatched = nextLatched + } + } + + private func finishBackSwipe( + translationX: CGFloat, + containerWidth: CGFloat, + velocityX: CGFloat, + didFinish: Bool + ) { + guard backSwipeIsActive else { + resetBackSwipe(animated: false) + return + } + + let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth) + let finalLatched = BackSwipeMetrics.isLatched( + offset: finalOffset, + width: containerWidth, + isCurrentlyLatched: backSwipeHasLatched + ) + updateBackSwipe(with: translationX, containerWidth: containerWidth) + + if didFinish && BackSwipeMetrics.shouldComplete( + offset: finalOffset, + velocityX: velocityX, + width: containerWidth, + isLatched: finalLatched + ) { + Task { + await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX) + } + return + } + + resetBackSwipe(animated: true, velocityX: velocityX) + } + + @MainActor + private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async { + guard !path.isEmpty else { + resetBackSwipe(animated: false) + return + } + guard !backSwipeIsCompleting else { + return + } + + backSwipeIsCompleting = true + let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth) + + withAnimation( + BackSwipeMetrics.springAnimation( + currentOffset: backSwipeOffset, + targetOffset: targetOffset, + velocityX: releaseVelocityX + ) + ) { + backSwipeCompletionOffset = targetOffset - backSwipeOffset + } + + try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs)) + popRoute(disablesAnimations: true) + resetBackSwipe(animated: false) + } + + private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) { + let currentOffset = backSwipeOffset + backSwipeCompletionOffset + let reset = { + backSwipeOffset = 0 + backSwipeCompletionOffset = 0 + backSwipeIsActive = false + backSwipeIsCompleting = false + backSwipeHasLatched = false + } + + if animated { + withAnimation( + BackSwipeMetrics.springAnimation( + currentOffset: currentOffset, + targetOffset: 0, + velocityX: velocityX + ) + ) { + reset() + } + } else { + reset() + } + } } private struct SybilPhoneSidebarRoot: View { @@ -164,6 +408,12 @@ private struct SybilPhoneSidebarRoot: View { } } } + .refreshable { + await viewModel.refreshVisibleContent( + refreshCollections: true, + refreshSelection: false + ) + } } } .background(SybilTheme.panelGradient) @@ -180,7 +430,7 @@ private struct SybilPhoneSidebarRoot: View { HStack(spacing: 12) { toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { clearOpeningSelection() - path = [.settings] + showRoute(.settings) } Spacer() @@ -188,13 +438,13 @@ private struct SybilPhoneSidebarRoot: View { toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { clearOpeningSelection() viewModel.startNewSearch() - path = [.draftSearch] + showRoute(.draftSearch) } toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { clearOpeningSelection() viewModel.startNewChat() - path = [.draftChat] + showRoute(.draftChat) } } .padding(.horizontal, 18) @@ -236,6 +486,12 @@ private struct SybilPhoneSidebarRoot: View { openingSelection = nil } + private func showRoute(_ route: PhoneRoute) { + withAnimation(.easeOut(duration: 0.22)) { + path = [route] + } + } + private func open(_ selection: SidebarSelection) { guard openingSelection != selection else { return @@ -249,7 +505,7 @@ private struct SybilPhoneSidebarRoot: View { guard openingRequestID == requestID else { return } - path = [PhoneRoute.from(selection: selection)] + showRoute(PhoneRoute.from(selection: selection)) openingRequestID = nil openingSelection = nil } @@ -336,26 +592,19 @@ private struct SybilPhoneSidebarRow: View { private struct SybilPhoneDestinationView: View { @Bindable var viewModel: SybilViewModel - @Binding var path: [PhoneRoute] @Binding var composerFocusRequest: Int let route: PhoneRoute + let onRequestBack: (_ animateNavigation: Bool) -> Void + let onRequestNewChat: () -> Void var body: some View { SybilWorkspaceView( viewModel: viewModel, composerFocusRequest: composerFocusRequest, - onRequestNewChat: { - viewModel.startNewChat() - composerFocusRequest += 1 - if path.isEmpty { - path = [.draftChat] - } else { - path[path.index(before: path.endIndex)] = .draftChat - } - } + onRequestBack: onRequestBack, + onRequestNewChat: onRequestNewChat ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .navigationBarTitleDisplayMode(.inline) .task(id: route) { applyRoute() } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index 7719dbf..3d1dbd2 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -149,6 +149,12 @@ struct SybilSidebarView: View { } .padding(10) } + .refreshable { + await viewModel.refreshVisibleContent( + refreshCollections: true, + refreshSelection: false + ) + } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 26a7330..3c78ac6 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -649,7 +649,7 @@ final class SybilViewModel { SybilLog.info( SybilLog.ui, - "Foreground refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))" + "Visible content refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))" ) if shouldRefreshCollections { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 325a8cf..bc0955c 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -5,7 +5,7 @@ import SwiftUI import UniformTypeIdentifiers import UIKit -enum SybilWorkspaceNavigationLeadingControl { +enum SybilWorkspaceNavigationLeadingControl: Equatable { case back case hidden case showSidebar @@ -17,6 +17,7 @@ struct SybilWorkspaceView: View { var usesCustomWorkspaceNavigation: Bool = true var navigationLeadingControl: SybilWorkspaceNavigationLeadingControl = .back var onShowSidebar: (() -> Void)? = nil + var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil var onRequestNewChat: (() -> Void)? = nil @FocusState private var composerFocused: Bool @Environment(\.dismiss) private var dismiss @@ -49,7 +50,7 @@ struct SybilWorkspaceView: View { } private var showsCustomWorkspaceNavigation: Bool { - usesCustomWorkspaceNavigation && !isSettingsSelected + usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl == .back) } private var transcriptScrollContextID: String { @@ -91,8 +92,20 @@ struct SybilWorkspaceView: View { canSwipeToCreateChat || newChatSwipeIsCompleting } + private var workspaceSwipeOffset: CGFloat { + newChatSwipeOffset + } + + private var workspaceCompletionOffset: CGFloat { + newChatSwipeCompletionOffset + } + + private var workspaceSwipeBlurRadius: CGFloat { + NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth) + } + var body: some View { - ZStack(alignment: .trailing) { + ZStack { if showsNewChatSwipeBackdrop { NewChatSwipeBackdrop( progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth), @@ -105,10 +118,10 @@ struct SybilWorkspaceView: View { workspaceContent .compositingGroup() - .offset(x: newChatSwipeOffset) - .blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth)) + .offset(x: workspaceSwipeOffset) + .blur(radius: workspaceSwipeBlurRadius) } - .offset(x: newChatSwipeCompletionOffset) + .offset(x: workspaceCompletionOffset) .background(SybilTheme.background) .navigationTitle(showsCustomWorkspaceNavigation ? "" : viewModel.selectedTitle) .navigationBarTitleDisplayMode(.inline) @@ -161,6 +174,7 @@ struct SybilWorkspaceView: View { ZStack(alignment: .bottom) { if isSettingsSelected { SybilSettingsView(viewModel: viewModel) + .padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0) } else if viewModel.isSearchMode { SybilSearchResultsView( search: viewModel.displayedSearch, @@ -191,7 +205,8 @@ struct SybilWorkspaceView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background { - NewChatSwipePanInstaller( + WorkspaceSwipePanInstaller( + direction: .left, isEnabled: canRecognizeNewChatSwipe, onBegan: { width in beginNewChatSwipe(containerWidth: width) @@ -199,14 +214,16 @@ struct SybilWorkspaceView: View { onChanged: { translationX, width in updateNewChatSwipe(with: translationX, containerWidth: width) }, - onEnded: { translationX, width, didFinish in + onEnded: { translationX, width, velocityX, didFinish in finishNewChatSwipe( translationX: translationX, containerWidth: width, + velocityX: velocityX, didFinish: didFinish ) } ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } @@ -239,7 +256,7 @@ struct SybilWorkspaceView: View { switch navigationLeadingControl { case .back: Button { - dismiss() + requestBack() } label: { SybilNavigationIcon(systemImage: "chevron.left") } @@ -260,6 +277,14 @@ struct SybilWorkspaceView: View { } } + private func requestBack(animateNavigation: Bool = true) { + if let onRequestBack { + onRequestBack(animateNavigation) + } else { + dismiss() + } + } + private func beginNewChatSwipe(containerWidth: CGFloat) { let update = { newChatSwipeContainerWidth = max(containerWidth, 1) @@ -301,30 +326,56 @@ struct SybilWorkspaceView: View { } } - private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) { + private func finishNewChatSwipe( + translationX: CGFloat, + containerWidth: CGFloat, + velocityX: CGFloat, + didFinish: Bool + ) { guard newChatSwipeIsActive else { resetNewChatSwipe(animated: false) return } + let finalOffset = NewChatSwipeMetrics.clampedOffset(for: translationX, width: containerWidth) + let finalLatched = NewChatSwipeMetrics.isLatched( + offset: finalOffset, + width: containerWidth, + isCurrentlyLatched: newChatSwipeHasLatched + ) updateNewChatSwipe(with: translationX, containerWidth: containerWidth) - if didFinish && newChatSwipeHasLatched { + if didFinish && NewChatSwipeMetrics.shouldComplete( + offset: finalOffset, + velocityX: velocityX, + width: containerWidth, + isLatched: finalLatched + ) { Task { - await completeNewChatSwipe(containerWidth: containerWidth) + await completeNewChatSwipe( + containerWidth: containerWidth, + releaseVelocityX: velocityX + ) } return } - resetNewChatSwipe(animated: true) + resetNewChatSwipe(animated: true, velocityX: velocityX) } @MainActor - private func completeNewChatSwipe(containerWidth: CGFloat) async { + private func completeNewChatSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async { newChatSwipeIsCompleting = true + let targetOffset = NewChatSwipeMetrics.completionTargetOffset(for: containerWidth) - withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) { - newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot) + withAnimation( + NewChatSwipeMetrics.springAnimation( + currentOffset: newChatSwipeOffset, + targetOffset: targetOffset, + velocityX: releaseVelocityX + ) + ) { + newChatSwipeCompletionOffset = targetOffset - newChatSwipeOffset } try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs)) @@ -332,7 +383,8 @@ struct SybilWorkspaceView: View { resetNewChatSwipe(animated: false) } - private func resetNewChatSwipe(animated: Bool) { + private func resetNewChatSwipe(animated: Bool, velocityX: CGFloat = 0) { + let currentOffset = newChatSwipeOffset + newChatSwipeCompletionOffset let reset = { newChatSwipeOffset = 0 newChatSwipeCompletionOffset = 0 @@ -343,7 +395,13 @@ struct SybilWorkspaceView: View { } if animated { - withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { + withAnimation( + NewChatSwipeMetrics.springAnimation( + currentOffset: currentOffset, + targetOffset: 0, + velocityX: velocityX + ) + ) { reset() } } else { @@ -405,7 +463,9 @@ struct SybilWorkspaceView: View { @ViewBuilder private var workspaceNavigationTrailingControl: some View { - if viewModel.isSearchMode { + if isSettingsSelected { + EmptyView() + } else if viewModel.isSearchMode { searchModeNavigationLabel } else { providerModelNavigationMenu @@ -550,7 +610,7 @@ struct SybilWorkspaceView: View { } } .padding(.horizontal, 14) - .padding(.top, 34) + .padding(.top, 64) .padding(.bottom, 12) .background(alignment: .bottom) { SybilComposerFadeBackground() @@ -717,9 +777,8 @@ enum NewChatSwipeMetrics { static let directionDominanceRatio: CGFloat = 1.22 static let minimumLeftwardVelocity: CGFloat = 55 static let latchHysteresis: CGFloat = 32 - static let completionOvershoot: CGFloat = 180 - static let completionAnimationDuration = 0.24 - static let completionAnimationDelayMs: UInt64 = 240 + static let completionOvershoot = WorkspaceSwipePhysics.completionOvershoot + static let completionAnimationDelayMs = WorkspaceSwipePhysics.completionAnimationDelayMs static func maxTravel(for width: CGFloat) -> CGFloat { min(max(width * 0.46, 156), 240) @@ -775,13 +834,185 @@ enum NewChatSwipeMetrics { } return distance >= latchDistance(for: width) } + + static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool { + WorkspaceSwipePhysics.shouldComplete( + offset: offset, + velocityX: velocityX, + width: width, + directionSign: -1, + isLatched: isLatched, + latchDistance: latchDistance(for: width) + ) + } + + static func completionTargetOffset(for width: CGFloat) -> CGFloat { + WorkspaceSwipePhysics.completionTargetOffset(for: width, directionSign: -1) + } + + static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation { + WorkspaceSwipePhysics.springAnimation( + currentOffset: currentOffset, + targetOffset: targetOffset, + velocityX: velocityX + ) + } } -private struct NewChatSwipePanInstaller: UIViewRepresentable { +enum BackSwipeMetrics { + static let referenceWidth: CGFloat = NewChatSwipeMetrics.referenceWidth + static let horizontalActivationDistance: CGFloat = NewChatSwipeMetrics.horizontalActivationDistance + static let directionDominanceRatio: CGFloat = NewChatSwipeMetrics.directionDominanceRatio + static let minimumRightwardVelocity: CGFloat = NewChatSwipeMetrics.minimumLeftwardVelocity + static let latchHysteresis: CGFloat = NewChatSwipeMetrics.latchHysteresis + static let completionOvershoot: CGFloat = NewChatSwipeMetrics.completionOvershoot + static let completionAnimationDelayMs = NewChatSwipeMetrics.completionAnimationDelayMs + + static func maxTravel(for width: CGFloat) -> CGFloat { + NewChatSwipeMetrics.maxTravel(for: width) + } + + static func latchDistance(for width: CGFloat) -> CGFloat { + NewChatSwipeMetrics.latchDistance(for: width) + } + + static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat { + min(max(rawTranslation, 0), maxTravel(for: width)) + } + + static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat { + NewChatSwipeMetrics.progress(for: offset, width: width) + } + + static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat { + NewChatSwipeMetrics.blurRadius(for: offset, width: width) + } + + static func shouldBeginPan( + rightwardTravel: CGFloat, + verticalTravel: CGFloat, + rightwardVelocity: CGFloat, + verticalVelocity: CGFloat + ) -> Bool { + guard rightwardTravel > 0 || rightwardVelocity > 0 else { + return false + } + + if rightwardTravel >= horizontalActivationDistance, + rightwardTravel >= verticalTravel * directionDominanceRatio { + return true + } + + return rightwardVelocity >= minimumRightwardVelocity && + rightwardVelocity >= verticalVelocity * directionDominanceRatio + } + + static func latchReleaseDistance(for width: CGFloat) -> CGFloat { + NewChatSwipeMetrics.latchReleaseDistance(for: width) + } + + static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool { + NewChatSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched) + } + + static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool { + WorkspaceSwipePhysics.shouldComplete( + offset: offset, + velocityX: velocityX, + width: width, + directionSign: 1, + isLatched: isLatched, + latchDistance: latchDistance(for: width) + ) + } + + static func completionTargetOffset(for width: CGFloat) -> CGFloat { + WorkspaceSwipePhysics.completionTargetOffset(for: width, directionSign: 1) + } + + static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation { + WorkspaceSwipePhysics.springAnimation( + currentOffset: currentOffset, + targetOffset: targetOffset, + velocityX: velocityX + ) + } +} + +enum WorkspaceSwipePhysics { + static let velocityProjectionDuration: CGFloat = 0.18 + static let completionVelocityThreshold: CGFloat = 620 + static let completionOvershoot: CGFloat = 180 + static let completionAnimationDelayMs: UInt64 = 320 + + private static let springMass: Double = 1 + private static let springStiffness: Double = 300 + private static let springDamping: Double = 34 + private static let maximumInitialVelocity: CGFloat = 10 + + static func shouldComplete( + offset: CGFloat, + velocityX: CGFloat, + width: CGFloat, + directionSign: CGFloat, + isLatched: Bool, + latchDistance: CGFloat + ) -> Bool { + let directionalOffset = offset * directionSign + let directionalVelocity = velocityX * directionSign + + if directionalVelocity <= -completionVelocityThreshold { + return false + } + + if directionalVelocity >= completionVelocityThreshold { + return true + } + + let projectedOffset = directionalOffset + directionalVelocity * velocityProjectionDuration + return isLatched || projectedOffset >= latchDistance + } + + static func completionTargetOffset(for width: CGFloat, directionSign: CGFloat) -> CGFloat { + directionSign * (max(width, 1) + completionOvershoot) + } + + static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation { + .interpolatingSpring( + mass: springMass, + stiffness: springStiffness, + damping: springDamping, + initialVelocity: springInitialVelocity( + currentOffset: currentOffset, + targetOffset: targetOffset, + velocityX: velocityX + ) + ) + } + + static func springInitialVelocity(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Double { + let distance = targetOffset - currentOffset + guard abs(distance) > 1 else { + return 0 + } + + let normalizedVelocity = velocityX / distance + let clampedVelocity = min(max(normalizedVelocity, -maximumInitialVelocity), maximumInitialVelocity) + return Double(clampedVelocity) + } +} + +enum WorkspaceSwipeDirection { + case left + case right +} + +struct WorkspaceSwipePanInstaller: UIViewRepresentable { + var direction: WorkspaceSwipeDirection var isEnabled: Bool var onBegan: (CGFloat) -> Void var onChanged: (CGFloat, CGFloat) -> Void - var onEnded: (CGFloat, CGFloat, Bool) -> Void + var onEnded: (CGFloat, CGFloat, CGFloat, Bool) -> Void func makeCoordinator() -> Coordinator { Coordinator() @@ -797,6 +1028,7 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable { func updateUIView(_ uiView: InstallerView, context: Context) { context.coordinator.update( + direction: direction, isEnabled: isEnabled, onBegan: onBegan, onChanged: onChanged, @@ -831,10 +1063,11 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable { private let panGesture = UIPanGestureRecognizer() private var preparedScrollRecognizers: Set = [] + private var direction: WorkspaceSwipeDirection = .left 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 } + private var onEnded: (CGFloat, CGFloat, CGFloat, Bool) -> Void = { _, _, _, _ in } override init() { super.init() @@ -846,11 +1079,13 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable { } func update( + direction: WorkspaceSwipeDirection, isEnabled: Bool, onBegan: @escaping (CGFloat) -> Void, onChanged: @escaping (CGFloat, CGFloat) -> Void, - onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void + onEnded: @escaping (CGFloat, CGFloat, CGFloat, Bool) -> Void ) { + self.direction = direction self.isEnabled = isEnabled self.onBegan = onBegan self.onChanged = onChanged @@ -909,6 +1144,7 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable { let width = max(markerView.bounds.width, 1) let translationX = recognizer.translation(in: markerView).x + let velocityX = recognizer.velocity(in: markerView).x switch recognizer.state { case .began: @@ -919,16 +1155,16 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable { onChanged(translationX, width) case .ended: - onEnded(translationX, width, true) + onEnded(translationX, width, velocityX, true) case .cancelled, .failed: - onEnded(translationX, width, false) + onEnded(translationX, width, velocityX, false) case .possible: break @unknown default: - onEnded(translationX, width, false) + onEnded(translationX, width, velocityX, false) } } @@ -947,12 +1183,22 @@ private struct NewChatSwipePanInstaller: UIViewRepresentable { 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) - ) + switch direction { + case .left: + return NewChatSwipeMetrics.shouldBeginPan( + leftwardTravel: max(-translation.x, 0), + verticalTravel: abs(translation.y), + leftwardVelocity: max(-velocity.x, 0), + verticalVelocity: abs(velocity.y) + ) + case .right: + return BackSwipeMetrics.shouldBeginPan( + rightwardTravel: max(translation.x, 0), + verticalTravel: abs(translation.y), + rightwardVelocity: max(velocity.x, 0), + verticalVelocity: abs(velocity.y) + ) + } } func gestureRecognizer( @@ -1027,10 +1273,10 @@ private struct SybilComposerFadeBackground: View { ], center: .bottomLeading, startRadius: 8, - endRadius: 180 + endRadius: 80 ) .blendMode(.screen) - .offset(x: -42, y: 42) + .offset(y: 42) } .ignoresSafeArea(edges: .bottom) } @@ -1076,7 +1322,7 @@ private struct SybilNavigationFadeBackground: View { endRadius: 210 ) .blendMode(.screen) - .offset(x: -44, y: -46) + .offset(y: -46) } .frame(height: 200.0) .ignoresSafeArea(edges: .top) diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index 9d6bc86..3c48028 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -503,4 +503,14 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD #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)) + #expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false)) + #expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false)) + #expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true)) + #expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel) + #expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5) + #expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width)) + #expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0)) + #expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140)) + #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false)) + #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true)) }