chore: input box ux

This commit is contained in:
Lakr
2025-06-18 19:58:37 +08:00
parent ea1e7076d7
commit c19ef05534
7 changed files with 598 additions and 298 deletions
@@ -35,7 +35,7 @@ class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private let presenting: Bool private let presenting: Bool
private let duration: TimeInterval = 0.5 private let duration: TimeInterval = 0.75
private let snapshotViewTag = "snapshotView".hashValue private let snapshotViewTag = "snapshotView".hashValue
private let blurViewTag = "blurView".hashValue private let blurViewTag = "blurView".hashValue
@@ -52,8 +52,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
UIView.animate( UIView.animate(
withDuration: duration, withDuration: duration,
delay: 0, delay: 0,
usingSpringWithDamping: 0.75, usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.75, initialSpringVelocity: 0.8,
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut], options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
animations: animations, animations: animations,
completion: completion completion: completion
@@ -106,11 +106,15 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
containerView.addSubview(toView) containerView.addSubview(toView)
toView.layoutIfNeeded()
performAnimation(animations: { performAnimation(animations: {
blurEffectView.effect = UIBlurEffect(style: .systemMaterial) blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
toView.alpha = 1 toView.alpha = 1
toView.transform = .identity toView.transform = .identity
fromView.layoutIfNeeded()
toView.layoutIfNeeded()
}) { _ in }) { _ in
let success = !transitionContext.transitionWasCancelled let success = !transitionContext.transitionWasCancelled
if !success { if !success {
@@ -157,6 +161,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
toView.isHidden = false toView.isHidden = false
fromViewSnapshot.removeFromSuperview() fromViewSnapshot.removeFromSuperview()
blurEffectView.removeFromSuperview() blurEffectView.removeFromSuperview()
fromView.layoutIfNeeded()
toView.layoutIfNeeded()
} else { } else {
assertionFailure() assertionFailure()
fromView.transform = .identity fromView.transform = .identity
@@ -23,7 +23,9 @@ class MainViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.layoutIfNeeded()
setupUI() setupUI()
view.layoutIfNeeded()
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@@ -31,6 +33,11 @@ class MainViewController: UIViewController {
navigationController!.setNavigationBarHidden(true, animated: animated) navigationController!.setNavigationBarHidden(true, animated: animated)
} }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
inputBox.textView.becomeFirstResponder()
}
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
navigationController!.setNavigationBarHidden(false, animated: animated) navigationController!.setNavigationBarHidden(false, animated: animated)
@@ -41,6 +48,19 @@ class MainViewController: UIViewController {
private func setupUI() { private func setupUI() {
view.backgroundColor = .systemBackground 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(headerView)
view.addSubview(inputBox) view.addSubview(inputBox)
@@ -77,31 +97,15 @@ extension MainViewController: MainHeaderViewDelegate {
// MARK: - InputBoxDelegate // MARK: - InputBoxDelegate
extension MainViewController: InputBoxDelegate { extension MainViewController: InputBoxDelegate {
func inputBoxDidTapAddAttachment() { func inputBoxDidSelectAttachment(_ inputBox: InputBox) {
// TODO: print(#function, inputBox)
print("Add attachment tapped")
} }
func inputBoxDidTapTool() { func inputBoxDidSend(_ inputBox: InputBox) {
print("Tool toggled: \(inputBox.viewModel.isToolEnabled)") print(#function, inputBox, inputBox.viewModel)
}
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 inputBoxTextDidChange(_ text: String) { func inputBoxTextDidChange(_ text: String) {
// print(#function, text)
print("Text changed: \(text)")
} }
} }
@@ -3,24 +3,13 @@ import SnapKit
import Then import Then
import UIKit import UIKit
protocol InputBoxDelegate: AnyObject {
func inputBoxDidTapAddAttachment()
func inputBoxDidTapTool()
func inputBoxDidTapNetwork()
func inputBoxDidTapDeepThinking()
func inputBoxDidTapSend(data: InputBoxData)
func inputBoxTextDidChange(_ text: String)
}
class InputBox: UIView { class InputBox: UIView {
weak var delegate: InputBoxDelegate? weak var delegate: InputBoxDelegate?
// MARK: - ViewModel
public let viewModel = InputBoxViewModel() 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.backgroundColor = .systemBackground
$0.layer.cornerRadius = 12 $0.layer.cornerRadius = 12
$0.layer.borderWidth = 0.5 $0.layer.borderWidth = 0.5
@@ -32,7 +21,7 @@ class InputBox: UIView {
$0.clipsToBounds = false $0.clipsToBounds = false
} }
private lazy var textView = UITextView().then { lazy var textView = UITextView().then {
$0.backgroundColor = .clear $0.backgroundColor = .clear
$0.font = .systemFont(ofSize: 16) $0.font = .systemFont(ofSize: 16)
$0.textColor = .label $0.textColor = .label
@@ -43,91 +32,30 @@ class InputBox: UIView {
$0.text = "This is AFFiNE AI" $0.text = "This is AFFiNE AI"
} }
private lazy var placeholderLabel = UILabel().then { lazy var placeholderLabel = UILabel().then {
$0.text = "Write your message..." $0.text = "Write your message..."
$0.font = .systemFont(ofSize: 16) $0.font = .systemFont(ofSize: 16)
$0.textColor = .systemGray3 $0.textColor = .systemGray3
$0.isHidden = true $0.isHidden = true
} }
private lazy var addButton = UIButton(type: .system).then { lazy var functionBar = InputBoxFunctionBar().then {
$0.backgroundColor = .systemBackground $0.delegate = self
$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)
} }
private lazy var toolButton = UIButton(type: .system).then { lazy var imageBar = InputBoxImageBar(frame: bounds)
$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)
}
private lazy var webButton = UIButton(type: .system).then { lazy var mainStackView = UIStackView().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 {
$0.axis = .vertical $0.axis = .vertical
$0.spacing = 16 $0.spacing = 16
$0.alignment = .fill $0.alignment = .fill
$0.addArrangedSubview(textView) $0.addArrangedSubview(textView)
$0.addArrangedSubview(functionsStackView) $0.addArrangedSubview(functionBar)
} }
private var textViewHeightConstraint: Constraint? var textViewHeightConstraint: Constraint?
private let minTextViewHeight: CGFloat = 22 let minTextViewHeight: CGFloat = 22
private let maxTextViewHeight: CGFloat = 100 let maxTextViewHeight: CGFloat = 100
var text: String { var text: String {
get { textView.text ?? "" } get { textView.text ?? "" }
@@ -137,9 +65,9 @@ class InputBox: UIView {
updateTextViewHeight() updateTextViewHeight()
} }
} }
init() { override init(frame: CGRect = .zero) {
super.init(frame: .zero) super.init(frame: frame)
setupViews() setupViews()
setupConstraints() setupConstraints()
setupBindings() setupBindings()
@@ -151,14 +79,16 @@ class InputBox: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
private func setupViews() { func setupViews() {
backgroundColor = .clear backgroundColor = .clear
addSubview(containerView) addSubview(containerView)
containerView.addSubview(mainStackView) containerView.addSubview(mainStackView)
containerView.addSubview(placeholderLabel) containerView.addSubview(placeholderLabel)
imageBar.imageBarDelegate = self
} }
private func setupBindings() { func setupBindings() {
// ViewModel UI // ViewModel UI
viewModel.$inputText viewModel.$inputText
.sink { [weak self] text in .sink { [weak self] text in
@@ -172,43 +102,42 @@ class InputBox: UIView {
viewModel.$isToolEnabled viewModel.$isToolEnabled
.sink { [weak self] enabled in .sink { [weak self] enabled in
self?.toolButton.isSelected = enabled self?.functionBar.updateToolState(isEnabled: enabled)
self?.toolButton.tintColor = enabled ? .systemBlue : .secondaryLabel
} }
.store(in: &cancellables) .store(in: &cancellables)
viewModel.$isNetworkEnabled viewModel.$isNetworkEnabled
.sink { [weak self] enabled in .sink { [weak self] enabled in
self?.webButton.isSelected = enabled self?.functionBar.updateNetworkState(isEnabled: enabled)
self?.webButton.tintColor = enabled ? .systemBlue : .secondaryLabel
} }
.store(in: &cancellables) .store(in: &cancellables)
viewModel.$isDeepThinkingEnabled viewModel.$isDeepThinkingEnabled
.sink { [weak self] enabled in .sink { [weak self] enabled in
self?.reactButton.isSelected = enabled self?.functionBar.updateDeepThinkingState(isEnabled: enabled)
self?.reactButton.tintColor = enabled ? .systemBlue : .secondaryLabel
} }
.store(in: &cancellables) .store(in: &cancellables)
viewModel.$canSend viewModel.$canSend
.sink { [weak self] canSend in .sink { [weak self] canSend in
self?.sendButton.isEnabled = canSend self?.functionBar.updateSendState(canSend: canSend)
self?.sendButton.alpha = canSend ? 1.0 : 0.5
} }
.store(in: &cancellables) .store(in: &cancellables)
viewModel.$isSending viewModel.$hasAttachments
.sink { [weak self] isSending in .sink { [weak self] hasAttachments in
self?.sendButton.isEnabled = !isSending self?.updateImageBarVisibility(hasAttachments)
if isSending { }
// TODO: .store(in: &cancellables)
}
viewModel.$attachments
.sink { [weak self] attachments in
self?.updateImageBarContent(attachments)
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
private func setupConstraints() { func setupConstraints() {
containerView.snp.makeConstraints { make in containerView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16) make.edges.equalToSuperview().inset(16)
} }
@@ -217,20 +146,6 @@ class InputBox: UIView {
make.edges.equalToSuperview().inset(16) 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 textView.snp.makeConstraints { make in
textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint 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 size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height)) let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
let height = textView.frame.height
guard height != newHeight else { return }
textViewHeightConstraint?.update(offset: newHeight) textViewHeightConstraint?.update(offset: newHeight)
textView.isScrollEnabled = size.height > maxTextViewHeight textView.isScrollEnabled = size.height > maxTextViewHeight
if height == 0 || superview == nil || window == nil || isHidden { return }
UIView.animate( UIView.animate(
withDuration: 0.5, withDuration: 0.5,
delay: 0, delay: 0,
usingSpringWithDamping: 0.8, usingSpringWithDamping: 0.8,
initialSpringVelocity: 1.0, initialSpringVelocity: 1.0,
options: [.curveEaseInOut] options: .curveEaseInOut
) { ) {
self.layoutIfNeeded() self.layoutIfNeeded()
self.superview?.layoutIfNeeded() self.superview?.layoutIfNeeded()
} }
} }
private func updatePlaceholderVisibility() { func updatePlaceholderVisibility() {
placeholderLabel.isHidden = !textView.text.isEmpty placeholderLabel.isHidden = !textView.text.isEmpty
} }
@objc private func addButtonTapped() { func updateImageBarVisibility(_ hasAttachments: Bool) {
delegate?.inputBoxDidTapAddAttachment() 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() viewModel.toggleTool()
delegate?.inputBoxDidTapTool()
} }
@objc private func webButtonTapped() { func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
viewModel.toggleNetwork() viewModel.toggleNetwork()
delegate?.inputBoxDidTapNetwork()
} }
@objc private func reactButtonTapped() { func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
viewModel.toggleDeepThinking() viewModel.toggleDeepThinking()
delegate?.inputBoxDidTapDeepThinking()
} }
@objc private func sendButtonTapped() { func functionBarDidTapSend(_: InputBoxFunctionBar) {
let data = viewModel.prepareSendData() delegate?.inputBoxDidSend(self)
delegate?.inputBoxDidTapSend(data: data)
}
}
// MARK: - UITextViewDelegate
extension InputBox: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
updatePlaceholderVisibility()
updateTextViewHeight()
viewModel.updateText(textView.text)
delegate?.inputBoxTextDidChange(textView.text)
} }
} }
@@ -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()
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -8,123 +8,23 @@
import Combine import Combine
import Foundation import Foundation
public class InputBoxViewModel: ObservableObject { // MARK: - Data Models
// MARK: - Published Properties
@Published public var inputText: String = "" public enum AttachmentType: Equatable {
@Published public var isToolEnabled: Bool = false case image
@Published public var isNetworkEnabled: Bool = false case document
@Published public var isDeepThinkingEnabled: Bool = false case video
@Published public var hasAttachments: Bool = false case audio
@Published public var attachments: [InputAttachment] = [] case other(String)
@Published public var isSending: Bool = false
// MARK: - Private Properties public var displayName: String {
switch self {
private var cancellables = Set<AnyCancellable>() case .image: "Image"
case .document: "Document"
// MARK: - Initialization case .video: "Video"
case .audio: "Audio"
public init() { case let .other(type): type
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
} }
} }
@@ -153,20 +53,127 @@ public struct InputAttachment {
} }
} }
public enum AttachmentType { public struct InputBoxData {
case image public let text: String
case document public let attachments: [InputAttachment]
case video public let isToolEnabled: Bool
case audio public let isNetworkEnabled: Bool
case other(String) public let isDeepThinkingEnabled: Bool
public var displayName: String { public init(
switch self { text: String,
case .image: "Image" attachments: [InputAttachment],
case .document: "Document" isToolEnabled: Bool,
case .video: "Video" isNetworkEnabled: Bool,
case .audio: "Audio" isDeepThinkingEnabled: Bool
case let .other(type): type ) {
} 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
} }
} }