├── .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 | 
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 |
--------------------------------------------------------------------------------