From c19ef055344886b6a4f548627e2f02b36d7fc4d3 Mon Sep 17 00:00:00 2001 From: Lakr Date: Wed, 18 Jun 2025 19:58:37 +0800 Subject: [PATCH] chore: input box ux --- .../BlurTransition.swift | 12 +- .../MainViewController.swift | 46 +-- .../Interface/View/InputBox/InputBox.swift | 237 ++++++---------- .../View/InputBox/InputBoxDelegate.swift | 29 ++ .../View/InputBox/InputBoxFunctionBar.swift | 155 ++++++++++ .../View/InputBox/InputBoxImageBar.swift | 152 ++++++++++ .../View/InputBox/InputBoxViewModel.swift | 265 +++++++++--------- 7 files changed, 598 insertions(+), 298 deletions(-) create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift index 02587fa28a..7a01044370 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift @@ -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 diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index a016245eb0..456f7b2ee9 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -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) } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 050acf2c3f..1092968c2e 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -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() + var cancellables = Set() - 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) } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift new file mode 100644 index 0000000000..f62a37df2a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift @@ -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() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift new file mode 100644 index 0000000000..52ae48188a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift @@ -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) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift new file mode 100644 index 0000000000..9de8681d37 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift @@ -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) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift index d59faca7ff..3b7828a9fc 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift @@ -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() - - // 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() + + // 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 } }