Introducing BezelKit: Perfecting Corners, One Radius at a Time


BezelKit is a Swift package designed to zero in on an often under appreciated yet pivotal piece of the UI puzzle — the bezel size of an iOS/iPadOS device.

This package came to life as a solution to a complex problem, and today, it stands - as I hope - as a testament to what can be achieved with a bit of creativity, patience, and yearning for pixel perfection.

The Genesis of BezelKit

Before I even started writing the package, I hadn't even thought of the complexities Apple has with device bezel radii.

I was updating my calendar app, where I imagined having the status of the person or room bordered with green for free, and red for busy.

It was a design I had seen in some other systems so thought, "how hard can it be to add a border around an iPad's curve?"

What I was aiming for
What I was aiming for

Turns out, the answer is not straightforward.

Stack Overflow to the rescue

Without much knowledge (at the time) of the different bezel sizes, I did a quick search for "rounded border SwiftUI", and found the countless answers on how to achieve this.

My assumption was, and particularly from a manufacturing standpoint, that the bezel would be identical for every Apple device.

I mean, if I needed to make a curved screen wouldn't I want to have one template for all devices? Turns out though being a trillion plus dollar company means you can do whatever you want.

Static radius border

The first thing I did was fiddle with the radius for a RoundedRectangle for an iPad Air (5th generation) in the simulator.

I settled on a radius of 18, which seemed to match the device perfectly, and then stored that into an extension of CGFloat called deviceRadius.

iPad Air 5th generation at 18pt vs 19pt
iPad Air 5th generation at 18pt vs 19pt
iPad Air 5th generation at 18pt vs 19pt - zoomed
iPad Air 5th generation at 18pt vs 19pt - zoomed

As you can slightly see, there is a hairline white exposed background at 19pt.

This was exacerbated when I switched over to the iPad mini (6th generation) that 18 points wasn't hugging the border anymore.

Turns out the iPad mini needed 21.5 as it's radius.

The hurdle

Seeing there was differences between devices I dug deeper into this thinking, "I'm surely not the only person wanting to do this".

But after some research, I realised that Apple wasn’t doing developers any favours which resulted in not many results from developers to achieve this (at least from my searches).

Apple doesn't provide a public API for pulling device-specific bezel sizes, with the only thing I came across was using ContainerRelativeShape().

However, this shape has a brief but annoying description (emphasis mine):

A shape that is replaced by an inset version of the current container shape. If no container shape was defined, is replaced by a rectangle.

Essentially, if you're not calling that shape in a widget you get a Rectangle() - great.

My options

With no public API available, I was thinking about what alternatives could I do?

My initial thought was, "how do designers make device frames?" I mean, they make pretty pixel accurate overlays for our screenshots, so they mush get their data from somewhere.

