Files
ForumMobile/native-input/ios/Plugin/ChatInputBar.swift
2026-06-18 05:07:41 -05:00

494 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}