[Chat] Native input bar

This commit is contained in:
metacryst
2026-06-18 05:07:41 -05:00
parent 5304ab1d6d
commit 8453819f50
12 changed files with 713 additions and 8 deletions

View File

@@ -0,0 +1,493 @@
import UIKit
/// A floating chat input bar (Liquid Glass on iOS 26+, blur material fallback)
/// that pins itself above the keyboard and reports the bottom inset it occupies
/// so the web layout can pad its scroll area accordingly.
class ChatInputBar: UIView, UITextViewDelegate {
// MARK: Callbacks to the plugin
var onTextChange: ((String) -> Void)?
var onSend: ((String) -> Void)?
var onAttach: (() -> Void)?
var onFocusChange: ((Bool) -> Void)?
/// Reports the bottom inset (in points == CSS px for this viewport) that the
/// bar + keyboard occupy at the bottom of the host view.
var onInsetChange: ((CGFloat) -> Void)?
// MARK: Tunables
private let minTextHeight: CGFloat = 45 // ~25% taller than the old 36
private let maxTextHeight: CGFloat = 150
private let fieldVerticalPadding: CGFloat = 8
private let barVerticalPadding: CGFloat = 8
// MARK: Views
// Hosts the controls in a scroll view with `.interactive` keyboard dismiss
// so dragging the bar down lets the keyboard follow the finger.
private let panScroll = UIScrollView()
private let contentView = UIView()
private let field = UIView()
private let fieldEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect())
private let plusEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect())
private let textView = UITextView()
private let placeholderLabel = UILabel()
private let plusButton = UIButton(type: .system)
private let sendButton = UIButton(type: .system)
private weak var hostView: UIView?
private var textHeightConstraint: NSLayoutConstraint!
// Pins the *visible* bar (the field) above the keyboard. The bar view itself
// spans the whole host so its scroll view can drive interactive dismissal.
private var fieldBottomConstraint: NSLayoutConstraint!
private var canSend = true
/// True while the keyboard is up — used to widen the bar's touch-catch area
/// (see hitTest) only when there's actually a keyboard to dismiss.
private var keyboardUp = false
/// True when the running OS gave us a real, interactive `UIGlassEffect`
/// (so the glass surfaces animate themselves on press). When false we fall
/// back to a manual press animation on the buttons.
private static var supportsInteractiveGlass = false
// MARK: Lifecycle
init(hostView: UIView) {
self.hostView = hostView
super.init(frame: .zero)
setupViews()
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
private static func makeEffect() -> UIVisualEffect {
// Use real Liquid Glass on iOS 26+. Resolved dynamically via the
// Objective-C runtime so the plugin still compiles against older SDKs
// (where the UIGlassEffect symbol does not yet exist).
if #available(iOS 26.0, *),
let glassClass = NSClassFromString("UIGlassEffect") as? NSObject.Type {
let glass = glassClass.init()
// Opt into the system's built-in touch animation (the scale + tint
// "squish" on press). Set via KVC behind a responds(to:) guard so it
// compiles against SDKs that lack UIGlassEffect and never crashes if
// the property is renamed/absent — it just stays static.
let setter = Selector("setInteractive:")
if glass.responds(to: setter) {
glass.setValue(true, forKey: "interactive")
supportsInteractiveGlass = true
}
if let effect = glass as? UIVisualEffect { return effect }
}
return UIBlurEffect(style: .systemUltraThinMaterial)
}
/// Adds the bar to the host view. The bar view fills the whole host (so its
/// scroll view's bounds overlap the keyboard and can drive interactive
/// dismissal); only the visible field/"+" capture touches (see hitTest), and
/// the visible field is pinned just above the keyboard.
func attach() {
guard let host = hostView else { return }
translatesAutoresizingMaskIntoConstraints = false
host.addSubview(self)
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: host.leadingAnchor),
trailingAnchor.constraint(equalTo: host.trailingAnchor),
topAnchor.constraint(equalTo: host.topAnchor),
bottomAnchor.constraint(equalTo: host.bottomAnchor),
])
if #available(iOS 15.0, *) {
// keyboardLayoutGuide rides the keyboard and rests at the safe-area
// bottom when the keyboard is dismissed.
host.keyboardLayoutGuide.followsUndockedKeyboard = true
fieldBottomConstraint = field.bottomAnchor.constraint(
equalTo: host.keyboardLayoutGuide.topAnchor, constant: -barVerticalPadding)
} else {
fieldBottomConstraint = field.bottomAnchor.constraint(
equalTo: host.safeAreaLayoutGuide.bottomAnchor, constant: -barVerticalPadding)
}
fieldBottomConstraint.isActive = true
observeKeyboard()
host.layoutIfNeeded() // so the field has a real frame before measuring
reportInset()
}
func removeFromHost() {
NotificationCenter.default.removeObserver(self)
removeFromSuperview()
onInsetChange?(0)
}
/// The bar view spans the entire host so its scroll view can drive
/// interactive keyboard dismissal, but only the visible field/"+" should
/// swallow touches — every other point falls through to the web view beneath.
/// (A touch that begins on the bar is still tracked by the scroll view's pan
/// as the finger then drags down across the rest of the screen.)
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let hit = super.hitTest(point, with: event) else { return nil }
// Always let the visible controls handle their own touches.
if hit == field || hit == plusEffect
|| hit.isDescendant(of: field) || hit.isDescendant(of: plusEffect) {
return hit
}
// While the keyboard is up, also claim an invisible strip ~1.5× the input
// height above the field (and everything below it) so a downward drag
// begun just above the bar still drives the interactive dismissal. Above
// the strip, fall through to the web view.
if keyboardUp {
let fieldInSelf = field.convert(field.bounds, to: self)
let catchTop = fieldInSelf.minY - 1.5 * minTextHeight
if point.y >= catchTop { return panScroll }
}
return nil
}
// MARK: Setup
private func setupViews() {
// The bar itself is transparent so only the field + "+" button float.
backgroundColor = .clear
// Scroll view that drives the native interactive keyboard dismissal.
// It fills the whole host so its bounds overlap the keyboard region: a
// downward drag that begins on the bar stays inside the scroll view as it
// travels toward the keyboard, which is what `.interactive` needs to track
// the finger. It never really scrolls (content == frame) but
// `alwaysBounceVertical` keeps its pan recognizer live.
panScroll.translatesAutoresizingMaskIntoConstraints = false
panScroll.keyboardDismissMode = .interactive
panScroll.alwaysBounceVertical = true
panScroll.showsVerticalScrollIndicator = false
panScroll.showsHorizontalScrollIndicator = false
panScroll.delaysContentTouches = false
panScroll.backgroundColor = .clear
if #available(iOS 11.0, *) { panScroll.contentInsetAdjustmentBehavior = .never }
addSubview(panScroll)
contentView.translatesAutoresizingMaskIntoConstraints = false
contentView.backgroundColor = .clear
panScroll.addSubview(contentView)
// Circular "+" attach button — same glass + border as the input field.
// Interaction is enabled so the interactive glass effect can observe
// touches and run its built-in press animation.
plusEffect.translatesAutoresizingMaskIntoConstraints = false
plusEffect.isUserInteractionEnabled = true
plusEffect.layer.cornerRadius = minTextHeight / 2
plusEffect.clipsToBounds = true
plusEffect.layer.borderWidth = 1
plusEffect.layer.borderColor = UIColor.separator.cgColor
contentView.addSubview(plusEffect)
// The button lives inside the effect's contentView so its touches are
// delivered through the glass (required for the interactive animation).
plusButton.translatesAutoresizingMaskIntoConstraints = false
plusButton.setImage(UIImage(systemName: "plus"), for: .normal)
plusButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
plusEffect.contentView.addSubview(plusButton)
addPressAnimation(to: plusButton, enabled: !ChatInputBar.supportsInteractiveGlass)
// Floating rounded input field.
field.translatesAutoresizingMaskIntoConstraints = false
field.layer.cornerRadius = 18
field.layer.borderWidth = 1
field.layer.borderColor = UIColor.separator.cgColor
field.clipsToBounds = true
contentView.addSubview(field)
// Glass / blur background fills the field (behind the content).
// Interaction is enabled so tapping the input runs the glass's built-in
// press animation; the text view and placeholder live in its contentView.
fieldEffect.translatesAutoresizingMaskIntoConstraints = false
fieldEffect.isUserInteractionEnabled = true
fieldEffect.layer.cornerRadius = 18
fieldEffect.clipsToBounds = true
field.addSubview(fieldEffect)
// Growing text view.
textView.translatesAutoresizingMaskIntoConstraints = false
textView.backgroundColor = .clear
textView.font = .systemFont(ofSize: 17)
textView.isScrollEnabled = false
textView.textContainerInset = UIEdgeInsets(top: 11, left: 6, bottom: 11, right: 6)
textView.delegate = self
fieldEffect.contentView.addSubview(textView)
// Placeholder overlay.
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
placeholderLabel.font = .systemFont(ofSize: 17)
placeholderLabel.textColor = UIColor.placeholderText
placeholderLabel.isUserInteractionEnabled = false
fieldEffect.contentView.addSubview(placeholderLabel)
// Send button — a solid (non-glass) button overlaid inside the field,
// bottom-right. Since it is not a glass surface it gets a manual press
// animation so it still scales/dims like the glass controls.
sendButton.translatesAutoresizingMaskIntoConstraints = false
sendButton.setImage(UIImage(systemName: "arrow.up"), for: .normal)
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
sendButton.layer.cornerRadius = 18
sendButton.clipsToBounds = true
field.addSubview(sendButton)
addPressAnimation(to: sendButton, enabled: true)
textHeightConstraint = textView.heightAnchor.constraint(equalToConstant: minTextHeight)
NSLayoutConstraint.activate([
// Scroll view fills the bar; content view matches its frame so it
// never scrolls but its pan stays active for interactive dismiss.
panScroll.topAnchor.constraint(equalTo: topAnchor),
panScroll.leadingAnchor.constraint(equalTo: leadingAnchor),
panScroll.trailingAnchor.constraint(equalTo: trailingAnchor),
panScroll.bottomAnchor.constraint(equalTo: bottomAnchor),
contentView.topAnchor.constraint(equalTo: panScroll.contentLayoutGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: panScroll.contentLayoutGuide.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: panScroll.frameLayoutGuide.widthAnchor),
contentView.heightAnchor.constraint(equalTo: panScroll.frameLayoutGuide.heightAnchor),
// Circular "+" button sized to the input's resting height, aligned
// to the field's bottom.
plusEffect.widthAnchor.constraint(equalToConstant: minTextHeight),
plusEffect.heightAnchor.constraint(equalToConstant: minTextHeight),
plusEffect.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
plusEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor),
plusButton.leadingAnchor.constraint(equalTo: plusEffect.leadingAnchor),
plusButton.trailingAnchor.constraint(equalTo: plusEffect.trailingAnchor),
plusButton.topAnchor.constraint(equalTo: plusEffect.topAnchor),
plusButton.bottomAnchor.constraint(equalTo: plusEffect.bottomAnchor),
field.leadingAnchor.constraint(equalTo: plusEffect.trailingAnchor, constant: 8),
field.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
// field.bottom is pinned to the keyboard guide in attach(); its top is
// driven by the text view's height. Its vertical position is therefore
// independent of the (full-host) content view.
fieldEffect.topAnchor.constraint(equalTo: field.topAnchor),
fieldEffect.leadingAnchor.constraint(equalTo: field.leadingAnchor),
fieldEffect.trailingAnchor.constraint(equalTo: field.trailingAnchor),
fieldEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor),
// Send button overlaid inside the field, bottom-right.
sendButton.widthAnchor.constraint(equalToConstant: 36),
sendButton.heightAnchor.constraint(equalToConstant: 36),
sendButton.trailingAnchor.constraint(equalTo: field.trailingAnchor, constant: -4),
sendButton.bottomAnchor.constraint(equalTo: field.bottomAnchor, constant: -4),
textView.leadingAnchor.constraint(equalTo: field.leadingAnchor, constant: 6),
textView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -4),
textView.topAnchor.constraint(equalTo: field.topAnchor),
textView.bottomAnchor.constraint(equalTo: field.bottomAnchor),
textHeightConstraint,
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 11),
placeholderLabel.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
])
sendButton.backgroundColor = tintColor
updateTextHeight()
}
// MARK: Public API
var text: String {
get { textView.text ?? "" }
set {
textView.text = newValue
textChanged(notify: false)
}
}
func setPlaceholder(_ value: String) {
placeholderLabel.text = value
}
func setEnabled(_ enabled: Bool) {
canSend = enabled
textView.isEditable = enabled
sendButton.isEnabled = enabled
plusButton.isEnabled = enabled
alpha = enabled ? 1.0 : 0.6
}
func focus() { textView.becomeFirstResponder() }
func blur() { textView.resignFirstResponder() }
func setVisible(_ visible: Bool) {
isHidden = !visible
if !visible { onInsetChange?(0) } else { reportInset() }
}
// MARK: Theme
func applyTheme(_ theme: [String: Any]) {
// The field/"+" keep a clear background so the glass material shows
// through and they read as floating; only content colors are themed.
if let txt = (theme["text"] as? String).flatMap(UIColor.fromCSS) {
textView.textColor = txt
textView.tintColor = txt
}
if let ph = (theme["placeholder"] as? String).flatMap(UIColor.fromCSS) {
placeholderLabel.textColor = ph
}
if let accent = (theme["accent"] as? String).flatMap(UIColor.fromCSS) {
plusButton.tintColor = accent
}
if let sendBg = (theme["sendBackground"] as? String).flatMap(UIColor.fromCSS) {
sendButton.backgroundColor = sendBg
}
if let sendTxt = (theme["sendText"] as? String).flatMap(UIColor.fromCSS) {
sendButton.tintColor = sendTxt
}
if let dark = theme["dark"] as? Bool {
overrideUserInterfaceStyle = dark ? .dark : .light
}
}
// MARK: Actions
@objc private func attachTapped() { onAttach?() }
@objc private func sendTapped() {
guard canSend else { return }
onSend?(textView.text ?? "")
}
// MARK: Press animation
// Manual scale + dim used for the solid send button, and for the "+" when
// the OS doesn't provide interactive glass (so it still feels pressable).
private func addPressAnimation(to button: UIButton, enabled: Bool) {
guard enabled else { return }
button.addTarget(self, action: #selector(pressDown(_:)),
for: [.touchDown, .touchDragEnter])
button.addTarget(self, action: #selector(pressUp(_:)),
for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit])
}
@objc private func pressDown(_ button: UIButton) {
UIView.animate(withDuration: 0.12, delay: 0,
options: [.allowUserInteraction, .beginFromCurrentState]) {
button.transform = CGAffineTransform(scaleX: 0.88, y: 0.88)
button.alpha = 0.7
}
}
@objc private func pressUp(_ button: UIButton) {
UIView.animate(withDuration: 0.28, delay: 0,
usingSpringWithDamping: 0.5, initialSpringVelocity: 0.6,
options: [.allowUserInteraction, .beginFromCurrentState]) {
button.transform = .identity
button.alpha = 1.0
}
}
// MARK: UITextViewDelegate
func textViewDidChange(_ textView: UITextView) {
textChanged(notify: true)
}
func textViewDidBeginEditing(_ textView: UITextView) { onFocusChange?(true) }
func textViewDidEndEditing(_ textView: UITextView) { onFocusChange?(false) }
private func textChanged(notify: Bool) {
placeholderLabel.isHidden = !(textView.text ?? "").isEmpty
updateTextHeight()
if notify { onTextChange?(textView.text ?? "") }
}
private func updateTextHeight() {
let fitting = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude))
let clamped = min(max(fitting.height, minTextHeight), maxTextHeight)
if abs(textHeightConstraint.constant - clamped) > 0.5 {
textHeightConstraint.constant = clamped
textView.isScrollEnabled = fitting.height > maxTextHeight
superview?.layoutIfNeeded()
DispatchQueue.main.async { [weak self] in self?.reportInset() }
}
}
// MARK: Inset reporting
private func observeKeyboard() {
// Observe the *will* notifications — they fire at the START of the
// keyboard animation with the final frame, so the web layout can move in
// step with the keyboard instead of lagging until the animation ends
// (which is what the *did* notifications would do).
let names: [NSNotification.Name] = [
UIResponder.keyboardWillChangeFrameNotification,
UIResponder.keyboardWillShowNotification,
UIResponder.keyboardWillHideNotification,
]
for name in names {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChange(_:)),
name: name, object: nil)
}
}
@objc private func keyboardWillChange(_ note: Notification) {
guard let host = hostView, !isHidden else { return }
guard let end = (note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
reportInset(); return
}
// Where the bar will rest: just above the keyboard, but never below the
// safe-area bottom (its resting place when the keyboard is dismissed).
let kbTop = host.convert(end, from: nil).minY
let safeBottom = host.bounds.height - host.safeAreaInsets.bottom
let barBottom = min(kbTop, safeBottom)
keyboardUp = barBottom < safeBottom - 0.5
if #available(iOS 15.0, *) {
// keyboardLayoutGuide animates the bar itself; nothing to drive here.
} else {
fieldBottomConstraint.constant = (barBottom - safeBottom) - barVerticalPadding
host.layoutIfNeeded()
}
// Report the *target* inset (computed from the keyboard's end frame, since
// the field hasn't moved yet on a *will* notification) so the web updates
// at the animation's start.
let fieldTop = barBottom - barVerticalPadding - field.bounds.height
let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding)
onInsetChange?(inset)
}
private func reportInset() {
guard let host = hostView, !isHidden else { return }
// The bar view spans the whole host, so measure the *visible* field's top
// (in host coordinates) and report from there down to the host bottom.
let fieldTop = field.convert(field.bounds, to: host).minY
let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding)
onInsetChange?(inset)
}
}
// MARK: - CSS color parsing
extension UIColor {
/// Parses the common CSS color strings that `getComputedStyle` returns
/// (`rgb(...)`, `rgba(...)`) plus `#rgb`/`#rrggbb`.
static func fromCSS(_ string: String) -> UIColor? {
let s = string.trimmingCharacters(in: .whitespaces).lowercased()
if s.hasPrefix("#") {
var hex = String(s.dropFirst())
if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() }
guard hex.count == 6, let v = UInt32(hex, radix: 16) else { return nil }
return UIColor(red: CGFloat((v >> 16) & 0xff) / 255,
green: CGFloat((v >> 8) & 0xff) / 255,
blue: CGFloat(v & 0xff) / 255, alpha: 1)
}
if s.hasPrefix("rgb") {
let inner = s.drop { $0 != "(" }.dropFirst().prefix { $0 != ")" }
let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3,
let r = Double(parts[0]), let g = Double(parts[1]), let b = Double(parts[2]) else { return nil }
let a = parts.count >= 4 ? (Double(parts[3]) ?? 1) : 1
return UIColor(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a))
}
return nil
}
}