ekazaev / route-composer

Protocol oriented, Cocoa UI abstractions based library that helps to handle view controllers composition, navigation and deep linking tasks in the iOS application. Can be used as the universal replacement for the Coordinator pattern.
MIT License
896 stars 63 forks source link

Setting title/navigationItem title in a UIViewController in a UITabBarController #98

Closed bennnjamin closed 1 year ago

bennnjamin commented 1 year ago

Hello, I'm having some trouble figuring out how to set the title and other content inside the navigation bar/title area of a view controller.

I have a UITabBarController and a Factory to build it. I also have a UIViewController with its own Factory. I use CompleteFactoryAssembly to add my controller to the tab bar. When I try to set the title in the Factory for the controller, it doesn't work. It only works if I set the title after the view controller has been loaded (inside viewDidLoad or viewWillAppear). This makes it difficult to set the title inside of the factory.

Is there a way for to access a parent view controller, like a UITabBarController or UINavigationController from inside the factory of a child view controller? For example, I also want to adjust the navigationItems when changing tabs in a tab bar. Some tabs might update the appearance of the navigation bar with content that is relevant to that tab. I think the correct place to do this is when building the view controller in its own Factory. Maybe I am not integrating my view controllers correctly into the tab bar.

Even in the Example project it has a similar problem, SquareViewController has a title "Square" but when you navigate to it, there is no title. CircleViewController is almost identical, but its title displays correctly.

ekazaev commented 1 year ago

Hi @bennnjamin

Thank you for the question. Yes I remember such thing. I hit it long time ago. While it didn't bother me in the Example project it did in some of the production ones. The issue itself is related to the UITabBarController implementation (incorrect in my opinion as it works fine with other containers) rather than the RouteComposer itself. But let's leave it aside. So the idea of the Factory os that it only builds the view controller and doesn't know how is it going to be used, will it be inserted into UITabBarController or some other, or eve the result will even be ignored is the action that integrates it decides so. So the answer to your question is no you cant access the parent view controller in the Factory. The core idea of the factory is to be agnostic to what is going to happen to the result. As far as I remember that placing such code in the viewDidLoad is not exactly correct as well as the container view controllers like UITabBarController do not load the invisible views of its view controllers if it is not necessary. Like if user never switched to the second tab there is to point to load the second view controller at all as user never switched to the second tab. Such philosophy even though it has sense brings some complexity. I think the .tabBarItem that you can set within the factory was designed exactly to avoid the complexity I mentioned and UITabBar will read it and adjust its tab configuration accordingly without loading the view of view controller itself. The problem is that if the view controller that is produced by your factory is wrapped into any other container view controller like UINavigationController and then added into a UITabBarController it wont read the tabBarItem of your view view controller and only read the tabBarItem configuration of the UINavigationController. As far as I recall you cant avoid this problem with or without RouteComposer anyway. I dont know if it was designed like this (I think not) or it is a lasting bug. I don't know. Such view controller composition issues is another reason why you should not access parent view controller in the factory, as even if you access parent UINaviagationController in your factory at the moment of build it may not yet be integrated into the enclosing UITabBarController. My suggestion would be to write a Generic context task attached to the UITabBarController that will look at its view controllers and will go deep into hierarchy if needed and extract the tabBarItem settings from the lowest level view controllers and set them correctly on the UITabBarController. I will come back to you later with some code. But i hope that I explained you the issue correctly and the way to resolve them correctly.

PS: Please remember that I am writing that out of my memory without checking the exact behavior in the latest versions of IOS. So feel free to correct me.

ekazaev commented 1 year ago

@bennnjamin To illustrate what I am saying about weird thing in UIKit you can modify an example like that:

    var starScreen: DestinationStep<StarViewController, Any?> {
        StepAssembly(
            finder: ClassFinder<StarViewController, Any?>(options: .currentAllStack),
            factory: ClassFactory())
            .adding(ExampleGenericContextTask<StarViewController, Any?>())
+            .using(UINavigationController.push())
+            .from(NavigationControllerStep())
            .using(UITabBarController.add())
            .from(homeScreen)
            .assemble()
    }

You will see that only title is being propagated up. My assumption is that originally it was designed that the tabBarItem and so on should also be propagated. But the fact that if everything is controlled by the RouteComposer you can do it yourself.

One of the ways to do so is to write an wrapper container action like this:

extension UIViewController {
    static func propagateTabInfo<A: ContainerAction>(_ action: A) -> PropagatingAction<A> {
        return PropagatingAction(action)
    }

    public struct PropagatingAction<A: ContainerAction>: ContainerAction where A.ViewController: ContainerViewController {

        // MARK: Associated types

        /// Type of the `UIViewController` that `Action` can start from.
        public typealias ViewController = A.ViewController

        // MARK: Properties

        let action: A

        // MARK: Methods

        init(_ action: A) {
            self.action = action
        }

        public func perform(embedding viewController: UIViewController, in childViewControllers: inout [UIViewController]) throws {
            guard let tabContainerController = findTabContainingViewController(in: viewController) else {
                return
            }
            viewController.tabBarItem.image = tabContainerController.tabBarItem.image
            viewController.tabBarItem.title = tabContainerController.tabBarItem.title

            try action.perform(embedding: viewController, in: &childViewControllers)
        }

        public func perform(with viewController: UIViewController, on existingController: A.ViewController, animated: Bool, completion: @escaping (RoutingResult) -> Void) {
            action.perform(with: viewController, on: existingController, animated: true, completion: { result in
                switch result {
                case .success:
                    guard let tabContainerController = findTabContainingViewController(in: viewController) else {
                        break
                    }
                    viewController.tabBarItem.image = tabContainerController.tabBarItem.image
                    viewController.tabBarItem.title = tabContainerController.tabBarItem.title
                case .failure:
                    break
                }
                completion(result)
            })
        }

        private func findTabContainingViewController(in viewController: UIViewController) -> UIViewController? {
            guard let tabContainingController = try? UIViewController.findViewController(in: viewController,
                    options: [.current, .contained],
                    using: { viewController in
                        return viewController.tabBarItem.image != nil && viewController.tabBarItem.title != nil
                    }) else {
                return nil
            }
            return tabContainingController
        }
    }

}

You can even modify it and add your own empty protocol to mark such view controller exactly. But this one also should do. Then you can apply it to the action where you need such behaviour and everything can be controlled from one configuration point like this .using(UIViewController.propagateTabInfo(UITabBarController.add())). And keep setting your tabBarInfo within the factory. And your factory stays agnostic and doesnt even need to know if it is contained in the UITabBarController directly or wrapped it in a several view controllers.

Please let me know if you are satisfied with the answer.

bennnjamin commented 1 year ago

Yes this is a great answer, thank you! It's good to know this is more of an issue with UIKit. I will try to work your example into my project - I think it will solve my issue.

ekazaev commented 1 year ago

@bennnjamin hi. Is it safe to close the issue?

bennnjamin commented 1 year ago

Yes thanks

ekazaev commented 1 year ago

@bennnjamin thank you and good luck.