如何 show/hide 将按钮添加到 NSWindow 的标题栏?

How can I show/hide a button added to the title bar of an NSWindow?

我在 NSWindow 扩展中创建了一个方法,允许我在标题栏中的文本旁边添加一个按钮。这类似于 Pages and Numbers 标题栏中出现的“向下 V 形”按钮。单击按钮时,任意代码,表示为闭包,为运行.

虽然这部分工作正常,但我也希望按钮在大多数情况下是不可见的,只有当鼠标滚动到标题栏区域时才可见。这将模仿 Pages 和 Numbers 显示按钮的方式。

但是,我很难让 show/hide 正常工作。我相信如果我在应用程序委托中完全自定义它,并且可能通过子类化 NSWindow,我相信我可以做到这一点,但我真的很想将它作为单个方法保留在 NSWindow 扩展中。通过这种方式,代码可以很容易地在多个应用程序中重用。

为了实现这一点,我相信我需要注入一个额外的 handler/listener 来告诉我鼠标何时进入和离开适当的区域。我可以使用 NSTrackingArea 定义必要的区域,但我还没有弄清楚如何在不需要子类的情况下“注入”事件侦听器。有谁知道如何(或如果)这样的事情是可能的?

根据鼠标位置处理 show/hide 的关键是使用 NSTrackingArea 来表示我们感兴趣的部分,并处理鼠标进入和鼠标退出事件。但是由于这不能直接在标题栏视图上完成(因为我们必须 subclass 视图才能添加事件处理程序)我们需要创建一个额外的 NSView ,它是不可见的但覆盖了我们的区域想追踪。

我将 post 下面的完整代码,但与此问题相关的关键部分是文件底部附近定义的 TrackingHelper class 及其方式添加到 titleBarView,其约束设置为等于标题栏的大小。 class 本身设计了三个闭包,一个用于鼠标进入事件,一个用于鼠标退出事件,一个用于按下按钮时采取的操作。 (从技术上讲,后者并不真的需要成为 TrackingHelper 的一部分,但它是一个方便的地方,以确保在 UI 仍然存在时它不会超出范围。更多正确的解决方案是 subclass NSButton 保持关闭,但我一直发现 subclassing NSButton 是一种皇家痛苦。)

这里是解决方案的全文。请注意,这有一些依赖于我的另一个库的东西——但它们不是理解这个问题所必需的,而是用于处理按钮图像。如果您希望使用此代码,您需要将 getImage 函数替换为创建所需图像的函数。 (如果您想查看 KSSCocoa 添加的内容,您可以从 https://github.com/klassen-software-solutions/KSSCore 获取)

//
//  NSWindowExtension.swift
//
//  Created by Steven W. Klassen on 2020-02-24.
//

import os
import Cocoa
import KSSCocoa

public extension NSWindow {
    /**
     Add an action button to the title bar. This will add a "down chevron" icon, similar to the one used in
     Numbers and Pages, just to the right of the title in the title bar. When clicked it will run the given
     lambda.
     */
    @available(OSX 10.14, *)
    func addTitleActionButton(_ lambda: @escaping () -> Void) -> NSButton {
        guard let titleBarView = getTitleBarView() else {
            fatalError("You can only add a title action to an app that has a title bar")
        }
        guard let titleTextField = getTextFieldChild(of: titleBarView) else {
            fatalError("You can only add a title action to an app that has a title field")
        }

        let trackingHelper = TrackingHelper()
        let actionButton = NSButton(image: getImage(),
                                    target: trackingHelper,
                                    action: #selector(trackingHelper.action))
        actionButton.setButtonType(.momentaryPushIn)
        actionButton.translatesAutoresizingMaskIntoConstraints = false
        actionButton.isBordered = false
        actionButton.isEnabled = false
        actionButton.alphaValue = 0

        trackingHelper.translatesAutoresizingMaskIntoConstraints = false
        trackingHelper.onButtonAction = lambda
        trackingHelper.onMouseEntered = {
            actionButton.isEnabled = true
            actionButton.alphaValue = 1
        }
        trackingHelper.onMouseExited = {
            actionButton.isEnabled = false
            actionButton.alphaValue = 0
        }
        titleBarView.addSubview(trackingHelper)
        titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[trackingHelper]-0-|",
                                                                   options: [], metrics: nil,
                                                                   views: ["trackingHelper": trackingHelper]))
        titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[trackingHelper]-0-|",
                                                                   options: [], metrics: nil,
                                                                   views: ["trackingHelper": trackingHelper]))

        titleBarView.addSubview(actionButton)
        titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:[titleTextField]-[actionButton(==7)]",
                                                                   options: [], metrics: nil,
                                                                   views: ["actionButton": actionButton,
                                                                           "titleTextField": titleTextField]))
        titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-1-[actionButton]-3-|",
                                                                   options: [], metrics: nil,
                                                                   views: ["actionButton": actionButton]))

        DistributedNotificationCenter.default().addObserver(
            actionButton,
            selector: #selector(actionButton.onThemeChanged(notification:)),
            name: NSNotification.Name(rawValue: "AppleInterfaceThemeChangedNotification"),
            object: nil
        )

        return actionButton
    }

    fileprivate func getTitleBarView() -> NSView? {
        return standardWindowButton(.closeButton)?.superview
    }

    fileprivate func getTextFieldChild(of view: NSView) -> NSTextField? {
        for subview in view.subviews {
            if let textField = subview as? NSTextField {
                return textField
            }
        }
        return nil
    }
}


fileprivate extension NSButton {
    @available(OSX 10.14, *)
    @objc func onThemeChanged(notification: NSNotification) {
        image = image?.inverted()
    }
}

@available(OSX 10.14, *)
fileprivate func getImage() -> NSImage {
    var image = NSImage(sfSymbolName: "chevron.down")!
    if NSApplication.shared.isDarkMode {
        image = image.inverted()
    }
    return image
}


fileprivate final class TrackingHelper : NSView {
    typealias Callback = ()->Void

    var onMouseEntered: Callback? = nil
    var onMouseExited: Callback? = nil
    var onButtonAction: Callback? = nil

    override func mouseEntered(with event: NSEvent) {
        onMouseEntered?()
    }

    override func mouseExited(with event: NSEvent) {
        onMouseExited?()
    }

    @objc func action() {
        onButtonAction?()
    }

    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        for trackingArea in self.trackingAreas {
            self.removeTrackingArea(trackingArea)
        }

        let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
        let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
        self.addTrackingArea(trackingArea)
    }
}