How to Preserve User-Preferred Window Size in Mac Catalyst Apps
When building a Mac app with Mac Catalyst, you may face a challenge when it comes to preserving the user-preferred window size. Unlike on iOS and iPadOS, where the app can take some predefined size, on macOS users can resize windows to fit their needs. However, Mac Catalyst apps do not automatically preserve the user's preferred window size when the app is relaunched. This can be frustrating for users, who have to resize the window every time they open the app.
In this article, we will explore how to solve this problem using new in iOS 16.0 UIWindowScene.effectiveGeometry
and UIWindowScene.requestGeometryUpdate(_:errorHandler:)
APIs. I'll walk you through the steps necessary to preserve the user-preferred window size and ensure your app looks and feels just right on macOS.
Let's outline what needs to be done to solve this task.
- Track user's window size and position
- Store window's frame value
- Validate and adjust window's frame
- Request the system to update the window frame
Track user's window size and position
First things first, we need to observe user's window size and position. If we open UIWindowScene.effectiveGeometry
documentation, we can find that it is key-value observing (KVO) compliant and is a recommended way to receive window scene's geometry.
If you support multiple windows, your app can have more than one window scene, which means we will need to hold multiple observers. One way of doing this is to create an observer class that will be initialized for every new connected scene:
final class WindowSizeObserver: NSObject {
@objc private(set) var observedScene: UIWindowScene?
private var observation: NSKeyValueObservation?
init(windowScene: UIWindowScene) {
self.observedScene = windowScene
super.init()
startObserving()
}
private func startObserving() {
// Observe scene geometry changes
}
}
When observing a KVO property, we can customize observing options. In our example, we need to track only .new
options. Also, I noticed that on scene launch, the system adjusts geometry multiple times until it defines the final position, so I skip zero frame sizes and zero frame positions. On macOS, zero position is not valid as it's the menu bar territory.
private func startObserving() {
observation = observe(\.observedScene?.effectiveGeometry, options: [.new]) { _, change in
guard let newSystemFrame = change.newValue??.systemFrame,
newSystemFrame.size != .zero, newSystemFrame.origin != .zero else { return }
// Store new system frame to use in the future
}
}
Next, to hold these observers and request window scene changes, we can create something like a WindowScenesManager
that we can initialize in the AppDelegate
. The manager should have an API to add and discard scenes that we can call from our scene delegate's sceneDidBecomeActive
and app delegate's didDiscardSceneSessions
methods.
final class WindowScenesManager {
private var windowSizeObservers: [WindowSizeObserver] = []
func sceneDidBecomeActive(_ scene: UIWindowScene) {
startObservingScene(scene)
}
private func startObservingScene(_ scene: UIWindowScene) {
let observer = WindowSizeObserver(windowScene: scene)
windowSizeObservers.append(observer)
}
func didDiscardScene(_ scene: UIScene) {
windowSizeObservers.removeAll(where: { $0.observedScene == scene })
}
}
Note: If you have various window scene configurations, you can check the
scene.session.configuration.name
to apply the logic based on the window type.
Store window's frame value
Storing the window scene's frame in UserDefaults
sounds like a good solution, as we need it for future app launches.
Most apps usually have some kind of UserDefaults
wrapper that can be used for this task. For the purpose of this article, I'll create a dummy static property that will work as well.
Note: For a wrapper, I personally prefer a
@PropertyWrapper
approach that you can find here: Property Wrappers in Swift explained with code examples by Antoine van der Lee. I upgraded it to read launch time arguments, and it works great for me.
So, let's go ahead and create a static
computed property that can save and retrieve our system frame geometry as Data
from UserDefaults
:
enum UserDefaultsConfig {
private static var defaultSceneLatestSystemFrameData: Data? {
get {
UserDefaults.standard.data(forKey: "default-scene-latest-system-frame-data")
}
set {
UserDefaults.standard.set(newValue, forKey: "default-scene-latest-system-frame-data")
}
}
}
Now we should transform CGRect
into Data
. Since CGRect
conforms to Codable
out of the box, let's use JSON
to code/decode it. Add this static property to our enum UserDefaultsConfig
:
enum UserDefaultsConfig {
static var defaultSceneLatestSystemFrame: CGRect? {
get {
guard let savedData = defaultSceneLatestSystemFrameData else { return nil }
return try? JSONDecoder().decode(CGRect.self, from: savedData)
}
set {
if let newValue {
if let newData = try? JSONEncoder().encode(newValue) {
defaultSceneLatestSystemFrameData = newData
}
} else {
defaultSceneLatestSystemFrameData = nil
}
}
}
}
Finally, let's update our WindowSizeObserver.startObserving()
implementation to save the frame changes:
private func startObserving() {
observation = observe(\.observedScene?.effectiveGeometry, options: [.new]) { _, change in
guard let newSystemFrame = change.newValue??.systemFrame,
newSystemFrame.size != .zero, newSystemFrame.origin != .zero else { return }
UserDefaultsConfig.defaultSceneLatestSystemFrame = newSystemFrame
}
}
Validate and adjust window frame
Before we request the system to configure the window frame, let's think about some edge cases and improvements we can handle:
- Different screen sizes. A user uses another screen which is smaller than the previously opened window.
- New windows should not completely overlap previous windows. We want them to open in a "cascade" mode like non-catalyst macOS apps do.
- Etc. Other edge cases can exist, so be sure to test and adjust for your app's needs.
First, let's validate if our frame fits the current screen size. If not, we'll let the system use what it thinks is the default window size for the app.
private func sceneFrameIsValid(_ sceneFrame: CGRect, screenSize: CGSize) -> Bool {
return sceneFrame.height <= screenSize.height && sceneFrame.width <= screenSize.width
}
Next, let's create a helper method to replicate the "cascade" windows placement (each window has some offset relative to the previous one) for new windows:
private func adjustedSystemFrame(_ systemFrame: CGRect, for screenSize: CGSize, numberOfConnectedScenes: Int) -> CGRect {
guard numberOfConnectedScenes > 1 else { return systemFrame }
var adjustedFrame = systemFrame
// Inset from the already presented scene
// 29 is used by default by the system
adjustedFrame = adjustedFrame.offsetBy(dx: 29, dy: 29)
// Move to the top if we are out of the screen's bottom
if adjustedFrame.origin.y + adjustedFrame.height > screenSize.height - 80 {
adjustedFrame.origin.y = 80
}
// Move to left if we are out of the screen's right side
if adjustedFrame.origin.x + adjustedFrame.width > screenSize.width - 20 {
adjustedFrame.origin.x = 20
}
return adjustedFrame
}
"Magic numbers" alert.
29
offset is the default used by the system. However,80
and20
were dialled in specifically for my use case which might differ from what you need.
Request the system to update the window frame
Now let's put everything together and request the system to update the window geometry. We'll do this from the previously created sceneDidBecomeActive(_:)
method that should be called from the UISceneDelegate
delegate methods:
extension WindowScenesManager {
func sceneDidBecomeActive(_ scene: UIWindowScene) {
if #available(macCatalyst 16.0, *) {
configureSceneSize(scene)
startObservingScene(scene)
}
}
@available(macCatalyst 16.0, *)
private func configureSceneSize(_ scene: UIWindowScene) {
guard let preferredSystemFrame = UserDefaultsConfig.defaultSceneLatestSystemFrame,
preferredSystemFrame != .zero else { return }
let screenSize = scene.screen.bounds.size
guard sceneFrameIsValid(preferredSystemFrame, screenSize: screenSize) else { return }
let numberOfConnectedScenes = UIApplication.shared.connectedScenes.count
let adjustedSystemFrame = adjustedSystemFrame(preferredSystemFrame, for: screenSize, numberOfConnectedScenes: numberOfConnectedScenes)
scene.requestGeometryUpdate(.Mac(systemFrame: systemFrame)) { [weak self] error in
self?.log.error("\(error.localizedDescription)")
}
}
}
Possible Bug Workaround
There is a bug in macOS Ventura 13.2.1 that configures the window for a smaller height than we requested. Hopefully, this will be fixed in future updates, but for now, we can request a geometry update on the next main thread run as a workaround. Add a second request at the end of the configureSceneSize(_:)
method:
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) {
scene.requestGeometryUpdate(.Mac(systemFrame: systemFrame))
}
Conclusion
In conclusion, preserving the user-preferred window size is crucial for providing a seamless user experience in Mac Catalyst apps. While this functionality is not built-in by default, using the new UIWindowScene.effectiveGeometry
and UIWindowScene.requestGeometryUpdate(_:errorHandler:)
APIs introduced in iOS 16.0, we can track and store the user's preferred window size and position, validate and adjust the window's frame, and request the system to update it accordingly.
I hope you enjoyed this article. If you have any questions, suggestions, or feedback, please let me know on Twitter.
Thanks for reading!