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