1
\$\begingroup\$

For apps which don't have significant layout changes between the iPhone and iPad versions, I usually set the constraints of all UI elements (views, labels etc.) as fractions of the current device screen's width and height, in order to scale the content for different sizes and aspect ratios.

The way I currently do this has a lot of 'boilerplate' code. First, there is a long column of @IBOutlets at the top of each View Controller, which connect to all of the constraints which I've set up with initial (approximate) values in Main.storyboard. Since I usually use the iPhone 12 simulator for the initial design, these initial constraints are optimized for that specific device shape and don't scale. To fix this, I set the values of all the constraints (using the @IBOutlets) inside viewDidLayoutSubviews (since this is the point at which the safe area's dimensions can be determined). These are relative values expressed as fractions of the safe area width and height. At this point, the app's interface is scalable and displays correctly on other devices.

Since this method would also produce a very long column of declarative code inside viewDidLayoutSubviews, which had the same form across every View Controller, I made some changes to eliminate some of the repetitious parts. Changing the individual IBOutlets to outlet collections cut the length of the column of variables by a factor of 4. To replace all the individual assignments of constraints to numeric values, I wrote a function which performs these using a loop, and put it inside my Constants file. This function gets called inside viewDidLayoutSubviews. Since the @IBOutlet collections each contain one element's top, bottom, leading and trailing constraints in unknown order, I have a helper function which first sorts the collections into the same order, and is called by the assignment function.

Implementing these changes eliminated many lines of repetitious code from the top of each View Controller and from inside viewDidLayoutSubviews, but it still seems like a cumbersome way of doing things. I'm open to any and all suggestions for improvement: here is the MCVE:

ViewController.swift

import UIKit

class ViewController: UIViewController {
    // Constraints of all UI elements, in sets of 4 (one for each edge), in no particular order.
    // All have been initially set in Main.storyboard, expressed in the following form:
    // First Item:   The relevant UI element
    // Relation:     Equal
    // Second Item:  The Safe Area
    // Constant:     Precise value doesn't matter, because final values will be set in viewDidLayoutSubviews
    // Priority:     1000 (the default)
    // Multiplier:   1
    @IBOutlet var titleBannerConstraints: [NSLayoutConstraint]!
    @IBOutlet var centerViewConstraints: [NSLayoutConstraint]!
    @IBOutlet var testButtonConstraints: [NSLayoutConstraint]!
    
    // These are a different format of constraint from the ones above,
    // so they have to be specified individually instead of being set
    // by the function (see Constants)
    @IBOutlet weak var squareLabelWidth: NSLayoutConstraint!
    @IBOutlet weak var squareLabelHeight: NSLayoutConstraint!
    @IBOutlet weak var squareLabelTop: NSLayoutConstraint!
    @IBOutlet weak var squareLabelLeading: NSLayoutConstraint!
    
    @IBOutlet weak var titleBannerLabel: UILabel!
    @IBOutlet weak var squareLabel: UILabel!
    @IBOutlet weak var testButton: UIButton!
    
    // These values won't be determinable until viewDidLayoutSubviews.
    // They will be used to set all constraints relative to the dimensions of the device running the app.
    var screenWidth: Double?
    var screenHeight: Double?
    
    // Constraints which don't change at any time only need to be set once inside viewDidLayoutSubviews.
    // This boolean keeps track of whether they have been initially set, to avoid reassigning the same values
    // every time viewDidLayoutSubviews runs.
    var constantConstraintsHaveBeenSet = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        // Only set these values once
        if !constantConstraintsHaveBeenSet {
            // Cannot be global variables, as they depend on the view controller
            screenWidth = view.safeAreaLayoutGuide.layoutFrame.width;
            screenHeight = view.safeAreaLayoutGuide.layoutFrame.height;
            
            // Assign constraints to values relative to safe area width and height (see Constants file for implementation details)
            globalVars.applyConstraints(items: [titleBannerConstraints, centerViewConstraints, testButtonConstraints], values: [[0.07, -0.85, 0, 0], [0.21, -0.2, 0.08, -0.08], [0.9, -0.05, 0.28, -0.28]], w: screenWidth!, h: screenHeight!)
            
            // Assign constraints to the square label which preserve its aspect ratio and set top left corner relative to device screen size
            squareLabelTop.constant = 0.245*screenHeight!
            squareLabelLeading.constant = 0.15*screenWidth!
            squareLabelWidth.constant = 0.3*screenWidth!
            // Since width is always less than height for any iOS device,
            // this will never cause problems
            squareLabelHeight.constant = 0.3*screenWidth!
            
            constantConstraintsHaveBeenSet = true
        }
        
        // Set fonts of title label and button here too, so that they also scale for different device sizes
        titleBannerLabel.font = UIFont.systemFont(ofSize: 0.08*screenWidth!, weight: .bold)
        squareLabel.font = UIFont.systemFont(ofSize: 0.04*screenWidth!, weight: .medium)
        testButton.titleLabel?.font = UIFont.systemFont(ofSize: 0.045*screenWidth!, weight: .semibold)
    }
}

Constants.swift

import UIKit

struct globalVars {
    // Takes an @IBOutletCollection of constraints and returns a new array sorted in the order Top, Bottom, Leading, Trailing
    static func getSortedConstraints(_ constraintsCollection: [NSLayoutConstraint]) -> [NSLayoutConstraint?] {
        var sortedConstraints: [NSLayoutConstraint?] = [nil, nil, nil, nil]
        for c in constraintsCollection {
            let side = c.firstAnchor.description.replacingOccurrences(of: "\">", with: "").split(separator: ".")[1]
            if side == "top" {
                sortedConstraints[0] = c
            } else if side == "bottom" {
                sortedConstraints[1] = c
            } else if side == "leading" {
                sortedConstraints[2] = c
            } else if side == "trailing" {
                sortedConstraints[3] = c
            }
        }
        return sortedConstraints
    }
    
    // To be called inside the View Controllers' viewDidLayoutSubviews method.
    // Assigns values to the constraints relative to the current device screen's width and height.
    static func applyConstraints(items constraintsCollections: [[NSLayoutConstraint]], values valuesForConstants: [[Double]], w screenWidth: Double, h screenHeight: Double) {
        var sortedConstraintsCollections: [[NSLayoutConstraint?]] = []
        // Iterate over indices, not items, to preserve order of original input array
        for i in 0..<constraintsCollections.count {
            // Sort each collection of constraints in order of Top, Bottom, Leading, Trailing
            sortedConstraintsCollections.append(getSortedConstraints(constraintsCollections[i]))
        }
        
        for i in 0..<sortedConstraintsCollections.count {
            for j in 0..<4 {
                // Top and bottom constraints are relative to screen height; leading and trailing constraints are relative to screen width
                if j < 2 {
                    sortedConstraintsCollections[i][j]!.constant = valuesForConstants[i][j] * screenHeight
                } else {
                    sortedConstraintsCollections[i][j]!.constant = valuesForConstants[i][j] * screenWidth
                }
            }
        }
    }
}

And here are screenshots of the content scaling for the iPhone 12 and iPad Pro simulators:

iPhone 12 simulatoriPad Pro simulator

Note that the images show the app displaying as I intend it to. Most content (e.g. the title banner, teal background view and button) changes its aspect ratio depending on the device size. Only the square label keeps its original aspect ratio (if it didn't, it wouldn't be square on other devices). My actual apps have this same model: most content adjusts to fill available space, with exceptions where keeping the same shape is important.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.