better new chat animation

This commit is contained in:
2026-05-02 22:51:59 -07:00
parent 7360604136
commit 94565298d8

View File

@@ -15,8 +15,10 @@ struct SybilWorkspaceView: View {
@State private var photoPickerItems: [PhotosPickerItem] = [] @State private var photoPickerItems: [PhotosPickerItem] = []
@State private var isComposerDropTargeted = false @State private var isComposerDropTargeted = false
@State private var newChatSwipeOffset: CGFloat = 0 @State private var newChatSwipeOffset: CGFloat = 0
@State private var newChatSwipeCompletionOffset: CGFloat = 0
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth @State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
@State private var newChatSwipeIsActive = false @State private var newChatSwipeIsActive = false
@State private var newChatSwipeIsCompleting = false
@State private var newChatSwipeHasLatched = false @State private var newChatSwipeHasLatched = false
@State private var newChatSwipeDidTriggerHaptic = false @State private var newChatSwipeDidTriggerHaptic = false
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator? @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
@@ -55,9 +57,17 @@ struct SybilWorkspaceView: View {
return true return true
} }
private var canRecognizeNewChatSwipe: Bool {
canSwipeToCreateChat && !newChatSwipeIsCompleting
}
private var showsNewChatSwipeBackdrop: Bool {
canSwipeToCreateChat || newChatSwipeIsCompleting
}
var body: some View { var body: some View {
ZStack(alignment: .trailing) { ZStack(alignment: .trailing) {
if canSwipeToCreateChat { if showsNewChatSwipeBackdrop {
NewChatSwipeBackdrop( NewChatSwipeBackdrop(
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth), progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
hasLatched: newChatSwipeHasLatched hasLatched: newChatSwipeHasLatched
@@ -72,6 +82,7 @@ struct SybilWorkspaceView: View {
.offset(x: newChatSwipeOffset) .offset(x: newChatSwipeOffset)
.blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth)) .blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth))
} }
.offset(x: newChatSwipeCompletionOffset)
.background(SybilTheme.background) .background(SybilTheme.background)
.navigationTitle(viewModel.selectedTitle) .navigationTitle(viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -134,7 +145,7 @@ struct SybilWorkspaceView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background { .background {
NewChatSwipePanInstaller( NewChatSwipePanInstaller(
isEnabled: canSwipeToCreateChat, isEnabled: canRecognizeNewChatSwipe,
onBegan: { width in onBegan: { width in
beginNewChatSwipe(containerWidth: width) beginNewChatSwipe(containerWidth: width)
}, },
@@ -209,16 +220,34 @@ struct SybilWorkspaceView: View {
updateNewChatSwipe(with: translationX, containerWidth: containerWidth) updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
if didFinish && newChatSwipeHasLatched { if didFinish && newChatSwipeHasLatched {
onRequestNewChat?() Task {
await completeNewChatSwipe(containerWidth: containerWidth)
}
return
} }
resetNewChatSwipe(animated: true) 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) { private func resetNewChatSwipe(animated: Bool) {
let reset = { let reset = {
newChatSwipeOffset = 0 newChatSwipeOffset = 0
newChatSwipeCompletionOffset = 0
newChatSwipeIsActive = false newChatSwipeIsActive = false
newChatSwipeIsCompleting = false
newChatSwipeHasLatched = false newChatSwipeHasLatched = false
newChatSwipeDidTriggerHaptic = false newChatSwipeDidTriggerHaptic = false
} }
@@ -443,11 +472,9 @@ struct SybilWorkspaceView: View {
Button("Files") { Button("Files") {
isShowingFileImporter = true isShowingFileImporter = true
} }
if canPasteFromClipboard { Button("Paste from Clipboard") {
Button("Paste from Clipboard") { Task {
Task { await pasteAttachmentsFromClipboard()
await pasteAttachmentsFromClipboard()
}
} }
} }
Button("Cancel", role: .cancel) {} 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 @MainActor
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async { private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
do { do {
@@ -558,6 +571,11 @@ struct SybilWorkspaceView: View {
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text)) 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) try viewModel.appendComposerAttachments(attachments)
composerFocused = true composerFocused = true
} catch { } catch {
@@ -573,6 +591,9 @@ enum NewChatSwipeMetrics {
static let directionDominanceRatio: CGFloat = 1.22 static let directionDominanceRatio: CGFloat = 1.22
static let minimumLeftwardVelocity: CGFloat = 55 static let minimumLeftwardVelocity: CGFloat = 55
static let latchHysteresis: CGFloat = 32 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 { static func maxTravel(for width: CGFloat) -> CGFloat {
min(max(width * 0.46, 156), 240) min(max(width * 0.46, 156), 240)