VButko

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.

  1. Track user's window size and position
  2. Store window's frame value
  3. Validate and adjust window's frame
  4. 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 and 20 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!