Files
mobile-ios/Rosetta/Features/MainTabView.swift
senseiGai e26d94b268 Исправление бесконечного рендер-цикла SearchView и поиск по публичному ключу
- 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>
2026-03-08 05:14:54 +05:00

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