AI 智能体界面探究
Swift实现一个类似ChatGPT简单的AI智能体聊天界面Demo。这个界面包含了消息列表、输入框、发送按钮等基本元素,并实现了键盘响应、自动滚动等交互功能。
功能特点
- 类ChatGPT的对话界面布局
- 支持用户输入和AI回复的交互
- 消息气泡样式的展示
- 智能体的列表自动滚动,手动滑动时停止
- 手动滑动到底部时继续自动滚动
核心实现
1. 视图结构
主要包含以下UI组件:
- TableView:用于显示对话消息列表
- InputContainerView:底部输入区域容器
- InputTextView:消息输入框
- SendButton:发送按钮
- ThinkingIndicatorView:AI思考时的加载指示器
2. 布局实现
使用SnapKit进行约束布局
3. 消息处理
实现了消息发送和接收的基本流程:
- 用户消息的发送
- AI回复的模拟
- 消息列表的模拟更新
- 自动滚动到最新消息
关键技术
生成文字自动滚动:
/// 滚动到对话列表底部
private func scrollToBottom(animated: Bool) {
guard !messages.isEmpty && shouldAutoScroll else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self,
self.messages.count > 0,
let lastSection = self.tableView.numberOfSections > 0 ? self.tableView.numberOfSections - 1 : nil,
let lastRow = self.tableView.numberOfRows(inSection: lastSection) > 0 ? self.tableView.numberOfRows(inSection: lastSection) - 1 : nil else {
return
}
let lastIndex = IndexPath(row: lastRow, section: lastSection)
self.tableView.scrollToRow(at: lastIndex, at: .bottom, animated: false)
}
}
UIScrollView代理方法,实现手动滚动查看历史时停止滚动,滑动到最底部时继续自动滚动:
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserScrolling = true
shouldAutoScroll = false
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
isUserScrolling = false
// 检查是否滚动到接近底部的位置
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.frame.height
let buffer: CGFloat = 20 // 增加缓冲区域为可视区域的一半
// 检测滚动方向
let direction = offsetY - lastContentOffset1
lastContentOffset1 = offsetY
// 向上滑动时停止自动滚动
if direction < 0 {
shouldAutoScroll = false
return
}
// 向下滑动且接近底部时启用自动滚动
if direction > 0 && offsetY + scrollViewHeight >= contentHeight - buffer {
shouldAutoScroll = true
// 如果接近底部,立即触发一次滚动
if !messages.isEmpty {
scrollToBottom(animated: true)
}
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 检测滚动方向
let currentOffset = scrollView.contentOffset.y
let direction = currentOffset - lastContentOffset
lastContentOffset = currentOffset
// 计算距离底部的距离
let offsetFromBottom = scrollView.contentSize.height - (currentOffset + scrollView.frame.height)
// 向上滑动时停止自动滚动
if direction < 0 {
shouldAutoScroll = false
}
// 当用户滚动到接近底部时启用自动滚动,增加判断条件确保内容完整显示
else if offsetFromBottom <= 20 && direction > 0 {
shouldAutoScroll = true
// 立即触发一次滚动以确保显示最新内容
if !messages.isEmpty {
scrollToBottom(animated: true)
}
}
}
AI智能体
📄 完整实现代码
import UIKit
import SnapKit
class AIAssistantViewController: UIViewController {
// MARK: - 常量定义
private enum Constants {
static let inputViewHeight: CGFloat = 50
static let inputViewBottomPadding: CGFloat = 10
static let sendButtonWidth: CGFloat = 60
static let inputTextViewInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)
}
// MARK: - UI组件
private lazy var tableView: UITableView = {
let tableView = UITableView(frame: .zero, style: .plain)
tableView.delegate = self
tableView.dataSource = self
tableView.separatorStyle = .none
tableView.backgroundColor = .systemBackground
tableView.keyboardDismissMode = .onDrag
tableView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0)
return tableView
}()
private lazy var inputContainerView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
return view
}()
private lazy var inputTextView: UITextView = {
let textView = UITextView()
textView.font = .systemFont(ofSize: 16)
textView.layer.cornerRadius = 18
textView.layer.borderWidth = 1
textView.layer.borderColor = UIColor.systemGray5.cgColor
textView.textContainerInset = Constants.inputTextViewInsets
return textView
}()
private lazy var sendButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("发送", for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
return button
}()
private lazy var thinkingIndicatorView: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.hidesWhenStopped = true
return indicator
}()
// MARK: - 数据属性
private var messages: [(isUser: Bool, text: String)] = []
private var keyboardHeight: CGFloat = 0
private var isUserScrolling = false
private var shouldAutoScroll = true
private var lastContentOffset: CGFloat = 0
// MARK: - 生命周期
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupKeyboardObservers()
}
// MARK: - UI配置
private func setupUI() {
view.backgroundColor = .systemBackground
title = "AI智能体"
view.addSubview(tableView)
view.addSubview(inputContainerView)
inputContainerView.addSubview(inputTextView)
inputContainerView.addSubview(sendButton)
view.addSubview(thinkingIndicatorView)
inputContainerView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(Constants.inputViewHeight + Constants.inputViewBottomPadding)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(inputContainerView.snp.top)
}
inputTextView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.equalToSuperview().offset(6)
make.bottom.equalToSuperview().offset(-Constants.inputViewBottomPadding)
}
sendButton.snp.makeConstraints { make in
make.leading.equalTo(inputTextView.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalTo(inputTextView)
make.width.equalTo(Constants.sendButtonWidth)
}
thinkingIndicatorView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(inputContainerView.snp.top).offset(-20)
}
}
// MARK: - 消息处理
@objc private func sendButtonTapped() {
guard let text = inputTextView.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return }
messages.append((isUser: true, text: text))
tableView.reloadData()
shouldAutoScroll = true
scrollToBottom(animated: true)
inputTextView.text = ""
thinkingIndicatorView.startAnimating()
let aiMessage = (isUser: false, text: "")
messages.append(aiMessage)
var currentText = ""
var count = 0
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
count += 1
currentText += "这是第\(count)秒生成的内容..."
self.messages[self.messages.count - 1].text = currentText
self.tableView.reloadData()
self.scrollToBottom(animated: true)
if count >= 3 {
timer.invalidate()
self.thinkingIndicatorView.stopAnimating()
}
}
}
// MARK: - 滚动控制
private func scrollToBottom(animated: Bool) {
guard !messages.isEmpty && shouldAutoScroll else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self, messages.count > 0 else { return }
let indexPath = IndexPath(row: messages.count - 1, section: 0)
self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated)
}
}
}
// MARK: - UITableView 数据源和代理
extension AIAssistantViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
let message = messages[indexPath.row]
let bubbleView = UIView()
bubbleView.layer.cornerRadius = 16
bubbleView.backgroundColor = message.isUser ? .systemBlue : .systemGray6
let label = UILabel()
label.text = message.text
label.numberOfLines = 0
label.textColor = message.isUser ? .white : .label
cell.contentView.addSubview(bubbleView)
cell.contentView.addSubview(label)
label.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.width.lessThanOrEqualToSuperview().multipliedBy(0.75)
message.isUser ? make.trailing.equalToSuperview().offset(-20) : make.leading.equalToSuperview().offset(20)
}
bubbleView.snp.makeConstraints { make in
make.edges.equalTo(label).inset(UIEdgeInsets(top: -8, left: -12, bottom: -8, right: -12))
}
return cell
}
}
// MARK: - 键盘处理扩展
extension AIAssistantViewController {
private func setupKeyboardObservers() {
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil)
}
@objc private func keyboardWillShow(_ notification: Notification) {
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let keyboardHeight = keyboardFrame.height
UIView.animate(withDuration: 0.3) {
self.inputContainerView.transform = CGAffineTransform(translationX: 0, y: -keyboardHeight)
self.tableView.contentInset.bottom = keyboardHeight
self.tableView.scrollIndicatorInsets.bottom = keyboardHeight
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
UIView.animate(withDuration: 0.3) {
self.inputContainerView.transform = .identity
self.tableView.contentInset.bottom = 0
self.tableView.scrollIndicatorInsets.bottom = 0
}
}
}