From fbd456476d35ef4fa1fdacc371e173bf0afccccc Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 24 Nov 2025 10:28:18 +0800 Subject: [PATCH] wip: native bottom sheet --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/AppViewController.swift | 1 + ios/App/App/NativeBottomSheetPlugin.swift | 188 ++++++++++++++++++++++ ios/App/App/SharedWebViewController.swift | 22 +++ src/main/frontend/mobile/util.cljs | 2 + src/main/mobile/components/app.cljs | 20 ++- src/main/mobile/components/app.css | 8 + src/main/mobile/components/header.cljs | 8 +- src/main/mobile/components/popup.cljs | 138 ++++++++-------- src/main/mobile/components/ui.cljs | 6 +- 10 files changed, 306 insertions(+), 91 deletions(-) create mode 100644 ios/App/App/NativeBottomSheetPlugin.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 67515d23ee..53a65f9690 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 5FFF7D7427E343FA00B00DA8 /* ShareViewController.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5FFF7D6A27E343FA00B00DA8 /* ShareViewController.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7435D10C2704659F00AB88E0 /* FolderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7435D10B2704659F00AB88E0 /* FolderPicker.swift */; }; 7435D10F2704660B00AB88E0 /* FolderPicker.m in Sources */ = {isa = PBXBuildFile; fileRef = 7435D10E2704660B00AB88E0 /* FolderPicker.m */; }; + D3F4A5B62F1234567890ABCE /* NativeBottomSheetPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */; }; A1B2C3D41E2F3A4B5C6D7E90 /* NativePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41E2F3A4B5C6D7E8F /* NativePageViewController.swift */; }; A1B2C3D41E2F3A4B5C6D7E92 /* SharedWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41E2F3A4B5C6D7E91 /* SharedWebViewController.swift */; }; ABCDEF0123456789000000AB /* NativeTopBarPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCDEF0123456789000000AA /* NativeTopBarPlugin.swift */; }; @@ -98,6 +99,7 @@ 7435D10D2704660A00AB88E0 /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = ""; }; 7435D10E2704660B00AB88E0 /* FolderPicker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FolderPicker.m; sourceTree = ""; }; 8A489CEC51E94726DDD58810 /* Pods-Logseq.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Logseq.release.xcconfig"; path = "Target Support Files/Pods-Logseq/Pods-Logseq.release.xcconfig"; sourceTree = ""; }; + D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBottomSheetPlugin.swift; sourceTree = ""; }; A1B2C3D41E2F3A4B5C6D7E8F /* NativePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePageViewController.swift; sourceTree = ""; }; A1B2C3D41E2F3A4B5C6D7E91 /* SharedWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWebViewController.swift; sourceTree = ""; }; ABCDEF0123456789000000AA /* NativeTopBarPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTopBarPlugin.swift; sourceTree = ""; }; @@ -205,6 +207,7 @@ CBF2D2E12DE95970006338BE /* AppViewController.swift */, CBF2D2D92DE83CB0006338BE /* UILocalPlugin.swift */, ABCDEF0123456789000000AA /* NativeTopBarPlugin.swift */, + D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */, 5FF86329283B5ADB0047731B /* Utils.swift */, 5FF8632B283B5BFD0047731B /* Utils.m */, D32752BF2754C5AB0039291C /* AppDebug.entitlements */, @@ -456,6 +459,7 @@ D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */, CBF2D2DA2DE83CB8006338BE /* UILocalPlugin.swift in Sources */, ABCDEF0123456789000000AB /* NativeTopBarPlugin.swift in Sources */, + D3F4A5B62F1234567890ABCE /* NativeBottomSheetPlugin.swift in Sources */, A1B2C3D41E2F3A4B5C6D7E90 /* NativePageViewController.swift in Sources */, A1B2C3D41E2F3A4B5C6D7E92 /* SharedWebViewController.swift in Sources */, D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */, diff --git a/ios/App/App/AppViewController.swift b/ios/App/App/AppViewController.swift index b829fb70b7..4621c44109 100644 --- a/ios/App/App/AppViewController.swift +++ b/ios/App/App/AppViewController.swift @@ -13,5 +13,6 @@ import Capacitor bridge?.registerPluginInstance(UILocalPlugin()) bridge?.registerPluginInstance(NativeTopBarPlugin()) bridge?.registerPluginInstance(LiquidTabsPlugin()) + bridge?.registerPluginInstance(NativeBottomSheetPlugin()) } } diff --git a/ios/App/App/NativeBottomSheetPlugin.swift b/ios/App/App/NativeBottomSheetPlugin.swift new file mode 100644 index 0000000000..ffbbe368f2 --- /dev/null +++ b/ios/App/App/NativeBottomSheetPlugin.swift @@ -0,0 +1,188 @@ +import Capacitor +import UIKit + +@objc(NativeBottomSheetPlugin) +public class NativeBottomSheetPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "NativeBottomSheetPlugin" + public let jsName = "NativeBottomSheetPlugin" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "present", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "dismiss", returnType: CAPPluginReturnPromise) + ] + + private weak var backgroundSnapshotView: UIView? + private weak var previousParent: UIViewController? + private var sheetController: NativeBottomSheetViewController? + + @objc func present(_ call: CAPPluginCall) { + guard #available(iOS 15.0, *) else { + call.reject("Native sheet requires iOS 15 or newer") + return + } + + DispatchQueue.main.async { + if self.sheetController != nil { + call.resolve() + return + } + + guard let host = self.bridge?.viewController?.parent else { + call.reject("Unable to locate host view controller") + return + } + + let config = NativeBottomSheetConfiguration( + defaultHeight: self.height(from: call, key: "defaultHeight"), + allowFullHeight: call.getBool("allowFullHeight") ?? true + ) + + let controller = NativeBottomSheetViewController(configuration: config) + controller.onDismiss = { [weak self] in + self?.handleSheetDismissed() + } + + self.previousParent = host + let hasSnapshot = self.showSnapshot(in: host) + SharedWebViewController.instance.attach( + to: controller, + leavePlaceholderInPreviousParent: !hasSnapshot + ) + + host.present(controller, animated: true) { + self.notifyListeners("state", data: ["presented": true]) + } + self.sheetController = controller + call.resolve() + } + } + + @objc func dismiss(_ call: CAPPluginCall) { + DispatchQueue.main.async { + guard let controller = self.sheetController else { + call.resolve() + return + } + controller.dismiss(animated: true) { + call.resolve() + } + } + } + + private func handleSheetDismissed() { + guard sheetController != nil else { return } + + DispatchQueue.main.async { + if let previous = self.previousParent { + SharedWebViewController.instance.attach(to: previous) + } + self.clearSnapshot() + SharedWebViewController.instance.clearPlaceholder() + self.sheetController = nil + self.previousParent = nil + self.notifyListeners("state", data: ["presented": false]) + } + } + + private func showSnapshot(in host: UIViewController) -> Bool { + clearSnapshot() + guard let snapshot = SharedWebViewController.instance.makeSnapshotView() else { + return false + } + snapshot.frame = host.view.bounds + snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.view.addSubview(snapshot) + backgroundSnapshotView = snapshot + return true + } + + private func clearSnapshot() { + backgroundSnapshotView?.removeFromSuperview() + backgroundSnapshotView = nil + } + + private func height(from call: CAPPluginCall, key: String) -> CGFloat? { + guard let value = call.getValue(key) else { return nil } + if let number = value as? NSNumber { + return CGFloat(truncating: number) + } + return nil + } +} + +// MARK: - View controller + configuration + +private struct NativeBottomSheetConfiguration { + let defaultHeight: CGFloat? + let allowFullHeight: Bool +} + +@available(iOS 15.0, *) +private class NativeBottomSheetViewController: UIViewController, UISheetPresentationControllerDelegate { + let configuration: NativeBottomSheetConfiguration + var onDismiss: (() -> Void)? + private var didNotifyDismiss = false + + init(configuration: NativeBottomSheetConfiguration) { + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + configureSheet() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || presentingViewController == nil { + notifyDismissed() + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + notifyDismissed() + } + + private func configureSheet() { + guard let sheet = sheetPresentationController else { return } + sheet.delegate = self + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 18 + sheet.prefersScrollingExpandsWhenScrolledToEdge = true + sheet.largestUndimmedDetentIdentifier = configuration.allowFullHeight ? .large : nil + + if let height = configuration.defaultHeight { + configureCustomDetent(sheet: sheet, height: height) + } else { + sheet.detents = [.medium(), .large()] + sheet.selectedDetentIdentifier = .medium + } + } + + private func configureCustomDetent(sheet: UISheetPresentationController, height: CGFloat) { + if #available(iOS 16.0, *) { + let identifier = UISheetPresentationController.Detent.Identifier("logseq.custom") + let custom = UISheetPresentationController.Detent.custom(identifier: identifier) { _ in + height + } + sheet.detents = configuration.allowFullHeight ? [custom, .large()] : [custom] + sheet.selectedDetentIdentifier = identifier + } else { + sheet.detents = [.medium(), .large()] + let threshold = UIScreen.main.bounds.height * 0.65 + sheet.selectedDetentIdentifier = height >= threshold ? .large : .medium + } + } + + private func notifyDismissed() { + guard !didNotifyDismiss else { return } + didNotifyDismiss = true + onDismiss?() + } +} diff --git a/ios/App/App/SharedWebViewController.swift b/ios/App/App/SharedWebViewController.swift index 065a8ec7e9..f5bfe3b0f7 100644 --- a/ios/App/App/SharedWebViewController.swift +++ b/ios/App/App/SharedWebViewController.swift @@ -58,6 +58,28 @@ import Capacitor placeholderView = nil } + func makeSnapshotView() -> UIView? { + let vc = bridgeController + let bounds = vc.view.bounds + guard bounds.width > 0, bounds.height > 0 else { return nil } + + if let snapshotView = vc.view.snapshotView(afterScreenUpdates: true) { + snapshotView.frame = bounds + snapshotView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + return snapshotView + } + + let renderer = UIGraphicsImageRenderer(bounds: bounds) + let image = renderer.image { _ in + vc.view.drawHierarchy(in: bounds, afterScreenUpdates: true) + } + let imageView = UIImageView(image: image) + imageView.frame = bounds + imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + imageView.contentMode = .scaleToFill + return imageView + } + func storeSnapshot(for parent: UIViewController) { let vc = bridgeController guard currentParent === parent else { return } diff --git a/src/main/frontend/mobile/util.cljs b/src/main/frontend/mobile/util.cljs index bd9545cdff..073b6a8361 100644 --- a/src/main/frontend/mobile/util.cljs +++ b/src/main/frontend/mobile/util.cljs @@ -24,9 +24,11 @@ (defonce folder-picker (registerPlugin "FolderPicker")) (defonce ui-local (registerPlugin "UILocal")) (defonce native-top-bar nil) +(defonce native-bottom-sheet nil) (defonce ios-utils nil) (when (native-ios?) (set! native-top-bar (registerPlugin "NativeTopBarPlugin")) + (set! native-bottom-sheet (registerPlugin "NativeBottomSheetPlugin")) (set! ios-utils (registerPlugin "Utils"))) (defn hide-splash [] diff --git a/src/main/mobile/components/app.cljs b/src/main/mobile/components/app.cljs index ed386cc36d..a20e637108 100644 --- a/src/main/mobile/components/app.cljs +++ b/src/main/mobile/components/app.cljs @@ -152,17 +152,21 @@ (shui-toaster/install-toaster) (shui-dialog/install-modals) - (shui-popup/install-popups) - (popup/popup)])) + (shui-popup/install-popups)])) (rum/defc main < rum/reactive [] (let [current-repo (state/sub :git/current-repo) login? (and (state/sub :auth/id-token) (user-handler/logged-in?)) - show-action-bar? (state/sub :mobile/show-action-bar?)] - [:<> - (app current-repo {:login? login?}) - (editor-toolbar/mobile-bar) - (when show-action-bar? - (selection-toolbar/action-bar))])) + show-action-bar? (state/sub :mobile/show-action-bar?) + {:keys [open? content-fn opts]} (rum/react mobile-state/*popup-data) + show-popup? (and open? content-fn)] + [:div.app-main.w-full.h-full + [:div.flex.flex-col.flex-1.w-full.h-full {:class (when show-popup? "hidden")} + (app current-repo {:login? login?}) + (editor-toolbar/mobile-bar) + (when show-action-bar? + (selection-toolbar/action-bar))] + (when show-popup? + (popup/popup opts content-fn))])) diff --git a/src/main/mobile/components/app.css b/src/main/mobile/components/app.css index 9cb147accc..41988eb72b 100644 --- a/src/main/mobile/components/app.css +++ b/src/main/mobile/components/app.css @@ -564,3 +564,11 @@ body, #root { -webkit-overflow-scrolling: touch; padding-bottom: 48px; } + +.cp__select-main { + width: 100%; +} + +.cp__select { + --palettle-container-height: 100%; +} diff --git a/src/main/mobile/components/header.cljs b/src/main/mobile/components/header.cljs index cc74f5d09a..3fdd48428c 100644 --- a/src/main/mobile/components/header.cljs +++ b/src/main/mobile/components/header.cljs @@ -97,7 +97,7 @@ (defn- open-settings-actions! [] (ui-component/open-popup! (fn [] - [:div.-mx-2 + [:div (when (user-handler/logged-in?) (ui/menu-link {:on-click #(user-handler/logout)} [:span.text-lg.flex.gap-2.items-center.text-red-700 @@ -115,8 +115,7 @@ [:span.text-lg.flex.gap-2.items-center "Check log"])]) {:title "Actions" - :default-height false - :type :action-sheet})) + :default-height false})) (defn- open-graph-switcher! [] (ui-component/open-popup! @@ -124,8 +123,7 @@ [:div.px-1 (repo/repos-dropdown-content {})]) {:title "Select a Graph" - :default-height false - :type :action-sheet})) + :default-height false})) (defn- register-native-top-bar-events! [] (when (and (mobile-util/native-ios?) diff --git a/src/main/mobile/components/popup.cljs b/src/main/mobile/components/popup.cljs index d24936d9b2..370aa50ab2 100644 --- a/src/main/mobile/components/popup.cljs +++ b/src/main/mobile/components/popup.cljs @@ -1,18 +1,53 @@ (ns mobile.components.popup "Mobile popup" (:require [frontend.handler.editor :as editor-handler] + [frontend.mobile.util :as mobile-util] [frontend.state :as state] [frontend.ui :as ui] - [goog.object :as gobj] [logseq.shui.popup.core :as shui-popup] - [logseq.shui.silkhq :as silkhq] [logseq.shui.ui :as shui] - [mobile.bottom-tabs :as bottom-tabs] [mobile.state :as mobile-state] [rum.core :as rum])) (defonce *last-popup-modal? (atom nil)) +(defn- popup-min-height + [default-height] + (cond + (false? default-height) nil + (number? default-height) default-height + :else 400)) + +(defn- present-native-sheet! + [opts] + (when-let [plugin mobile-util/native-bottom-sheet] + (.present + plugin + (clj->js + (let [height (popup-min-height (:default-height opts))] + (cond-> {:allowFullHeight (not= (:type opts) :action-sheet)} + height (assoc :defaultHeight height))))))) + +(defn- dismiss-native-sheet! + [] + (when-let [plugin mobile-util/native-bottom-sheet] + (.dismiss plugin #js {}))) + +(defn- handle-native-sheet-state! + [^js data] + (let [presented? (.-presented data)] + (if presented? + (when (mobile-state/quick-add-open?) + (editor-handler/quick-add-open-last-block!)) + (when (some? @mobile-state/*popup-data) + (state/pub-event! [:mobile/clear-edit]) + (mobile-state/set-popup! nil))))) + +(defonce native-sheet-listener + (when (mobile-util/native-ios?) + (when-let [plugin mobile-util/native-bottom-sheet] + (.addListener plugin "state" handle-native-sheet-state!)))) + (defn wrap-calc-commands-popup-side [pos opts] (let [[side mh] (let [[_x y _ height] pos @@ -28,7 +63,7 @@ (assoc-in [:content-props :side] side)))) (defn popup-show! - [event content-fn {:keys [id dropdown-menu?] :as opts}] + [event content-fn {:keys [id] :as opts}] (cond (and (keyword? id) (= "editor.commands" (namespace id))) (let [opts (wrap-calc-commands-popup-side event opts) @@ -39,94 +74,49 @@ pid (shui-popup/show! event content-fn opts)] (reset! *last-popup-modal? false) pid) - dropdown-menu? - (let [pid (shui-popup/show! event content-fn opts)] - (reset! *last-popup-modal? false) pid) - :else (when content-fn (mobile-state/set-popup! {:open? true :content-fn content-fn :opts opts}) - (reset! *last-popup-modal? true)))) + (reset! *last-popup-modal? true) + (when (mobile-util/native-ios?) + (present-native-sheet! opts))))) (defn popup-hide! [& args] (cond (= :download-rtc-graph (first args)) (do + (when (mobile-util/native-ios?) + (dismiss-native-sheet!)) (mobile-state/set-popup! nil) (mobile-state/redirect-to-tab! "home")) :else (if (and @*last-popup-modal? (not (= (first args) :editor.commands/commands))) - (mobile-state/set-popup! nil) + (if (mobile-util/native-ios?) + (dismiss-native-sheet!) + (mobile-state/set-popup! nil)) (apply shui-popup/hide! args)))) (set! shui/popup-show! popup-show!) (set! shui/popup-hide! popup-hide!) -(rum/defc popup < rum/reactive - [] - (let [{:keys [open? content-fn opts]} (rum/react mobile-state/*popup-data) - quick-add? (= :ls-quick-add (:id opts)) - audio-record? (= :ls-audio-record (:id opts)) - action-sheet? (= :action-sheet (:type opts)) - default-height (:default-height opts)] - - (when open? - (bottom-tabs/hide!) - (silkhq/bottom-sheet - (merge - {:presented (boolean open?) - :onPresentedChange (fn [v?] - (when (false? v?) - (state/pub-event! [:mobile/clear-edit]) - ;; allows closing animation - (js/setTimeout #(do - (mobile-state/set-popup! nil) - (bottom-tabs/show!)) 150)))} - (:modal-props opts)) - (silkhq/bottom-sheet-portal - (silkhq/bottom-sheet-view - {:class (str "app-silk-popup-sheet-view as-" (name (or (:type opts) "default"))) - :inertOutside false - :onTravelStatusChange (fn [status] - (when (and quick-add? (= status "entering")) - (editor-handler/quick-add-open-last-block!))) - :onPresentAutoFocus #js {:focus false}} - (silkhq/bottom-sheet-backdrop - (when (or quick-add? audio-record?) - {:travelAnimation {:opacity (fn [data] - (let [progress (gobj/get data "progress")] - (js/Math.min (* progress 0.9) 0.9)))}})) - (silkhq/bottom-sheet-content - {:class "flex flex-col items-center p-2"} - (silkhq/bottom-sheet-handle) - (silkhq/scroll - {:as-child true} - (silkhq/scroll-view - {:class "app-silk-scroll-view overflow-y-scroll" - :scrollGestureTrap {:yEnd true} - :style {:min-height (cond - (false? default-height) - nil - (number? default-height) - default-height - :else - 400) - :max-height "80vh"}} - (silkhq/scroll-content - (let [title (or (:title opts) (when (string? content-fn) content-fn)) - content (if (fn? content-fn) - (content-fn) - (if-let [buttons (and action-sheet? (:buttons opts))] - [:div.-mx-2 - (for [{:keys [role text]} buttons] - (ui/menu-link {:on-click #(some-> (:on-action opts) (apply [{:role role}])) - :data-role role} - [:span.text-lg.flex.items-center text]))] - (when-not (string? content-fn) content-fn)))] - [:div.w-full.app-silk-popup-content-inner.p-2 - (when title [:h2.py-2.opacity-40 title]) - content]))))))))))) +(rum/defc popup + [opts content-fn] + (let [title (or (:title opts) (when (string? content-fn) content-fn)) + content (if (fn? content-fn) + (content-fn) + (if-let [buttons (:buttons opts)] + [:div.-mx-2 + (for [{:keys [role text]} buttons] + (ui/menu-link + {:on-click #(some-> (:on-action opts) (apply [{:role role}])) + :data-role role} + [:span.text-lg.flex.items-center text]))] + (when-not (string? content-fn) content-fn)))] + [:div {:class "flex flex-col items-center p-2 w-full h-full"} + [:div.app-silk-popup-content-inner.w-full.h-full + (when title [:h2.py-2.opacity-40 title]) + content]])) diff --git a/src/main/mobile/components/ui.cljs b/src/main/mobile/components/ui.cljs index 573d3926a9..17df2676a3 100644 --- a/src/main/mobile/components/ui.cljs +++ b/src/main/mobile/components/ui.cljs @@ -4,6 +4,7 @@ [frontend.rum :as r] [frontend.state :as state] [logseq.shui.ui :as shui] + [mobile.components.popup :as popup] [mobile.state :as mobile-state] [react-transition-group :refer [CSSTransition TransitionGroup]] [rum.core :as rum])) @@ -111,7 +112,4 @@ (defn open-popup! [content-fn opts] - (mobile-state/set-popup! - {:open? true - :content-fn content-fn - :opts opts})) + (popup/popup-show! nil content-fn opts))