ios: more ambitious gestures / navigation

This commit is contained in:
2026-05-03 23:06:39 -07:00
parent bb713f8806
commit 91ef28bf29
6 changed files with 579 additions and 68 deletions

View File

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