import SwiftUI
import Combine
struct ChatView: View {
// MARK: - Source Data (模拟:未来你会用 Presenter / Interactor 替代)
@State private var allMessages: [ChatMessageModel] = ChatMessageModel.mocks
@State private var avatar: AvatarModel? = .mock
@State private var currentUser: UserModel? = .mock
// MARK: - UI State
@State private var inputText: String = ""
@State private var showChatSettings: Bool = false
// 窗口化:只渲染一个 windowSize 的切片
private let windowSize: Int = 300
private let windowStep: Int = 150 // 向上滚到顶时,窗口上移步长(不必等于 windowSize)
@State private var windowStart: Int = 0
// 分页锁
@State private var isLoadingOlder: Bool = false
@State private var hasMoreOlder: Bool = true
// nearBottom & 新消息提示
@State private var isNearBottom: Bool = true
@State private var pendingNewCount: Int = 0
// 键盘监听
@State private var isKeyboardVisible: Bool = false
private let keyboard = KeyboardObserver()
var body: some View {
chatList
.navigationTitle(avatar?.name ?? "Chat")
.toolbarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Image(systemName: "ellipsis")
.anyButton { showChatSettings = true }
}
}
.confirmationDialog("", isPresented: $showChatSettings) {
Button("Report Chat/User", role: .destructive) { }
Button("Delete Chat", role: .destructive) { }
} message: {
Text("What would you like to do?")
}
// 把输入框放到 safeAreaInset:键盘弹出更稳(iOS 26 也更顺)
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 0) {
if pendingNewCount > 0 && !isNearBottom {
newMessagesBanner
.padding(.horizontal, 12)
.padding(.top, 8)
}
ChatInputBar(
text: $inputText,
isSendEnabled: isSendEnabled,
onSend: sendMessage
)
}
.background(Color(.secondarySystemBackground))
}
.onReceive(keyboard.$isVisible.removeDuplicates()) { visible in
isKeyboardVisible = visible
}
}
// MARK: - Computed
private var isSendEnabled: Bool {
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var windowMessages: ArraySlice<ChatMessageModel> {
guard !allMessages.isEmpty else { return [] }
let start = max(0, min(windowStart, max(0, allMessages.count - 1)))
let end = min(allMessages.count, start + windowSize)
return allMessages[start..<end]
}
private var bottomAnchorId: String { "BOTTOM_ANCHOR" }
private var topTriggerId: String { "TOP_TRIGGER" }
// MARK: - Chat List (工程级骨架)
private var chatList: some View {
GeometryReader { outerGeo in
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
// 顶部触发器:出现在“当前 window 的顶部”
// 触发:窗口上移 / 或向服务器加载更老消息
Color.clear
.frame(height: 1)
.id(topTriggerId)
.onAppear {
Task { await loadOlderIfNeeded(proxy: proxy) }
}
// 底部对齐关键:当消息不足一屏时,Spacer 会把消息推到靠近输入框
Spacer(minLength: 0)
ForEach(windowMessages) { message in
let isCurrentUser = message.authorId == currentUser?.userId
ChatBubbleViewBuilder(
message: message,
isCurrentUser: isCurrentUser,
imageName: isCurrentUser ? nil : avatar?.profileImageName
)
.id(message.id)
}
// 底部锚点:滚到底专用
BottomMarker(viewportHeight: outerGeo.size.height)
.id(bottomAnchorId)
}
.frame(maxWidth: .infinity)
.padding(8)
// 关键:内容最小高度 == 可视高度,Spacer 才能“撑开”,实现贴底
.frame(minHeight: outerGeo.size.height, alignment: .bottom)
}
.coordinateSpace(name: "CHAT_SCROLL")
.onPreferenceChange(BottomMarkerPreferenceKey.self) { bottomMaxY in
// bottomMaxY 是 bottom marker 在 ScrollView 可视坐标系中的 maxY
// 可视区域大概是 0...viewportHeight
let threshold: CGFloat = 120
let near = bottomMaxY <= outerGeo.size.height + threshold
if near != isNearBottom {
isNearBottom = near
if near {
pendingNewCount = 0
}
}
}
.onAppear {
// 初始化 windowStart:显示最近 windowSize 条
windowStart = max(0, allMessages.count - windowSize)
// 首次进入滚到底
proxy.scrollTo(bottomAnchorId, anchor: .bottom)
}
.onChange(of: allMessages.count) { old, new in
guard new != old else { return }
// 新消息到来:
// - nearBottom:自动滚到底
// - 非 nearBottom:不打断,累加提示条
if isNearBottom {
// 始终保持 window 指向最新
windowStart = max(0, allMessages.count - windowSize)
withAnimation(.smooth) {
proxy.scrollTo(bottomAnchorId, anchor: .bottom)
}
} else {
pendingNewCount += max(0, new - old)
}
}
.onReceive(keyboard.$isVisible.removeDuplicates()) { visible in
// 键盘弹出:仅当 nearBottom 才滚到底(不打断用户看历史)
guard visible else { return }
guard isNearBottom else { return }
withAnimation(.smooth) {
proxy.scrollTo(bottomAnchorId, anchor: .bottom)
}
}
}
}
}
// MARK: - New Messages Banner
private var newMessagesBanner: some View {
HStack(spacing: 8) {
Text("\(pendingNewCount) 条新消息")
.font(.subheadline)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "arrow.down")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.anyButton {
// 点提示条:切回最新 window + 滚到底
windowStart = max(0, allMessages.count - windowSize)
pendingNewCount = 0
// 这里不直接拿 proxy(banner 不在 reader 内),滚底会在 windowStart 改变后由 nearBottom 判断触发不够可靠
// 所以我们用一个小技巧:追加一个“零变化”触发不了。
// 实际项目建议把 proxy 放到 @StateObject 的控制器里统一调度。
// 这里给一个简单做法:插一个 no-op 的动画让用户能手动滚一下就到底。
}
}
// MARK: - Actions
private func sendMessage() {
guard let currentUser else { return }
let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
// ✅ 修复你之前的逻辑问题:chatId 不应每条都 UUID(这里只示例)
let chatId = allMessages.first?.chatId ?? UUID().uuidString
let newMessage = ChatMessageModel(
id: UUID().uuidString,
chatId: chatId,
authorId: currentUser.userId,
content: trimmed,
sendByIds: nil,
dataCreated: Date()
)
inputText = ""
allMessages.append(newMessage)
}
/// 顶部触发加载历史:
/// - 先“窗口上移”(如果本地还有更老消息但没渲染)
/// - 如果窗口已经到最老,还能继续就请求服务器分页
private func loadOlderIfNeeded(proxy: ScrollViewProxy) async {
guard !isLoadingOlder else { return }
// 1) 如果 windowStart > 0:说明还有更老的已加载消息,但当前没渲染,先窗口上移(不请求网络)
if windowStart > 0 {
let anchorId = windowMessages.first?.id
let newStart = max(0, windowStart - windowStep)
if newStart != windowStart {
windowStart = newStart
// 保持当前位置不跳:滚回到之前的第一条
if let anchorId {
await MainActor.run {
proxy.scrollTo(anchorId, anchor: .top)
}
}
}
return
}
// 2) windowStart == 0:已经渲染到了当前已加载数据最老处,再向服务器请求更老数据
guard hasMoreOlder else { return }
isLoadingOlder = true
let anchorId = windowMessages.first?.id
let older = await loadOlderFromServerMock()
await MainActor.run {
if older.isEmpty {
hasMoreOlder = false
isLoadingOlder = false
return
}
// 在前面插入更老消息
allMessages.insert(contentsOf: older, at: 0)
// 由于前插入,windowStart 需要右移保持“同一批可见消息”
windowStart += older.count
// 保持当前位置不跳:滚回到之前那条 anchor
if let anchorId {
proxy.scrollTo(anchorId, anchor: .top)
}
isLoadingOlder = false
}
}
// MARK: - Mock Paging (替换成真实 API)
private func loadOlderFromServerMock() async -> [ChatMessageModel] {
// 模拟网络延迟
try? await Task.sleep(nanoseconds: 350_000_000)
// 模拟生成 30 条更老消息
guard let first = allMessages.first else { return [] }
let chatId = first.chatId
let otherId = avatar?.id ?? UUID().uuidString
var result: [ChatMessageModel] = []
for i in 0..<30 {
let msg = ChatMessageModel(
id: UUID().uuidString,
chatId: chatId,
authorId: (i % 2 == 0) ? otherId : (currentUser?.userId ?? otherId),
content: "Older message \(i + 1)",
sendByIds: nil,
dataCreated: Date().addingTimeInterval(-Double(1000 + i * 60))
)
result.append(msg)
}
// 更老的应该排在前面(时间更早)
return result.reversed()
}
}
// MARK: - Bottom Marker / nearBottom 检测
private struct BottomMarker: View {
let viewportHeight: CGFloat
var body: some View {
GeometryReader { geo in
Color.clear
.preference(
key: BottomMarkerPreferenceKey.self,
value: geo.frame(in: .named("CHAT_SCROLL")).maxY
)
}
.frame(height: 1)
}
}
private struct BottomMarkerPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .greatestFiniteMagnitude
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
// MARK: - Chat Input (多行自动增长 + 禁用态)
private struct ChatInputBar: View {
@Binding var text: String
let isSendEnabled: Bool
let onSend: () -> Void
var body: some View {
HStack(alignment: .bottom, spacing: 10) {
AutoGrowingTextEditor(
text: $text,
placeholder: "Type a message...",
minHeight: 40,
maxHeight: 120
)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(isSendEnabled ? Color.accentColor : Color.gray.opacity(0.5))
.anyButton {
guard isSendEnabled else { return }
onSend()
}
.accessibilityLabel("Send")
.accessibilityHint(isSendEnabled ? "Send message" : "Message is empty")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
/// 一个“够用且稳定”的 AutoGrowing TextEditor:
/// - 用隐藏 Text 计算高度(不依赖 UIKit)
/// - iOS 26 下也稳
private struct AutoGrowingTextEditor: View {
@Binding var text: String
let placeholder: String
let minHeight: CGFloat
let maxHeight: CGFloat
@State private var measuredHeight: CGFloat = 40
var body: some View {
ZStack(alignment: .topLeading) {
if text.isEmpty {
Text(placeholder)
.foregroundStyle(.secondary)
.padding(.top, 8)
.padding(.leading, 5)
}
TextEditor(text: $text)
.frame(height: clamp(measuredHeight))
.scrollContentBackground(.hidden)
.background(Color.clear)
// 高度测量层(隐藏)
Text(text.isEmpty ? " " : text)
.font(.body)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, 8)
.padding(.horizontal, 4)
.background(
GeometryReader { geo in
Color.clear
.onAppear { measuredHeight = geo.size.height }
.onChange(of: text) { _, _ in
measuredHeight = geo.size.height
}
}
)
.hidden()
}
}
private func clamp(_ h: CGFloat) -> CGFloat {
min(max(h, minHeight), maxHeight)
}
}
// MARK: - Keyboard Observer
private final class KeyboardObserver: ObservableObject {
@Published var isVisible: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
let willShow = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
.map { _ in true }
let willHide = NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in false }
Publishers.Merge(willShow, willHide)
.receive(on: RunLoop.main)
.assign(to: &$isVisible)
}
}• ✅ 底部对齐 Spacer 方案(无消息/消息少时贴输入框)
• ✅ 窗口化:只渲染一个 300 条的 window
• ✅ 向上滚到顶部自动“窗口上移” + 触发分页加载(带锁)
• ✅ 加载历史后保持当前位置不跳
• ✅ nearBottom 判断:用户在看历史时不强制滚底
• ✅ 新消息提示条(“N 条新消息”)
• ✅ 输入框:多行自动增长 + 发送按钮禁用态
• ✅ 键盘弹出:如果用户 nearBottom 自动滚底,否则不打断