From 94565298d87568657b9a99c0093ae1cf47ca8a35 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 22:51:59 -0700 Subject: [PATCH] better new chat animation --- .../Sources/Sybil/SybilWorkspaceView.swift | 65 ++++++++++++------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index b1cb5ba..6d27778 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -15,8 +15,10 @@ struct SybilWorkspaceView: View { @State private var photoPickerItems: [PhotosPickerItem] = [] @State private var isComposerDropTargeted = false @State private var newChatSwipeOffset: CGFloat = 0 + @State private var newChatSwipeCompletionOffset: CGFloat = 0 @State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth @State private var newChatSwipeIsActive = false + @State private var newChatSwipeIsCompleting = false @State private var newChatSwipeHasLatched = false @State private var newChatSwipeDidTriggerHaptic = false @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator? @@ -55,9 +57,17 @@ struct SybilWorkspaceView: View { return true } + private var canRecognizeNewChatSwipe: Bool { + canSwipeToCreateChat && !newChatSwipeIsCompleting + } + + private var showsNewChatSwipeBackdrop: Bool { + canSwipeToCreateChat || newChatSwipeIsCompleting + } + var body: some View { ZStack(alignment: .trailing) { - if canSwipeToCreateChat { + if showsNewChatSwipeBackdrop { NewChatSwipeBackdrop( progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth), hasLatched: newChatSwipeHasLatched @@ -72,6 +82,7 @@ struct SybilWorkspaceView: View { .offset(x: newChatSwipeOffset) .blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth)) } + .offset(x: newChatSwipeCompletionOffset) .background(SybilTheme.background) .navigationTitle(viewModel.selectedTitle) .navigationBarTitleDisplayMode(.inline) @@ -134,7 +145,7 @@ struct SybilWorkspaceView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background { NewChatSwipePanInstaller( - isEnabled: canSwipeToCreateChat, + isEnabled: canRecognizeNewChatSwipe, onBegan: { width in beginNewChatSwipe(containerWidth: width) }, @@ -209,16 +220,34 @@ struct SybilWorkspaceView: View { updateNewChatSwipe(with: translationX, containerWidth: containerWidth) if didFinish && newChatSwipeHasLatched { - onRequestNewChat?() + Task { + await completeNewChatSwipe(containerWidth: containerWidth) + } + return } resetNewChatSwipe(animated: true) } + @MainActor + private func completeNewChatSwipe(containerWidth: CGFloat) async { + newChatSwipeIsCompleting = true + + withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) { + newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot) + } + + try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs)) + onRequestNewChat?() + resetNewChatSwipe(animated: false) + } + private func resetNewChatSwipe(animated: Bool) { let reset = { newChatSwipeOffset = 0 + newChatSwipeCompletionOffset = 0 newChatSwipeIsActive = false + newChatSwipeIsCompleting = false newChatSwipeHasLatched = false newChatSwipeDidTriggerHaptic = false } @@ -443,11 +472,9 @@ struct SybilWorkspaceView: View { Button("Files") { isShowingFileImporter = true } - if canPasteFromClipboard { - Button("Paste from Clipboard") { - Task { - await pasteAttachmentsFromClipboard() - } + Button("Paste from Clipboard") { + Task { + await pasteAttachmentsFromClipboard() } } Button("Cancel", role: .cancel) {} @@ -500,20 +527,6 @@ struct SybilWorkspaceView: View { } } - private var canPasteFromClipboard: Bool { - let pasteboard = UIPasteboard.general - if pasteboard.hasImages { - return true - } - if let url = pasteboard.url, url.isFileURL { - return true - } - if let string = pasteboard.string, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - return false - } - @MainActor private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async { do { @@ -558,6 +571,11 @@ struct SybilWorkspaceView: View { attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text)) } + guard !attachments.isEmpty else { + viewModel.errorMessage = "Clipboard does not contain a supported attachment." + return + } + try viewModel.appendComposerAttachments(attachments) composerFocused = true } catch { @@ -573,6 +591,9 @@ 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 func maxTravel(for width: CGFloat) -> CGFloat { min(max(width * 0.46, 156), 240)