Building iPad apps, prototyping Instagram for iPad.

James Rochabrun
Dev Genius
Published in
13 min readMay 8, 2020

--

Instagram iPad Prototype

In this post, we are going to talk about building for the iPad. We are going to use a prototype for Instagram to highlight key topics in how the OS manages its layout system to adapt to different iPad devices and to multitasking environments. Not sure why Instagram is not supported for iPad yet but I am sure it may not be far to be available. The prototype looks like this…

Ipad Instagram.

It adapts its layouts on device orientation.

Changing Layout on landscape and portrait mode.

It adapts UI based on size classes.

Stories header resize in a compact/regular traitcollection environment.

It adapts UI listening to iPad display mode changes.

Hide/show highlights UI based on split view controller display mode.

You can find the full repo here, it contains mock images and models to display the UI, also a mix of techniques including generics and usage of latest iOS 13 API’s like diffable datasources and compositional layout, however on this post we won't discuss how to build models, views or layouts, (maybe I will do another post just to talk how to build the UI using compositional layout).🙃

Instead, we will focus on the details of UISplitViewController and how it works for different size classes.

If you want to know more about generics and UICollectionViewDiffableDataSource you can go here and here. 🤓

If this sounds interesting here is the list of topics we will cover, if you just want to jump in the code or even contribute here is the repo.

Part 1

  • UISplitViewController overall.
  • UISplitViewController display mode.
  • Protocol Oriented UI updates.
  • UISplitViewController isCollapsed state.
  • UISplitViewControllerDelegate.

What is a UISplitViewController?

From apple:

A split view controller is a container view controller that manages two child view controllers in a master-detail interface. In this type of interface, changes in the primary view controller (the master) drive changes in a secondary view controller (the detail). The two view controllers can be arranged so that they are side-by-side, so that only one at a time is visible, or so that one only partially hides the other. In iOS 8 and later, you can use the UISplitViewController class on all iOS devices; in previous versions of iOS, the class is available only on iPad.

So what does this mean, it basically manages a navigation hierarchy with a primary or “master” (left view controller) and secondary or “detail” (right view controller) relationship which also can have their own independent navigation hierarchy. Its appearance is defined by the child view controllers you install setting it viewControllers property.

It also responds to traitCollection changes “merging” master and detail in one single navigation stack in horizontal compact environments which allows push navigations, this is what it makes it work even in iPhone devices 📱.

Instagram iPad Prototype.

Ok, so let’s say we want to follow Instagram design and make our root view controller a tab bar controller, something that will look like this (In our demo we are using an iPad 12.9 inches 3rd generation iOS 13.3)…

Profile tab using a UISPlitViewController.

Here you can see the profile tab as a master-detail relationship, it displays the user's content in the left side (primary) and an empty detail (secondary). The detail will change once the user performs a selection. (This is a common iPad design where the detail is a blank placeholder on launch, until the user performs a selection from the master view controller).

So how does the code look like when you instantiate a UISplitViewController? we have a subclass calledSplitViewController and on this snippet, you can see how to instantiate it and embed it as a tab bar item in a tab bar controller.

Starting set up code.
  1. We have a subclass called SplitViewController it has a convenience initializer that takes an array of viewControllers.
  2. UISplitViewController has a viewControllers property that takes an array of vc’s, not sure why it takes an array if only uses the first and second element on the array to display the master (index 0) and the detail (index 1) and discards the rest. 🤷‍♂️
  3. preferredDisplayMode, An animatable property that controls how the primary view controller (master) is hidden and/or displayed, we will talk about it in detail later, for now set it to .allVisible will display primary and secondary together.
  4. As you can see SplitViewController takes the ProfileViewController and an instance of EmptyDetailViewController (any kind of view controller that you want to display on launch as the detail) you can even ideally display the detail populated with the first selectable element from the master. Later it just assigns an icon to the tabBarItem image.
  5. The TabBarController subclass sets the viewControllers on viewDidLoad as you can see profileViewController is one of the elements in the array. (we have a MVVM setup that instantiates every tab of the app in the repo).

UISplitViewController DisplayMode

As you saw above, on step 3 we can set the split view controller arrangement by setting the prefferedDisplayMode.

An animatable property that controls how the primary view controller is hidden and displayed. A value of `UISplitViewControllerDisplayModeAutomatic` specifies the default behavior split view controller, which on an iPad, corresponds to an overlay mode in portrait and a side-by-side mode in landscape.

This property is an enum instance of UISPlitViewController.DisplayMode and here is an example of how the app looks for each case.

case automatic:

The split view controller automatically decides the most appropriate display mode based on the device and the current app size. You can assign this constant as the value of the preferredDisplayMode property but this value is never reported by the displayMode property.

In a regular horizontal width environment, it will show the app on launch like this...

primary is hidden in horizontal regular width environment with .automatic

