Ever wanted to quickly resize the simulator iOS screen so you can test how your view performs on smaller devices. Enter shake to debug.
Shake is a good motion to use for presenting a debug menu because it’s easy to detect and can be done from anywhere. Whenever a user shakes their iPhone, the OS will call the motionEnded
instance method of any UIResponder
. As most UI elements inherit from UIResponder
it’s easy to override motionEnded
wherever we need, then display a debug menu when it happens.
Because I want to change the size of a visible view at almost any time, I want my debug menu to display any time. So I subclass UIWindow
and present my debug menu anytime I detect .motionShake
.
final class ShakeableWindow: UIWindow {
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
// present a debug menu
}
}
}
Somewhere in an app delegate or scene delegate, we’re probably creating a key window. We want to make this window a ShakeableWindow
but only in debug builds for testers and developers. So we can do the following.
// SceneDelegate
#if DEBUG
let window = ShakeableWindow(windowScene: windowScene)
#else
let window = UIWindow(windowScene: windowScene)
#endif
// AppDelegate
#if DEBUG
let window = ShakeableWindow(frame: UIScreen.main.bounds)
#else
let window = UIWindow(frame: UIScreen.main.bounds)
#endif
Next, we need the view controller at the top of the view controller hierarchy. We need this top-most view controller because it will present our debug menu. It also contains the view that we want to resize.
To get the top-most view controller we add an extension onto UIWindow
that checks the rootViewController
for and returns either:
- A presented view controller
- A view controller in a navigation controller
- A view controller in a tab bar controller
- The root view controller
extension UIWindow {
var topViewController: UIViewController? {
if let presented = rootViewController?.presentedViewController {
return presented
} else if let nav = rootViewController as? UINavigationController {
return nav.visibleViewController
} else if let tab = rootViewController as? UITabBarController {
return tab.selectedViewController
} else {
return rootViewController
}
}
}
Finally, we can update our motionEnded
function to present a new view controller.
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake,
let topViewController = topViewController {
topViewController.present(UIViewController(), animated: true, completion: nil)
}
}
Changing View Size to a Different Device Size
In our debug menu we want to display a list of devices. When we tap a device we want to resize the top-most view controller’s size to the selected device size. Ideally, we then build our app to larger devices and resize our view down to see how they would look.
Most device sizes are known, both in terms of points and pixels. Here is a device size list I found. We need a similar list in our code, so we create an enum.
enum Device: CaseIterable {
case iPhone5
case iPhone8
case iPhone12Mini
case iPhone12Pro
case iPhone12ProMax
case iPadAir
case iPadPro11Inch
case iPadPro12_9Inch
var size: CGSize {
switch self {
case .iPhone5: return CGSize(width: 320, height: 568)
case .iPhone8: return CGSize(width: 375, height: 667)
case .iPhone12Mini: return CGSize(width: 360, height: 780)
case .iPhone12Pro: return CGSize(width: 390, height: 844)
case .iPhone12ProMax: return CGSize(width: 428, height: 926)
case .iPadAir: return CGSize(width: 820, height: 1180)
case .iPadPro11Inch: return CGSize(width: 834, height: 1194)
case .iPadPro12_9Inch: return CGSize(width: 1024, height: 1366)
}
}
var title: String {
switch self {
case .iPhone5: return "iPhone 5"
case .iPhone8: return "iPhone 8"
case .iPhone12Mini: return "iPhone 12 Mini"
case .iPhone12Pro: return "iPhone 12 Pro"
case .iPhone12ProMax: return "iPhone 12 Pro Max"
case .iPadAir: return "iPad Air"
case .iPadPro11Inch: return "iPad Pro (11 inch)"
case .iPadPro12_9Inch: return "iPad Pro (12.9 inch)"
}
}
}
We use the enum to create a basic table view that displays titles for each device. In our didSelectItemAt
delegate method, we get the size for the selected device.
let size = deviceSizes[indexPath.row].size
Using the selected device size we can create a new frame for the view controller that presented our debug menu. The frame for a view controller takes a size. So injecting the new size is simple. The frame also takes an origin. This is the point in the x and y axis of the screen that view draws its top and leading from. I want to keep the view centered on the screen, so I have to offset the origin given the new height and width.
private func makeNewFrame(for size: CGSize) -> CGRect {
let fullScreen = UIScreen.main.bounds
let x = makeOffset(current: fullScreen.width, new: size.width)
let y = makeOffset(current: fullScreen.height, new: size.height)
let origin = CGPoint(x: x, y: y)
return CGRect(origin: origin, size: size)
}
private func makeOffset(current: CGFloat, new: CGFloat) -> CGFloat {
(current - new) / 2
}
Finally, I can give the presenting view controllers view the new frame and dismiss the debug view controller.
presentingViewController.view.frame = makeNewFrame(for: size)
dismiss(animated: true, completion: nil)
Notice in the list above that I also have options to resize and reset.
Reset
The reset option is simple. When reset is tapped I reset the frame to the screen’s bounds.
presentingViewController.view.frame = UIScreen.main.bounds
dismiss(animated: true, completion: nil)
The resizing option is more complicated.
Resizing Dynamically
Resizing views in response to user interaction is a useful tool for highlighting constraint conflicts or missing constraints. The below example shows this. Notice how as the view gets smaller, the bottom labels and icons aren’t pinned to the long text.
Admittedly, squashing an entire view controller this much is unlikely. But if you are building highly reusable view components, it’s not unrealistic to imagine consumers putting unexpected constraints on your view. Developing for these scenarios means deciding which constraints will take precedence and ideally trying to avoid Auto Layout constraint debug messages.
This is why I added the dynamic resizer option to my debug list. It simply adds a UISlider
to a UIView
and constrains the slider in such a way that it will not be squashed by the frame. The slider values are then used to adjust the view’s frames. The code is very similar to what I used to change the view controller size to a specific device size.
Conclusion
- Shake is good way to get a quick debug menu
- Debug menu for views handy
- Debug menu for other stuff also an option