├── .github └── example.gif ├── .gitignore ├── Example.swiftpm ├── .swiftpm │ └── xcode │ │ └── package.xcworkspace │ │ └── contents.xcworkspacedata ├── App.swift ├── ExampleSideMenuViewController.swift ├── Package.swift └── TableViewController.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── DrawerPresentation │ ├── CancellableGestureWeakBox.swift │ ├── DimmingView.swift │ ├── DrawerInteractionDelegate.swift │ ├── DrawerTransitionAnimator.swift │ ├── DrawerTransitionController.swift │ ├── DrawerTransitionInteraction.swift │ └── TapActionInteraction.swift └── Tests └── DrawerPresentationTests └── CancellableGestureWeakBoxTests.swift /.github/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noppefoxwolf/DrawerPresentation/1755935911118930f71e5cb433eeb08771b8618d/.github/example.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Example.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example.swiftpm/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import DrawerPresentation 3 | 4 | @main 5 | struct App: SwiftUI.App { 6 | var body: some Scene { 7 | WindowGroup { 8 | ContentView() 9 | } 10 | } 11 | } 12 | 13 | struct ContentView: UIViewControllerRepresentable { 14 | func makeUIViewController(context: Context) -> some UIViewController { 15 | UINavigationController(rootViewController: TableViewController(style: .plain)) 16 | } 17 | 18 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { 19 | 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Example.swiftpm/ExampleSideMenuViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @MainActor 4 | protocol ExampleSideMenuViewControllerDelegate: AnyObject { 5 | func exampleSideMenuViewControllerDidSelect(_ viewController: ExampleSideMenuViewController) 6 | } 7 | 8 | final class ExampleSideMenuViewController: UIViewController { 9 | let label: UILabel = UILabel() 10 | let button: UIButton = UIButton(configuration: .filled()) 11 | weak var delegate: (any ExampleSideMenuViewControllerDelegate)? = nil 12 | 13 | override func viewDidLoad() { 14 | super.viewDidLoad() 15 | view.backgroundColor = .systemBackground 16 | 17 | label.text = "Hello, World!" 18 | button.configuration?.title = "Button" 19 | 20 | let stackView = UIStackView( 21 | arrangedSubviews: [ 22 | label, 23 | button 24 | ] 25 | ) 26 | stackView.axis = .vertical 27 | view.addSubview(stackView) 28 | stackView.translatesAutoresizingMaskIntoConstraints = false 29 | NSLayoutConstraint.activate([ 30 | stackView.centerYAnchor.constraint( 31 | equalTo: view.centerYAnchor 32 | ), 33 | stackView.leadingAnchor.constraint( 34 | equalTo: view.safeAreaLayoutGuide.leadingAnchor, 35 | constant: 20 36 | ), 37 | view.trailingAnchor.constraint( 38 | equalTo: stackView.safeAreaLayoutGuide.trailingAnchor, 39 | constant: 20 40 | ), 41 | ]) 42 | 43 | button.addAction(UIAction { [unowned self] _ in 44 | delegate?.exampleSideMenuViewControllerDidSelect(self) 45 | }, for: .primaryActionTriggered) 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /Example.swiftpm/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | import AppleProductTypes 5 | 6 | let package = Package( 7 | name: "Playground", 8 | platforms: [ 9 | .iOS(.v16) 10 | ], 11 | products: [ 12 | .iOSApplication( 13 | name: "Playground", 14 | targets: ["AppModule"], 15 | bundleIdentifier: "D7E9E73B-EC8A-4C1D-9264-C96BB5A4ABFE", 16 | teamIdentifier: "", 17 | displayVersion: "1.0", 18 | bundleVersion: "1", 19 | supportedDeviceFamilies: [ 20 | .pad, 21 | .phone 22 | ], 23 | supportedInterfaceOrientations: [ 24 | .portrait, 25 | .landscapeRight, 26 | .landscapeLeft, 27 | .portraitUpsideDown(.when(deviceFamilies: [.pad])) 28 | ] 29 | ) 30 | ], 31 | 32 | dependencies: [ 33 | .package(path: "../") 34 | ], 35 | 36 | targets: [ 37 | .executableTarget( 38 | name: "AppModule", 39 | 40 | dependencies: [ 41 | .product( 42 | name: "DrawerPresentation", 43 | package: "DrawerPresentation" 44 | ) 45 | ], 46 | 47 | path: "." 48 | ) 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /Example.swiftpm/TableViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import DrawerPresentation 4 | 5 | enum Section: Int { 6 | case items 7 | } 8 | 9 | struct Item: Hashable { 10 | let id: UUID = UUID() 11 | } 12 | 13 | final class TableViewController: UITableViewController, ExampleSideMenuViewControllerDelegate { 14 | lazy var dataSource = UITableViewDiffableDataSource( 15 | tableView: tableView, 16 | cellProvider: { [unowned self] (tableView, indexPath, item) in 17 | let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) 18 | cell.contentConfiguration = UIHostingConfiguration(content: { 19 | Text("Hello, World!") 20 | }) 21 | return cell 22 | } 23 | ) 24 | 25 | var snapshot = NSDiffableDataSourceSnapshot() 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") 31 | _ = dataSource 32 | 33 | snapshot.appendSections([.items]) 34 | snapshot.appendItems((0..<100).map({ _ in Item() }), toSection: .items) 35 | 36 | dataSource.apply(snapshot) 37 | 38 | let interaction = DrawerInteraction(delegate: self) 39 | navigationController!.view.addInteraction(interaction) 40 | 41 | navigationItem.leftBarButtonItems = [ 42 | UIBarButtonItem( 43 | image: UIImage(systemName: "line.3.horizontal"), 44 | primaryAction: UIAction { _ in 45 | interaction.present() 46 | } 47 | ) 48 | ] 49 | 50 | navigationItem.rightBarButtonItems = [ 51 | UIBarButtonItem( 52 | systemItem: .search, 53 | primaryAction: UIAction { [unowned self] _ in 54 | presentDrawerManually() 55 | } 56 | ), 57 | ] 58 | } 59 | 60 | let manualTransitionDelegate = DrawerTransitionController(drawerWidth: 300) 61 | func presentDrawerManually() { 62 | let vc = UIHostingController(rootView: Text("Hello, World!!")) 63 | vc.modalPresentationStyle = .custom 64 | vc.transitioningDelegate = manualTransitionDelegate 65 | present(vc, animated: true) 66 | } 67 | 68 | func exampleSideMenuViewControllerDidSelect(_ viewController: ExampleSideMenuViewController) { 69 | viewController.dismiss(animated: true) 70 | 71 | let vc = UIHostingController(rootView: Text("Child View")) 72 | navigationController?.pushViewController(vc, animated: true) 73 | } 74 | } 75 | 76 | 77 | extension TableViewController: DrawerInteractionDelegate { 78 | func viewController(for interaction: DrawerInteraction) -> UIViewController { 79 | navigationController! 80 | } 81 | 82 | func drawerInteraction(_ interaction: DrawerInteraction, widthForDrawer drawerViewController: UIViewController) -> CGFloat { 83 | 300 84 | } 85 | 86 | func drawerInteraction(_ interaction: DrawerInteraction, presentingViewControllerFor viewController: UIViewController) -> UIViewController? { 87 | let vc = ExampleSideMenuViewController() 88 | vc.delegate = self 89 | return vc 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 noppe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DrawerPresentation", 8 | platforms: [.iOS(.v16), .visionOS(.v1)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "DrawerPresentation", 13 | targets: ["DrawerPresentation"]), 14 | ], 15 | targets: [ 16 | // Targets are the basic building blocks of a package, defining a module or a test suite. 17 | // Targets can depend on other targets in this package and products from dependencies. 18 | .target( 19 | name: "DrawerPresentation"), 20 | .testTarget( 21 | name: "DrawerPresentationTests", 22 | dependencies: ["DrawerPresentation"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DrawerPresentation 2 | 3 | DrawerPresentation is a library that provides a customizable drawer presentation style for iOS applications. 4 | 5 | ![](https://github.com/noppefoxwolf/DrawerPresentation/blob/main/.github/example.gif) 6 | 7 | ## Installation 8 | 9 | ``` 10 | .target( 11 | name: "YourProject", 12 | dependencies: [ 13 | .package(url: "https://github.com/noppefoxwolf/DrawerPresentation", from: "1.0.0") 14 | ] 15 | ) 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```swift 21 | 22 | // Add Interaction 23 | let interaction = DrawerInteraction(delegate: self) 24 | view.addInteraction(interaction) 25 | 26 | // Delegate Example 27 | extension ViewController: DrawerInteractionDelegate { 28 | func viewController(for interaction: DrawerInteraction) -> UIViewController { 29 | self 30 | } 31 | 32 | func drawerInteraction(_ interaction: DrawerInteraction, widthForDrawer drawerViewController: UIViewController) -> CGFloat { 33 | 300 34 | } 35 | 36 | func drawerInteraction(_ interaction: DrawerInteraction, presentingViewControllerFor viewController: UIViewController) -> UIViewController? { 37 | UIHostingController(rootView: Text("Interactive side menu")) 38 | } 39 | } 40 | 41 | // Perform interaction manually 42 | interaction.present() 43 | 44 | // Using transitioningDelegate directly 45 | self.transitionController = DrawerTransitionController(drawerWidth: 300) 46 | let vc = UIHostingController(rootView: Text("Hello, World!!")) 47 | vc.modalPresentationStyle = .custom 48 | vc.transitioningDelegate = transitionController 49 | present(vc, animated: true) 50 | ``` 51 | 52 | ## Contributing 53 | 54 | Let people know how they can contribute into your project. A contributing guideline will be a big plus. 55 | 56 | ## Apps Using 57 | 58 |

59 | 60 |

61 | 62 | ## License 63 | 64 | This project is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for details. 65 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/CancellableGestureWeakBox.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class CancellableGestureWeakBox { 4 | weak var gestureRecognizer: UIGestureRecognizer? = nil 5 | 6 | init(_ gestureRecognizer: UIGestureRecognizer) { 7 | self.gestureRecognizer = gestureRecognizer 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/DimmingView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Combine 3 | 4 | final class DimmingView: UIView { 5 | override init(frame: CGRect) { 6 | super.init(frame: frame) 7 | backgroundColor = UIColor.black.withAlphaComponent(0.5) 8 | } 9 | 10 | required init?(coder: NSCoder) { 11 | fatalError("init(coder:) has not been implemented") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/DrawerInteractionDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @MainActor 4 | public protocol DrawerInteractionDelegate: AnyObject, Sendable { 5 | func drawerInteraction(_ interaction: DrawerInteraction, widthForDrawer drawerViewController: UIViewController) -> CGFloat 6 | func drawerInteraction(_ interaction: DrawerInteraction, presentingViewControllerFor viewController: UIViewController) -> UIViewController? 7 | func viewController(for interaction: DrawerInteraction) -> UIViewController 8 | } 9 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/DrawerTransitionAnimator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @MainActor 4 | final class DrawerTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { 5 | let drawerWidth: Double 6 | var isPresenting: Bool = true 7 | let dimmingView = DimmingView() 8 | let dismissPanGesture = UIPanGestureRecognizer() 9 | 10 | var dimmingTapInteraction: TapActionInteraction? { 11 | didSet { 12 | if let oldValue { 13 | dimmingView.removeInteraction(oldValue) 14 | } 15 | if let interaction = dimmingTapInteraction { 16 | dimmingView.addInteraction(interaction) 17 | } 18 | } 19 | } 20 | 21 | var onDismissGesture: ((_ dismissPanGesture: UIPanGestureRecognizer, _ drawerWidth: CGFloat) -> Void)? = nil 22 | 23 | init(drawerWidth: CGFloat) { 24 | self.drawerWidth = drawerWidth 25 | super.init() 26 | dismissPanGesture.addTarget(self, action: #selector(onDismissPan)) 27 | } 28 | 29 | deinit { 30 | // https://forums.swift.org/t/cleaning-up-in-deinit-with-self-and-complete-concurrency-checking/70012/3 31 | MainActor.assumeIsolated { 32 | dismissPanGesture.removeTarget(self, action: #selector(onDismissPan)) 33 | } 34 | } 35 | 36 | @objc func onDismissPan(_ dismissPanGesture: UIPanGestureRecognizer) { 37 | onDismissGesture?(dismissPanGesture, drawerWidth) 38 | } 39 | 40 | func transitionDuration( 41 | using transitionContext: (any UIViewControllerContextTransitioning)? 42 | ) -> TimeInterval { 43 | CATransaction.animationDuration() 44 | } 45 | 46 | func animateTransition( 47 | using transitionContext: any UIViewControllerContextTransitioning 48 | ) { 49 | if isPresenting { 50 | animatePresentTransition(using: transitionContext) 51 | } else { 52 | dismissPresentTransition(using: transitionContext) 53 | } 54 | } 55 | 56 | func animatePresentTransition( 57 | using transitionContext: any UIViewControllerContextTransitioning 58 | ) { 59 | let fromView = transitionContext.viewController(forKey: .from)?.view 60 | let toView = transitionContext.viewController(forKey: .to)?.view 61 | 62 | guard let fromView, let toView else { return } 63 | 64 | transitionContext.containerView.addSubview(dimmingView) 65 | dimmingView.translatesAutoresizingMaskIntoConstraints = false 66 | NSLayoutConstraint.activate([ 67 | dimmingView.topAnchor.constraint(equalTo: transitionContext.containerView.topAnchor), 68 | transitionContext.containerView.bottomAnchor.constraint(equalTo: dimmingView.bottomAnchor), 69 | dimmingView.leadingAnchor.constraint(equalTo: transitionContext.containerView.leadingAnchor), 70 | transitionContext.containerView.trailingAnchor.constraint(equalTo: dimmingView.trailingAnchor), 71 | ]) 72 | 73 | transitionContext.containerView.addSubview(toView) 74 | toView.translatesAutoresizingMaskIntoConstraints = false 75 | NSLayoutConstraint.activate([ 76 | toView.leftAnchor.constraint(equalTo: transitionContext.containerView.leftAnchor), 77 | toView.topAnchor.constraint(equalTo: transitionContext.containerView.topAnchor), 78 | toView.bottomAnchor.constraint(equalTo: transitionContext.containerView.bottomAnchor), 79 | toView.widthAnchor.constraint(equalToConstant: drawerWidth) 80 | ]) 81 | toView.transform = CGAffineTransform(translationX: -drawerWidth, y: 0) 82 | dimmingView.alpha = 0 83 | 84 | UIView.animate( 85 | withDuration: transitionDuration(using: transitionContext), 86 | delay: 0, 87 | options: .curveEaseOut, 88 | animations: { [dimmingView, drawerWidth] in 89 | dimmingView.alpha = 1 90 | toView.transform = .identity 91 | // workaround: view.transform hangs SwiftUI gesture. use layer.transform instead view.transform. 92 | fromView.layer.transform = CATransform3DMakeTranslation(drawerWidth, 0, 0) 93 | }, 94 | completion: { [dimmingView, dismissPanGesture] _ in 95 | if transitionContext.transitionWasCancelled { 96 | dimmingView.removeFromSuperview() 97 | toView.removeFromSuperview() 98 | } else { 99 | transitionContext.containerView.addGestureRecognizer(dismissPanGesture) 100 | } 101 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 102 | } 103 | ) 104 | } 105 | 106 | func dismissPresentTransition( 107 | using transitionContext: any UIViewControllerContextTransitioning 108 | ) { 109 | let fromView = transitionContext.viewController(forKey: .from)?.view 110 | let toView = transitionContext.viewController(forKey: .to)?.view 111 | 112 | guard let fromView, let toView else { return } 113 | 114 | UIView.animate( 115 | withDuration: transitionDuration(using: transitionContext), 116 | delay: 0, 117 | options: .curveEaseOut, 118 | animations: { [dimmingView, drawerWidth] in 119 | dimmingView.alpha = 0 120 | toView.transform = .identity 121 | // workaround: view.transform hangs SwiftUI gesture. use layer.transform instead view.transform. 122 | fromView.layer.transform = CATransform3DMakeTranslation(-drawerWidth, 0, 0) 123 | }, 124 | completion: { [dimmingView, dismissPanGesture] _ in 125 | if transitionContext.transitionWasCancelled { 126 | } else { 127 | fromView.removeFromSuperview() 128 | dimmingView.removeFromSuperview() 129 | transitionContext.containerView.removeGestureRecognizer(dismissPanGesture) 130 | } 131 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 132 | } 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/DrawerTransitionController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class DrawerTransitionController: NSObject, UIViewControllerTransitioningDelegate { 4 | let drawerWidth: CGFloat 5 | var animator: DrawerTransitionAnimator? = nil 6 | var interactiveTransition: UIPercentDrivenInteractiveTransition? = nil 7 | 8 | public init(drawerWidth: CGFloat) { 9 | self.drawerWidth = drawerWidth 10 | } 11 | 12 | public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? { 13 | let animator = DrawerTransitionAnimator(drawerWidth: drawerWidth) 14 | animator.dimmingTapInteraction = TapActionInteraction(action: { [weak presented] in 15 | presented?.dismiss(animated: true) 16 | }) 17 | animator.onDismissGesture = { [weak presented] (gesture, drawerWidth) in 18 | switch gesture.state { 19 | case .began: 20 | self.interactiveTransition = UIPercentDrivenInteractiveTransition() 21 | self.interactiveTransition?.completionCurve = .linear 22 | presented?.dismiss(animated: true) 23 | case .changed: 24 | let x = gesture.translation(in: gesture.view).x 25 | let percentComplete = -min(x / drawerWidth, 0) 26 | self.interactiveTransition?.update(percentComplete) 27 | case .ended: 28 | if gesture.velocity(in: gesture.view).x < 0 { 29 | self.interactiveTransition?.finish() 30 | } else { 31 | self.interactiveTransition?.cancel() 32 | } 33 | self.interactiveTransition = nil 34 | case .cancelled: 35 | self.interactiveTransition?.cancel() 36 | self.interactiveTransition = nil 37 | default: 38 | break 39 | } 40 | } 41 | animator.isPresenting = true 42 | self.animator = animator 43 | return animator 44 | } 45 | 46 | public func interactionControllerForPresentation(using animator: any UIViewControllerAnimatedTransitioning) -> (any UIViewControllerInteractiveTransitioning)? { 47 | if animator is DrawerTransitionAnimator { 48 | return interactiveTransition 49 | } else { 50 | return nil 51 | } 52 | } 53 | 54 | public func animationController(forDismissed dismissed: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? { 55 | animator?.isPresenting = false 56 | return animator 57 | } 58 | 59 | public func interactionControllerForDismissal(using animator: any UIViewControllerAnimatedTransitioning) -> (any UIViewControllerInteractiveTransitioning)? { 60 | if animator is DrawerTransitionAnimator { 61 | return interactiveTransition 62 | } else { 63 | return nil 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/DrawerTransitionInteraction.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @MainActor 4 | open class DrawerInteraction: NSObject, UIInteraction { 5 | public weak var delegate: (any DrawerInteractionDelegate)? = nil 6 | 7 | let presentPanGesture = UIPanGestureRecognizer() 8 | let presentSwipeGesture = UISwipeGestureRecognizer() 9 | var cancellableGestures: [CancellableGestureWeakBox] = [] 10 | 11 | var transitionController: DrawerTransitionController? = nil 12 | 13 | public init(delegate: any DrawerInteractionDelegate) { 14 | self.delegate = delegate 15 | super.init() 16 | } 17 | 18 | public weak var view: UIView? = nil 19 | 20 | public func willMove(to view: UIView?) { 21 | self.view = view 22 | } 23 | 24 | public func didMove(to view: UIView?) { 25 | #if os(iOS) 26 | presentPanGesture.delegate = self 27 | presentPanGesture.addTarget(self, action: #selector(onPan)) 28 | presentPanGesture.maximumNumberOfTouches = 1 29 | view?.addGestureRecognizer(presentPanGesture) 30 | 31 | presentSwipeGesture.delegate = self 32 | presentSwipeGesture.direction = .right 33 | view?.addGestureRecognizer(presentSwipeGesture) 34 | #endif 35 | } 36 | 37 | public func present() { 38 | present(isInteractiveTransitoionEnabled: false) 39 | } 40 | 41 | private func present(isInteractiveTransitoionEnabled: Bool) { 42 | guard let parent = delegate?.viewController(for: self) else { return } 43 | guard let vc = delegate?.drawerInteraction(self, presentingViewControllerFor: parent) else { return } 44 | let drawerWidth = delegate?.drawerInteraction(self, widthForDrawer: vc) ?? 300 45 | transitionController = DrawerTransitionController(drawerWidth: drawerWidth) 46 | if isInteractiveTransitoionEnabled { 47 | transitionController?.interactiveTransition = UIPercentDrivenInteractiveTransition() 48 | } 49 | #if os(iOS) 50 | vc.modalPresentationStyle = .custom 51 | vc.transitioningDelegate = transitionController 52 | #endif 53 | if #available(iOS 17.0, *) { 54 | vc.traitOverrides.userInterfaceLevel = .elevated 55 | } 56 | parent.present(vc, animated: true) 57 | } 58 | 59 | @objc 60 | private func onPan(_ gesture: UIPanGestureRecognizer) { 61 | guard presentSwipeGesture.state == .ended else { return } 62 | switch gesture.state { 63 | case .began: 64 | break 65 | case .changed: 66 | if transitionController?.interactiveTransition == nil { 67 | present(isInteractiveTransitoionEnabled: true) 68 | transitionController?.interactiveTransition?.completionCurve = .linear 69 | transitionController?.interactiveTransition?.update(0) 70 | // delay to begin 71 | cancellableGestures.compactMap(\.gestureRecognizer).forEach { gestureRecognizer in 72 | gestureRecognizer.state = .cancelled 73 | } 74 | } else { 75 | let x = gesture.translation(in: gesture.view).x 76 | let presentedViewController = delegate?.viewController(for: self) 77 | let width = presentedViewController.map { delegate?.drawerInteraction(self, widthForDrawer: $0) }?.flatMap({ $0 }) ?? 300.0 78 | let percentComplete = max(x / width, 0) 79 | transitionController?.interactiveTransition?.update(percentComplete) 80 | } 81 | case .ended: 82 | if gesture.velocity(in: gesture.view).x > 0 { 83 | transitionController?.interactiveTransition?.finish() 84 | } else { 85 | transitionController?.interactiveTransition?.cancel() 86 | } 87 | transitionController?.interactiveTransition = nil 88 | cancellableGestures.removeAll() 89 | case .cancelled: 90 | transitionController?.interactiveTransition?.cancel() 91 | transitionController?.interactiveTransition = nil 92 | cancellableGestures.removeAll() 93 | default: 94 | break 95 | } 96 | } 97 | } 98 | 99 | extension DrawerInteraction: UIGestureRecognizerDelegate { 100 | public func gestureRecognizer( 101 | _ gestureRecognizer: UIGestureRecognizer, 102 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer 103 | ) -> Bool { 104 | // enable multiple gesture 105 | if gestureRecognizer == presentPanGesture && otherGestureRecognizer == presentSwipeGesture { 106 | return true 107 | } 108 | 109 | let scrollView = otherGestureRecognizer.view as? UIScrollView 110 | guard let scrollView else { return false } 111 | 112 | // Save gestureRecognizer reference for lazy cancel 113 | if otherGestureRecognizer.view is UIScrollView { 114 | let box = CancellableGestureWeakBox(otherGestureRecognizer) 115 | cancellableGestures.append(box) 116 | } 117 | 118 | /* Enable only on left */ 119 | 120 | // Special case 1: _UIQueuingScrollView always centered offset. 121 | if String(describing: type(of: scrollView)) == "_UIQueuingScrollView" { 122 | let isItemFit = scrollView.contentOffset.x == scrollView.bounds.width 123 | let isLeft = scrollView.adjustedContentInset.left <= 0 124 | return isItemFit && isLeft 125 | } 126 | 127 | return scrollView.contentOffset.x <= 0 128 | } 129 | 130 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 131 | let parent = delegate?.viewController(for: self) 132 | if gestureRecognizer == presentSwipeGesture { 133 | let navigating: Bool 134 | if let nc = parent as? UINavigationController { 135 | navigating = nc.viewControllers.count > 1 136 | } else if let nc = parent?.navigationController { 137 | navigating = nc.viewControllers.count > 1 138 | } else if let nc = (parent as? UITabBarController)?.selectedViewController as? UINavigationController { 139 | navigating = nc.viewControllers.count > 1 140 | } else { 141 | navigating = false 142 | } 143 | if navigating { 144 | return false 145 | } 146 | } 147 | return true 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Sources/DrawerPresentation/TapActionInteraction.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TapActionInteraction: NSObject, UIInteraction { 4 | weak var view: UIView? 5 | let action: @MainActor @Sendable () -> Void 6 | 7 | init(action: @MainActor @escaping @Sendable () -> Void) { 8 | self.action = action 9 | } 10 | 11 | func willMove(to view: UIView?) { 12 | self.view = view 13 | } 14 | 15 | func didMove(to view: UIView?) { 16 | let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onTap)) 17 | view?.addGestureRecognizer(tapGesture) 18 | } 19 | 20 | @objc 21 | func onTap(_ gesture: UITapGestureRecognizer) { 22 | guard gesture.state == .ended else { return } 23 | action() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/DrawerPresentationTests/CancellableGestureWeakBoxTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DrawerPresentation 3 | 4 | final class CancellableGestureWeakBoxTests: XCTestCase { 5 | } 6 | --------------------------------------------------------------------------------