Search code examples
iosswiftmacoscocoamac-catalyst

MacOS Catalyst: how to allow double click on UIView to zoom window?


By default, in MacOS, you can double click the titlebar of a window to zoom it (resize to fit the screen - different from maximize). Double clicking it again brings it back to the previous size.

This works fine on my Catalyst app too. However I need to hide the titlebar and need to give my own custom UIView in that titlebar area the double click behavior. I am able to hide it using this:

#if os(OSX) || os(macOS) || targetEnvironment(macCatalyst)
    UIApplication.shared.connectedScenes.forEach({
        if let titlebar = ($0 as? UIWindowScene)?.titlebar {
            titlebar.titleVisibility = .hidden
            titlebar.toolbar = nil
        }
    })
#endif

Is there a method which lets me toggle the window zoom?


Solution

  • I was able to figure this out using a workaround. UIKit/Catalyst itself doesn't provide any way to do this. But I was able to use the second method outline in this post on

    How to Access the AppKit API from Mac Catalyst Apps

    https://betterprogramming.pub/how-to-access-the-appkit-api-from-mac-catalyst-apps-2184527020b5

    I used the second method and not the first one as the first one seems to be private API (I could be wrong) and will get rejected in App Store. The second method of using a plugin bundle and calling methods on that works well for me. This way I was able to not just perform the zoom, I was also able to perform other MacOS Appkit functionality like listening for keyboard, mouse scroll, hover detection etc.

    After creating the plugin bundle, here's my code inside the plugin:

    Plugin.swift:

    import Foundation
    @objc(Plugin)
    protocol Plugin: NSObjectProtocol {
        init()
        func toggleZoom()
        func macOSStartupStuff()
    }
    

    MacPlugin.swift:

    import AppKit
    class MacPlugin: NSObject, Plugin {
        required override init() {}
    
        func macOSStartupStuff() {
            NSApplication.shared.windows.forEach({
                $0.titlebarAppearsTransparent = true
                $0.titleVisibility = .hidden
                $0.backgroundColor = .clear
                ($0.contentView?.superview?.allSubviews.first(where: { String(describing: type(of: $0)).hasSuffix("TitlebarDecorationView") }))?.alphaValue = 0
            })
        }
    
        func toggleZoom(){
            NSApplication.shared.windows.forEach({
                $0.performZoom(nil)
            })
        }
    }
    
    extension NSView {
        var allSubviews: [NSView] {
            return subviews.flatMap { [$0] + $0.allSubviews }
        }
    }
    

    Then I call this from my iOS app code. This adds a transparent view at the top where double clicking calls the plugin code for toggling zoom.

    NOTE that you must call this from viewDidAppear or somewhere when the windows have been initialized and presented. Otherwise it won't work.

    #if os(OSX) || os(macOS) || targetEnvironment(macCatalyst)
        @objc func zoomTapped(){
            plugin?.toggleZoom()
        }
        
        var pluginWasLoaded = false
        lazy var plugin : Plugin? = {
            pluginWasLoaded = true
            if let window = (UIApplication.shared.delegate as? AppDelegate)?.window {
                let transparentTitleBarForDoubleClick = UIView(frame: .zero)
                let tapGesture = UITapGestureRecognizer(target: self, action: #selector(zoomTapped))
                tapGesture.numberOfTapsRequired = 2
                transparentTitleBarForDoubleClick.addGestureRecognizer(tapGesture)
                transparentTitleBarForDoubleClick.isUserInteractionEnabled = true
                
                transparentTitleBarForDoubleClick.backgroundColor = .clear
                transparentTitleBarForDoubleClick.translatesAutoresizingMaskIntoConstraints = false
                window.addSubview(transparentTitleBarForDoubleClick)
                window.bringSubviewToFront(transparentTitleBarForDoubleClick)
                
                window.addConstraints([
                                    NSLayoutConstraint(item: transparentTitleBarForDoubleClick, attribute: .leading, relatedBy: .equal, toItem: window, attribute: .leading, multiplier: 1, constant: 0),
                                     NSLayoutConstraint(item: transparentTitleBarForDoubleClick, attribute: .top, relatedBy: .equal, toItem: window, attribute: .top, multiplier: 1, constant: 0),
                                     NSLayoutConstraint(item: transparentTitleBarForDoubleClick, attribute: .trailing, relatedBy: .equal, toItem: window, attribute: .trailing, multiplier: 1, constant: 0),
                                     transparentTitleBarForDoubleClick.bottomAnchor.constraint(equalTo: window.safeTopAnchor)
                                     ])
                window.layoutIfNeeded()
            }
            
            guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent("MacPlugin.bundle") else { return nil }
            guard let bundle = Bundle(url: bundleURL) else { return nil }
            guard let pluginClass = bundle.classNamed("MacPlugin.MacPlugin") as? Plugin.Type else { return nil }
            return pluginClass.init()
        }()
    #endif
    

    Calling it from viewDidAppear:

    override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            #if os(OSX) || os(macOS) || targetEnvironment(macCatalyst)
            if !Singleton.shared.pluginWasLoaded {
                Singleton.shared.plugin?.macOSStartupStuff()
            }
            #endif
    }