Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TabBarNavigator - present ViewController #192

Open
tonyskansf opened this issue Sep 24, 2020 · 4 comments
Open

TabBarNavigator - present ViewController #192

tonyskansf opened this issue Sep 24, 2020 · 4 comments
Assignees

Comments

@tonyskansf
Copy link

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
Copy link
Collaborator

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
    }

}

@pauljohanneskraft pauljohanneskraft self-assigned this Oct 23, 2020
@tonyskansf
Copy link
Author

👋 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
Copy link

Hi. Is there any progress on this issue?

@canbalkaya
Copy link

canbalkaya commented Jul 3, 2022

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)
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants