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 {
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
@@ -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)
}
}
@@ -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)
}
}
@@ -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 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
}
}