about summary refs log tree commit diff
path: root/modules/bottom-sheet/ios
diff options
context:
space:
mode:
Diffstat (limited to 'modules/bottom-sheet/ios')
-rw-r--r--modules/bottom-sheet/ios/BottomSheet.podspec21
-rw-r--r--modules/bottom-sheet/ios/BottomSheetModule.swift47
-rw-r--r--modules/bottom-sheet/ios/SheetManager.swift28
-rw-r--r--modules/bottom-sheet/ios/SheetView.swift189
-rw-r--r--modules/bottom-sheet/ios/SheetViewController.swift76
-rw-r--r--modules/bottom-sheet/ios/Util.swift18
6 files changed, 379 insertions, 0 deletions
diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec
new file mode 100644
index 000000000..a42356f61
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheet.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+  s.name           = 'BottomSheet'
+  s.version        = '1.0.0'
+  s.summary        = 'A bottom sheet for use in Bluesky'
+  s.description    = 'A bottom sheet for use in Bluesky'
+  s.author         = ''
+  s.homepage       = 'https://github.com/bluesky-social/social-app'
+  s.platforms      = { :ios => '15.0', :tvos => '15.0' }
+  s.source         = { git: '' }
+  s.static_framework = true
+
+  s.dependency 'ExpoModulesCore'
+
+  # Swift/Objective-C compatibility
+  s.pod_target_xcconfig = {
+    'DEFINES_MODULE' => 'YES',
+    'SWIFT_COMPILATION_MODE' => 'wholemodule'
+  }
+
+  s.source_files = "**/*.{h,m,swift}"
+end
diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift
new file mode 100644
index 000000000..579608e75
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheetModule.swift
@@ -0,0 +1,47 @@
+import ExpoModulesCore
+
+public class BottomSheetModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("BottomSheet")
+
+    AsyncFunction("dismissAll") {
+      SheetManager.shared.dismissAll()
+    }
+
+    View(SheetView.self) {
+      Events([
+        "onAttemptDismiss",
+        "onSnapPointChange",
+        "onStateChange"
+      ])
+
+      AsyncFunction("dismiss") { (view: SheetView) in
+        view.dismiss()
+      }
+
+      AsyncFunction("updateLayout") { (view: SheetView) in
+        view.updateLayout()
+      }
+
+      Prop("cornerRadius") { (view: SheetView, prop: Float) in
+        view.cornerRadius = CGFloat(prop)
+      }
+
+      Prop("minHeight") { (view: SheetView, prop: Double) in
+        view.minHeight = prop
+      }
+
+      Prop("maxHeight") { (view: SheetView, prop: Double) in
+        view.maxHeight = prop
+      }
+
+      Prop("preventDismiss") { (view: SheetView, prop: Bool) in
+        view.preventDismiss = prop
+      }
+
+      Prop("preventExpansion") { (view: SheetView, prop: Bool) in
+        view.preventExpansion = prop
+      }
+    }
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetManager.swift b/modules/bottom-sheet/ios/SheetManager.swift
new file mode 100644
index 000000000..e4e843bea
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetManager.swift
@@ -0,0 +1,28 @@
+//
+//  SheetManager.swift
+//  Pods
+//
+//  Created by Hailey on 10/1/24.
+//
+
+import ExpoModulesCore
+
+class SheetManager {
+  static let shared = SheetManager()
+
+  private var sheetViews = NSHashTable<SheetView>(options: .weakMemory)
+
+  func add(_ view: SheetView) {
+    sheetViews.add(view)
+  }
+
+  func remove(_ view: SheetView) {
+    sheetViews.remove(view)
+  }
+
+  func dismissAll() {
+    sheetViews.allObjects.forEach { sheetView in
+      sheetView.dismiss()
+    }
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift
new file mode 100644
index 000000000..cf2019c6a
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetView.swift
@@ -0,0 +1,189 @@
+import ExpoModulesCore
+import UIKit
+
+class SheetView: ExpoView, UISheetPresentationControllerDelegate {
+  // Views
+  private var sheetVc: SheetViewController?
+  private var innerView: UIView?
+  private var touchHandler: RCTTouchHandler?
+
+  // Events
+  private let onAttemptDismiss = EventDispatcher()
+  private let onSnapPointChange = EventDispatcher()
+  private let onStateChange = EventDispatcher()
+
+  // Open event firing
+  private var isOpen: Bool = false {
+    didSet {
+      onStateChange([
+        "state": isOpen ? "open" : "closed"
+      ])
+    }
+  }
+
+  // React view props
+  var preventDismiss = false
+  var preventExpansion = false
+  var cornerRadius: CGFloat?
+  var minHeight = 0.0
+  var maxHeight: CGFloat! {
+    didSet {
+      let screenHeight = Util.getScreenHeight() ?? 0
+      if maxHeight > screenHeight {
+        maxHeight = screenHeight
+      }
+    }
+  }
+
+  private var isOpening = false {
+    didSet {
+      if isOpening {
+        onStateChange([
+          "state": "opening"
+        ])
+      }
+    }
+  }
+  private var isClosing = false {
+    didSet {
+      if isClosing {
+        onStateChange([
+          "state": "closing"
+        ])
+      }
+    }
+  }
+  private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
+    didSet {
+      if selectedDetentIdentifier == .large {
+        onSnapPointChange([
+          "snapPoint": 2
+        ])
+      } else {
+        onSnapPointChange([
+          "snapPoint": 1
+        ])
+      }
+    }
+  }
+
+  // MARK: - Lifecycle
+
+  required init (appContext: AppContext? = nil) {
+    super.init(appContext: appContext)
+    self.maxHeight = Util.getScreenHeight()
+    self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
+    SheetManager.shared.add(self)
+  }
+
+  deinit {
+    self.destroy()
+  }
+
+  // We don't want this view to actually get added to the tree, so we'll simply store it for adding
+  // to the SheetViewController
+  override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
+    self.touchHandler?.attach(to: subview)
+    self.innerView = subview
+  }
+
+  // We'll grab the content height from here so we know the initial detent to set
+  override func layoutSubviews() {
+    super.layoutSubviews()
+
+    guard let innerView = self.innerView else {
+      return
+    }
+
+    if innerView.subviews.count != 1 {
+      return
+    }
+
+    self.present()
+  }
+
+  private func destroy() {
+    self.isClosing = false
+    self.isOpen = false
+    self.sheetVc = nil
+    self.touchHandler?.detach(from: self.innerView)
+    self.touchHandler = nil
+    self.innerView = nil
+    SheetManager.shared.remove(self)
+  }
+
+  // MARK: - Presentation
+
+  func present() {
+    guard !self.isOpen,
+          !self.isOpening,
+          !self.isClosing,
+          let innerView = self.innerView,
+          let contentHeight = innerView.subviews.first?.frame.height,
+          let rvc = self.reactViewController() else {
+      return
+    }
+
+    let sheetVc = SheetViewController()
+    sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
+    if let sheet = sheetVc.sheetPresentationController {
+      sheet.delegate = self
+      sheet.preferredCornerRadius = self.cornerRadius
+      self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
+    }
+    sheetVc.view.addSubview(innerView)
+
+    self.sheetVc = sheetVc
+    self.isOpening = true
+
+    rvc.present(sheetVc, animated: true) { [weak self] in
+      self?.isOpening = false
+      self?.isOpen = true
+    }
+  }
+
+  func updateLayout() {
+    if let contentHeight = self.innerView?.subviews.first?.frame.size.height {
+      self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
+                               preventExpansion: self.preventExpansion)
+      self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
+    }
+  }
+
+  func dismiss() {
+    self.isClosing = true
+    self.sheetVc?.dismiss(animated: true) { [weak self] in
+      self?.destroy()
+    }
+  }
+
+  // MARK: - Utils
+
+  private func clampHeight(_ height: CGFloat) -> CGFloat {
+    if height < self.minHeight {
+      return self.minHeight
+    } else if height > self.maxHeight {
+      return self.maxHeight
+    }
+    return height
+  }
+
+  // MARK: - UISheetPresentationControllerDelegate
+
+  func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
+    self.onAttemptDismiss()
+    return !self.preventDismiss
+  }
+
+  func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
+    self.isClosing = true
+  }
+
+  func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+    self.destroy()
+  }
+
+  func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
+    self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift
new file mode 100644
index 000000000..56473b21c
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetViewController.swift
@@ -0,0 +1,76 @@
+//
+//  SheetViewController.swift
+//  Pods
+//
+//  Created by Hailey on 9/30/24.
+//
+
+import Foundation
+import UIKit
+
+class SheetViewController: UIViewController {
+  init() {
+    super.init(nibName: nil, bundle: nil)
+
+    self.modalPresentationStyle = .formSheet
+    self.isModalInPresentation = false
+
+    if let sheet = self.sheetPresentationController {
+      sheet.prefersGrabberVisible = false
+    }
+  }
+
+  func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+    guard let sheet = self.sheetPresentationController,
+          let screenHeight = Util.getScreenHeight()
+    else {
+      return
+    }
+
+    if contentHeight > screenHeight - 100 {
+      sheet.detents = [
+        .large()
+      ]
+      sheet.selectedDetentIdentifier = .large
+    } else {
+      if #available(iOS 16.0, *) {
+        sheet.detents = [
+          .custom { _ in
+            return contentHeight
+          }
+        ]
+      } else {
+        sheet.detents = [
+          .medium()
+        ]
+      }
+
+      if !preventExpansion {
+        sheet.detents.append(.large())
+      }
+      sheet.selectedDetentIdentifier = .medium
+    }
+  }
+
+  func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+    if let sheet = self.sheetPresentationController {
+      sheet.animateChanges {
+        self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
+        if #available(iOS 16.0, *) {
+          sheet.invalidateDetents()
+        }
+      }
+    }
+  }
+  
+  func getCurrentDetentIdentifier() -> UISheetPresentationController.Detent.Identifier? {
+    guard let sheet = self.sheetPresentationController else {
+      return nil
+    }
+    return sheet.selectedDetentIdentifier
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+}
diff --git a/modules/bottom-sheet/ios/Util.swift b/modules/bottom-sheet/ios/Util.swift
new file mode 100644
index 000000000..c654596a7
--- /dev/null
+++ b/modules/bottom-sheet/ios/Util.swift
@@ -0,0 +1,18 @@
+//
+//  Util.swift
+//  Pods
+//
+//  Created by Hailey on 10/2/24.
+//
+
+class Util {
+  static func getScreenHeight() -> CGFloat? {
+    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+     let window = windowScene.windows.first {
+      let safeAreaInsets = window.safeAreaInsets
+      let fullScreenHeight = UIScreen.main.bounds.height
+      return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom)
+    }
+    return nil
+  }
+}