ios: more ambitious gestures / navigation
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -149,6 +149,12 @@ struct SybilSidebarView: View {
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: true,
|
||||
refreshSelection: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user