case primaryHidden:

The primary view controller is hidden.

primary hidden.

case .primaryOverlay:

The primary view controller is layered on top of the secondary view controller, leaving the secondary view controller partially visible.

.primaryOverlay

case .allVisible

The primary and secondary view controllers are displayed side-by-side onscreen.

primary and secondary are visible.

In our prototype we use the .allVisile state on launch but let’s say we want to expand the detail view controller to show the posts in full screen, at the end of the day Instagram is all about how good the content can look right? something like this…

Expand detail hiding the primary view controller.

This is straight forward to implement and UISplitViewController and its delegate, gives us this for free! you only need to add a displayModeButtonItem in the detail view controller.

A system bar button item whose action will change the displayMode property depending on the result of targetDisplayModeForActionInSplitViewController:. When inserted into the navigation bar of the secondary view controller it will change its appearance to match its target display mode. When the target displayMode is PrimaryHidden, this will appear as a fullscreen button, for AllVisible or PrimaryOverlay it will appear as a Back button, and when it won’t cause any action it will become hidden.

As you see in the gif, after selecting a post the empty view controller placeholder changes to a different detail view controller that displays the current selection, we will talk more about navigation in part two but for now lets focus in the display mode button that is located at the top left corner of the ContentDetailViewController.

On tap it hides the primary view controller and makes the detail full width, these 2 lines in the view Controller where you want to display the button are all you need.

/// 1
navigationItem.leftItemsSupplementBackButton = true
/// 2
navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
  1. leftItemsSupplementBackButton makes possible to display a back button instead of the expand arrows in a horizontal compact width environment.
  2. We just need to assign the displayModeButtonItem to the viewController left bar button item.

This is cool, but I am not convinced about how it looks, I don’t want a back button when the display mode is .primaryHidden, which is what the API gives us by default; I will like to customize the button to add a more appropriate icon. Plus the detail view looks kind of huge going edge to edge, a nice padding will make it look better and also just for fun lets take advantage of the amount of space and let’s display the highlights carrousel on top, something like this…

display mode changes.

Let’s start customizing the display mode button item (if you have an easier approach will love to know!), here is the code.

Display mode code.
  1. We create a button, SplitViewControllerViewModel is just a view model that provides the icons for different states.
  2. We construct a selector that will toggle the display mode state.
  3. Here we find something interesting, what is displayMode and how is different from prefferedDisplayMode well this is also a UISplitViewController property but is just a getter and represents the current arrangement of your viewControllers.

This property reflects the arrangement of the two child view controllers in a split view interface. The value in this property is never set to UISplitViewController.DisplayMode.automatic. To change the current display mode, change the value of the preferredDisplayMode property.

We just use it as a reference to determine the current display mode state and then toggle the prefferedDisplayMode which is what makes the trick. 🤘

4. We will talk more about UISPlitViewControllerDelegate later but here is a little peek, every time you set the prefferedDisplayMode property this delegate method will get triggered.

And here is where we can notify changes to the child controllers, and thats exactly what we will do next.

Protocol Oriented UI updates.

Now we are going to add the padding to the detail view controller and display the highlights UI on the top as we planned, we need a way to communicate the SplitViewController to the ContentDetailViewController (the current secondary view controller), we can use notifications, delegates etc. but we are going to use a protocol-oriented approach here is the code…

  1. We created a protocol with 2 methods, we will use them to trigger UI updates to child view controllers that conforms to it.
  2. We have a reference to the primary and secondary view controller of split view controller so we can use that to get atopViewController that conforms to DisplayModeUpdatable , and execute the method passing the updated displayMode as argument.
  3. Same here, but we can call the displayModeWillChange instead and pass the new displayMode as argument.
  4. If you go to ContentDetailViewController file you can see that it conforms to DisplayModeUpdatable.
  5. This is just the logic needed to add/remove horizontal padding updating constraint constants. Also, you can see that verticalFeedTableView, which is a reusable view that displays the feed, has also a displayMode property, this will later be used in step 6.
  6. The highlights carrousel is just a header in the tableView which sets its height based on the displayMode current value, making the header visible when appropriate.

That is all you need here, you can take this example and implement small protocols that the children can adopt while UISplitViewController delegate methods get triggered.

One last thing, UISplitViewController has a property called isCollapsed which when is true it makes the display mode ignored.

Display modes apply to a split view controller in an expanded arrangement. When the split view interface is collapsed, the display mode is ignored.

We will talk about it next.

UISplitViewController isCollapsed

This property is set to true when the split view controller content is semantically collapsed into a single container. Collapsing happens when the split view controller transitions from a horizontally regular to a horizontally compact environment. 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. When collapsed, the displayMode property has no impact on the appearance of the split view controller interface.

Here is how iPad looks when isCollapsed is true…

isCollapsed = true

