Skip to content

Instantly share code, notes, and snippets.

@noe
Created September 30, 2020 15:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save noe/d177bb9758f047ffc11b151d36bf5b53 to your computer and use it in GitHub Desktop.
Save noe/d177bb9758f047ffc11b151d36bf5b53 to your computer and use it in GitHub Desktop.
SwiftUI component to integrate SideMenu (https://github.com/jonkykong/SideMenu) in a SwiftUI application. It hides all UIKit details.
//
// Copyright 2020 Noe Casas.
//
// MIT License:
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import SideMenu
/**
Scaffolding view to enable a side (drawer) menu.
*/
struct SideDrawer<MainContent:View, MenuContent:View>: View {
@ObservedObject private var control: SideDrawerControl
private let mainContent: () -> MainContent
private let menuContent: () -> MenuContent
private var menuController: SideMenuNavigationController?
init(control: SideDrawerControl,
@ViewBuilder main: @escaping () -> MainContent,
@ViewBuilder menu: @escaping () -> MenuContent
) {
self.control = control
self.mainContent = main
self.menuContent = menu
}
var body: some View {
self.main()
.onAppear {
configureMenu()
control.allowDragOpen(value: true)
}
}
func main() -> some View {
return self.mainContent()
}
func configureMenu() {
control.connect(controllerFactory: ControllerFactory(menuContent))
}
}
/**
Protocol to have the generic type of ControllerFactory<MenuContent> erased.
*/
fileprivate protocol IControllerFactory {
func create(disappearCallback: @escaping () -> Void) -> SideMenuNavigationController
}
/**
Factory to create SideMenuNavigationController's. We need to create a new one every time the menu is dismissed.
*/
fileprivate class ControllerFactory<MenuContent:View>: IControllerFactory {
private let menuContent: () -> MenuContent
init(_ menuContent: @escaping () -> MenuContent) {
self.menuContent = menuContent
}
func create(disappearCallback: @escaping () -> Void) -> SideMenuNavigationController {
let menuView = DrawerMenu(menuContent: self.menuContent, disappearCallback: disappearCallback)
let controller = UIHostingController(rootView: menuView)
let menuController = SideMenuNavigationController(
rootViewController: controller
)
return menuController
}
}
/**
View that is responsible for drawing the menu.
*/
fileprivate struct DrawerMenu<MenuContent:View>: View {
private let menuContent: () -> MenuContent
private let disappearCallback: () -> Void
init(
@ViewBuilder menuContent: @escaping () -> MenuContent,
disappearCallback: @escaping () -> Void
) {
self.menuContent = menuContent
self.disappearCallback = disappearCallback
}
var body: some View {
self.menuContent()
.onDisappear {
disappearCallback()
}
}
}
/**
Class to allow opening/closing the menu. The idea is to have it in the
environment so that any view can open and close the drawer menu.
*/
class SideDrawerControl: ObservableObject {
private var rootController: UIViewController? = nil
private var controllerFactory: IControllerFactory? = nil
private var menuController: SideMenuNavigationController? = nil
private var panGesture: UIPanGestureRecognizer?
private var screenPanGesture: UIScreenEdgePanGestureRecognizer?
private var isDragAllowed: Bool = false
fileprivate func connect(controllerFactory: IControllerFactory) {
self.rootController = (UIApplication.shared.windows.last?.rootViewController)!
self.controllerFactory = controllerFactory
replaceController()
}
/**
Function to work around a problem where after the side menu is dismissed,
clicks are never detected any more. We just replace the menu view controller
with a new one every time the menu is closed.
*/
private func replaceController() {
guard let controllerFactory = controllerFactory else {
return
}
let isAllowed = isDragAllowed
self.allowDragOpen(value: false)
let menuController = controllerFactory.create {
self.replaceController()
}
// Configure the side menu
menuController.presentationStyle = .menuSlideIn
menuController.leftSide = true
SideMenuManager.default.leftMenuNavigationController = menuController
menuController.statusBarEndAlpha = 0
self.menuController = menuController
self.allowDragOpen(value: isAllowed)
}
/**
Opens the menu. This can be called e.g. from a hamburger button.
*/
func open() {
guard let rootController = rootController, let menuController = menuController else {
return
}
rootController.present(menuController, animated: true, completion: nil)
}
/**
Closes the menu.
*/
func close() {
guard let menuController = menuController else {
return
}
menuController.dismiss(animated: true, completion: nil)
}
/**
Enables/disables the global pan gesture.
*/
func allowDragOpen(value: Bool) {
guard let navigationController = navigationController,
menuController != nil,
value != isDragAllowed else {
return
}
let navigation = navigationController
if value {
panGesture = SideMenuManager.default.addPanGestureToPresent(
toView: navigation.navigationBar
)
screenPanGesture = SideMenuManager.default.addScreenEdgePanGesturesToPresent(
toView: navigation.view,
forMenu: .left
)
} else {
if let panGesture = panGesture {
navigation.navigationBar.removeGestureRecognizer(panGesture)
}
if let screenPanGesture = screenPanGesture {
navigation.view.removeGestureRecognizer(screenPanGesture)
}
}
isDragAllowed = value
}
private var navigationController: UINavigationController? {
return SideDrawerControl.navigations.first(where: {
$0.value != nil
})?.value
}
static func registerNavigation(navigation: UINavigationController) {
navigations.append(Weak(navigation))
}
private static var navigations: [Weak<UINavigationController>] = []
}
/**
Container for weak references. Its purpose is to allow having an array of weak references.
*/
fileprivate class Weak<T: AnyObject> {
weak var value : T?
init (_ value: T) {
self.value = value
}
}
/**
Extension of UINavigationController that adds a reference to itself
into the static list SideDrawerControl.navigations. This is needed
because from SwiftUI it is not possible to get the UINavigationController
where to register the pan gesture.
*/
extension UINavigationController {
open override func viewDidLoad() {
if self.debugDescription.contains("UINavigationController") {
SideDrawerControl.registerNavigation(navigation: self)
}
}
}
@noe
Copy link
Author

noe commented Sep 30, 2020

To use it, implement something like this:

struct MainView: View {

    private let control = SideDrawerControl()

    var body: some View {
        SideDrawer(
            control: self.control,
            main: {
                /* Your view */
                    .environmentObject(self.control)
            },
            menu: {
                /* Here add your menu contents */
            }
        )
    }

@noe
Copy link
Author

noe commented Sep 30, 2020

Note that this solution is not problem-free. I comment on the issues this approach has in the comments of this SideMenu issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment