QuickBirdEng / XCoordinator

🎌 Powerful navigation library for iOS based on the coordinator pattern
MIT License
2.24k stars 176 forks source link

TabBarNavigator - present ViewController #192

Open tonyskansf opened 3 years ago

tonyskansf commented 3 years ago

Hi, I am trying out the XCoordinator and got this problem, which I do not know the solution to. Not sure if this is the right place to ask such question so I am sorry if this does not belong here.

I want to implement a tab bar that has one specific tab, which only presents a view controller on tap.

Code

For the sake of example the code is simplified to two tabs -- SpecialCoordinator is the one I want to present a view controller.

// AppCoordinator
class AppCoordinator: TabBarCoordinator<AppRoute> {
    convenience init() {
        let normalCoordinator = NormalCoordinator()
        normalCoordinator.rootViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .recents, tag: 0)

        let specialCoordinator = SpecialCoordinator()
        specialCoordinator.rootViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .more, tag: 1)

        self.init(normalRouter: normalCoordinator.strongRouter, specialRouter: specialCoordinator.strongRouter)
    }

    init(normalRouter: StrongRouter<NormalRoute>, specialRouter: StrongRouter<SpecialRoute>) {
         super.init(tabs: [normalRouter, specialRouter], select: normalRouter)
    }
}

I have created an extension to present a Presentable modally.

extension Transition {
    static func presentAsSheet(_ presentable: Presentable, animation: Animation? = nil) -> Transition {
        presentable.viewController?.modalPresentationStyle = .popover
        return .present(presentable, animation: animation)
    }
}

Both Normal and Special Coordinators are NavigationCoordinator<...> and the implementation of SpecialCoordinator looks like this:

enum SpecialRoute {
    case presentSheet
}

class SpecialCoordinator: NavigationCoordinator<SpecialRoute> {
    init() {
        super.init(initialRoute: .presentSheet)
    }

    override func prepareTransition(for route: SpecialRoute) -> NavigationTransition {
        switch route {
        case .presentSheet:
            let vc = UIViewController()
            vc.view.backgroundColor = .orange
            return .presentAsSheet(vc)
        }
    }
}

If I use .push(vc) the view controller gets pushed; however, if .presentAsSheet(vc) is used tabs are switched but the screen is black (nothing gets presented).

Can you please help me? Thanks.

pauljohanneskraft commented 3 years ago

Hey @tonyskansf, a coordinator is an object controlling a certain view controller. In the case of NavigationCoordinator, this is a viewcontroller of type "UINavigationController". If you use a TabBarCoordinator, the rootViewController (i.e. the one being coordinated) is a UITabBarCoordinator.

When you specify these routers in the initializer of a TabBarCoordinator, it will call the UITabBarController's setViewControllers method. So, when a tab is selected, the UITabBarController will switch the tab to that NavigationCoordinator's rootViewController, which is an empty UINavigationController (hence, the screen is black).

What should actually happen is, that whenever you create the TabBarCoordinator, it should try to present the sheet and fail, since the SpecialCoordinator's rootViewController is not yet in the view hierarchy. You can probably see something similar in the console.

A possible solution to this would be a custom UITabBarControllerDelegate implementation overriding the tabBarController(_:shouldSelect:) method and set this to your AppCoordinator's delegate property. In your custom override, you should decipher, whether the viewController to be selected is your SpecialCoordinator's rootViewController. If it is, then return false, but also figure out how to then trigger the sheet route.

I would probably do something like this:


class TabSheetViewController: UIViewController {} // This is the type of viewController to present a sheet for, instead of selecting it.

class SheetTabBarDelegate: NSObject, UITabBarControllerDelegate {

    var didSelectSheet: (UIViewController) -> Void

    init(didSelectSheet: @escaping (UIViewController) -> Void) {
        self.didSelectSheet = didSelectSheet
    }

    open func tabBarController(_ tabBarController: UITabBarController,
                                                   shouldSelect viewController: UIViewController) -> Bool {
        if viewController is TabSheetViewController {
            didSelectSheet()
            return false
        } else {
            return true
        }
    }
}

class AppCoordinator: TabBarCoordinator<AppRoute> {

   var sheetDelegateObject: SheetTabBarDelegate?

   init() {
        let normalCoordinator = NormalCoordinator()
        normalCoordinator.rootViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .recents, tag: 0)

        let sheetVC = TabSheetViewController()
        sheetVC.tabBarItem = UITabBarItem(tabBarSystemItem: .more, tag: 1)

        super.init(tabs: [normalCoordinator, sheetVC], select: normalCoordinator)
        sheetDelegateObject = SheetTabBarDelegate { [weak self] in self?.trigger(.presentSheet) }
        delegate = sheetDelegateObject
    }

}
tonyskansf commented 3 years ago

πŸ‘‹ Hi @pauljohanneskraft, thanks for your response. Your suggestion put me on a right track.

So intially I've tried to alter the code a bit. (For readability I've cut out some part of the code)

class SheetTabBarDelegate: NSObject, UITabBarControllerDelegate {
    open func tabBarController(...) -> Bool {
        if viewController is TabSheetViewController {
            didSelectSheet(viewController) // pass viewController to be presented to the delegate
            return false
        } else {
            return true
        }
    }
}

class AppCoordinator: TabBarCoordinator<AppRoute> {
   init() {
       // ...
       sheetDelegateObject = SheetTabBarDelegate { [weak self] in self?.trigger(.presentSheet($0) }
    }

    override func prepareTransition(for route: AppRoute) -> TabBarTransition {
        switch route {
        case let .presentSheet(viewController):
            return .present(viewController)
        }
    }
}

However, the application crashed whenever I tapped the bar that was supposed to present a sheet due to:

Application tried to present modally an active controller <UITabBarController: 0x106076000>

The workaround I've tried and seemed to work, but I'm not sure if it is a good practice or if the library should be used like this. I've basically created a coordinator for the view controller to be presented with two routes -- initial & sheet -- and put this coordinator in the place of the previous sheetVC. The initial route is just a dummy holding a view controller that will satisfy the if-statement in UITabBarControllerDelegate.

So instead of calling self.trigger(.presentSheet) I call coordinator.trigger(.sheet) in the delegate callback. While this solution "works" I believe it might be error prone as I got this warning instead.

Presenting view controllers on detached view controllers is discouraged <UINavigationController: 0x1088ada00>

I will keep this solution for now as this feature is not fundamental for my application. Although, if you know why this is, I'd highly appreciate your help as I already do. πŸ™

canbalkaya commented 2 years ago

Hi. Is there any progress on this issue?

canbalkaya commented 2 years ago

I found a simple solution.

The reason for the error is to use the same UIViewController inside the UITabBarController.

I create a new coordinator and UIViewController like this:

import UIKit
import XCoordinator

enum EmptyRoute: Route {
    case empty
}

class EmptyViewController: UIViewController {

    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

class EmptyCoordinator: NavigationCoordinator<EmptyRoute> {

    // MARK: - Initialization
    init() {
        super.init(initialRoute: .empty)
    }

    // MARK: - Overrides
    override func prepareTransition(for route: EmptyRoute) -> NavigationTransition {
        let viewController = EmptyViewController()
        return .push(viewController)
    }
}

Then I brought this coordinator instead of the coordinator you want to present:

convenience init() {
    let firstCoordinator = FirstCoordinator()
    firstCoordinator.rootViewController.tabBarItem = .init(title: "First", image: nil, tag: 0)

    let emptyCoordinator = EmptyCoordinator()
    emptyCoordinator.rootViewController.tabBarItem = .init(title: "Second", image: nil, tag: 0)

    let thirdCoordinator = ThirdCoordinator()
    thirdCoordinator.rootViewController.tabBarItem = .init(title: "Third", image: nil, tag: 0)

    self.init(
        firstRouter: firstCoordinator.strongRouter,
        emptyRouter: emptyCoordinator.strongRouter,
        secondRouter: thirdCoordinator.strongRouter
    )
}

I use custom tab bar delegate like this:

super.init(
    tabs: [
        firstRouter,
        emptyRouter,
        thirdRouter
    ],
    select: firstRouter
)

sheetDelegateObject = SheetTabBarDelegate { [unowned self] _ in
    self.trigger(.second)
}
delegate = sheetDelegateObject

SheetTabBarDelegate itself:

open class SheetTabBarDelegate: NSObject, UITabBarControllerDelegate {

    // MARK: - Properties
    public var didSelectSheet: (UIViewController) -> Void

    // MARK: - Initialization
    public init(didSelectSheet: @escaping (UIViewController) -> Void) {
        self.didSelectSheet = didSelectSheet
    }

    // MARK: - Methods
    open func tabBarController(
        _ tabBarController: UITabBarController,
        shouldSelect viewController: UIViewController
    ) -> Bool {
        if viewController.children.first is EmptyViewController {
            didSelectSheet(viewController)
            return false
        } else {
            return true
        }
    }
}

Finally, I use SecondCoordinator (which is the coordinator we want to present) at prepareTransition:


override func prepareTransition(for route: MainRoute) -> TabBarTransition {
    switch route {
    case .first:
        return .select(firstRouter)
    case .second:
        return .presentFullScreen(secondRouter)
    case .third:
        return .select(thirdRouter)
    }
}