ios: redesign top navbar

This commit is contained in:
2026-05-03 17:52:57 -07:00
parent 2403dd99ae
commit e6fe63280a
3 changed files with 163 additions and 34 deletions

View File

@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var topContentInset: CGFloat = 0
@State private var hasHandledInitialTranscriptScroll = false
private var hasPendingAssistant: Bool {
@@ -48,7 +49,8 @@ struct SybilChatTranscriptView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 18)
.padding(.top, 18 + topContentInset)
.padding(.bottom, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)

View File

@@ -262,7 +262,11 @@ private struct SybilPhoneDestinationView: View {
let route: PhoneRoute
var body: some View {
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
SybilWorkspaceView(
viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
usesCustomChatNavigation: route.isChatTranscript
) {
viewModel.startNewChat()
composerFocusRequest += 1
if path.isEmpty {
@@ -293,3 +297,14 @@ private struct SybilPhoneDestinationView: View {
}
}
}
private extension PhoneRoute {
var isChatTranscript: Bool {
switch self {
case .chat, .draftChat:
return true
case .search, .draftSearch, .settings:
return false
}
}
}

View File

@@ -7,8 +7,10 @@ import UIKit
struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel
var composerFocusRequest: Int = 0
var usesCustomChatNavigation: Bool = false
var onRequestNewChat: (() -> Void)? = nil
@FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss
@State private var isShowingAttachmentOptions = false
@State private var isShowingFileImporter = false
@State private var isShowingPhotoPicker = false
@@ -23,6 +25,8 @@ struct SybilWorkspaceView: View {
@State private var newChatSwipeDidTriggerHaptic = false
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
private let customChatNavigationContentInset: CGFloat = 88
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
@@ -34,6 +38,10 @@ struct SybilWorkspaceView: View {
viewModel.errorMessage != nil
}
private var showsCustomChatNavigation: Bool {
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
}
private var transcriptScrollContextID: String {
if viewModel.draftKind == .chat {
return "draft-chat"
@@ -84,16 +92,17 @@ struct SybilWorkspaceView: View {
}
.offset(x: newChatSwipeCompletionOffset)
.background(SybilTheme.background)
.navigationTitle(viewModel.selectedTitle)
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbarRole(.editor)
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
.toolbar {
if !isSettingsSelected {
if !isSettingsSelected && !showsCustomChatNavigation {
ToolbarItem(placement: .topBarTrailing) {
if viewModel.isSearchMode {
searchModeChip
} else {
providerModelMenu
providerModelToolbarMenu
}
}
}
@@ -111,6 +120,16 @@ struct SybilWorkspaceView: View {
}
private var workspaceContent: some View {
ZStack(alignment: .top) {
workspaceContentStack
if showsCustomChatNavigation {
customChatNavigationBar
}
}
}
private var workspaceContentStack: some View {
VStack(spacing: 0) {
if showsHeader {
header
@@ -137,7 +156,8 @@ struct SybilWorkspaceView: View {
SybilChatTranscriptView(
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSending
isSending: viewModel.isSending,
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
)
.id(transcriptScrollContextID)
}
@@ -170,6 +190,35 @@ struct SybilWorkspaceView: View {
}
}
private var customChatNavigationBar: some View {
HStack(spacing: 14) {
Button {
dismiss()
} label: {
SybilNavigationIcon(systemImage: "chevron.left")
}
.buttonStyle(.plain)
.accessibilityLabel("Back")
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
providerModelNavigationMenu
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 34)
.background(alignment: .top) {
SybilNavigationFadeBackground()
.allowsHitTesting(false)
}
}
private func beginNewChatSwipe(containerWidth: CGFloat) {
let update = {
newChatSwipeContainerWidth = max(containerWidth, 1)
@@ -292,34 +341,8 @@ struct SybilWorkspaceView: View {
.background(SybilTheme.panelGradient.opacity(0.58))
}
private var providerModelMenu: some View {
Menu {
Text("\(viewModel.provider.displayName)\(viewModel.model)")
.font(.sybil(.caption))
Divider()
ForEach(Provider.allCases, id: \.self) { candidate in
Menu(candidate.displayName) {
let models = viewModel.modelOptions(for: candidate)
if models.isEmpty {
Text("No models")
} else {
ForEach(models, id: \.self) { candidateModel in
Button {
viewModel.setProvider(candidate, model: candidateModel)
} label: {
if viewModel.provider == candidate && viewModel.model == candidateModel {
Label(candidateModel, systemImage: "checkmark")
} else {
Text(candidateModel)
}
}
}
}
}
}
} label: {
private var providerModelToolbarMenu: some View {
providerModelMenu {
Image(systemName: "ellipsis")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(SybilTheme.text)
@@ -333,9 +356,52 @@ struct SybilWorkspaceView: View {
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
)
}
}
private var providerModelNavigationMenu: some View {
providerModelMenu {
SybilNavigationIcon(systemImage: "ellipsis")
}
}
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
Menu {
providerModelMenuItems
} label: {
label()
}
.accessibilityLabel("Provider and model")
}
@ViewBuilder
private var providerModelMenuItems: some View {
Text("\(viewModel.provider.displayName)\(viewModel.model)")
.font(.sybil(.caption))
Divider()
ForEach(Provider.allCases, id: \.self) { candidate in
Menu(candidate.displayName) {
let models = viewModel.modelOptions(for: candidate)
if models.isEmpty {
Text("No models")
} else {
ForEach(models, id: \.self) { candidateModel in
Button {
viewModel.setProvider(candidate, model: candidateModel)
} label: {
if viewModel.provider == candidate && viewModel.model == candidateModel {
Label(candidateModel, systemImage: "checkmark")
} else {
Text(candidateModel)
}
}
}
}
}
}
}
private var searchModeChip: some View {
Label("Search", systemImage: "globe")
.font(.sybil(.caption, weight: .medium))
@@ -868,6 +934,52 @@ private extension UIView {
}
}
private struct SybilNavigationIcon: View {
var systemImage: String
var body: some View {
Image(systemName: systemImage)
.font(.system(size: 21, weight: .semibold, design: .rounded))
.foregroundStyle(SybilTheme.text)
.frame(width: 46, height: 46)
.contentShape(Rectangle())
.shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
}
}
private struct SybilNavigationFadeBackground: View {
var body: some View {
ZStack(alignment: .topLeading) {
LinearGradient(
colors: [
SybilTheme.background.opacity(1.0),
SybilTheme.background.opacity(0.90),
SybilTheme.background.opacity(0.80),
SybilTheme.background.opacity(0.80),
SybilTheme.background.opacity(0.28),
Color.clear
],
startPoint: .top,
endPoint: .bottom
)
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.10),
Color.clear
],
center: .topLeading,
startRadius: 6,
endRadius: 210
)
.blendMode(.screen)
.offset(x: -44, y: -46)
}
.ignoresSafeArea(edges: .top)
}
}
private struct NewChatSwipeBackdrop: View {
var progress: CGFloat
var hasLatched: Bool