Enable SwiftUI Button Click-Through for Inactive Windows on macOS

null

On macOS, there’s 1 active window that receives your key input. Then there’s all the other windows, which are potentially invisible or obstructed by other windows. These inactive windows can be brought to the front and made active with a click. Then you can interact with these windows like normal.

With inactive but visible windows, you cannot select items from lists, for example. The UI reflects this with a greyed-out or dimmed representation. You need to click once to activate the window (on the surface of the window, so to speak), then another time to interact with the control.

There are notable exceptions: click-through controls. These can be interacted with even when the window is not active. So you don’t click on the window to bring it to front, you click through to its control. (Through what you click exactly is left to interprepation as far as I know: I imagine you click through its foggy inactivity, piercing the clouds, to reach the bottom you can visually make out.)

NSButton once became a click-through control. That wasn’t a popular change in 2003. And it wasn’t done consistently.

20 years later, with SwiftUI, we have to be careful to not create inconsistent behavior in macOS apps again by forgetting about click-through controls.

Update 2024-07-09: macOS 15 beta 2 fixed these problems and all custom button style variations I tested now work with click-through “as expected”!

Limitations Up to macOS 15

Of the SwiftUI button styles I tested, only .bordered supports click-through. That looks like a vanilla macOS button.

If you used .plain, you lose this behavior.

If you used a custom ButtonStyle, you lose this behavior.

To support click-through in custom views, you can override NSView’s acceptsFirstMouse(for:) and return true. That will tell the inactive window which is being clicked to consider your view’s subview-hierarchy to handle the click.

We can use this technique to fix custom SwiftUI buttons, too.

I wrote a ViewModifier that works on SwiftUI buttons with custom styles, but also any other tap-gesture using custom view:

VStack {
    ForEach(...) { item in
         MyCustomView(item)
            .acceptClickThrough()
            .onTapGesture { ... }
    }
}

In practice, it doesn’t matter whether you put .acceptClickThrough before or after .onTapGesture. The click-through check and the actual click are handled separately.

Think of handling the click event being performed in steps:

  1. The NSWindow being clicked on checks which view is under the mouse cursor with a hitTest(_:),
  2. the match determined whether it acceptsFirstMouse(for:) with the click event (otherwise, the hit test continues with other views at the pixel location). If that’s returning true, then
  3. the click event is being sent to an appropriate click event responder in its subview-hierarchy.

For our purposes, acceptsFirstMouse enables the view and its children to handle the click event “like normal”. The window does both in one go: finding out whether the click should be handled “like normal” by any control at all, then performing that effect (or dropping the event, merely activating in the process).

Caveats:

  • Keep this close to the button or custom view in the view hierarchy. You can’t slap this onto the main ContentView() in all circumstances, because other views will obstruct it and forbid click-through events.
  • Apply this to contents of scroll views, not to scroll views themselves. The backing NSScrollView’s NSClipView responds to acceptsFirstMouse(for:), meaning that it will swallow the activating click.
  • This is a patch for something that arguably ought to default behavior for buttons. I wouldn’t scatter this everywhere a button is used. Keep it close in custom views. Consider exposing a struct MyButton: View for the mere benefit of coupling usage of .buttonStyle(MyButtonStyle()) and .acceptClickThrough() in one modifier sequence.

Code for the .acceptClickThrough modifier

extension SwiftUI.View {
    /// Enable the view to receive "first mouse" events.
    ///
    /// "First mouse" is the click into an inactive window that brings it to
    /// the front (activates it) and potentially triggers whatever control was
    /// clicked on. Controls that do support this are "click-through", because
    /// you can click on the inactive window, "through" its activation
    /// process, into the control.
    ///
    /// ## Using Buttons
    /// 
    /// Wrap a button like this to make it respond to first clicks. The first
    /// mouse acceptance of this wrapper makes the button perform its action:
    ///
    /// ```swift
    /// Button { ... } label: { ... }
    ///     .style(.plain) // Style breaks default click-through
    ///     .acceptClickThrough() // Enables click-through again
    /// ```
    ///
    /// > Note: You need to stay somewhat close to the button. You can use 
    ///         an `HStack`/`VStack` that wrap buttons, but not on a stack
    ///         that wraps custom views that contain the button 2+ levels deep.
    ///
    /// ## Using other tap gesture-enabled controls
    /// 
    /// This also propagates "first mouse" tap gestures to interactive
    /// controls that are not buttons:
    /// 
    /// ```swift
    /// VStack {
    ///     ForEach(...) { item in
    ///          CustomViewWithoutButtons(item)
    ///             .acceptClickThrough()
    ///             .onTapGesture { ... }
    ///     }
    /// }
    /// ```
    public func acceptClickThrough() -> some View {
        ClickThroughBackdrop(self)
    }
}

fileprivate struct ClickThroughBackdrop<Content: SwiftUI.View>: NSViewRepresentable {
    final class Backdrop: NSHostingView<Content> {
        override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
            return true
        }
    }

    let content: Content

    init(_ content: Content) {
        self.content = content
    }

    func makeNSView(context: Context) -> Backdrop {
        let backdrop = Backdrop(rootView: content)
        backdrop.translatesAutoresizingMaskIntoConstraints = false
        return backdrop
    }

    func updateNSView(_ nsView: Backdrop, context: Context) {
        nsView.rootView = content
    }
}