Files
ForumMobile/native-sidebar/ios/Plugin/SidebarDrawer.swift
2026-06-22 04:35:01 -05:00

366 lines
15 KiB
Swift

import UIKit
import WebKit
/// A native left-edge drawer that *pushes* the app content aside (rather than
/// overlaying it). The sidebar panel sits at a fixed position on the left,
/// behind the content; opening slides the whole content layer (the Capacitor
/// web view plus any sibling overlays such as the native chat input bar) to the
/// right to reveal it.
///
/// View layers in the host (`webView.superview`):
/// • `panelHost` — the sidebar, inserted *below* the main web view. Fixed at
/// the left, ~85% width. Hosts a transparent `contentWebView`
/// that renders the existing `Sidebar.js`.
/// • content — the main web view + other native overlays. Translated
/// right by `currentOffset` when open (visual transform only,
/// so the web layout never reflows).
/// • `self` — a transparent gesture layer on top. Catches the left-edge
/// open drag when closed, and tap / drag-to-close over the
/// shifted content when open. Everything else falls through.
class SidebarDrawer: UIView, WKScriptMessageHandler, UIGestureRecognizerDelegate {
// MARK: Callbacks to the plugin
var onAction: (([String: Any]) -> Void)?
var onStateChange: ((Bool) -> Void)?
// MARK: Tunables
private let panelRatio: CGFloat = 0.85
private let edgeStripWidth: CGFloat = 20
private let velocityToOpen: CGFloat = 500 // pt/s shortcut to decide snap
// MARK: Views
/// The sidebar, kept behind the content and revealed as content slides away.
private let panelHost = UIView()
private var contentWebView: WKWebView!
/// Owns the content web view so WebKit can find a presenting view controller
/// for native form controls (e.g. the theme `<select>`'s picker).
private let contentVC = UIViewController()
private weak var host: UIView?
private weak var mainWebView: WKWebView?
private weak var parentVC: UIViewController?
private var pan: UIPanGestureRecognizer!
// MARK: State
private var isOpen = false
private var isDragging = false
private var isAnimating = false
private var gestureEnabled = true
/// How far (pt) the content is currently pushed right. 0 = closed.
private var currentOffset: CGFloat = 0
private var dragStartOffset: CGFloat = 0
/// Origin currently loaded into the content web view, so we only load once.
private var loadedOrigin: String?
/// True once the content page has signalled it is ready to receive data.
private var contentReady = false
/// Latest payload, buffered until the content page is ready.
private var pendingData: [String: Any]?
private var panelWidth: CGFloat { (host?.bounds.width ?? bounds.width) * panelRatio }
// MARK: Lifecycle
init(hostView: UIView, mainWebView: WKWebView?, parentVC: UIViewController?) {
self.host = hostView
self.mainWebView = mainWebView
self.parentVC = parentVC
super.init(frame: .zero)
setupViews()
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
func attach() {
guard let host = host else { return }
// Sidebar goes *below* the content so the content covers it when closed.
panelHost.translatesAutoresizingMaskIntoConstraints = false
if let webView = mainWebView, webView.superview === host {
host.insertSubview(panelHost, belowSubview: webView)
} else {
host.insertSubview(panelHost, at: 0)
}
// Transparent gesture layer on top of everything.
translatesAutoresizingMaskIntoConstraints = false
host.addSubview(self)
NSLayoutConstraint.activate([
panelHost.leadingAnchor.constraint(equalTo: host.leadingAnchor),
panelHost.topAnchor.constraint(equalTo: host.topAnchor),
panelHost.bottomAnchor.constraint(equalTo: host.bottomAnchor),
panelHost.widthAnchor.constraint(equalTo: host.widthAnchor, multiplier: panelRatio),
contentVC.view.leadingAnchor.constraint(equalTo: panelHost.leadingAnchor),
contentVC.view.trailingAnchor.constraint(equalTo: panelHost.trailingAnchor),
contentVC.view.topAnchor.constraint(equalTo: panelHost.topAnchor),
contentVC.view.bottomAnchor.constraint(equalTo: panelHost.bottomAnchor),
contentWebView.leadingAnchor.constraint(equalTo: contentVC.view.leadingAnchor),
contentWebView.trailingAnchor.constraint(equalTo: contentVC.view.trailingAnchor),
contentWebView.topAnchor.constraint(equalTo: contentVC.view.topAnchor),
contentWebView.bottomAnchor.constraint(equalTo: contentVC.view.bottomAnchor),
leadingAnchor.constraint(equalTo: host.leadingAnchor),
trailingAnchor.constraint(equalTo: host.trailingAnchor),
topAnchor.constraint(equalTo: host.topAnchor),
bottomAnchor.constraint(equalTo: host.bottomAnchor),
])
// Register the content view controller so WebKit can find a presenter
// for native UI (the theme <select> picker, etc.).
if let parentVC = parentVC {
parentVC.addChild(contentVC)
contentVC.didMove(toParent: parentVC)
}
}
func removeFromHost() {
contentWebView?.configuration.userContentController.removeScriptMessageHandler(forName: "sidebar")
contentVC.willMove(toParent: nil)
contentVC.removeFromParent()
applyOffset(0) // restore content to its resting position
panelHost.removeFromSuperview()
removeFromSuperview()
}
// MARK: Setup
private func setupViews() {
backgroundColor = .clear
panelHost.backgroundColor = .clear
panelHost.clipsToBounds = true
// Transparent content web view hosting Sidebar.js (it paints its own
// themed, opaque background).
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
config.userContentController.add(WeakScriptMessageProxy(self), name: "sidebar")
contentWebView = WKWebView(frame: .zero, configuration: config)
contentWebView.translatesAutoresizingMaskIntoConstraints = false
contentWebView.isOpaque = false
contentWebView.backgroundColor = .clear
contentWebView.scrollView.backgroundColor = .clear
if #available(iOS 11.0, *) { contentWebView.scrollView.contentInsetAdjustmentBehavior = .never }
// The web view lives inside the content view controller's view so it has
// an owning VC for presenting native controls.
let container = contentVC.view!
container.translatesAutoresizingMaskIntoConstraints = false
container.backgroundColor = .clear
panelHost.addSubview(container)
container.addSubview(contentWebView)
pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
addGestureRecognizer(pan)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
addGestureRecognizer(tap)
}
override func layoutSubviews() {
super.layoutSubviews()
// Keep the resting position correct as the host sizes (and on rotation),
// but never fight an in-flight drag/animation.
if !isDragging && !isAnimating {
applyOffset(isOpen ? panelWidth : 0)
}
}
// MARK: Content translation
/// All content layers that should slide together — everything in the host
/// except the sidebar and this gesture layer.
private func contentSiblings() -> [UIView] {
guard let host = host else { return [] }
return host.subviews.filter { $0 !== self && $0 !== panelHost }
}
private func applyOffset(_ x: CGFloat) {
currentOffset = x
let t = x == 0 ? .identity : CGAffineTransform(translationX: x, y: 0)
for v in contentSiblings() { v.transform = t }
}
// MARK: Public API
func setData(_ payload: [String: Any]) {
pendingData = payload
if let origin = payload["uiOrigin"] as? String, origin != loadedOrigin {
loadedOrigin = origin
contentReady = false
if let url = URL(string: "\(origin)/83947261/sidebar-host.html") {
contentWebView.load(URLRequest(url: url))
}
}
flushDataIfReady()
}
func setTheme(_ theme: String) {
guard contentReady, !theme.isEmpty else { return }
let safe = theme.replacingOccurrences(of: "'", with: "")
contentWebView.evaluateJavaScript("window.__sidebarSetTheme && window.__sidebarSetTheme('\(safe)')")
}
func setEnabled(_ enabled: Bool) {
gestureEnabled = enabled
pan.isEnabled = enabled
}
func open() { animate(open: true, velocity: 0) }
func close() { animate(open: false, velocity: 0) }
// MARK: Data injection
private func flushDataIfReady() {
guard contentReady, let payload = pendingData,
let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) else { return }
contentWebView.evaluateJavaScript("window.__sidebarSetData && window.__sidebarSetData(\(json))")
}
// MARK: WKScriptMessageHandler
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let action = body["action"] as? String else { return }
switch action {
case "ready":
contentReady = true
flushDataIfReady()
case "selectNetwork":
onAction?(["type": "selectNetwork", "abbreviation": body["abbreviation"] as? String ?? ""])
close()
case "openProfile":
onAction?(["type": "openProfile"])
close()
case "createNetwork":
onAction?(["type": "createNetwork"])
close()
case "logout":
onAction?(["type": "logout"])
close()
case "setTheme":
// The content page applies the theme to itself locally; just relay
// so the main app updates globally. Keep the drawer open.
onAction?(["type": "setTheme", "value": body["value"] as? String ?? ""])
case "close":
close()
default:
break
}
}
// MARK: Gestures
@objc private func handleTap() {
// A tap only reaches us (see hitTest) over the shifted content when open.
if isOpen { close() }
}
@objc private func handlePan(_ g: UIPanGestureRecognizer) {
guard let host = host else { return }
switch g.state {
case .began:
isDragging = true
superview?.bringSubviewToFront(self)
dragStartOffset = currentOffset
mainWebView?.allowsBackForwardNavigationGestures = false
case .changed:
let t = g.translation(in: host).x
applyOffset(min(max(dragStartOffset + t, 0), panelWidth))
case .ended, .cancelled, .failed:
isDragging = false
let v = g.velocity(in: host).x
let target: Bool
if abs(v) > velocityToOpen {
target = v > 0
} else if isOpen {
// Was open → close once dragged left past ~1/3 (stay open otherwise).
target = currentOffset > panelWidth * 2 / 3
} else {
// Was closed → open once dragged right past ~1/3.
target = currentOffset > panelWidth / 3
}
animate(open: target, velocity: v)
default:
break
}
}
private func animate(open: Bool, velocity: CGFloat) {
isAnimating = true
if open {
superview?.bringSubviewToFront(self)
mainWebView?.allowsBackForwardNavigationGestures = false
}
let target: CGFloat = open ? panelWidth : 0
let remaining = max(abs(target - currentOffset), 1)
let springVelocity = min(abs(velocity) / remaining, 4)
UIView.animate(withDuration: 0.32, delay: 0,
usingSpringWithDamping: 0.9, initialSpringVelocity: springVelocity,
options: [.allowUserInteraction, .beginFromCurrentState]) {
self.applyOffset(target)
} completion: { _ in
self.isAnimating = false
let changed = self.isOpen != open
self.isOpen = open
if !open { self.mainWebView?.allowsBackForwardNavigationGestures = true }
if changed { self.onStateChange?(open) }
}
}
// MARK: UIGestureRecognizerDelegate
override func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
guard gestureEnabled else { return g is UITapGestureRecognizer ? isOpen : false }
guard let pan = g as? UIPanGestureRecognizer else { return true }
// Start position is already gated by hitTest (closed → only the edge
// strip reaches us; open → only the shifted content). Require a
// horizontal-dominant drag so vertical scrolls pass through.
let vel = pan.velocity(in: self)
return abs(vel.x) >= abs(vel.y)
}
func gestureRecognizer(_ g: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
return true
}
// MARK: hitTest — gate which touches the gesture layer claims
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if isHidden || alpha == 0 { return nil }
if isOpen || isDragging {
// The revealed sidebar (left of the content's left edge) stays
// interactive — fall through to the sidebar web view beneath.
if point.x < currentOffset { return nil }
// Over the shifted content: claim the touch (tap / drag to close).
return self
}
// Closed: only the left strip is live (to start the open drag); every
// other touch falls through to the content beneath.
if gestureEnabled && point.x <= edgeStripWidth { return self }
return nil
}
}
// MARK: - Weak message-handler proxy
/// `WKUserContentController` retains its message handlers strongly. This weak
/// proxy breaks the drawer ⇄ web view ⇄ controller retain cycle.
private final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler {
weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) { self.target = target }
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
target?.userContentController(controller, didReceive: message)
}
}