Пустой чат: glass-подложка и composer на iOS < 26, empty state анимируется с клавиатурой через UIKit
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user