Implementing Universal Interfaces
With the addition of Catalyst last year in macOS Catalina, it can be tempting to want to click a checkbox to make an iPhone app available for the Mac, but it is first necessary to add support for various iPad layouts if you hope your app will behave nicely in a windowed environment. This can be a more difficult process to get right than many assume, as not every iPhone layout can expand to fill a much larger space without compromising the original design choices made for the smaller screen.
📏 Using Trait Variations and Storyboards
The method Apple recommends is to use trait variations within a storyboard to vary the user interface with a different set of layout rules, or constraints, depending on the size of the device being used. This system will adapt the position, size, and availability of various interface elements on the app's behalf, taking care of a variety of situations, from the smallest to the largest iPhones, to multitasking on the iPad.
For instance, for a given element such as a button on screen, it can be given the full width available on a device with a compact size class such as an iPhone, but would be given a smaller width and moved to the side on a device with a regular size class, such as an iPad. This allows a variety of constraints to be set of ahead of time for a variety of devices.
Unfortunately, using trait variations in a storyboard requires that interfaces at any size be fairly similar, which can lead to one extreme being non-ideal, or worse, to a compromised interface for all screen sizes. This leads to implementations that can become quite messy and difficult to maintain.
🗂 Using Multiple Storyboards
One solution that enables multiple interface layouts relatively easily is to use multiple storyboards, and choose a storyboard at launch depending on the user interface idiom, which lets the app know if it is running as an iPhone app, iPad app, or with Catalyst, a macOS app.
This is, however, not a new technique. Before storyboards became available in iOS 5, the recommendation was to have a separate interface, or NIB, per device type, and to switch to the corresponding interface on launch. The system would even make this automatic if you suffixed your iPad specific interface file with
~ipad.nib, which would automatically be preferred when running on an iPad.
Unfortunately, this technique does not lend itself well to multitasking on the iPad. As an app is resized to the thinner presentations on either side of the iPad’s screen, it starts to resemble a vertically-stretched out iPhone, and can benefit from layout decisions that work better on an iPhone rather than an iPad. Unfortunately, to best take advantage of this change in layout when using multiple storyboards would be to re-build the entire interface from scratch, which is not ideal.
📜 A Historical Note About NIBs
A NIB, or NeXT Interface Builder document, is a format that encodes the position, size, attributes, and connections of various interface elements relative to one another, so that they can be loaded at runtime and connected to objects that the app is currently controlling. The format has undergone several changes since it was originally invented in the late 80s, but is ultimately still used in Storyboards to this day.
In a typical Model-View-Controller organization of an app, the NIB represents the portion that is displayed on screen, known as the view. In order to achieve re-usable code that could work for multiple user interfaces — for instance pertaining to different variations of an app, or even the same interface, but in different languages with different layout considerations — multiple NIBs would be created by the developer, but they would all be connected to the same managing object within the app, known as the controller.
Modern View Controllers on both iOS and macOS maintain this tradition, as their preferred way of being created, know as a designated initializer, reflects their preference for a localized NIB to connect to:
🧮 Programmatic Layout
Another approach that could work well is to lay all the views out programmatically. This approach tends to work best when the number of constraints within a storyboard climbs to an amount where making any changes or tweaks becomes burdensome, but comes with the downside that the developer no longer has a visual canvas to use when editing their interface.
In fact, sometimes this is a necessary approach, especially if your interface could benefit from additional flexibility that size classes do not account for. For instance, an iPad in both landscape and portrait is considered to be a regular size class device, no matter the orientation, but if you wanted to take advantage of the extra width when in landscape, you would be unable to do so using only a storyboard.
This solution is not too different from the declarative nature of SwiftUI. Essentially, in
viewWillLayout() you are given the opportunity to position all views according to the current state of the view controller. You can go further and evaluate exactly how many pixels are available for your controls, and position things accordingly.
🏗 Assembling Different Techniques
Unfortunately, solutions that are entirely programmatic tend to be a nightmare when working with others, because without the visual reinforcement of where things are, along with the usual lack of well documented code, teammates may be intimidated to jump in and offer changes. A hybrid approach is encouraged, where individual groups of views could be laid out programmatically, but those groups are available as views configurable in a storyboard directly. Ultimately, such solutions rely on not only the app being well designed, but the code itself being architected and thoughtful as well.
Using compositional data sources offer an alternative solution, especially when the main UI is a collection of entries that should take advantage of as much space as they can use. This can even be a solution that is more cleanly represented as code, since a storyboard would only serve to obscure the properties being set on a single view behind different panels, while the code itself is not actually doing any layout work itself: the collection view is.