ios: swipe to create new conversation

This commit is contained in:
2026-05-02 22:46:25 -07:00
parent ca6b5e0807
commit 7360604136
4 changed files with 568 additions and 22 deletions

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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<ObjectIdentifier> = []
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)
}
}

View File

@@ -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))
}