- SearchViewModel: заменён @Observable на ObservableObject + @Published (устранён infinite body loop SearchView → 99% CPU фриз после логина) - SearchView: @State → @StateObject, RecentSection: @ObservedObject - Добавлен клиентский поиск по публичному ключу (сервер ищет только по нику) - ChatDetailView: убран @State на DialogRepository singleton - ChatListView: замена closure на @Binding, убран DispatchQueue.main.async - MainTabView: убран пустой onChange, замена closure на @Binding - SettingsViewModel: конвертирован в ObservableObject - Добавлены debug-принты для отладки рендер-циклов Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
6.5 KiB
Swift
176 lines
6.5 KiB
Swift
import SwiftUI
|
|
|
|
/// Main container view with tab-based navigation.
|
|
struct MainTabView: View {
|
|
var onLogout: (() -> Void)?
|
|
@State private var selectedTab: RosettaTab = .chats
|
|
@State private var isChatSearchActive = false
|
|
@State private var isChatListDetailPresented = false
|
|
@State private var isSearchDetailPresented = false
|
|
/// All tabs are pre-activated so that switching only changes the offset,
|
|
/// not the view structure. Creating a NavigationStack mid-animation causes
|
|
/// "Update NavigationRequestObserver tried to update multiple times per frame" → freeze.
|
|
@State private var activatedTabs: Set<RosettaTab> = Set(RosettaTab.interactionOrder)
|
|
/// When non-nil, the tab bar is being dragged and the pager follows interactively.
|
|
@State private var dragFractionalIndex: CGFloat?
|
|
|
|
var body: some View {
|
|
let _ = Self._bodyCount += 1
|
|
let _ = print("🔴 MainTabView.body #\(Self._bodyCount) search=\(isChatSearchActive) chatDetail=\(isChatListDetailPresented) searchDetail=\(isSearchDetailPresented)")
|
|
mainTabView
|
|
}
|
|
@MainActor static var _bodyCount = 0
|
|
|
|
private var mainTabView: some View {
|
|
ZStack(alignment: .bottom) {
|
|
RosettaColors.Adaptive.background
|
|
.ignoresSafeArea()
|
|
|
|
GeometryReader { geometry in
|
|
tabPager(availableSize: geometry.size)
|
|
}
|
|
|
|
if !isChatSearchActive && !isAnyChatDetailPresented {
|
|
RosettaTabBar(
|
|
selectedTab: selectedTab,
|
|
onTabSelected: { tab in
|
|
activatedTabs.insert(tab)
|
|
// Activate adjacent tabs for smooth paging
|
|
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
|
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
|
selectedTab = tab
|
|
}
|
|
},
|
|
onSwipeStateChanged: { state in
|
|
if let state {
|
|
// Activate all main tabs during drag for smooth paging
|
|
for tab in RosettaTab.interactionOrder {
|
|
activatedTabs.insert(tab)
|
|
}
|
|
dragFractionalIndex = state.fractionalIndex
|
|
} else {
|
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
|
dragFractionalIndex = nil
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.ignoresSafeArea(.keyboard)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentPageIndex: CGFloat {
|
|
CGFloat(selectedTab.interactionIndex)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabPager(availableSize: CGSize) -> some View {
|
|
let width = max(1, availableSize.width)
|
|
let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count)
|
|
|
|
// Child views are in a separate HStack that does NOT read dragFractionalIndex,
|
|
// so they won't re-render during drag — only the offset modifier updates.
|
|
HStack(spacing: 0) {
|
|
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
|
|
tabView(for: tab)
|
|
.frame(width: width, height: availableSize.height)
|
|
}
|
|
}
|
|
.frame(width: totalWidth, alignment: .leading)
|
|
.modifier(PagerOffsetModifier(
|
|
effectiveIndex: dragFractionalIndex ?? currentPageIndex,
|
|
pageWidth: width,
|
|
isDragging: dragFractionalIndex != nil
|
|
))
|
|
.clipped()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabView(for tab: RosettaTab) -> some View {
|
|
if activatedTabs.contains(tab) {
|
|
switch tab {
|
|
case .chats:
|
|
ChatListView(
|
|
isSearchActive: $isChatSearchActive,
|
|
isDetailPresented: $isChatListDetailPresented
|
|
)
|
|
case .settings:
|
|
SettingsView(onLogout: onLogout)
|
|
case .search:
|
|
SearchView(isDetailPresented: $isSearchDetailPresented)
|
|
}
|
|
} else {
|
|
RosettaColors.Adaptive.background
|
|
}
|
|
}
|
|
|
|
private var isAnyChatDetailPresented: Bool {
|
|
isChatListDetailPresented || isSearchDetailPresented
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Pager Offset Modifier
|
|
|
|
/// Isolates the offset/animation from child view identity so that
|
|
/// changing `effectiveIndex` only redraws the transform, not the child views.
|
|
private struct PagerOffsetModifier: ViewModifier {
|
|
let effectiveIndex: CGFloat
|
|
let pageWidth: CGFloat
|
|
let isDragging: Bool
|
|
@MainActor static var _bodyCount = 0
|
|
|
|
func body(content: Content) -> some View {
|
|
let _ = Self._bodyCount += 1
|
|
let _ = print("⬛ PagerOffset.body #\(Self._bodyCount) idx=\(effectiveIndex) w=\(pageWidth)")
|
|
content
|
|
.offset(x: -effectiveIndex * pageWidth)
|
|
.animation(
|
|
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
|
|
value: effectiveIndex
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Placeholder
|
|
|
|
struct PlaceholderTabView: View {
|
|
let title: String
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
RosettaColors.Adaptive.background
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 16) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 52))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
|
|
|
Text(title)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
|
|
Text("Coming soon")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
Text(title)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
}
|
|
}
|
|
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
}
|
|
}
|
|
}
|