UISplitViewController: How to force showing the master ViewController - ios

I am using a UISplitViewController, with the master and the detail viewcontrollers, without UINavigationControllers.
In some cases (for example when clicking on a universal link), I would like to force the app to always show the master viewcontroller.
How can I do that?
Is there a way to switch back from detail to master programmatically?

The split view controller is a beast, and the documentation is confusing. It is best understood by considering it as operating in two different modes: collapsed or not. Collapsed mode applies when the split view is presented in a horizontally compact view (i.e. iPhone), otherwise it is not collapsed (i.e. iPad).
The property preferredDisplayMode only applies if the view is NOT collapsed (i.e. iPad), and you can use this to select the master or detail view.
In collapsed mode, unless you are using navigation controllers, the original master view may be discarded:
After it has been collapsed, the split view controller reports having
only one child view controller in its viewControllers property. The
other view controller is collapsed into the other view controller’s
content with the help of the delegate object or discarded temporarily
But it is much better to use navigation controllers, as the split view controller is designed to work in conjunction with them:
The split view controller knows how to adjust the interface in more
intuitive ways. It even works with other container view controllers
(like navigation controllers) to present view controllers.
If you are using navigation controllers then the original master view may be at the bottom of the navigation stack:
In a horizontally compact environment, the split view controller acts
more like a navigation controller, displaying the primary view
controller initially and pushing or popping the secondary view
controller as needed
So you can do something like this:
if split.isCollapsed,
let nav = split.viewControllers[0] as? UINavigationController
{
nav.popToRootViewController(animated:false)
} else {
split.preferredDisplayMode = .allVisible
}
(It can get even more complicated if your master view pushes views in master as well as showing detail views. This code will pop to the root of the master view navigation stack)

You can set the preferredDisplayMode
self.splitViewController?.preferredDisplayMode = UISplitViewControllerDisplayMode.allVisible
Or if you are looking for something like a toggle action:
extension UISplitViewController {
func toggleMasterView() {
let barButtonItem = self.displayModeButtonItem
UIApplication.shared.sendAction(barButtonItem.action!, to: barButtonItem.target, from: nil, for: nil)
}
}
Usage:
self.splitViewController?.toggleMasterView()

You can define a custom UISplitViewController and assign it to your split view in storyboard:
import UIKit
class GlobalSplitViewController: UISplitViewController, UISplitViewControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
}
func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
return true
}
}

My solution is to swap the position of your primary and secondary ViewControllers if user is using an iPad. Then set preferredDisplayMode = .primaryHidden. Example code below.
splitViewVieController = UISplitViewController()
let isIphone = UIDevice.current.userInterfaceIdiom == .phone
splitViewVieController.viewControllers = isIphone ? [primaryNavController, seconaryNavController] : [seconaryNavController, primaryNavController]
splitViewVieController.preferredDisplayMode = .primaryHidden
We can change the position or width of the primary ViewController if needed.
splitViewVieController.maximumPrimaryColumnWidth = splitViewVieController.view.bounds.width
splitViewVieController.preferredPrimaryColumnWidthFraction = 0.5
splitViewVieController.primaryEdge = .trailing

Related

How to close multiple viewcontrollers in one time?

For my requirement, I have to go back to my rootviewcontroller (tabbar) but It have many page present on it.
example flow
my tabbar (have 4 tabs each tab has own navigation ) -> push(vc1) -> present nav(vc2) -> push vc3 -> present nav(vc4)
If I want to close all viewcontroller ( vc1 - vc4 ) How to dismiss them by one function ?
You can use UIViewController.dismiss(animated:completion:) call on the base view controller that started presentation from tab bar (root level).
If you present several view controllers in succession, thus building a stack of presented view controllers, calling this method on a view controller lower in the stack dismisses its immediate child view controller and all view controllers above that child on the stack. When this happens, only the top-most view is dismissed in an animated fashion; any intermediate view controllers are simply removed from the stack. The top-most view is dismissed using its modal transition style, which may differ from the styles used by other view controllers lower in the stack.
Given this hierarchy
my tabbar (have 4 tabs each tab has own navigation ) -> push(vc1) -> present nav(vc2) -> push vc3 -> present nav(vc4)
You can walk back the presentation hierarchy like following -
let vc4Presenter = vc4.navigationController?.presentingViewController
let vc2NavCtrl = (vc4Presenter as? UINavigationController) ?? vc4Presenter?.navigationController
let vc2Presenter = vc2NavCtrl?.presentingViewController
let vc1NavCtrl = (vc2Presenter as? UINavigationController) ?? vc2Presenter?.navigationController
vc2Presenter?.dismiss(animated: true, completion: {
vc1NavCtrl?.popToRootViewController(animated: false)
})
Above is merely an example of how you can find the correct view controller instance to call dismiss on in the view hierarchy. This is definitely not well suited for dynamic number of presentation layers.
You can -
Write this in a recursive way (so that it keeps looking for presentingViewController until it finds nil for the root level).
Have a convenient reference to tab bar controller throughout the app and call dismiss on it's currently selected view controller (tab).
If you want go directly to first viewController:
self.popToRootViewController(animated: true)
If you want to see how it is going away one by one
#objc func buttonPressed() {
let viewControllers: [UIViewController] = self.viewControllers
for aViewController in viewControllers {
self.popViewController(animated: true)
}
self.popToRootViewController(animated: true)
}

Swift UISplitViewController - unable to present master view before detail view (iOS 14, Xcode 12.4)

I am having this issue where I am using a UISplitViewController MainSplitVC, and I am unable to present the master view controller over the detail view controller. Basically, when testing this on an iPad, I want the master and detail VC to be visible side by side, but on an iPhone (portrait mode), I want only the master VC. Currently, this works on iPad, but on an iPhone in portrait mode, Swift is showing the detail view controller first, and I have to click the back button in the navigation bar to return to the master view controller.
I have tried every possible approach that I could think of. For instance, I created a class for the Split View Controller, MainSplitVC, where I subclass UISplitViewController and UISplitViewControllerDelegate. Then, in viewDidLoad(), I set the preferred display mode to oneBesideSecondary (since allVisible was replaced by that according to Xcode). I also include the function collapseSecondary() to always collapse back to the master view controller.
class MainSplitVC: UISplitViewController, UISplitViewControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
print("viewDidLoad called")
self.preferredDisplayMode = UISplitViewController.DisplayMode.oneBesideSecondary
}
func splitViewController(
_ splitViewController: UISplitViewController,
collapseSecondary secondaryViewController: UIViewController,
onto primaryViewController: UIViewController) -> Bool {
print("collapseSecondary called")
return true
}
}
I have consulted many other posts regarding this issue, and all the posts indicate that using the collapseSecondary() function and setting preferredDisplayMode to oneBesideSecondary or allVisible should do the job. However, none of this is working. What's more is that the collapseSecondary() function is not even being called, even though I included UISplitViewControllerDelegate in the class header.
Could anyone clarify if I made any mistakes in the code below? When the app opens, I just want the master view controller to be shown; the detail view controller is simply a blank view controller that changes to another when something in the tableview of the master view controller is clicked.
The links I used for reference were as follows:
UISplitViewController showing detail view controller first on iPhone, also delegate not calling proper functions
UISplitViewController in portrait on iPhone shows detail VC instead of master
Open UISplitViewController to Master View rather than Detail
Thanks!
EDIT 2/3/21: I've resolved the issue. I instead extended SceneDelegate under UISplitViewControllerDelegate and included the following function:
func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column {
return .primary
}
The collapseSecondary function didn't work apparently because there is a bug where "Interface Builder doesn’t allow creating a classic style UISplitViewController. (65966010) (FB8107534)", so I get the error message "Skipping delegate callback, splitViewController:collapseSecondaryViewController:ontoPrimaryViewController:. Unsupported for UISplitViewController style DoubleColumn".
Thanks!
Returning .primary should solve your issue.
#available(iOS 14.0, *)
public func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column {
return .primary
}
[From Documentation]
Asks the delegate to provide the column to display after the split view interface collapses.
When the split view controller transitions from a horizontally regular to a horizontally compact size class, it calls this method and asks you for the column to display when that transition is complete. Use this method to customize the view controller you’re collapsing to.

Swift: How to segue between view controllers and use navigation bar to go backwards within a child view controller (XLPagerTabStrip)

I am currently implementing the XLPagerTabStrip (https://github.com/xmartlabs/XLPagerTabStrip) which effectively creates a tab bar at the top of the view controller. I want to be able to segue to a new view controller from one of the tabbed controllers and be able to use the navigation bar to move backwards (or a custom version of the navigation bar if this isn't possible).
XLPagerTabStrip provides the moveToViewController and moveToViewControllerAtIndex functions to navigate between child view controllers, but this method doesn't allow use of a navigation bar to go backwards.
Conceptually XLPagerTabStrip is a collection of view controllers declared and initialized during the XLPagerTabStrip model creation.
It has virtually no sense to use a UINavigationController if you already have all the viewcontrollers available.
You can create a global var previousIndex to store the previous viewController index and allow users to go back by using canonical methods:
func moveToViewControllerAtIndex(index: Int)
func moveToViewControllerAtIndex(index: Int, animated: Bool)
func moveToViewController(viewController: UIViewController)
func moveToViewController(viewController: UIViewController, animated: Bool)
About a new viewController, suppose you have 4 viewControllers that built your container (XLPagerTabStrip) named for example z1, z2, z3 e z4.
You can embed to z4 a UINavigationController (so it have the z4 controller as rootViewController) and start to push or pop your external views. When you want to return to your z4 you can do popToRootViewControllerAnimated to your UINavigationController
When you are go back to z4 , here you can handle your global var previousIndex to moving inside XLPagerTabStrip.
I'm not familiar with XLPagerTabStrip, but I had a similar problem recently and the solution was to use an unwind segue to go back to the previous view controller. It's pretty trivial to implement so probably worth a try.
To navigate back to your previous view tab controller, you had initially navigated from;
Embed your new view controller, from which you wish to navigate
away from in a navigation bar
Connect it's Navigation Bar Button to the Parent view containing the
tab bar by dragging a segue between the 2 views
Create a global variable in App delegate to store current index
which you will use in the Parent view to determine what tab view
controller to be shown
var previousIndex: Int = 0 //0 being a random tab index I have chosen
In your new view controller's (the one you wish to segue from)
viewdidload function, create an instance of your global variable as
shown below and assign a value to represent a representative index
of the child tab bar view controller which houses it.
//Global variable instance to set tab index on segue
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.previousIndex = 2
You can write this for as many child-tab connected views as you wish, remembering to set the appropriate child-tab index you wish to segue back to
Now, create a class property to reference your global variable and a function in your Parent view as shown below
let appDelegatefetch = UIApplication.shared.delegate as! AppDelegate
The function
func moveToViewControllerAtIndex(){
if (appDelegatefetch.previousIndex == 1){
self.moveToViewControllerAtIndex((self.appDelegatefetch.previousIndex), animated: false)
} else if (appDelegatefetch.previousIndex == 2){
self.moveToViewControllerAtIndex((self.appDelegatefetch.previousIndex), animated: false)
}
}
You may now call this function in the Parent View Controller's viewDidLoad, as shown below.
moveToViewControllerAtIndex()
Run your project and that's it.

How to push new view in detail view controller from master view controller?

I have been scratching my head trying to figure out how to do this. If anyone has some insight it would be greatly appreciated. I've attempted to do this using segues and push/presentViewController methods. With pushViewController nothing happens.
Scenario: Split view controller has two navigation controllers connected (one as master, one as detail). The master's navigation controller has a form with various cells that should control what is being displayed in the right hand side detail view when in landscape mode on the iPad. The navigation controller connected to the detail view has storyboard references connected to it (3 of them).
What I want to do: From the master view controller (which is the app menu), I would like to control what is being displayed in the detail view while maintaining navigation bar.
Attempt 1:
let detailVC = self.splitViewController!.viewControllers[1]
let newVC = UIStoryboard(name: "D", bundle: nil).instantiateViewControllerWithIdentifier("P")
detailVC.self.navigationController?.pushViewController(newVC, animated: true)
Attempt 2:
let detailVC = self.splitViewController!.viewControllers[1]
let newVC = UIStoryboard(name: "D", bundle: nil).instantiateViewControllerWithIdentifier("P")
detailVC.performSegueWithIdentifier("navP", sender: self)
One other related question I had...if a user does many hops between several of the menu options, how can one "reset" the back button's history in the navigation bar to prevent a case where clicking back will cycle you through the same several views?
You shouldn't be pushing the view controller or performing a segue but calling showViewController.
Do you definitely need to maintain the navigation bar or can you show different UINavigationBars (via showing your view controller embedded in an UINavigationViewController potentially)?
Alternatively just show a single view controller and use the logic you add in your view controller to change the content under the control of your master view controller.
After some experimenting with the best approach I solved this. :) Solution below for all devices (iPhone/iPad).
Define extension for UISplitViewController:
Modified version based off https://stackoverflow.com/users/4418308/santiago-bendavid
extension UISplitViewController {
func toggleMasterView() {
if UIScreen.mainScreen().bounds.height > UIScreen.mainScreen().bounds.width {
var nextDisplayMode: UISplitViewControllerDisplayMode
switch(self.preferredDisplayMode){
case .PrimaryHidden:
nextDisplayMode = .AllVisible
default:
nextDisplayMode = .PrimaryHidden
}
UIView.animateWithDuration(0.2) { () -> Void in
self.preferredDisplayMode = nextDisplayMode
}
} else {
// do nothing
}
}
}
Code in master navigation controller's root view controller:
let newVC = UIStoryboard(name: "some_storyboard_id", bundle: nil).instantiateInitialViewController()
if self.splitViewController!.viewControllers.count == 2 {
let detailVC = self.splitViewController!.viewControllers[self.splitViewController!.viewControllers.endIndex - 1]
if detailVC.childViewControllers[detailVC.childViewControllers.count - 1].restorationIdentifier! != "some_id_here" {
detailVC.childViewControllers[0].navigationController?.pushViewController(newVC!, animated: true)
}
self.splitViewController!.toggleMasterView()
self.navigationController?.splitViewController!.preferredDisplayMode = .Automatic
} else {
self.navigationController?.pushViewController(newVC!, animated: true)
}
This code works on regular iPhone (non-Plus) where the master view controller is the sole controller used for navigation. On iPads and iPhone 6/6s+ models (which behave same as an iPad) I check whether the current view is already present using the restoration ID property, and then present the new view if it's not the same as the one already rendered on screen and dismiss the master view controller if we're in portrait mode. If in landscape, we keep it on screen (default behavior).

UISplitViewController: How to prevent expansion when rotating from Compact to Regular

There are many answers to the complementary question, which is how to prevent a transition to PrimaryOverLay on a from Regular to Compact interface change, eg use
func splitViewController(splitViewController: UISplitViewController, collapseSecondaryViewController secondaryViewController: UIViewController, ontoPrimaryViewController primaryViewController: UIViewController) -> Bool
In my case, I have an iPhone 6+ with the detail view showing in portrait. When I rotate the device to horizontal (Compact to Regular), I want the primary view to stay hidden. I've tried setting the preferredDisplayMode to .PrimaryHidden in many places, but it has no apparent affect. Googling has turned up nothing.
Well, after I wrote the question, but before posting it, I tripped on a possible solution, which is to override the trait collection that the split view controller references.
I took that idea and decided to subclass UISplitViewController, and override the traitCollection property. That did the trick:
final class MySplitViewController: UISplitViewController {
var didOnce = false
override var traitCollection: UITraitCollection {
let old = super.traitCollection
let change = UITraitCollection(horizontalSizeClass: .Compact)
let new = UITraitCollection(traitsFromCollections: [old, change])
return new
}
Obviously this is hardcoded for one device - later I'll go and add some functions that I can use to control what is in fact returned.
Don't override traitCollection, instead use the method setOverrideTraitCollection:forChildViewController: in a parent view controller of your split controller, like in Apple's example AAPLTraitOverrideViewController.m
If your split controller doesn't have a parent, making a parent is really easy in the Storyboard. Add a new view controller, make it the entry point, add a container view, delete the default embedded view and instead add an embed segue to the split controller and set the override on self.childViewControllers.firstObject in viewDidLoad.

Resources