Programatically creating UINavigationController which isn't a rootViewController - ios

I am aware that it is possible for you to do:
let foo = MyView()
let nav = UINavigationController(rootViewController: foo)
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = nav
window?.makeKeyAndVisible()
Or something like that in AppDelegate.swift and have a navigation controller up and running. However, what if my intended navigation controller isn't my rootViewController? Do I do something like this or is there a completely different approach?

Since a UINavigationController is merely a subclassed UIViewController, I believe the only other code-only approach is to make it a child view controller to your actual root controller:
let navController = UINavigationController(nibName: nil, bundle: nil)
addChild(navController)
view.addSubview(navController.view)
navController.didMove(toParent: self)
That code is for your "root" UIViewController that will containnavController`.
My usage is subtly different. I actually have *two custom/subclassed nav controllers inside a view controller, and I use auto layout to place them where I need them. Your needs were pretty vague on details, but if you do not want your nav controller to be a root, I see no reason why you couldn't make one a child of the root and have it be full screen. (You should even be able to present it.
If my code isn't enough to help you, let me know and I'll expand on things.
If you wish to use a storyboard, there is another option: Create a new storyboard, make everything in it embedded in a nav controller, and open it.
let storyboard = UIStoryboard(name: "mySecondStoryboard", bundle: nil)
let vc = storyboardCompose.instantiateViewController(withIdentifier: "myRootVC") as! MyNextViewController
let navController = UINavigationController(rootViewController: myRootVC)
self.present(navController, animated:true, completion: nil)
But please note, you are both presenting a view controller and making it a root!
Just remember, a UINavigationController is merely a subclassed "container" UIViewController with some enhancements for navigation like push/pop.

Related

Go back previous ViewController from NavigationController in TabViewController

I got the problem is I cannot make go back previous ViewController of different storyboard from NavigationController in TabViewController.
I've already tried with
_ = navigationController?.popViewController(animated: true)
unfortunately, it does not work. And I know that it can be going back with segue but it's not good practice. Please let me know how to do it. Thanks. Following is my hierarchy.
I had the same issue. In this case I am on a different Storyboard and want to return to the Main Storyboard and present the first view controller. Since this controller is embedded in a Navigation Controller that is embedded in a Tab Bar controller, you must instantiate the Tab Bar Controller. Be sure to set the indentifier on the Tab Bar Controller as "TabBarController"... or whatever you like.
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let controller = storyboard.instantiateViewController(withIdentifier: "TabBarController") as! UITabBarController
self.present(controller, animated: true, completion: nil)

Navigate to a specific View Controller inside a TabBarController from AppDelegate, Swift

I have a TabBarController with two tabs Cases & Settings
I would like to take the user to CaseSummaryTVC which is nested like this
TabBarController > Cases (NavigationController, Storyboard Id = 'tvcNav' ) > CasesTVC (TableViewController) > CaseSummaryTVC (TableViewController, Storyboard Id = 'CaseSummaryTVC').
I am using the below code in AppDelegate, which takes me to the 'CaseSummaryTVC' but doesn't show the TabBar on the bottom.
let mainStoryboard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
let navController = mainStoryboard.instantiateViewController(withIdentifier: "tvcNav") as! UINavigationController
let caseSummaryTVC = mainStoryboard.instantiateViewController(withIdentifier: "CaseSummaryTVC") as! CaseSummaryTVC
navController.pushViewController(caseSummaryTVC, animated: true)
self.window?.rootViewController = navController
self.window?.makeKeyAndVisible()
It looks like you're setting your window's rootViewController to be 'navController', which doesn't seem to be wrapped in a UITabBarController.
I'm not sure what your storyboard looks like, but it sounds like you have the view hierarchy setup correctly there.
You could do one of the following:
1) Remove the programmatic view controller instantiation and have the storyboard take care of setting the rootViewController to the UITabBarController.
2) Instantiate the UITabBarController programmatically like you have done here with the other view controllers, and make sure it's set up to have the navController as one of it's tabs.

Log out and clear VCs below current VC

We are looking to change the way a user logs out of our app. In order to do that, we want to dismiss all the VCs below the current VC and put another VC on top as the root VC. Right now we are doing this which I believe does not dismiss any VC below from memory.
let viewController = storyboard?.instantiateViewController(withIdentifier: "SignIn")
if let unwrappedViewController = viewController {
self.present(unwrappedViewController, animated: true, completion: {})
}
The problem is that the VC that we want to put on top is not embedded in a Navigation Controller or tab bar controller. How would we dismiss the VCs and set the new VC as the main VC as if the user was opening the app for the first time without having previously logged in? We also do want the transition to be animated with whatever animation is normal for that event (modal animation is fine). I have read a bunch of different ways on doing it but I want to know which way is best practice and should be implemented specifically dismissing all VCs and putting a new VC that isn't in a Nav controller on top.
If you can access the UIWindow of the app, you can set its rootViewController property to your sign-in view controller, effectively removing all current view controllers and adding the sign-in view controller instead. Here's an example:
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
// Should remove all subsequent view controllers from memory.
appDelegate.window?.rootViewController.dismiss(animated: true, completion: nil)
// Set the root view controller to a new instance of the sign in view controller.
appDelegate.window?.rootViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "SignIn")

Best way to conditionally switch root view controller at iOS app start-up

New iOS developer here. I am working on a project which requires that the user log-in when they first open the app. From then on, I want the app to open directly to the main flow of the app (a tab bar controller in my case). After doing some research, I have come across what seems to be two main ways to do implement this functionality:
1) Conditionally set the root view controller of the app's window in the app delegate. For example:
if userLoggedIn {
let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle())
let tabBarController: UITabBarController = storyboard.instantiateViewControllerWithIdentifier("TabBarController") as! UITabBarController
self.window?.makeKeyAndVisible()
self.window?.rootViewController = tabBarController
} else {
let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle())
let logInViewController: LogInViewController = storyboard.instantiateViewControllerWithIdentifier("LogInViewController") as! LogInViewController
self.window?.makeKeyAndVisible()
self.window?.rootViewController = logInViewController
}
2) Use a navigation controller as the app's root view controller, and conditionally set the view controllers managed by the navigation controller in the app delegate. For example:
if userLoggedIn {
let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle())
let tabBarController: UITabBarController = storyboard.instantiateViewControllerWithIdentifier("TabBarController") as! UITabBarController
let navigationController = self.window?.rootViewController as! UINavigationController
navigationController.navigationBarHidden = true
navigationController.setViewControllers([tabBarController], animated: true)
} else {
let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle())
let tabBarController: ViewController = storyboard.instantiateViewControllerWithIdentifier("LoginViewController") as! ViewController
let navigationController = self.window?.rootViewController as! UINavigationController
navigationController.navigationBarHidden = true
navigationController.setViewControllers([tabBarController], animated: true)
}
If I go with the second option, I can easily transition to the app's main flow (let's say a tab bar controller) after I'm done logging in the user. In an appropriate place in the LogInViewController, I can say:
// Transition to tab bar controller
let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle())
let tabBarController: UITabBarController = storyboard.instantiateViewControllerWithIdentifier("TabBarController") as! UITabBarController
self.navigationController?.setViewControllers([tabBarController], animated: true)
Pretty easy.
As for the first method, I am not sure how I would transition to the main content of the app after logging the user in.
What I am looking for here is the "best" way to handle login flow. I've listed two methods but maybe there is another that is even better. Also, if the "best" method is the first I have listed, how can I get to my tab bar controller after I finish logging the user in? Thanks!
Those are both fine options. One thing that I've done that has worked well is implemented a custom container view controller as the root of my app (this might be a subclass of my main view controller like a tab bar controller or navigation controller).
In this container view controller, I can put code to display and dismiss a login view controller based on the user's login status. Note: you can also use temporary full-screen views here to hide your app's main content while the login screen is shown/dismissed - this is an easy way to get very smooth app launch transitions.
I particularly like this method, because the rest of the app doesn't need to worry about the details. Your container can act like a normal tab bar controller or navigation controller, but underneath, you get to encapsulate all of the login UI logic in one place.

View Controller TDD

I am trying to add some unit tests to my project to test view controllers. However I seem to be having problems with seemingly simple things. I have created a sample project which I will refer to. https://github.com/pangers/ViewControllerTesting
The sample contains a UINavigationController as the initial view controller. The root view controller of the UINavigationController is FirstViewController. There is a button on FirstViewController that segues to SecondViewController. In SecondViewController there is an empty textfield.
The two tests I am trying to add are:
1) Check button title in FirstViewController is "Next Screen".
2) Check textfield in SecondViewController is empty, "".
I have heard reports of adding your swift files to both the main target and the test target is not good practice. But rather it is better to make whatever you want to access in your tests public and import the main target into the tests. So that is what I have done. (I have also set the "Defines Module" for the main target to YES as that is what I have read in a few articles aswell).
In FirstViewControllerTests I have instantiated the first view controller with the following:
var viewController: FirstViewController!
override func setUp() {
let storyboard = UIStoryboard(name: "Main", bundle: NSBundle(forClass: self.dynamicType))
let navigationController = storyboard.instantiateInitialViewController() as UINavigationController
viewController = navigationController.topViewController as FirstViewController
viewController.viewDidLoad()
}
And I have added the test:
func testCheckButtonHasTextNextScreen() {
XCTAssertEqual(viewController.button.currentTitle!, "Next Screen", "Button should say Next Screen")
}
Similarly, for SecondViewControllerTest, I have set it up using:
var secondViewController:SecondViewController!
override func setUp() {
let storyboard = UIStoryboard(name: "Main", bundle: NSBundle(forClass: self.dynamicType))
let navigationController = storyboard.instantiateInitialViewController() as UINavigationController
let firstviewController = navigationController.topViewController as FirstViewController
firstviewController.performSegueWithIdentifier("FirstToSecond", sender: nil)
secondViewController = navigationController.topViewController as SecondViewController
secondViewController.viewDidLoad()
}
And the test:
func testTextFieldIsBlank() {
XCTAssertEqual(secondViewController.textField.text, "", "Nothing in textfield")
}
They both fail and I am not too sure as to why. My suspicion is that the way I am instantiating the view controllers is not correct. Is the best way to instantiate the view controllers is to use the storyboard (just like it would if it were to run in real life)? Or is it acceptable to be instantiated via:
var viewController = FirstViewController()
What are you guys' experience with TDD and view controllers in swift?
I am using Swift with XCode 6.1.1.
Thanks in advance.
Solved
Ok after considering the answers from modocache and Mike Taverne, I've found my solution and I've learnt a few things which I will write down below.
1) I made anything class/method/variable that I want to test public. I do not need to add the swift files to the test target.
2) I only needed to set "Defines Module" for the "Main" target (as opposed to the "Test" target or the entire project)
3) When instantiating the storyboard, the bundle should be set to nil rather than NSBundle(forClass: self.dynamicType), otherwise tests will fail.
4) As modocache stated, it is good to give your view controller's a StoryboardID and instantiate them like so:
viewController = storyboard.instantiateViewControllerWithIdentifier("FirstViewController") as FirstViewController
However, instantiating the view controller like this ONLY instantiates the view controller alone, and not any navigation controllers that it may be embedded in. That means, attempting to do
XCTAssertFalse(viewController.navigationController!.navigationBarHidden, "Bar should show by default")
will result in a nil exception. I confirmed this with
XCTAssertNil(viewController.navigationController?, "navigation controller doesn't exist")
which resulted in a successful test.
Since I wanted to check the state of the navigation bar in FirstViewController, you must instantiate the view controller like so:
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let navigationController = storyboard.instantiateInitialViewController() as UINavigationController
viewController = navigationController.topViewController as FirstViewController
Now performing the test
XCTAssertFalse(viewController.navigationController!.navigationBarHidden, "nav bar should be showing by default")
results in a successful test.
5) let _ = viewController.view does indeed trigger viewDidLoad() which was confirmed by a test
6) let _ = viewController.view does not trigger viewWillAppear(), and I presume anything afterwards aswell. viewController.viewWillAppear(false/true) needs to be called manually to trigger it (Confirmed by a test).
Hopefully this will be of help to people. I will push the updated project to GitHub (link above) if anyone would like to play around with it.
Update #2
After all the above, I still could not figure out how to transition from the first view controller to the second view controller (so that I may test navigation bar properties in SecondViewControllerTests.swift). I tried
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let nc = storyboard.instantiateInitialViewController() as UINavigationController
let firstVC = nc.topViewController as FirstViewController
firstVC.performSegueWithIdentifier("FirstToSecond", sender: nil)
secondVC = nc.topViewController as SecondViewController
which caused an error.
I also tried
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let nc = storyboard.instantiateInitialViewController() as UINavigationController
let firstVC = nc.topViewController as FirstViewController
firstVC.toSecondVCButton.sendActionsForControlEvents(UIControlEvents.TouchUpInside)
secondVC = nc.topViewController as SecondViewController
which did not work.
I eventually tried
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let nc = storyboard.instantiateInitialViewController() as UINavigationController
vc = storyboard.instantiateViewControllerWithIdentifier("Second") as SecondViewController
nc.pushViewController(vc, animated: false)
let _ = vc.view
vc.viewWillAppear(false)
which worked perfectly with my tests (allowed me to access navigation bar properties)!
I agree with #MikeTaverne's answer: I prefer accessing -[UIViewController view] in order to trigger -[UIViewController viewDidLoad], rather than calling it directly. See if the test failures for FirstViewController go away once you use this instead:
viewController = navigationController.topViewController as FirstViewController
let _ = viewController.view
I'd also recommend giving both view controllers identifiers in your storyboard. This will allow you to instantiate them directly, without accessing them via UINavigationController:
var secondViewController: SecondViewController!
override func setUp() {
let storyboard = UIStoryboard(name: "Main", bundle: NSBundle(forClass: self.dynamicType))
secondViewController = storyboard.instantiateViewControllerWithIdentifier("SecondViewController")
as SecondViewController
let _ = secondViewController.view
}
Check out my talk on testing UIViewController at Brooklyn Swift for details: https://vimeo.com/115671189#t=37m50s (my presentation begins around the 37'50" mark).
I've begun unit testing view controllers recently, and it poses some unique challenges.
One challenge is getting the view to load. Looking at your set up for FirstViewController, you are trying to do this with viewController.viewDidLoad().
My suggestion is to replace that line with this:
let dummy = viewController.view
Accessing the .view property will force the view to load. This will trigger the .viewDidLoad in your ViewController, so don't call that method explicitly in your test.
This approach is considered hacky by some people, but it is simple and effective. (See Clean way to force view to load subviews early)
As an aside, I am finding the best way to test view controllers is to move as much code out of the view controllers as possible into other classes that are more easily tested.
If your view controller is defined in a storyboard, then you need to instantiate it that way for your outlets to be set up properly. Trying to initialize it like an ordinary class won't work.

Resources