While working in iPad development this is the topic that it actually blew my mind 🤯 what does it mean “the split view controller content is semantically collapsed into a single container” well, it means that when isCollapsed is true the secondary view controller becomes the child of the primary view controller where both are navigation controllers, wait, what? a navigation controller child of a navigation controller?

secondary view controller as child of primary view controller when isCollapsed.

Wait this can’t be correct, this means that eventually we will have to push a navigation controller inside the stack of a navigation controller, but this will throw an exception right? 🤔

Terminating app due to uncaught exception ‘NSInvalidArgumentException’, reason: ‘Pushing a navigation controller is not supported’

Well looks like Apple makes an exception under the hood when this happens inside a UISplitViewController , if you put a break point inside the willShow NavigationController delegate method and print the viewController you can see that it is actually pushing a navigation controller.😳

Ok, thats cool I guess and seems that there is no problem with it but then QA starts playing with the app and… 😭

Bug on navigation items during pushes and device rotation

Did you see the issue? pay close attention to the navigation bar, now the navigation items of the secondary view controller are displayed as navigation items of the primary view controller, and if we do this in iOS 12 the app will even display a super weird state with empty child view controllers. I am sure I am not alone here, I saw issues during pushes and rotation even in Apple apps like the Files app. I spent some time trying to find a solution for this and I am still not sure if it was the best but after reading what we have available in the UISPlitViewControllerDelegate API I found a work around, lets talk about this delegate now.

UISplitViewControllerDelegate

After reviewing the UISplitViewControllerDelegate documentation I saw this method, is intended to help performing a “clean up” after the view controllers has been “merged” in collapsed mode. It gets triggered when the split view controller expands from CompactWidth to RegularWidth making the secondary visible.

For example, on device rotation when the secondary becomes visible like this…

Secondary becomes visible on device rotation when SplitViewController changes to regular width size class.

Or in a multitasking environment like this…

Secondary becomes visible on a multitasking environment when SplitViewController changes to regular width size class.

from the docs…

Use this method to designate the secondary view controller for your split view interface and to perform any additional cleanup that might be needed. After this method returns, the split view controller installs the newly designated primary and secondary view controllers in its viewControllers array.

When an interface collapses, some view controllers merge the contents of the primary and secondary view controllers. This method is your opportunity to undo those changes and return your split view interface to its original state.

When you return nil from this method, the split view controller calls the primary view controller’s separateSecondaryViewController(for:) method, giving it a chance to designate an appropriate secondary view controller. Most view controllers do nothing by default but the UINavigationController class responds by popping and returning the view controller from the top of its navigation stack.

I realize that here I may be able to do some arrangement to make things go back to “normal” here is the code that fixes our issue with the navigation items in iOS 13 and below…

func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? {/// 1
if
let masterAsNavigation = primaryViewController as? UINavigationController,
let masterFirstChild = masterAsNavigation.viewControllers.first {/// 2
masterAsNavigation.setViewControllers([masterFirstChild], animated: false)
}
/// 3
return nil
}
  1. Ok, primaryViewController is a navigation controller we need the first viewController on its navigation stack.
  2. Set it again in the primaryViewController’s array…It kind of not make sense I know, but fixes the problem. 🤷‍♂️
  3. If you read the documentation, returning nil here is ok, then splitViewController finds the appropriate view controller for you, at least in iOS 13.

Lastly, there is one bug in iOS 12 that I was not able to solve which is a pop operation during device rotation, does this exception looks familiar?

child view controller:              THING I'M MOVING
should have parent view controller: SOURCE NAVIGATION CONTROLLER
but requested parent is:

I did not found a work around for this so if you do let me know! here is a post that actually talks about the problem.

Let’s talk now about the delegate method that gets triggered when the split view controller collapses from RegularWidth to CompactWidth size class.

And same as the last method it also gets triggered on device rotation like this…

Secondary becomes hidden on device rotation when splitViewController adopts compact width.

Or in a multitasking environment like this…

Secondary becomes hidden on multi tasking when splitViewController adopts compact width.

So how can we use this method in our advantage, well collapse the app and launch it, you will see this…

Launch in collapse displays the detail by default.

We don’t want to display the detail by default mostly if there is no content right? so to fix this you can just add this piece of code…

func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
/// 1
return secondaryViewController is EmptyDetailViewController
}
  1. If the secondary is an empty EmptyDetailViewController this will return true meaning the user did not perform a selection and there is no reason to display this UI on launch when collapsed.

from the docs

Returning true from this method tells the split view controller not to apply any default behavior. You might return true in cases where you do not want the secondary view controller’s content incorporated into the resulting interface.

There are more UISplitViewControllerDelegate methods that you can use to fit your needs, but I think this is enough to discuss on this post, here are the docs if you want to take a look.

Again you can find the full code here, and you can find me here.

Cheers

--

--