Native Sidebar
This commit is contained in:
@@ -20,6 +20,7 @@ def capacitor_pods
|
|||||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||||
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
|
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
|
||||||
pod 'ForumCapacitorNativeInput', :path => '../../native-input'
|
pod 'ForumCapacitorNativeInput', :path => '../../native-input'
|
||||||
|
pod 'ForumCapacitorNativeSidebar', :path => '../../native-sidebar'
|
||||||
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
|
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- ForumCapacitorNativeInput (1.0.0):
|
- ForumCapacitorNativeInput (1.0.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
|
- ForumCapacitorNativeSidebar (1.0.0):
|
||||||
|
- Capacitor
|
||||||
- Google-Maps-iOS-Utils (5.0.0):
|
- Google-Maps-iOS-Utils (5.0.0):
|
||||||
- GoogleMaps (~> 8.0)
|
- GoogleMaps (~> 8.0)
|
||||||
- GoogleMaps (8.4.0):
|
- GoogleMaps (8.4.0):
|
||||||
@@ -48,6 +50,7 @@ DEPENDENCIES:
|
|||||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||||
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
|
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
|
||||||
- ForumCapacitorNativeInput (from `../../native-input`)
|
- ForumCapacitorNativeInput (from `../../native-input`)
|
||||||
|
- ForumCapacitorNativeSidebar (from `../../native-sidebar`)
|
||||||
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
|
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -80,6 +83,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@capacitor/splash-screen"
|
:path: "../../node_modules/@capacitor/splash-screen"
|
||||||
ForumCapacitorNativeInput:
|
ForumCapacitorNativeInput:
|
||||||
:path: "../../native-input"
|
:path: "../../native-input"
|
||||||
|
ForumCapacitorNativeSidebar:
|
||||||
|
:path: "../../native-sidebar"
|
||||||
NotnotsamuelCapacitorSwipeBack:
|
NotnotsamuelCapacitorSwipeBack:
|
||||||
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
|
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
|
||||||
|
|
||||||
@@ -95,12 +100,13 @@ SPEC CHECKSUMS:
|
|||||||
CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
|
CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
|
||||||
CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
|
CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
|
||||||
ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
|
ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
|
||||||
|
ForumCapacitorNativeSidebar: 2b93b6d0c8ae25a08437fe98bccfc528d8f9626a
|
||||||
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
|
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
|
||||||
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
|
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
|
||||||
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
||||||
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
|
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
|
||||||
NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
|
NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
|
||||||
|
|
||||||
PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d
|
PODFILE CHECKSUM: 5005cc1c35ad0396f6966ccf4d0c4d4790dac751
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
17
native-sidebar/ForumCapacitorNativeSidebar.podspec
Normal file
17
native-sidebar/ForumCapacitorNativeSidebar.podspec
Normal 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
|
||||||
24
native-sidebar/ios/Plugin/Info.plist
Normal file
24
native-sidebar/ios/Plugin/Info.plist
Normal 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>
|
||||||
13
native-sidebar/ios/Plugin/NativeSidebarPlugin.m
Normal file
13
native-sidebar/ios/Plugin/NativeSidebarPlugin.m
Normal 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);
|
||||||
|
)
|
||||||
88
native-sidebar/ios/Plugin/NativeSidebarPlugin.swift
Normal file
88
native-sidebar/ios/Plugin/NativeSidebarPlugin.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
native-sidebar/package.json
Normal file
31
native-sidebar/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@capacitor/push-notifications": "^7.0.6",
|
"@capacitor/push-notifications": "^7.0.6",
|
||||||
"@capacitor/splash-screen": "^7.0.3",
|
"@capacitor/splash-screen": "^7.0.3",
|
||||||
"@forum/capacitor-native-input": "file:native-input",
|
"@forum/capacitor-native-input": "file:native-input",
|
||||||
|
"@forum/capacitor-native-sidebar": "file:native-sidebar",
|
||||||
"@notnotsamuel/capacitor-swipe-back": "^3.0.2"
|
"@notnotsamuel/capacitor-swipe-back": "^3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Geolocation } from '@capacitor/geolocation';
|
|||||||
import { SplashScreen } from '@capacitor/splash-screen';
|
import { SplashScreen } from '@capacitor/splash-screen';
|
||||||
import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back';
|
import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back';
|
||||||
import { NativeInput } from '@forum/capacitor-native-input';
|
import { NativeInput } from '@forum/capacitor-native-input';
|
||||||
|
import { NativeSidebar } from '@forum/capacitor-native-sidebar';
|
||||||
|
|
||||||
window.capacitor = {
|
window.capacitor = {
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -21,7 +22,8 @@ window.capacitor = {
|
|||||||
Geolocation,
|
Geolocation,
|
||||||
SplashScreen,
|
SplashScreen,
|
||||||
CapacitorSwipeBackPlugin,
|
CapacitorSwipeBackPlugin,
|
||||||
NativeInput
|
NativeInput,
|
||||||
|
NativeSidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
window.mobileUtil = class mobileUtil {
|
window.mobileUtil = class mobileUtil {
|
||||||
|
|||||||
Reference in New Issue
Block a user