Administrator
发布于 2026-02-18 / 4 阅读
0
0

ChatView

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 自动滚底,否则不打断


评论