[Chat] Native input bar
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "1640"
|
||||||
version = "1.8">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def capacitor_pods
|
|||||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||||
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
|
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
|
||||||
|
pod 'ForumCapacitorNativeInput', :path => '../../native-input'
|
||||||
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
|
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
17
native-input/ForumCapacitorNativeInput.podspec
Normal file
17
native-input/ForumCapacitorNativeInput.podspec
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'ForumCapacitorNativeInput'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = 'https://frm.so'
|
||||||
|
s.author = package['author']
|
||||||
|
s.source = { :git => 'https://frm.so', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
493
native-input/ios/Plugin/ChatInputBar.swift
Normal file
493
native-input/ios/Plugin/ChatInputBar.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
24
native-input/ios/Plugin/Info.plist
Normal file
24
native-input/ios/Plugin/Info.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
7
native-input/ios/Plugin/NativeInputPlugin.h
Normal file
7
native-input/ios/Plugin/NativeInputPlugin.h
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
//! Project version number for Plugin.
|
||||||
|
FOUNDATION_EXPORT double PluginVersionNumber;
|
||||||
|
|
||||||
|
//! Project version string for Plugin.
|
||||||
|
FOUNDATION_EXPORT const unsigned char PluginVersionString[];
|
||||||
17
native-input/ios/Plugin/NativeInputPlugin.m
Normal file
17
native-input/ios/Plugin/NativeInputPlugin.m
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <Capacitor/Capacitor.h>
|
||||||
|
|
||||||
|
// Define the plugin using the CAP_PLUGIN Macro, and
|
||||||
|
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
|
||||||
|
CAP_PLUGIN(NativeInputPlugin, "NativeInput",
|
||||||
|
CAP_PLUGIN_METHOD(present, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setText, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(getText, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(focus, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(blur, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setPlaceholder, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setVisible, CAPPluginReturnPromise);
|
||||||
|
)
|
||||||
117
native-input/ios/Plugin/NativeInputPlugin.swift
Normal file
117
native-input/ios/Plugin/NativeInputPlugin.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
@objc(NativeInputPlugin)
|
||||||
|
public class NativeInputPlugin: CAPPlugin {
|
||||||
|
|
||||||
|
private var bar: ChatInputBar?
|
||||||
|
private var currentChatId: String?
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func hostView() -> UIView? {
|
||||||
|
// Pin the bar as a sibling of the web view inside the same parent so it
|
||||||
|
// can float above the web content and track the keyboard.
|
||||||
|
return webView?.superview ?? webView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureBar() -> ChatInputBar? {
|
||||||
|
if let bar = bar { return bar }
|
||||||
|
guard let host = hostView() else { return nil }
|
||||||
|
let bar = ChatInputBar(hostView: host)
|
||||||
|
bar.onTextChange = { [weak self] text in
|
||||||
|
self?.notifyListeners("inputChange", data: ["text": text])
|
||||||
|
}
|
||||||
|
bar.onSend = { [weak self] text in
|
||||||
|
self?.notifyListeners("send", data: ["text": text])
|
||||||
|
}
|
||||||
|
bar.onAttach = { [weak self] in
|
||||||
|
self?.notifyListeners("attach", data: [:])
|
||||||
|
}
|
||||||
|
bar.onFocusChange = { [weak self] focused in
|
||||||
|
self?.notifyListeners("focusChange", data: ["focused": focused])
|
||||||
|
}
|
||||||
|
bar.onInsetChange = { [weak self] inset in
|
||||||
|
self?.notifyListeners("heightChange", data: ["bottomInset": Double(inset)])
|
||||||
|
}
|
||||||
|
bar.attach()
|
||||||
|
self.bar = bar
|
||||||
|
return bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Plugin methods
|
||||||
|
|
||||||
|
@objc func present(_ call: CAPPluginCall) {
|
||||||
|
let chatId = call.getString("chatId") ?? ""
|
||||||
|
let placeholder = call.getString("placeholder") ?? ""
|
||||||
|
let text = call.getString("text") ?? ""
|
||||||
|
let canSend = call.getBool("canSend") ?? true
|
||||||
|
let theme = call.getObject("theme")
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let bar = self.ensureBar() else { call.reject("No host view"); return }
|
||||||
|
bar.setVisible(true)
|
||||||
|
if let theme = theme { bar.applyTheme(theme) }
|
||||||
|
bar.setPlaceholder(placeholder)
|
||||||
|
bar.setEnabled(canSend)
|
||||||
|
// Only reset text when switching to a different chat; otherwise keep
|
||||||
|
// whatever the user already had typed in this bar.
|
||||||
|
if self.currentChatId != chatId {
|
||||||
|
bar.text = text
|
||||||
|
self.currentChatId = chatId
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func dismiss(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bar?.removeFromHost()
|
||||||
|
self.bar = nil
|
||||||
|
self.currentChatId = nil
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setText(_ call: CAPPluginCall) {
|
||||||
|
let text = call.getString("text") ?? ""
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bar?.text = text
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func getText(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
call.resolve(["text": self.bar?.text ?? ""])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func focus(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async { self.bar?.focus(); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func blur(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async { self.bar?.blur(); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setPlaceholder(_ call: CAPPluginCall) {
|
||||||
|
let placeholder = call.getString("placeholder") ?? ""
|
||||||
|
DispatchQueue.main.async { self.bar?.setPlaceholder(placeholder); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setEnabled(_ call: CAPPluginCall) {
|
||||||
|
let enabled = call.getBool("enabled") ?? true
|
||||||
|
DispatchQueue.main.async { self.bar?.setEnabled(enabled); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setTheme(_ call: CAPPluginCall) {
|
||||||
|
let theme = call.getObject("theme") ?? [:]
|
||||||
|
DispatchQueue.main.async { self.bar?.applyTheme(theme); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setVisible(_ call: CAPPluginCall) {
|
||||||
|
let visible = call.getBool("visible") ?? true
|
||||||
|
DispatchQueue.main.async { self.bar?.setVisible(visible); call.resolve() }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
native-input/package.json
Normal file
31
native-input/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@forum/capacitor-native-input",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Native iOS chat input bar (Liquid Glass) for the Forum app.",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/Plugin/",
|
||||||
|
"ForumCapacitorNativeInput.podspec"
|
||||||
|
],
|
||||||
|
"author": "Forum",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"capacitor",
|
||||||
|
"plugin",
|
||||||
|
"native",
|
||||||
|
"ios",
|
||||||
|
"keyboard",
|
||||||
|
"input"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,11 @@
|
|||||||
"@capacitor/geolocation": "^7.1.5",
|
"@capacitor/geolocation": "^7.1.5",
|
||||||
"@capacitor/google-maps": "^7.2.0",
|
"@capacitor/google-maps": "^7.2.0",
|
||||||
"@capacitor/haptics": "^7.0.3",
|
"@capacitor/haptics": "^7.0.3",
|
||||||
"@capacitor/ios": "^7.4.4",
|
"@capacitor/ios": "^7.6.6",
|
||||||
"@capacitor/preferences": "^7.0.4",
|
"@capacitor/preferences": "^7.0.4",
|
||||||
"@capacitor/push-notifications": "^7.0.6",
|
"@capacitor/push-notifications": "^7.0.6",
|
||||||
"@capacitor/splash-screen": "^7.0.3",
|
"@capacitor/splash-screen": "^7.0.3",
|
||||||
|
"@forum/capacitor-native-input": "file:native-input",
|
||||||
"@notnotsamuel/capacitor-swipe-back": "^3.0.2"
|
"@notnotsamuel/capacitor-swipe-back": "^3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ If getting black screen:
|
|||||||
npx cap sync iOS
|
npx cap sync iOS
|
||||||
|
|
||||||
|
|
||||||
## iOS:
|
|
||||||
|
|
||||||
### Browser: Dev Front and Dev Back (localhost)
|
### Browser: Dev Front and Dev Back (localhost)
|
||||||
This option should be at the top level of capacitor.config.json
|
This option should be at the top level of capacitor.config.json
|
||||||
"server": {
|
"server": {
|
||||||
@@ -38,9 +36,6 @@ npx serve dist
|
|||||||
If you need to login again:
|
If you need to login again:
|
||||||
run localStorage.clear() in the browser dev tools console and then refresh the page.
|
run localStorage.clear() in the browser dev tools console and then refresh the page.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## iOS:
|
## iOS:
|
||||||
|
|
||||||
### Dev Front, Dev Back (localhost)
|
### Dev Front, Dev Back (localhost)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
|||||||
import { Geolocation } from '@capacitor/geolocation';
|
import { Geolocation } from '@capacitor/geolocation';
|
||||||
import { SplashScreen } from '@capacitor/splash-screen';
|
import { SplashScreen } from '@capacitor/splash-screen';
|
||||||
import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back';
|
import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back';
|
||||||
|
import { NativeInput } from '@forum/capacitor-native-input';
|
||||||
|
|
||||||
window.capacitor = {
|
window.capacitor = {
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -19,7 +20,8 @@ window.capacitor = {
|
|||||||
CameraSource,
|
CameraSource,
|
||||||
Geolocation,
|
Geolocation,
|
||||||
SplashScreen,
|
SplashScreen,
|
||||||
CapacitorSwipeBackPlugin
|
CapacitorSwipeBackPlugin,
|
||||||
|
NativeInput
|
||||||
}
|
}
|
||||||
|
|
||||||
window.mobileUtil = class mobileUtil {
|
window.mobileUtil = class mobileUtil {
|
||||||
|
|||||||
Reference in New Issue
Block a user