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