Turns out (and I didn't want to go too far into it) results for this type of research only returns more images for device frames. Nothing on how they calculate it or where they get it from.

Manual all the way

I thought the most trusted screenshot generating tool out there had to be fastlane.

Recommendation

If your gut is telling you this is a bad idea - trust it.

I (almost) went down the path of downloading all the frames from fastlane, throwing them into Photoshop or Illustrator, creating a circle shape, and writing down the radius.

I nearly gave up here. What a pointless, exhausting effort for what, a minor design attribute?

Alternative options

I had some options here:

  1. Re-design the app to not use a rounded perfectly fitting border
  2. Re-design the app to add some padding to the border from the edge, blurring the incorrect radius match
  3. Ignore it completely and stick with one value
  4. Calculate some average radius value and make it look okay
  5. Create some monstrosity of a switch statement per device extracting the numbers from each simulator or fastlane frame
  6. Cry uncontrollably

Although option #6 was tempting, I remember reading and being impressed by the extra steps David Smith went with on when talking about .continuous in RoundedRecatangle shapes.

There's more to your app than the functionalities, with one of the big players is the UI and how the feel of the app is.

One thing I always like to return my mind back to is the world of VFX. When VFXs are present but not obvious they are the ones that tie an entire film or TV show together.

Little aspects which have been inserted or removed with the only purpose to make the rest of the scene seamless.

I could easily give up on the UI of a perfect rounded border, but then I'd go against everything about good-looking design.

Re-visiting Xcode

Determined that this was something I wanted to achieve - not even as a Package, simply for myself - I did some extra searching to see if there were any developers who had looked into this.

I eventually came across a private Apple API _displayCornerRadius, which is fairly easy to implement:

extension UIScreen {
  public var displayCornerRadius: CGFloat {
    guard let cornerRadius = self.value(forKey: "_displayCornerRadius") as? CGFloat else {
        return 0
    }
    return cornerRadius
  }
}

But using that or similar within your app can lead to rejection from App Store reviews. It might not happen the first time, but with each submission you risk it more and more.

I don't know about you, but I'd like to not risk it - otherwise you have to have a fallback at some point when they reject the app.

Automating the process

Now that there is an actual accurate way to extract the display's corner radius using UIScreen.main.displayCornerRadius, the next best thing to do is automate it into collecting data.

There were some basic steps I wanted to do:

  1. Create a dummy Xcode project that reported the UIScreen.main.displayCornerRadius for the booted device
  2. Build the dummy project
  3. Create an array of simulator devices, and install the dummy project on it
  4. Extract the bezel data

Shell script

I started off building a shell script which would automate the process for me.

Forewarning to those heading down this path, that I ran into parsing issues extracting information correctly. I'm sure if I persisted it would have been feasible but I didn't continue with shell scripting.

In my initial testing this was my dummy Xcode project data, purely to see if I could extract data.

@main
struct testApp: App {
  var body: some Scene {
    WindowGroup {
      HStack {}
      let _ = print("I ran!")
    }
  }
}

This was the shell script at this point, currently with errors:

#!/bin/sh

# Path to your Xcode project
PROJECT_PATH="/Users/mb/Desktop/test/test.xcodeproj"

# App's bundle ID
BUNDLE_ID="com.mb.test"

# Path to the output file within the app
APP_FILE_PATH="Documents/output.txt"

# Destination path on Mac for the output file
DEST_PATH="/Users/mb/Desktop/test/output_"

# Build the app for simulator
xcodebuild -project "$PROJECT_PATH" -scheme "test" -destination 'generic/platform=iOS Simulator' build

# Get the build path for the simulator app
APP_BUILD_PATH="/Users/mb/Library/Developer/Xcode/DerivedData/test-dnsnlunniukkpwginzqajqaozuob/Build/Products/Debug-iphonesimulator/test.app"

# Define your list of simulators
SIMULATOR_NAMES=("iPhone 8" "iPhone 11")

# Loop to get UDIDs for each simulator name
SIMULATORS=""
for SIM_NAME in "${SIMULATOR_NAMES[@]}"; do
  UDID=$(xcrun simctl list devices | grep -E "$SIM_NAME \(" | grep -v unavailable | awk -F "[()]" '{print $2}')
  SIMULATORS="$SIMULATORS $UDID"
done

# Loop through each simulator by UDID
for UDID in $SIMULATORS; do
  echo "Processing simulator with UDID: $UDID"

  # Start the simulator
  xcrun simctl boot "$UDID"

  # Install the app
  xcrun simctl install "$UDID" "$APP_BUILD_PATH"

  # Launch the app
  xcrun simctl launch "$UDID" "$BUNDLE_ID"

  # Give the app some time to process
  sleep 5

  # capture the logs
  xcrun simctl spawn booted log show --time 1m > "$DEST_PATH$UDID"

  # Terminate the app
  xcrun simctl terminate "$UDID" "$BUNDLE_ID"

  # Give the app some time to process
  sleep 5

  # Uninstall the app
  xcrun simctl uninstall "$UDID" "$BUNDLE_ID"

  # Shutdown the simulator
  xcrun simctl shutdown "$UDID"
done

Shell script error

However, when I was running this I would run into errors:

** BUILD SUCCEEDED **

Processing simulator with UDID: 62B84148-0CCE-4D42-B9FA-358BE6453EA7
com.mb.test: 6855
Invalid device: 
Processing simulator with UDID: 13D1B3A3-A579-4AE2-A05E-4C5CC366C46A
com.mb.test: 7122
Invalid device: 

This was caused by the line: xcrun simctl spawn booted log show --time 1m > "$DEST_PATH$UDID" which resulted in this error: Invalid device:.

I tried changing from booted to using the $UDID but that too would result in the error: Error from getpwuid_r: 0 (Undefined error: 0).

NodeJS

Researching these errors I cam across the -j flag when running this command xcrun simctl list devices. This would output the data as JSON, which I immediately thought I'd probably better versed in Javascript to parsing JSON data.

In-case you didn't know

Running xcrun simctl list devices -j or xcrun simctl list runtimes -j returns the devices and runtime data as JSON.

This also let me think into the future where I could automate this with a Github Action easily tying into some of the previous articles I've written about them.

I'm not going to go too in-depth of the Javascript code, but you can see it in the Github repository.

Though it might be a foreign language, I have documented the entire script to help understand the functionalities behind it. But as a recap:

  1. Read the three text files:
    1. target-simulators.txt: These are the simulators to fetch new data for
    2. completed-simulators.txt: These are previously completed simulators
    3. problematic-simulators.txt: These are simulators which previously had issues (no runtime, matching device, etc.)
  1. Parse the CSV with a list of all the Apple device identifiers, and friendly names
  2. Get the previously run data (so new devices don't report 0.0 instead of last fetched value)
  1. Loop over the target-simulators.txt identifiers
    1. Boot the simulator, if exists
    2. Install the simulator, if does not exist and is able to be created
    3. Install the com.mb.FetchBezel app (which is from the included FetchBezel.xcodeproj)
    4. Output the bezel data to the app sandbox container
    5. Merge it into the final JSON data

Once the script completes, it outputs it to the Swift Package Resources folder.

Risks and mitigations

Though BezelKit does use the internal API for the device display radius, it isn't included or called within your app. As a result this is a safer approach.

The file is also very small (6KB), so if you wanted you could include that directly without using the package. Or make a call for the user on launch to fetch the data locally into the device.

I mean, that's 3 ways already you can use the data without Apple getting upset or blocking your app.

Benefits

Without overselling it, I think the benefits alone are that you can now "natively" scale your views to the actual bezel size for each device.

You don't have to cover 90% of devices, and then have a few which look odd - each one will match consistently.

Streamlined API

Another area I tried to make simple is the call site.

I'm still new to this development game, and sometimes when I use other packages or read implementations the APIs are so complex.

I'd like to think BezelKit has a simple and easy API.

It's as simple as:

import BezelKit
let currentBezel = CGFloat.deviceBezel

Fallback radius

One thing I did want to also take into account for simplicity was for older devices which have no screen radius - iPhone 8, iPhone SE, iPad Air 2, etc.

I didn't want to have to force the user - you - to have to create your own if/else each time you used BezelKit for these types of devices.

So I created the API for setFallbackDeviceBezel(_:ifZero:), which accepts a fallback radius, and to affect if the value is 0.0.

What this means is, the fallback will apply if the device is not found in the JSON. This could be new devices, missed identifiers, anything. The ifZero boolean says if the value is 0.0 then use the fallback value.

When using this, I've been able to have the radius match on all the square devices (iPhone 8, iPhone SE, iPad Air 2, etc.), but be unique for all curved screen devices.

Comprehensive device support

What good is a tool if it only works half the time?

BezelKit offers robust device support with a detailed list of supported devices on GitHub, giving you a complete overview.

It also runs monthly on a schedule to update - so it should always be up to date.

Conclusion

Without going too much more into it and sounding more salesperson-like, I'm hoping it bridges a crucial gap in the iOS development landscape.

If you're as keen on getting those pixel-perfect bezels as I am, head over to the GitHub repository to get started.

For those interested in contributing, we’re all ears! Check out the contribution guidelines and become a part of this exciting venture.


Help me write and make more!

You can help me continue to provide valuable content like this. If you found this article helpful, please consider supporting me.

Coffee Pizza Dinner