Browse Source

wip: native bottom sheet

Tienson Qin 1 week ago
parent
commit
fbd456476d

+ 4 - 0
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 = "<group>"; };
                 7435D10E2704660B00AB88E0 /* FolderPicker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FolderPicker.m; sourceTree = "<group>"; };
                 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 = "<group>"; };
+                D3F4A5B62F1234567890ABCD /* NativeBottomSheetPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBottomSheetPlugin.swift; sourceTree = "<group>"; };
                 A1B2C3D41E2F3A4B5C6D7E8F /* NativePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePageViewController.swift; sourceTree = "<group>"; };
                 A1B2C3D41E2F3A4B5C6D7E91 /* SharedWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWebViewController.swift; sourceTree = "<group>"; };
                 ABCDEF0123456789000000AA /* NativeTopBarPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTopBarPlugin.swift; sourceTree = "<group>"; };
@@ -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 */,

+ 1 - 0
ios/App/App/AppViewController.swift

@@ -13,5 +13,6 @@ import Capacitor
     bridge?.registerPluginInstance(UILocalPlugin())
     bridge?.registerPluginInstance(NativeTopBarPlugin())
     bridge?.registerPluginInstance(LiquidTabsPlugin())
+    bridge?.registerPluginInstance(NativeBottomSheetPlugin())
   }
 }

+ 188 - 0
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?()
+    }
+}

+ 22 - 0
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 }

+ 2 - 0
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 []

+ 12 - 8
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))]))

+ 8 - 0
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%;
+}

+ 3 - 5
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?)

+ 64 - 74
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]]))

+ 2 - 4
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))