Native Sidebar
This commit is contained in:
365
native-sidebar/ios/Plugin/SidebarDrawer.swift
Normal file
365
native-sidebar/ios/Plugin/SidebarDrawer.swift
Normal file
@@ -0,0 +1,365 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user