Пустой чат: glass-подложка и composer на iOS < 26, empty state анимируется с клавиатурой через UIKit

This commit is contained in:
2026-03-30 21:08:31 +05:00
parent dcefce7cd5
commit f3d5897b2b
8 changed files with 184 additions and 7 deletions

View File

@@ -730,9 +730,12 @@ private extension ChatDetailView {
if viewModel.isLoading && messages.isEmpty {
// Android parity: skeleton placeholder while loading from DB
ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth)
} else if messages.isEmpty {
} else if #available(iOS 26, *), messages.isEmpty {
// iOS 26+: ComposerOverlay is always added in `content`, so emptyStateView alone is fine.
emptyStateView
} else {
// iOS < 26 empty: NativeMessageListController shows empty state + composer (UIKit).
// iOS < 26 / 26+ non-empty: normal message list.
messagesScrollView(maxBubbleWidth: maxBubbleWidth)
}
}
@@ -770,6 +773,11 @@ private extension ChatDetailView {
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
.background {
glass(shape: .rounded(20), strokeOpacity: 0.18)
}
Spacer()
@@ -802,6 +810,15 @@ private extension ChatDetailView {
hasMoreMessages: viewModel.hasMoreMessages,
firstUnreadMessageId: firstUnreadMessageId,
useUIKitComposer: useComposer,
emptyChatInfo: useComposer ? EmptyChatInfo(
title: titleText,
subtitle: subtitleText,
initials: avatarInitials,
colorIndex: avatarColorIndex,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
avatarImage: opponentAvatar
) : nil,
scrollToMessageId: scrollToMessageId,
shouldScrollToBottom: shouldScrollOnNextMessage,
scrollToBottomRequested: $scrollToBottomRequested,

View File

@@ -90,6 +90,10 @@ final class NativeMessageListController: UIViewController {
/// Dedup for scrollViewDidScroll onScrollToBottomVisibilityChange callback.
private var lastReportedAtBottom: Bool = true
// MARK: - Empty State (UIKit-managed, animates with keyboard)
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
private var emptyStateGuide: UILayoutGuide?
// MARK: - Layout Cache (Telegram asyncLayout pattern)
/// Cache: messageId pre-calculated layout from background thread.
@@ -511,6 +515,53 @@ final class NativeMessageListController: UIViewController {
}
}
// MARK: - Empty State (UIKit-managed, animates with keyboard)
func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) {
if isEmpty {
if let hosting = emptyStateHosting {
hosting.rootView = EmptyChatContent(info: info)
hosting.view.isHidden = false
} else {
setupEmptyState(info: info)
}
} else {
emptyStateHosting?.view.isHidden = true
}
}
private func setupEmptyState(info: EmptyChatInfo) {
let hosting = UIHostingController(rootView: EmptyChatContent(info: info))
hosting.view.backgroundColor = .clear
hosting.view.translatesAutoresizingMaskIntoConstraints = false
addChild(hosting)
if let cv = collectionView {
view.insertSubview(hosting.view, aboveSubview: cv)
} else {
view.addSubview(hosting.view)
}
hosting.didMove(toParent: self)
// Layout guide spans from safe area top to composer top.
// When keyboard moves the composer, this guide shrinks and
// the empty state re-centers all in the same UIKit animation block.
let guide = UILayoutGuide()
view.addLayoutGuide(guide)
let bottomAnchor = composerView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor
NSLayoutConstraint.activate([
guide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
guide.bottomAnchor.constraint(equalTo: bottomAnchor),
hosting.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
hosting.view.centerYAnchor.constraint(equalTo: guide.centerYAnchor),
])
emptyStateHosting = hosting
emptyStateGuide = guide
}
// MARK: - Update
/// Called from SwiftUI when messages array changes.
@@ -897,6 +948,8 @@ struct NativeMessageListView: UIViewControllerRepresentable {
let firstUnreadMessageId: String?
/// true = create UIKit ComposerView (iOS < 26). false = iOS 26+ (SwiftUI overlay).
let useUIKitComposer: Bool
/// Empty chat state data (iOS < 26). nil = no empty state management.
var emptyChatInfo: EmptyChatInfo?
var scrollToMessageId: String?
var shouldScrollToBottom: Bool = false
@Binding var scrollToBottomRequested: Bool
@@ -1018,6 +1071,11 @@ struct NativeMessageListView: UIViewControllerRepresentable {
controller.scrollToMessage(id: targetId, animated: true)
}
}
// Empty state (iOS < 26 UIKit-managed for keyboard animation parity)
if let info = emptyChatInfo {
controller.updateEmptyState(isEmpty: messages.isEmpty, info: info)
}
}
private func wireCallbacks(_ controller: NativeMessageListController, context: Context) {
@@ -1079,3 +1137,56 @@ struct NativeMessageListView: UIViewControllerRepresentable {
var isAtBottom: Bool = true
}
}
// MARK: - Empty Chat State (UIKit-hosted SwiftUI content)
struct EmptyChatInfo {
let title: String
let subtitle: String
let initials: String
let colorIndex: Int
let isOnline: Bool
let isSavedMessages: Bool
let avatarImage: UIImage?
}
/// SwiftUI content rendered inside a UIHostingController so it participates
/// in the UIKit keyboard animation block (same timing as the composer).
struct EmptyChatContent: View {
let info: EmptyChatInfo
var body: some View {
VStack(spacing: 16) {
AvatarView(
initials: info.initials,
colorIndex: info.colorIndex,
size: 80,
isOnline: info.isOnline,
isSavedMessages: info.isSavedMessages,
image: info.avatarImage
)
VStack(spacing: 4) {
Text(info.title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
if !info.isSavedMessages && !info.subtitle.isEmpty {
Text(info.subtitle)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
Text(info.isSavedMessages
? "Save messages here for quick access"
: "No messages yet")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
.background { TelegramGlassRoundedRect(cornerRadius: 20) }
}
}