Native Sidebar

This commit is contained in:
metacryst
2026-06-22 04:35:01 -05:00
parent 21cc89f0af
commit 882d2767f9
10 changed files with 550 additions and 2 deletions

View File

@@ -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

View 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>

View File

@@ -0,0 +1,13 @@
#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(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);
)

View File

@@ -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()
}
}
}

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

View File

@@ -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"
}
}
}