From 882d2767f9dcec743b19b9f7f44e959a02fbd3bf Mon Sep 17 00:00:00 2001 From: metacryst Date: Mon, 22 Jun 2026 04:35:01 -0500 Subject: [PATCH] Native Sidebar --- ios/App/Podfile | 1 + ios/App/Podfile.lock | 8 +- .../ForumCapacitorNativeSidebar.podspec | 17 + native-sidebar/ios/Plugin/Info.plist | 24 ++ .../ios/Plugin/NativeSidebarPlugin.m | 13 + .../ios/Plugin/NativeSidebarPlugin.swift | 88 +++++ native-sidebar/ios/Plugin/SidebarDrawer.swift | 365 ++++++++++++++++++ native-sidebar/package.json | 31 ++ package.json | 1 + src/mobileutil.js | 4 +- 10 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 native-sidebar/ForumCapacitorNativeSidebar.podspec create mode 100644 native-sidebar/ios/Plugin/Info.plist create mode 100644 native-sidebar/ios/Plugin/NativeSidebarPlugin.m create mode 100644 native-sidebar/ios/Plugin/NativeSidebarPlugin.swift create mode 100644 native-sidebar/ios/Plugin/SidebarDrawer.swift create mode 100644 native-sidebar/package.json diff --git a/ios/App/Podfile b/ios/App/Podfile index 297f568..11ba037 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -20,6 +20,7 @@ def capacitor_pods pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' pod 'ForumCapacitorNativeInput', :path => '../../native-input' + pod 'ForumCapacitorNativeSidebar', :path => '../../native-sidebar' pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back' end diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index a1a7e94..e71ad86 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -24,6 +24,8 @@ PODS: - Capacitor - ForumCapacitorNativeInput (1.0.0): - Capacitor + - ForumCapacitorNativeSidebar (1.0.0): + - Capacitor - Google-Maps-iOS-Utils (5.0.0): - GoogleMaps (~> 8.0) - GoogleMaps (8.4.0): @@ -48,6 +50,7 @@ DEPENDENCIES: - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" - ForumCapacitorNativeInput (from `../../native-input`) + - ForumCapacitorNativeSidebar (from `../../native-sidebar`) - "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)" SPEC REPOS: @@ -80,6 +83,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/splash-screen" ForumCapacitorNativeInput: :path: "../../native-input" + ForumCapacitorNativeSidebar: + :path: "../../native-sidebar" NotnotsamuelCapacitorSwipeBack: :path: "../../node_modules/@notnotsamuel/capacitor-swipe-back" @@ -95,12 +100,13 @@ SPEC CHECKSUMS: CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b + ForumCapacitorNativeSidebar: 2b93b6d0c8ae25a08437fe98bccfc528d8f9626a Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321 GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e -PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d +PODFILE CHECKSUM: 5005cc1c35ad0396f6966ccf4d0c4d4790dac751 COCOAPODS: 1.16.2 diff --git a/native-sidebar/ForumCapacitorNativeSidebar.podspec b/native-sidebar/ForumCapacitorNativeSidebar.podspec new file mode 100644 index 0000000..6ce0ba1 --- /dev/null +++ b/native-sidebar/ForumCapacitorNativeSidebar.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'ForumCapacitorNativeSidebar' + 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 diff --git a/native-sidebar/ios/Plugin/Info.plist b/native-sidebar/ios/Plugin/Info.plist new file mode 100644 index 0000000..1007fd9 --- /dev/null +++ b/native-sidebar/ios/Plugin/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/native-sidebar/ios/Plugin/NativeSidebarPlugin.m b/native-sidebar/ios/Plugin/NativeSidebarPlugin.m new file mode 100644 index 0000000..69e9901 --- /dev/null +++ b/native-sidebar/ios/Plugin/NativeSidebarPlugin.m @@ -0,0 +1,13 @@ +#import +#import + +// Define the plugin using the CAP_PLUGIN Macro, and +// each method the plugin supports using the CAP_PLUGIN_METHOD macro. +CAP_PLUGIN(NativeSidebarPlugin, "NativeSidebar", + CAP_PLUGIN_METHOD(setData, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(open, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(close, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise); +) diff --git a/native-sidebar/ios/Plugin/NativeSidebarPlugin.swift b/native-sidebar/ios/Plugin/NativeSidebarPlugin.swift new file mode 100644 index 0000000..956b4ad --- /dev/null +++ b/native-sidebar/ios/Plugin/NativeSidebarPlugin.swift @@ -0,0 +1,88 @@ +import Foundation +import Capacitor + +/// Hosts a native left-edge drawer (the "sidebar") that floats above the +/// Capacitor web view. The drawer's *content* is a second, transparent +/// WKWebView that renders the existing `Sidebar.js` from data pushed in via +/// `setData`. Taps inside that content web view are forwarded back to the main +/// app as an `action` event, mirroring how the NativeInput plugin works. +@objc(NativeSidebarPlugin) +public class NativeSidebarPlugin: CAPPlugin { + + private var drawer: SidebarDrawer? + + // MARK: Helpers + + private func hostView() -> UIView? { + // Pin the drawer as a sibling of the web view inside the same parent so + // it can float above the web content (same pattern as NativeInput). + return webView?.superview ?? webView + } + + private func ensureDrawer() -> SidebarDrawer? { + if let drawer = drawer { return drawer } + guard let host = hostView() else { return nil } + let drawer = SidebarDrawer(hostView: host, mainWebView: webView, parentVC: bridge?.viewController) + drawer.onAction = { [weak self] data in + self?.notifyListeners("action", data: data) + } + drawer.onStateChange = { [weak self] open in + self?.notifyListeners("stateChange", data: ["open": open]) + } + drawer.attach() + self.drawer = drawer + return drawer + } + + // MARK: Plugin methods + + @objc func setData(_ call: CAPPluginCall) { + // Forward the whole options object (minus Capacitor's callbackId) to the + // content web view as the sidebar payload. + var payload = (call.options as? [String: Any]) ?? [:] + payload.removeValue(forKey: "callbackId") + DispatchQueue.main.async { + guard let drawer = self.ensureDrawer() else { call.reject("No host view"); return } + drawer.setData(payload) + call.resolve() + } + } + + @objc func setTheme(_ call: CAPPluginCall) { + let theme = call.getString("theme") ?? "" + DispatchQueue.main.async { + self.drawer?.setTheme(theme) + call.resolve() + } + } + + @objc func open(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.ensureDrawer()?.open() + call.resolve() + } + } + + @objc func close(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.drawer?.close() + call.resolve() + } + } + + @objc func setEnabled(_ call: CAPPluginCall) { + let enabled = call.getBool("enabled") ?? true + DispatchQueue.main.async { + self.drawer?.setEnabled(enabled) + call.resolve() + } + } + + @objc func dismiss(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.drawer?.removeFromHost() + self.drawer = nil + call.resolve() + } + } +} diff --git a/native-sidebar/ios/Plugin/SidebarDrawer.swift b/native-sidebar/ios/Plugin/SidebarDrawer.swift new file mode 100644 index 0000000..271cd10 --- /dev/null +++ b/native-sidebar/ios/Plugin/SidebarDrawer.swift @@ -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 ` 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) + } +} diff --git a/native-sidebar/package.json b/native-sidebar/package.json new file mode 100644 index 0000000..ede4f97 --- /dev/null +++ b/native-sidebar/package.json @@ -0,0 +1,31 @@ +{ + "name": "@forum/capacitor-native-sidebar", + "version": "1.0.0", + "description": "Native iOS left-edge drawer (sidebar) for the Forum app.", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "files": [ + "dist/", + "ios/Plugin/", + "ForumCapacitorNativeSidebar.podspec" + ], + "author": "Forum", + "license": "MIT", + "keywords": [ + "capacitor", + "plugin", + "native", + "ios", + "sidebar", + "drawer" + ], + "peerDependencies": { + "@capacitor/core": "^7.0.0" + }, + "capacitor": { + "ios": { + "src": "ios" + } + } +} diff --git a/package.json b/package.json index b4694be..f7a979c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@capacitor/push-notifications": "^7.0.6", "@capacitor/splash-screen": "^7.0.3", "@forum/capacitor-native-input": "file:native-input", + "@forum/capacitor-native-sidebar": "file:native-sidebar", "@notnotsamuel/capacitor-swipe-back": "^3.0.2" }, "devDependencies": { diff --git a/src/mobileutil.js b/src/mobileutil.js index 068e440..d6c75a7 100644 --- a/src/mobileutil.js +++ b/src/mobileutil.js @@ -7,6 +7,7 @@ import { Geolocation } from '@capacitor/geolocation'; import { SplashScreen } from '@capacitor/splash-screen'; import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back'; import { NativeInput } from '@forum/capacitor-native-input'; +import { NativeSidebar } from '@forum/capacitor-native-sidebar'; window.capacitor = { Preferences, @@ -21,7 +22,8 @@ window.capacitor = { Geolocation, SplashScreen, CapacitorSwipeBackPlugin, - NativeInput + NativeInput, + NativeSidebar } window.mobileUtil = class mobileUtil {