mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 09:06:19 +08:00
chore: input box ux
This commit is contained in:
+9
-3
@@ -35,7 +35,7 @@ class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate
|
||||
|
||||
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
private let presenting: Bool
|
||||
private let duration: TimeInterval = 0.5
|
||||
private let duration: TimeInterval = 0.75
|
||||
|
||||
private let snapshotViewTag = "snapshotView".hashValue
|
||||
private let blurViewTag = "blurView".hashValue
|
||||
@@ -52,8 +52,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.75,
|
||||
initialSpringVelocity: 0.75,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.8,
|
||||
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
|
||||
animations: animations,
|
||||
completion: completion
|
||||
@@ -106,11 +106,15 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||
containerView.addSubview(toView)
|
||||
|
||||
toView.layoutIfNeeded()
|
||||
|
||||
performAnimation(animations: {
|
||||
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
|
||||
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||
toView.alpha = 1
|
||||
toView.transform = .identity
|
||||
fromView.layoutIfNeeded()
|
||||
toView.layoutIfNeeded()
|
||||
}) { _ in
|
||||
let success = !transitionContext.transitionWasCancelled
|
||||
if !success {
|
||||
@@ -157,6 +161,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
toView.isHidden = false
|
||||
fromViewSnapshot.removeFromSuperview()
|
||||
blurEffectView.removeFromSuperview()
|
||||
fromView.layoutIfNeeded()
|
||||
toView.layoutIfNeeded()
|
||||
} else {
|
||||
assertionFailure()
|
||||
fromView.transform = .identity
|
||||
|
||||
+25
-21
@@ -23,7 +23,9 @@ class MainViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.layoutIfNeeded()
|
||||
setupUI()
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
@@ -31,6 +33,11 @@ class MainViewController: UIViewController {
|
||||
navigationController!.setNavigationBarHidden(true, animated: animated)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
inputBox.textView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
navigationController!.setNavigationBarHidden(false, animated: animated)
|
||||
@@ -41,6 +48,19 @@ class MainViewController: UIViewController {
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
// 计算 InputBox 的初始 frame 以避免布局动画
|
||||
let inputBoxFrame = CGRect(
|
||||
x: 0,
|
||||
y: view.bounds.height - 150, // 预估高度
|
||||
width: view.bounds.width,
|
||||
height: 150
|
||||
)
|
||||
let inputBox = InputBox(frame: inputBoxFrame).then {
|
||||
$0.delegate = self
|
||||
}
|
||||
self.inputBox = inputBox
|
||||
self.inputBox.layoutIfNeeded()
|
||||
|
||||
view.addSubview(headerView)
|
||||
view.addSubview(inputBox)
|
||||
|
||||
@@ -77,31 +97,15 @@ extension MainViewController: MainHeaderViewDelegate {
|
||||
// MARK: - InputBoxDelegate
|
||||
|
||||
extension MainViewController: InputBoxDelegate {
|
||||
func inputBoxDidTapAddAttachment() {
|
||||
// TODO: 实现添加附件功能
|
||||
print("Add attachment tapped")
|
||||
func inputBoxDidSelectAttachment(_ inputBox: InputBox) {
|
||||
print(#function, inputBox)
|
||||
}
|
||||
|
||||
func inputBoxDidTapTool() {
|
||||
print("Tool toggled: \(inputBox.viewModel.isToolEnabled)")
|
||||
}
|
||||
|
||||
func inputBoxDidTapNetwork() {
|
||||
print("Network toggled: \(inputBox.viewModel.isNetworkEnabled)")
|
||||
}
|
||||
|
||||
func inputBoxDidTapDeepThinking() {
|
||||
print("Deep thinking toggled: \(inputBox.viewModel.isDeepThinkingEnabled)")
|
||||
}
|
||||
|
||||
func inputBoxDidTapSend(data: InputBoxData) {
|
||||
// 处理发送逻辑
|
||||
guard !data.text.isEmpty else { return }
|
||||
print("[*] send tapped with text: \(data.text)")
|
||||
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||
print(#function, inputBox, inputBox.viewModel)
|
||||
}
|
||||
|
||||
func inputBoxTextDidChange(_ text: String) {
|
||||
// 可以在这里处理文本变化的其他逻辑
|
||||
print("Text changed: \(text)")
|
||||
print(#function, text)
|
||||
}
|
||||
}
|
||||
|
||||
+92
-145
@@ -3,24 +3,13 @@ import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxDelegate: AnyObject {
|
||||
func inputBoxDidTapAddAttachment()
|
||||
func inputBoxDidTapTool()
|
||||
func inputBoxDidTapNetwork()
|
||||
func inputBoxDidTapDeepThinking()
|
||||
func inputBoxDidTapSend(data: InputBoxData)
|
||||
func inputBoxTextDidChange(_ text: String)
|
||||
}
|
||||
|
||||
class InputBox: UIView {
|
||||
weak var delegate: InputBoxDelegate?
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
public let viewModel = InputBoxViewModel()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private lazy var containerView = UIView().then {
|
||||
lazy var containerView = UIView().then {
|
||||
$0.backgroundColor = .systemBackground
|
||||
$0.layer.cornerRadius = 12
|
||||
$0.layer.borderWidth = 0.5
|
||||
@@ -32,7 +21,7 @@ class InputBox: UIView {
|
||||
$0.clipsToBounds = false
|
||||
}
|
||||
|
||||
private lazy var textView = UITextView().then {
|
||||
lazy var textView = UITextView().then {
|
||||
$0.backgroundColor = .clear
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
@@ -43,91 +32,30 @@ class InputBox: UIView {
|
||||
$0.text = "This is AFFiNE AI"
|
||||
}
|
||||
|
||||
private lazy var placeholderLabel = UILabel().then {
|
||||
lazy var placeholderLabel = UILabel().then {
|
||||
$0.text = "Write your message..."
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .systemGray3
|
||||
$0.isHidden = true
|
||||
}
|
||||
|
||||
private lazy var addButton = UIButton(type: .system).then {
|
||||
$0.backgroundColor = .systemBackground
|
||||
$0.layer.cornerRadius = 6
|
||||
$0.layer.borderWidth = 0.5
|
||||
$0.layer.borderColor = UIColor.systemGray4.cgColor
|
||||
$0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside)
|
||||
lazy var functionBar = InputBoxFunctionBar().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
private lazy var toolButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage(named: "inputbox.tool", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
lazy var imageBar = InputBoxImageBar(frame: bounds)
|
||||
|
||||
private lazy var webButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage(named: "inputbox.network", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(webButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private lazy var reactButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage(named: "inputbox.deep.thinking", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(reactButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private lazy var sendButton = UIButton(type: .system).then {
|
||||
$0.backgroundColor = UIColor.systemBlue
|
||||
$0.layer.cornerRadius = 19
|
||||
$0.setImage(UIImage(named: "inputbox.send", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .white
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private lazy var leftButtonsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 16
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(addButton)
|
||||
}
|
||||
|
||||
private lazy var rightButtonsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 16
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(toolButton)
|
||||
$0.addArrangedSubview(webButton)
|
||||
$0.addArrangedSubview(reactButton)
|
||||
$0.addArrangedSubview(sendButton)
|
||||
}
|
||||
|
||||
private lazy var functionsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(leftButtonsStackView)
|
||||
$0.addArrangedSubview(UIView()) // spacer
|
||||
$0.addArrangedSubview(rightButtonsStackView)
|
||||
}
|
||||
|
||||
private lazy var mainStackView = UIStackView().then {
|
||||
lazy var mainStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 16
|
||||
$0.alignment = .fill
|
||||
$0.addArrangedSubview(textView)
|
||||
$0.addArrangedSubview(functionsStackView)
|
||||
$0.addArrangedSubview(functionBar)
|
||||
}
|
||||
|
||||
private var textViewHeightConstraint: Constraint?
|
||||
private let minTextViewHeight: CGFloat = 22
|
||||
private let maxTextViewHeight: CGFloat = 100
|
||||
var textViewHeightConstraint: Constraint?
|
||||
let minTextViewHeight: CGFloat = 22
|
||||
let maxTextViewHeight: CGFloat = 100
|
||||
|
||||
var text: String {
|
||||
get { textView.text ?? "" }
|
||||
@@ -137,9 +65,9 @@ class InputBox: UIView {
|
||||
updateTextViewHeight()
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
override init(frame: CGRect = .zero) {
|
||||
super.init(frame: frame)
|
||||
setupViews()
|
||||
setupConstraints()
|
||||
setupBindings()
|
||||
@@ -151,14 +79,16 @@ class InputBox: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
func setupViews() {
|
||||
backgroundColor = .clear
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(mainStackView)
|
||||
containerView.addSubview(placeholderLabel)
|
||||
|
||||
imageBar.imageBarDelegate = self
|
||||
}
|
||||
|
||||
private func setupBindings() {
|
||||
func setupBindings() {
|
||||
// 绑定 ViewModel 到 UI
|
||||
viewModel.$inputText
|
||||
.sink { [weak self] text in
|
||||
@@ -172,43 +102,42 @@ class InputBox: UIView {
|
||||
|
||||
viewModel.$isToolEnabled
|
||||
.sink { [weak self] enabled in
|
||||
self?.toolButton.isSelected = enabled
|
||||
self?.toolButton.tintColor = enabled ? .systemBlue : .secondaryLabel
|
||||
self?.functionBar.updateToolState(isEnabled: enabled)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isNetworkEnabled
|
||||
.sink { [weak self] enabled in
|
||||
self?.webButton.isSelected = enabled
|
||||
self?.webButton.tintColor = enabled ? .systemBlue : .secondaryLabel
|
||||
self?.functionBar.updateNetworkState(isEnabled: enabled)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isDeepThinkingEnabled
|
||||
.sink { [weak self] enabled in
|
||||
self?.reactButton.isSelected = enabled
|
||||
self?.reactButton.tintColor = enabled ? .systemBlue : .secondaryLabel
|
||||
self?.functionBar.updateDeepThinkingState(isEnabled: enabled)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$canSend
|
||||
.sink { [weak self] canSend in
|
||||
self?.sendButton.isEnabled = canSend
|
||||
self?.sendButton.alpha = canSend ? 1.0 : 0.5
|
||||
self?.functionBar.updateSendState(canSend: canSend)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isSending
|
||||
.sink { [weak self] isSending in
|
||||
self?.sendButton.isEnabled = !isSending
|
||||
if isSending {
|
||||
// TODO: 添加加载动画
|
||||
}
|
||||
viewModel.$hasAttachments
|
||||
.sink { [weak self] hasAttachments in
|
||||
self?.updateImageBarVisibility(hasAttachments)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$attachments
|
||||
.sink { [weak self] attachments in
|
||||
self?.updateImageBarContent(attachments)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
func setupConstraints() {
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
@@ -217,20 +146,6 @@ class InputBox: UIView {
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
|
||||
addButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(38)
|
||||
}
|
||||
|
||||
for button in [toolButton, webButton, reactButton] {
|
||||
button.snp.makeConstraints { make in
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
}
|
||||
|
||||
sendButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(38)
|
||||
}
|
||||
|
||||
textView.snp.makeConstraints { make in
|
||||
textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint
|
||||
}
|
||||
@@ -241,61 +156,93 @@ class InputBox: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTextViewHeight() {
|
||||
func updateTextViewHeight() {
|
||||
let size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
|
||||
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
|
||||
|
||||
|
||||
let height = textView.frame.height
|
||||
guard height != newHeight else { return }
|
||||
|
||||
textViewHeightConstraint?.update(offset: newHeight)
|
||||
textView.isScrollEnabled = size.height > maxTextViewHeight
|
||||
|
||||
|
||||
if height == 0 || superview == nil || window == nil || isHidden { return }
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 1.0,
|
||||
options: [.curveEaseInOut]
|
||||
options: .curveEaseInOut
|
||||
) {
|
||||
self.layoutIfNeeded()
|
||||
self.superview?.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlaceholderVisibility() {
|
||||
func updatePlaceholderVisibility() {
|
||||
placeholderLabel.isHidden = !textView.text.isEmpty
|
||||
}
|
||||
|
||||
@objc private func addButtonTapped() {
|
||||
delegate?.inputBoxDidTapAddAttachment()
|
||||
func updateImageBarVisibility(_ hasAttachments: Bool) {
|
||||
if hasAttachments, !mainStackView.arrangedSubviews.contains(imageBar) {
|
||||
mainStackView.insertArrangedSubview(imageBar, at: 1)
|
||||
} else if !hasAttachments, mainStackView.arrangedSubviews.contains(imageBar) {
|
||||
mainStackView.removeArrangedSubview(imageBar)
|
||||
imageBar.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func toolButtonTapped() {
|
||||
func updateImageBarContent(_ attachments: [InputAttachment]) {
|
||||
imageBar.clear()
|
||||
|
||||
for attachment in attachments {
|
||||
if attachment.type == .image, let data = attachment.data, let image = UIImage(data: data) {
|
||||
imageBar.addImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
public func addImageAttachment(_ image: UIImage) {
|
||||
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
|
||||
|
||||
let attachment = InputAttachment(
|
||||
type: .image,
|
||||
data: imageData,
|
||||
name: "image.jpg",
|
||||
size: Int64(imageData.count)
|
||||
)
|
||||
|
||||
viewModel.addAttachment(attachment)
|
||||
}
|
||||
|
||||
public var inputBoxData: InputBoxData {
|
||||
viewModel.prepareSendData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InputBoxFunctionBarDelegate
|
||||
|
||||
extension InputBox: InputBoxFunctionBarDelegate {
|
||||
func functionBarDidTapAttachment(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSelectAttachment(self)
|
||||
}
|
||||
|
||||
func functionBarDidTapTool(_: InputBoxFunctionBar) {
|
||||
viewModel.toggleTool()
|
||||
delegate?.inputBoxDidTapTool()
|
||||
}
|
||||
|
||||
@objc private func webButtonTapped() {
|
||||
func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
|
||||
viewModel.toggleNetwork()
|
||||
delegate?.inputBoxDidTapNetwork()
|
||||
}
|
||||
|
||||
@objc private func reactButtonTapped() {
|
||||
func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
|
||||
viewModel.toggleDeepThinking()
|
||||
delegate?.inputBoxDidTapDeepThinking()
|
||||
}
|
||||
|
||||
@objc private func sendButtonTapped() {
|
||||
let data = viewModel.prepareSendData()
|
||||
delegate?.inputBoxDidTapSend(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
|
||||
extension InputBox: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
updatePlaceholderVisibility()
|
||||
updateTextViewHeight()
|
||||
viewModel.updateText(textView.text)
|
||||
delegate?.inputBoxTextDidChange(textView.text)
|
||||
func functionBarDidTapSend(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSend(self)
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// InputBoxDelegate.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/18/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxDelegate: AnyObject {
|
||||
func inputBoxDidSelectAttachment(_ inputBox: InputBox)
|
||||
func inputBoxDidSend(_ inputBox: InputBox)
|
||||
func inputBoxTextDidChange(_ text: String)
|
||||
}
|
||||
|
||||
extension InputBox: InputBoxImageBarDelegate {
|
||||
func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageAt index: Int) {
|
||||
viewModel.removeAttachment(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
extension InputBox: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
viewModel.updateText(textView.text ?? "")
|
||||
delegate?.inputBoxTextDidChange(textView.text ?? "")
|
||||
updatePlaceholderVisibility()
|
||||
updateTextViewHeight()
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxFunctionBarDelegate: AnyObject {
|
||||
func functionBarDidTapAttachment(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
|
||||
}
|
||||
|
||||
class InputBoxFunctionBar: UIView {
|
||||
weak var delegate: InputBoxFunctionBarDelegate?
|
||||
|
||||
lazy var attachmentButton = UIButton(type: .system).then {
|
||||
$0.backgroundColor = .systemBackground
|
||||
$0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(attachmentButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var toolButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage(named: "inputbox.tool", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var networkButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage(named: "inputbox.network", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var deepThinkingButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage(named: "inputbox.deep.thinking", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .secondaryLabel
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var sendButton = UIButton(type: .system).then {
|
||||
$0.backgroundColor = UIColor.systemBlue
|
||||
$0.setImage(UIImage(named: "inputbox.send", in: .module, with: nil), for: .normal)
|
||||
$0.tintColor = .white
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var leftButtonsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 16
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(attachmentButton)
|
||||
}
|
||||
|
||||
lazy var rightButtonsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 16
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(toolButton)
|
||||
$0.addArrangedSubview(networkButton)
|
||||
$0.addArrangedSubview(deepThinkingButton)
|
||||
$0.addArrangedSubview(sendButton)
|
||||
}
|
||||
|
||||
lazy var stackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(leftButtonsStackView)
|
||||
$0.addArrangedSubview(UIView()) // spacer
|
||||
$0.addArrangedSubview(rightButtonsStackView)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupViews()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
addSubview(stackView)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
for button in [attachmentButton, toolButton, networkButton, deepThinkingButton, sendButton] {
|
||||
button.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
sendButton.layer.cornerRadius = sendButton.bounds.height / 2
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func updateToolState(isEnabled: Bool) {
|
||||
toolButton.isSelected = isEnabled
|
||||
toolButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel
|
||||
}
|
||||
|
||||
func updateNetworkState(isEnabled: Bool) {
|
||||
networkButton.isSelected = isEnabled
|
||||
networkButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel
|
||||
}
|
||||
|
||||
func updateDeepThinkingState(isEnabled: Bool) {
|
||||
deepThinkingButton.isSelected = isEnabled
|
||||
deepThinkingButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel
|
||||
}
|
||||
|
||||
func updateSendState(canSend: Bool) {
|
||||
sendButton.isEnabled = canSend
|
||||
sendButton.alpha = canSend ? 1.0 : 0.5
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func attachmentButtonTapped() {
|
||||
delegate?.functionBarDidTapAttachment(self)
|
||||
}
|
||||
|
||||
@objc private func toolButtonTapped() {
|
||||
delegate?.functionBarDidTapTool(self)
|
||||
}
|
||||
|
||||
@objc private func networkButtonTapped() {
|
||||
delegate?.functionBarDidTapNetwork(self)
|
||||
}
|
||||
|
||||
@objc private func deepThinkingButtonTapped() {
|
||||
delegate?.functionBarDidTapDeepThinking(self)
|
||||
}
|
||||
|
||||
@objc private func sendButtonTapped() {
|
||||
delegate?.functionBarDidTapSend(self)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
//
|
||||
// InputBoxImageBar.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/18/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxImageBarDelegate: AnyObject {
|
||||
func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageAt index: Int)
|
||||
}
|
||||
|
||||
private let constantHeight: CGFloat = 108
|
||||
|
||||
class InputBoxImageBar: UIScrollView {
|
||||
weak var imageBarDelegate: InputBoxImageBarDelegate?
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
$0.alignment = .center
|
||||
$0.distribution = .equalSpacing
|
||||
}
|
||||
|
||||
private var imageCells: [ImageCell] = []
|
||||
|
||||
override init(frame: CGRect = .zero) {
|
||||
super.init(frame: frame)
|
||||
setupViews()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
showsHorizontalScrollIndicator = false
|
||||
showsVerticalScrollIndicator = false
|
||||
addSubview(stackView)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(constantHeight)
|
||||
}
|
||||
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(constantHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func addImage(_ image: UIImage) {
|
||||
let imageCell = ImageCell(image: image)
|
||||
imageCell.onRemove = { [weak self] cell in
|
||||
self?.removeImageCell(cell)
|
||||
}
|
||||
|
||||
imageCells.append(imageCell)
|
||||
stackView.addArrangedSubview(imageCell)
|
||||
updateContentSize()
|
||||
}
|
||||
|
||||
func removeImageCell(_ cell: ImageCell) {
|
||||
if let index = imageCells.firstIndex(of: cell) {
|
||||
imageCells.remove(at: index)
|
||||
stackView.removeArrangedSubview(cell)
|
||||
cell.removeFromSuperview()
|
||||
imageBarDelegate?.inputBoxImageBar(self, didRemoveImageAt: index)
|
||||
updateContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
for cell in imageCells {
|
||||
stackView.removeArrangedSubview(cell)
|
||||
cell.removeFromSuperview()
|
||||
}
|
||||
imageCells.removeAll()
|
||||
updateContentSize()
|
||||
}
|
||||
|
||||
private func updateContentSize() {
|
||||
layoutIfNeeded()
|
||||
contentSize = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
}
|
||||
}
|
||||
|
||||
extension InputBoxImageBar {
|
||||
class ImageCell: UIView {
|
||||
var onRemove: ((ImageCell) -> Void)?
|
||||
|
||||
private lazy var imageView = UIImageView().then {
|
||||
$0.contentMode = .scaleAspectFill
|
||||
$0.clipsToBounds = true
|
||||
$0.layer.cornerRadius = 12
|
||||
$0.backgroundColor = .systemGray6
|
||||
}
|
||||
|
||||
private lazy var removeButton = UIButton(type: .system).then {
|
||||
$0.backgroundColor = UIColor.black.withAlphaComponent(0.52)
|
||||
$0.layer.cornerRadius = 8.5
|
||||
$0.layer.borderWidth = 1
|
||||
$0.layer.borderColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1).cgColor
|
||||
$0.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
$0.tintColor = .white
|
||||
$0.addTarget(self, action: #selector(removeButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
init(image: UIImage) {
|
||||
super.init(frame: .zero)
|
||||
setupViews()
|
||||
setupConstraints()
|
||||
imageView.image = image
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
addSubview(imageView)
|
||||
addSubview(removeButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
// 设置固定高度和1:1宽高比
|
||||
snp.makeConstraints { make in
|
||||
make.width.height.equalTo(constantHeight)
|
||||
}
|
||||
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
removeButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(6.5)
|
||||
make.width.height.equalTo(17)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func removeButtonTapped() {
|
||||
onRemove?(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-129
@@ -8,123 +8,23 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
public class InputBoxViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
// MARK: - Data Models
|
||||
|
||||
@Published public var inputText: String = ""
|
||||
@Published public var isToolEnabled: Bool = false
|
||||
@Published public var isNetworkEnabled: Bool = false
|
||||
@Published public var isDeepThinkingEnabled: Bool = false
|
||||
@Published public var hasAttachments: Bool = false
|
||||
@Published public var attachments: [InputAttachment] = []
|
||||
@Published public var isSending: Bool = false
|
||||
public enum AttachmentType: Equatable {
|
||||
case image
|
||||
case document
|
||||
case video
|
||||
case audio
|
||||
case other(String)
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init() {
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func setupBindings() {
|
||||
// 监听文本变化,自动更新发送按钮状态
|
||||
$inputText
|
||||
.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.assign(to: \.canSend, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 监听附件变化
|
||||
$attachments
|
||||
.map { !$0.isEmpty }
|
||||
.assign(to: \.hasAttachments, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public Properties
|
||||
|
||||
@Published public var canSend: Bool = false
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
public func updateText(_ text: String) {
|
||||
inputText = text
|
||||
}
|
||||
|
||||
public func toggleTool() {
|
||||
isToolEnabled.toggle()
|
||||
}
|
||||
|
||||
public func toggleNetwork() {
|
||||
isNetworkEnabled.toggle()
|
||||
}
|
||||
|
||||
public func toggleDeepThinking() {
|
||||
isDeepThinkingEnabled.toggle()
|
||||
}
|
||||
|
||||
public func addAttachment(_ attachment: InputAttachment) {
|
||||
attachments.append(attachment)
|
||||
}
|
||||
|
||||
public func removeAttachment(at index: Int) {
|
||||
guard index < attachments.count else { return }
|
||||
attachments.remove(at: index)
|
||||
}
|
||||
|
||||
public func clearAttachments() {
|
||||
attachments.removeAll()
|
||||
}
|
||||
|
||||
public func prepareSendData() -> InputBoxData {
|
||||
InputBoxData(
|
||||
text: inputText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
attachments: attachments,
|
||||
isToolEnabled: isToolEnabled,
|
||||
isNetworkEnabled: isNetworkEnabled,
|
||||
isDeepThinkingEnabled: isDeepThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
public func resetInput() {
|
||||
inputText = ""
|
||||
attachments.removeAll()
|
||||
isToolEnabled = false
|
||||
isNetworkEnabled = false
|
||||
isDeepThinkingEnabled = false
|
||||
isSending = false
|
||||
}
|
||||
|
||||
public func setSending(_ sending: Bool) {
|
||||
isSending = sending
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
public struct InputBoxData {
|
||||
public let text: String
|
||||
public let attachments: [InputAttachment]
|
||||
public let isToolEnabled: Bool
|
||||
public let isNetworkEnabled: Bool
|
||||
public let isDeepThinkingEnabled: Bool
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
attachments: [InputAttachment],
|
||||
isToolEnabled: Bool,
|
||||
isNetworkEnabled: Bool,
|
||||
isDeepThinkingEnabled: Bool
|
||||
) {
|
||||
self.text = text
|
||||
self.attachments = attachments
|
||||
self.isToolEnabled = isToolEnabled
|
||||
self.isNetworkEnabled = isNetworkEnabled
|
||||
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .image: "Image"
|
||||
case .document: "Document"
|
||||
case .video: "Video"
|
||||
case .audio: "Audio"
|
||||
case let .other(type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,20 +53,127 @@ public struct InputAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
public enum AttachmentType {
|
||||
case image
|
||||
case document
|
||||
case video
|
||||
case audio
|
||||
case other(String)
|
||||
public struct InputBoxData {
|
||||
public let text: String
|
||||
public let attachments: [InputAttachment]
|
||||
public let isToolEnabled: Bool
|
||||
public let isNetworkEnabled: Bool
|
||||
public let isDeepThinkingEnabled: Bool
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .image: "Image"
|
||||
case .document: "Document"
|
||||
case .video: "Video"
|
||||
case .audio: "Audio"
|
||||
case let .other(type): type
|
||||
}
|
||||
public init(
|
||||
text: String,
|
||||
attachments: [InputAttachment],
|
||||
isToolEnabled: Bool,
|
||||
isNetworkEnabled: Bool,
|
||||
isDeepThinkingEnabled: Bool
|
||||
) {
|
||||
self.text = text
|
||||
self.attachments = attachments
|
||||
self.isToolEnabled = isToolEnabled
|
||||
self.isNetworkEnabled = isNetworkEnabled
|
||||
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Model
|
||||
|
||||
public class InputBoxViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published public var inputText: String = ""
|
||||
@Published public var isToolEnabled: Bool = false
|
||||
@Published public var isNetworkEnabled: Bool = false
|
||||
@Published public var isDeepThinkingEnabled: Bool = false
|
||||
@Published public var hasAttachments: Bool = false
|
||||
@Published public var attachments: [InputAttachment] = []
|
||||
@Published public var canSend: Bool = false
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init() {
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func setupBindings() {
|
||||
// 监听文本变化,自动更新发送按钮状态
|
||||
$inputText
|
||||
.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.assign(to: \.canSend, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 监听附件变化
|
||||
$attachments
|
||||
.map { !$0.isEmpty }
|
||||
.assign(to: \.hasAttachments, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text Management
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func updateText(_ text: String) {
|
||||
inputText = text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Toggles
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func toggleTool() {
|
||||
isToolEnabled.toggle()
|
||||
}
|
||||
|
||||
func toggleNetwork() {
|
||||
isNetworkEnabled.toggle()
|
||||
}
|
||||
|
||||
func toggleDeepThinking() {
|
||||
isDeepThinkingEnabled.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Attachment Management
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func addAttachment(_ attachment: InputAttachment) {
|
||||
attachments.append(attachment)
|
||||
}
|
||||
|
||||
func removeAttachment(at index: Int) {
|
||||
guard index < attachments.count else { return }
|
||||
attachments.remove(at: index)
|
||||
}
|
||||
|
||||
func clearAttachments() {
|
||||
attachments.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send Management
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func prepareSendData() -> InputBoxData {
|
||||
InputBoxData(
|
||||
text: inputText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
attachments: attachments,
|
||||
isToolEnabled: isToolEnabled,
|
||||
isNetworkEnabled: isNetworkEnabled,
|
||||
isDeepThinkingEnabled: isDeepThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
func resetInput() {
|
||||
inputText = ""
|
||||
attachments.removeAll()
|
||||
isToolEnabled = false
|
||||
isNetworkEnabled = false
|
||||
isDeepThinkingEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user