ios: sidebar swipe
This commit is contained in:
@@ -22,75 +22,60 @@ enum PhoneRoute: Hashable {
|
||||
|
||||
struct SybilPhoneShellView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@State private var path: [PhoneRoute] = []
|
||||
@State private var route: PhoneRoute = .draftChat
|
||||
@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
|
||||
@State private var isSidebarOverlayPresented = false
|
||||
@State private var sidebarSwipeOffset: CGFloat = 0
|
||||
@State private var sidebarSwipeIsActive = false
|
||||
@State private var sidebarSwipeIsCompleting = false
|
||||
@State private var sidebarSwipeHasLatched = false
|
||||
@State private var sidebarHighlightSelection: SidebarSelection?
|
||||
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
||||
@State private var openingSelectionRequestID: UUID?
|
||||
|
||||
private var canRecognizeBackSwipe: Bool {
|
||||
!path.isEmpty && !backSwipeIsCompleting
|
||||
private var canRecognizeSidebarSwipe: Bool {
|
||||
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||
}
|
||||
|
||||
private var backSwipeVisualOffset: CGFloat {
|
||||
backSwipeOffset + backSwipeCompletionOffset
|
||||
private var sidebarOverlayProgress: CGFloat {
|
||||
if isSidebarOverlayPresented {
|
||||
return 1
|
||||
}
|
||||
|
||||
return SidebarOverlaySwipeMetrics.progress(
|
||||
for: sidebarSwipeOffset,
|
||||
width: phoneStackWidth
|
||||
)
|
||||
}
|
||||
|
||||
private var shouldRenderSidebarOverlay: Bool {
|
||||
isSidebarOverlayPresented ||
|
||||
sidebarSwipeIsActive ||
|
||||
sidebarSwipeIsCompleting ||
|
||||
sidebarOverlayProgress > 0.001
|
||||
}
|
||||
|
||||
private var currentRouteSelection: SidebarSelection? {
|
||||
switch route {
|
||||
case let .chat(chatID):
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var highlightedSidebarSelection: SidebarSelection? {
|
||||
sidebarHighlightSelection ?? currentRouteSelection
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .topLeading) {
|
||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
phoneRootTopBar
|
||||
}
|
||||
.zIndex(0)
|
||||
|
||||
if let route = path.last {
|
||||
SybilPhoneDestinationView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: $composerFocusRequest,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
phoneStack(width: proxy.size.width)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear {
|
||||
updatePhoneStackWidth(proxy.size.width)
|
||||
@@ -100,13 +85,8 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
}
|
||||
.tint(SybilTheme.primary)
|
||||
.animation(.easeOut(duration: 0.22), value: path.last)
|
||||
.onChange(of: path) { _, nextPath in
|
||||
guard nextPath.isEmpty else {
|
||||
return
|
||||
}
|
||||
resetBackSwipe(animated: false)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.22), value: route)
|
||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
@@ -120,8 +100,8 @@ struct SybilPhoneShellView: View {
|
||||
shouldRefreshOnForeground = false
|
||||
Task {
|
||||
await viewModel.refreshAfterAppBecameActive(
|
||||
refreshCollections: path.isEmpty,
|
||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||
refreshCollections: isSidebarOverlayPresented,
|
||||
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
|
||||
)
|
||||
}
|
||||
case .inactive:
|
||||
@@ -133,16 +113,117 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneRootTopBar: some View {
|
||||
HStack {
|
||||
private func phoneStack(width: CGFloat) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
phoneWorkspaceLayer
|
||||
.zIndex(0)
|
||||
|
||||
phoneSidebarOverlayLayer(width: width)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneWorkspaceLayer: some View {
|
||||
SybilPhoneDestinationView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: $composerFocusRequest,
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
|
||||
.allowsHitTesting(!shouldRenderSidebarOverlay)
|
||||
.background {
|
||||
sidebarSwipeInstaller
|
||||
}
|
||||
}
|
||||
|
||||
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
phoneOverlayTopBar
|
||||
|
||||
SybilPhoneSidebarRoot(
|
||||
viewModel: viewModel,
|
||||
highlightedSelection: highlightedSidebarSelection,
|
||||
onSelect: openSidebarSelection,
|
||||
onRoute: showRouteAndClearSidebarHighlight
|
||||
)
|
||||
}
|
||||
.opacity(sidebarOverlayProgress)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
|
||||
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
|
||||
.allowsHitTesting(isSidebarOverlayPresented)
|
||||
.accessibilityHidden(!isSidebarOverlayPresented)
|
||||
}
|
||||
|
||||
private var sidebarSwipeInstaller: some View {
|
||||
WorkspaceSwipePanInstaller(
|
||||
direction: .right,
|
||||
isEnabled: canRecognizeSidebarSwipe,
|
||||
onBegan: { width in
|
||||
beginSidebarSwipe(containerWidth: width)
|
||||
},
|
||||
onChanged: { translationX, width in
|
||||
updateSidebarSwipe(with: translationX, containerWidth: width)
|
||||
},
|
||||
onEnded: { translationX, width, velocityX, didFinish in
|
||||
finishSidebarSwipe(
|
||||
translationX: translationX,
|
||||
containerWidth: width,
|
||||
velocityX: velocityX,
|
||||
didFinish: didFinish
|
||||
)
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
|
||||
guard !isSidebarOverlayPresented else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return {
|
||||
startNewChatFromDestination()
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneOverlayTopBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
SybilWordmark(size: 21)
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
hideSidebarOverlay()
|
||||
} label: {
|
||||
Image(systemName: "chevron.right.2")
|
||||
.font(.system(size: 21, weight: .bold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.frame(width: 54, height: 54)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(SybilTheme.surface.opacity(0.76))
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Hide conversations")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background {
|
||||
SybilTheme.panelGradient
|
||||
SybilPhoneOverlayBlurBand(edge: .top)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
@@ -151,62 +232,98 @@ struct SybilPhoneShellView: View {
|
||||
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)
|
||||
showRoute(.draftChat)
|
||||
}
|
||||
|
||||
private func replaceTopRoute(with route: PhoneRoute) {
|
||||
if path.isEmpty {
|
||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||
let update = {
|
||||
route = nextRoute
|
||||
}
|
||||
|
||||
if isSidebarOverlayPresented {
|
||||
withAnimation(.easeOut(duration: 0.22)) {
|
||||
path = [route]
|
||||
update()
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
} else {
|
||||
path[path.index(before: path.endIndex)] = route
|
||||
update()
|
||||
}
|
||||
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func popRoute(disablesAnimations: Bool) {
|
||||
let pop = {
|
||||
guard !path.isEmpty else {
|
||||
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
|
||||
showRoute(nextRoute)
|
||||
clearSidebarHighlight()
|
||||
}
|
||||
|
||||
private func showSidebarOverlay() {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = true
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func hideSidebarOverlay() {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = false
|
||||
}
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||
return
|
||||
}
|
||||
|
||||
let requestID = UUID()
|
||||
openingSelectionRequestID = requestID
|
||||
setSidebarHighlight(selection)
|
||||
|
||||
Task {
|
||||
await viewModel.selectForNavigation(selection)
|
||||
guard openingSelectionRequestID == requestID else {
|
||||
return
|
||||
}
|
||||
_ = path.removeLast()
|
||||
}
|
||||
|
||||
if disablesAnimations {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
pop()
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.22)) {
|
||||
pop()
|
||||
}
|
||||
showRoute(PhoneRoute.from(selection: selection))
|
||||
openingSelectionRequestID = nil
|
||||
clearSidebarHighlight(selection, after: .milliseconds(260))
|
||||
}
|
||||
}
|
||||
|
||||
private func beginBackSwipe(containerWidth: CGFloat) {
|
||||
private func setSidebarHighlight(_ selection: SidebarSelection) {
|
||||
sidebarHighlightClearTask?.cancel()
|
||||
sidebarHighlightSelection = selection
|
||||
}
|
||||
|
||||
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
|
||||
sidebarHighlightClearTask?.cancel()
|
||||
sidebarHighlightClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: delay)
|
||||
guard !Task.isCancelled,
|
||||
sidebarHighlightSelection == selection,
|
||||
openingSelectionRequestID == nil else {
|
||||
return
|
||||
}
|
||||
sidebarHighlightSelection = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func clearSidebarHighlight() {
|
||||
sidebarHighlightClearTask?.cancel()
|
||||
openingSelectionRequestID = nil
|
||||
sidebarHighlightSelection = nil
|
||||
}
|
||||
|
||||
private func beginSidebarSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
backSwipeIsActive = true
|
||||
backSwipeHasLatched = false
|
||||
phoneStackWidth = max(containerWidth, 1)
|
||||
sidebarSwipeIsActive = true
|
||||
sidebarSwipeHasLatched = false
|
||||
}
|
||||
|
||||
var transaction = Transaction()
|
||||
@@ -214,97 +331,79 @@ struct SybilPhoneShellView: View {
|
||||
withTransaction(transaction, update)
|
||||
}
|
||||
|
||||
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||
let nextLatched = BackSwipeMetrics.isLatched(
|
||||
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||
offset: nextOffset,
|
||||
width: containerWidth,
|
||||
isCurrentlyLatched: backSwipeHasLatched
|
||||
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||
)
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
backSwipeOffset = nextOffset
|
||||
backSwipeHasLatched = nextLatched
|
||||
phoneStackWidth = max(containerWidth, 1)
|
||||
sidebarSwipeOffset = nextOffset
|
||||
sidebarSwipeHasLatched = nextLatched
|
||||
}
|
||||
}
|
||||
|
||||
private func finishBackSwipe(
|
||||
private func finishSidebarSwipe(
|
||||
translationX: CGFloat,
|
||||
containerWidth: CGFloat,
|
||||
velocityX: CGFloat,
|
||||
didFinish: Bool
|
||||
) {
|
||||
guard backSwipeIsActive else {
|
||||
resetBackSwipe(animated: false)
|
||||
guard sidebarSwipeIsActive else {
|
||||
resetSidebarSwipe(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||
let finalLatched = BackSwipeMetrics.isLatched(
|
||||
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||
offset: finalOffset,
|
||||
width: containerWidth,
|
||||
isCurrentlyLatched: backSwipeHasLatched
|
||||
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||
)
|
||||
updateBackSwipe(with: translationX, containerWidth: containerWidth)
|
||||
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
|
||||
|
||||
if didFinish && BackSwipeMetrics.shouldComplete(
|
||||
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
|
||||
offset: finalOffset,
|
||||
velocityX: velocityX,
|
||||
width: containerWidth,
|
||||
isLatched: finalLatched
|
||||
) {
|
||||
Task {
|
||||
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
|
||||
}
|
||||
completeSidebarSwipe()
|
||||
return
|
||||
}
|
||||
|
||||
resetBackSwipe(animated: true, velocityX: velocityX)
|
||||
resetSidebarSwipe(animated: true, velocityX: velocityX)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
|
||||
guard !path.isEmpty else {
|
||||
resetBackSwipe(animated: false)
|
||||
return
|
||||
}
|
||||
guard !backSwipeIsCompleting else {
|
||||
private func completeSidebarSwipe() {
|
||||
guard !sidebarSwipeIsCompleting else {
|
||||
return
|
||||
}
|
||||
|
||||
backSwipeIsCompleting = true
|
||||
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth)
|
||||
|
||||
withAnimation(
|
||||
BackSwipeMetrics.springAnimation(
|
||||
currentOffset: backSwipeOffset,
|
||||
targetOffset: targetOffset,
|
||||
velocityX: releaseVelocityX
|
||||
)
|
||||
) {
|
||||
backSwipeCompletionOffset = targetOffset - backSwipeOffset
|
||||
sidebarSwipeIsCompleting = true
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
isSidebarOverlayPresented = true
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
|
||||
popRoute(disablesAnimations: true)
|
||||
resetBackSwipe(animated: false)
|
||||
resetSidebarSwipe(animated: false)
|
||||
}
|
||||
|
||||
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||
let currentOffset = backSwipeOffset + backSwipeCompletionOffset
|
||||
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||
let currentOffset = sidebarSwipeOffset
|
||||
let reset = {
|
||||
backSwipeOffset = 0
|
||||
backSwipeCompletionOffset = 0
|
||||
backSwipeIsActive = false
|
||||
backSwipeIsCompleting = false
|
||||
backSwipeHasLatched = false
|
||||
sidebarSwipeOffset = 0
|
||||
sidebarSwipeIsActive = false
|
||||
sidebarSwipeIsCompleting = false
|
||||
sidebarSwipeHasLatched = false
|
||||
}
|
||||
|
||||
if animated {
|
||||
withAnimation(
|
||||
BackSwipeMetrics.springAnimation(
|
||||
SidebarOverlaySwipeMetrics.springAnimation(
|
||||
currentOffset: currentOffset,
|
||||
targetOffset: 0,
|
||||
velocityX: velocityX
|
||||
@@ -318,31 +417,79 @@ struct SybilPhoneShellView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneSidebarRoot: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@Binding var path: [PhoneRoute]
|
||||
@State private var openingSelection: SidebarSelection?
|
||||
@State private var openingRequestID: UUID?
|
||||
private enum SidebarOverlaySwipeMetrics {
|
||||
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
|
||||
}
|
||||
|
||||
private var highlightedSelection: SidebarSelection? {
|
||||
if let openingSelection {
|
||||
return openingSelection
|
||||
}
|
||||
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||
BackSwipeMetrics.progress(for: offset, width: width)
|
||||
}
|
||||
|
||||
guard let route = path.last else {
|
||||
return nil
|
||||
}
|
||||
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
||||
}
|
||||
|
||||
switch route {
|
||||
case let .chat(chatID):
|
||||
return .chat(chatID)
|
||||
case let .search(searchID):
|
||||
return .search(searchID)
|
||||
case .draftChat, .draftSearch, .settings:
|
||||
return nil
|
||||
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
|
||||
}
|
||||
|
||||
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
|
||||
}
|
||||
|
||||
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
|
||||
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
|
||||
}
|
||||
|
||||
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||
(1 - min(max(progress, 0), 1)) * 18
|
||||
}
|
||||
|
||||
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
|
||||
min(max(progress, 0), 1) * 14
|
||||
}
|
||||
|
||||
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
|
||||
1 - (min(max(progress, 0), 1) * 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneOverlayBlurBand: View {
|
||||
var edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.opacity(0.34)
|
||||
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: gradientColors,
|
||||
startPoint: edge == .top ? .top : .bottom,
|
||||
endPoint: edge == .top ? .bottom : .top
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var gradientColors: [Color] {
|
||||
[
|
||||
Color.black.opacity(0.94),
|
||||
SybilTheme.background.opacity(0.78),
|
||||
Color.black.opacity(0)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneSidebarRoot: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var highlightedSelection: SidebarSelection?
|
||||
var onSelect: (SidebarSelection) -> Void
|
||||
var onRoute: (PhoneRoute) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
@@ -363,7 +510,7 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
highlightedSelection == item.selection
|
||||
},
|
||||
onSelect: { item in
|
||||
open(item.selection)
|
||||
onSelect(item.selection)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -380,22 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
|
||||
HStack(spacing: 12) {
|
||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||
clearOpeningSelection()
|
||||
showRoute(.settings)
|
||||
viewModel.openSettings()
|
||||
onRoute(.settings)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||
clearOpeningSelection()
|
||||
viewModel.startNewSearch()
|
||||
showRoute(.draftSearch)
|
||||
onRoute(.draftSearch)
|
||||
}
|
||||
|
||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||
clearOpeningSelection()
|
||||
viewModel.startNewChat()
|
||||
showRoute(.draftChat)
|
||||
onRoute(.draftChat)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
@@ -431,36 +576,6 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private func clearOpeningSelection() {
|
||||
openingRequestID = nil
|
||||
openingSelection = nil
|
||||
}
|
||||
|
||||
private func showRoute(_ route: PhoneRoute) {
|
||||
withAnimation(.easeOut(duration: 0.22)) {
|
||||
path = [route]
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ selection: SidebarSelection) {
|
||||
guard openingSelection != selection else {
|
||||
return
|
||||
}
|
||||
|
||||
let requestID = UUID()
|
||||
openingRequestID = requestID
|
||||
openingSelection = selection
|
||||
Task {
|
||||
await viewModel.selectForNavigation(selection)
|
||||
guard openingRequestID == requestID else {
|
||||
return
|
||||
}
|
||||
showRoute(PhoneRoute.from(selection: selection))
|
||||
openingRequestID = nil
|
||||
openingSelection = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilPhoneDestinationView: View {
|
||||
@@ -468,12 +583,15 @@ private struct SybilPhoneDestinationView: View {
|
||||
@Binding var composerFocusRequest: Int
|
||||
let route: PhoneRoute
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: () -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: composerFocusRequest,
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat
|
||||
)
|
||||
|
||||
@@ -172,6 +172,10 @@ struct SybilSidebarRow: View {
|
||||
var item: SidebarItem
|
||||
var isSelected: Bool
|
||||
|
||||
private var isHighlighted: Bool {
|
||||
isSelected
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch item.kind {
|
||||
case .chat: return "message"
|
||||
@@ -184,14 +188,14 @@ struct SybilSidebarRow: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? SybilTheme.accent : SybilTheme.textMuted)
|
||||
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(isSelected ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(isSelected ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -229,12 +233,13 @@ struct SybilSidebarRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isSelected ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
private var showsCustomWorkspaceNavigation: Bool {
|
||||
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl == .back)
|
||||
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl != .hidden)
|
||||
}
|
||||
|
||||
private var transcriptScrollContextID: String {
|
||||
|
||||
Reference in New Issue
Block a user