Disabling the Mac Zoom/Maximise Button in Catalyst

I've recently been experimenting with converting some existing iOS/iPadOS apps to run on the Mac, using the new Catalyst system.

The first of these experiments was to convert a game I previous made called Hexiled. It a high-addictive frantic word game, where you need to find words to achieve one of four different goals.

For various performance and legacy reasons, I wanted to restrict the game to 768x1024, since all of the in-game assets were optimised for this size (it's the size of most models of iPad in portrait mode).

I achieved this using the following code:

#if TARGET_OS_MACCATALYST
- (void) setupMacCatalyst:(UIApplication *)application
{
    CGSize size = CGSizeMake(768, 1024);
    
    for (UIScene *scene in [application connectedScenes]) {
        
        if ([scene isKindOfClass:[UIWindowScene class]]) {
            UIWindowScene *windowScene = (UIWindowScene *) scene;
            
            [windowScene sizeRestrictions].maximumSize = size;
            [windowScene sizeRestrictions].minimumSize = size;
        }
    }
}
#endif

While this works as expected (you can't resize the window), the problem is that the green "zoom" button still appears in the top-left of the Window.

Green zoom button still appears.

When you maximise the window, the app moves to full screen, but still restricted to 768x1024, meaning it appears letterboxed.

Letterboxed when maximised.

Beacuse of this, the app was rejected. My solution: disable the zoom button completely.

In Catalyst, you don't have access to native macOS (AppKit) controls, so you can't access the underlying NSWindow in order fix this.

The solution is to add an AppKit bundle within your app. Its sole purpose is to modify the window. With a bit of Objective C magic, you can then call that method to disable the zoom button.

In your project in Xcode, add a new target. The type of target should be a macOS bundle. For this example, call it AppBundle.

Create a macOS bundle as a new target in your project.

Next, embed it in your iOS app's target. You can do this on the "General" tab on your app's main target. Select to only embed it in the macOS app (not the iOS app).

Embed in your main iOS app. Change the platform to macOS only.

Next, create an Objective C class in AppBundle. For this example, we called the class MacApp.

Create a new Objective C class in AppBundle.

The following is the code in MacApp.h. This is basically just the automatically generated code with the disableMaximizeButton method added.

//
//  MacApp.h
//  AppBundle
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface MacApp : NSObject

+ (void) disableMaximizeButton;

@end

NS_ASSUME_NONNULL_END

The implementation for this method, in MacApp.m, is as follows:

#import "MacApp.h"

@import AppKit;

@implementation MacApp

+ (void) disableMaximizeButton
{
    NSArray *windows = NSApplication.sharedApplication.windows;
    
    NSWindowCollectionBehavior behavior = NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorFullScreenNone;
    
    for (NSWindow *window in windows) {
        [window setCollectionBehavior:behavior];
        
        NSButton *button = [window standardWindowButton:NSWindowZoomButton];
        [button setEnabled:NO];
    }
}

@end

This code finds all of the NSWindow objects in the app and does two things:

  1. It explicitly disables the green zoom button (NSWindowZoomButton)
  2. It changes the properties of the window to indicate it cannot go full screen.

If the second step wasn't performed, then you could still maximize using the Window toolbar menu.

Next, you must create a way to call this method from the main iOS app. To do this, I created a helper class for when the app is running on Mac.

#if targetEnvironment(macCatalyst)
class CatalystAppManager {
    class func configureMacAppWindow() {
        guard let appBundleUrl = Bundle.main.builtInPlugInsURL else {
            return
        }
        
        let helperBundleUrl = appBundleUrl.appendingPathComponent("AppBundle.bundle")
        
        guard let bundle = Bundle(url: helperBundleUrl) else {
            return
        }

        bundle.load()
        
        guard let object = NSClassFromString("MacApp") as? NSObjectProtocol else {
            return
        }
        
        let selector = NSSelectorFromString("disableMaximizeButton")
        object.perform(selector)
    }
}
#endif

This works by first loading the AppBundle bundle, then loading the MacApp class. From there, it uses performSelector to call the method.

At this time, the code above generates a warning about casting to NSObjectProtocol. I'm not sure how to avoid this.

The final step is to call the configureMacAppWindow() method. I found it only worked if I called it in viewDidAppear() of the app's first view controller (or later).

class FirstViewController: UIViewController {
    
    #if targetEnvironment(macCatalyst)
    var hasDisabledZoom = false
    #endif

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        #if targetEnvironment(macCatalyst)
        if !hasDisabledZoom {
            hasDisabledZoom = true
            CatalystAppManager.configureMacAppWindow()
        }
        #endif
    }
}

Now when you run the app again, the zoom button is greyed out!

The zoom button in the upper-left is now greyed out.