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

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

View File

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

View File

@@ -149,6 +149,12 @@ struct SybilSidebarView: View {
}
.padding(10)
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
}
}

View File

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

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)

View File

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