【问题标题】:Cocoa customise NSView's tooltips SwiftCocoa 自定义 NSView 的工具提示 Swift
【发布时间】:2021-04-03 15:23:49
【问题描述】:

我正在尝试创建一个带有粗体文本的工具提示。 macOS 上的一些苹果应用程序使用这种行为。我如何实现这一目标? 我目前的代码

btn.tooltip = "Open Options"
//tooltip doesn't accept attributed strings.

这是我想要实现的一个示例(使用此行为的 Xcode 屏幕截图)。

【问题讨论】:

  • 我必须做一些研究,因为我不知道在我的脑海中,但我很确定实现样式/属性工具提示的应用程序会自己用他们的拥有自己的工具提示视图和跟踪矩形。
  • 你可能是对的。由于苹果应用程序使用它,我认为可可可以实现。
  • 这将是对 Cocoa 的有用补充,可提供更丰富的内置工具提示工具。对于它的价值,我做了一个快速的 GitHub 搜索,看看是否已经有可用的包或框架来做这件事。我什么也没找到。因此,如果您自己编写,这可能是一个获得认可的好地方。
  • 偶然发现了一个用于添加工具提示的函数,这可能是可可中的一个可能的解决方案。但似乎无法找出如何正确使用它。 btn.addToolTip(NSRect, owner: Any, userData: UnsafeMutableRawPointer?)
  • @StephanSchlecht,您没有遗漏任何内容,我认为您对文档的阅读为该方法的讨论带来了澄清。

标签: swift cocoa tooltip nsattributedstring


【解决方案1】:

对于带有 NSAttributedStrings 的工具提示,似乎没有内置的默认行为。作为一种解决方案,可以实现一个浮动的 NSPanel。

只要鼠标在按钮范围内至少有一段时间,您就可以显示带有 NSAttributedString 的弹出框。为此,您可以使用 mouseEntered 和 mouseExited 事件。不幸的是,这要求您继承 NSButton。

完整、独立的 Swift 程序

对于 ViewController,我们最有可能这样称呼它:

import Cocoa

class ViewController: NSViewController {

    private let button = ToolTipButton()

    override func viewDidLoad() {
        super.viewDidLoad()

        button.title = "Hoover over me"
        let headline = "isEnabled"
        let body = "A Boolean value that determines whether the label draws its text in an enabled state."
        button.setToolTip(headline: headline, body: body)
        view.addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
    
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

}

ToolTipButton 类可能如下所示:

import Cocoa

class ToolTipButton: NSButton {
    
    private var toolTipHandler: ToolTipHandler?
    
    func setToolTip(headline: String, body: String) {
        toolTipHandler = ToolTipHandler(headline: headline, body: body)
    }

    override func mouseEntered(with event: NSEvent) {
        toolTipHandler?.mouseEntered(into: self)
    }

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

    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        toolTipHandler?.updateTrackingAreas(for: self)
    }

}

ToolTipHandler 最终看起来像这样:

import Cocoa


final class ToolTipHandler {
    
    private var headline: String
    private var body: String
    private var mouseStillInside = false
    private var panel: NSPanel?

    init(headline: String, body: String) {
        self.headline = headline
        self.body = body
    }
    
    func setToolTip(headline: String, body: String) {
        self.headline = headline
        self.body = body
    }
    
    func mouseEntered(into view: NSView) {
        mouseStillInside = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.showToolTipIfMouseStillInside(for: view)
        }
    }

    func mouseExited() {
        mouseStillInside = false
        panel?.close()
        panel = nil
    }
    
    func updateTrackingAreas(for view: NSView) {
        for trackingArea in view.trackingAreas {
            view.removeTrackingArea(trackingArea)
        }
        
        let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
        let trackingArea = NSTrackingArea(rect: view.bounds, options: options, owner: view, userInfo: nil)
        view.addTrackingArea(trackingArea)
    }

    private func showToolTipIfMouseStillInside(for view: NSView) {
        guard mouseStillInside && panel == nil else { return }
        panel = Self.showToolTip(sender: view, headline: headline, body: body)
    }
    
    private static func showToolTip(sender: NSView, headline: String, body: String) -> NSPanel {
        let panel = NSPanel()
        panel.styleMask = [NSWindow.StyleMask.borderless]
        panel.level = .floating
        let attributedToolTip = Self.attributedToolTip(headline: headline, body: body)
        panel.contentViewController = ToolTipViewController(attributedToolTip: attributedToolTip, width: 200.0)
        let lowerLeftOfSender = sender.convert(NSPoint(x: sender.bounds.minX + 4.0, y: sender.bounds.maxY + 10.0), to: nil)
        let newOrigin = sender.window?.convertToScreen(NSRect(origin: lowerLeftOfSender, size: .zero)).origin ?? .zero
        panel.setFrameOrigin(newOrigin)

        panel.orderFrontRegardless()
        return panel
    }
    
    private static func attributedToolTip(headline: String, body: String) -> NSAttributedString {
        let headlineAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: NSColor.controlTextColor,
            .font: NSFont.boldSystemFont(ofSize: 11)
        ]
        let bodyAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: NSColor.controlTextColor,
            .font: NSFont.systemFont(ofSize: 11)
        ]

        let tooltip = NSMutableAttributedString(string: headline, attributes: headlineAttributes)
        tooltip.append(NSAttributedString(string: "\n" + body , attributes: bodyAttributes))
        return tooltip
    }

}

最后是 ToolTipViewController:

import Cocoa

final class ToolTipViewController: NSViewController {
    
    private let attributedToolTip: NSAttributedString
    private let width: CGFloat

    init(attributedToolTip: NSAttributedString, width: CGFloat) {
        self.attributedToolTip = attributedToolTip
        self.width = width
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        view = NSView()
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
    }
    
    private func setupUI() {
        let label = NSTextField()
        label.isEditable = false
        label.isBezeled = false
        label.attributedStringValue = attributedToolTip
        
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1.0),
            label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 1.0),
            label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -1.0),
            label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -1.0),
            label.widthAnchor.constraint(equalToConstant: width)
        ])
    }
    
}

根据实际需求,可能需要进行调整。但它至少应该是一个起点。

演示

【讨论】:

  • 我投了赞成票,因为在 cmets 进行了所有讨论之后,到目前为止,您是唯一一个实际实施任何解决方案的人。弹出框是一个简单的解决方案,可能就足够了。它的外观与标准工具提示不同,但也许没关系。完成我正在做的事情后,我可能会尝试一下。
  • 我对popover不是很熟悉。是否可以将其调整为看起来像问题中的工具提示(矩形)?
  • 不知道。您当然可以为指针设置首选边缘,但我不知道有一种方法可以让它在没有指针的情况下出现。如果你能做到这一点,它与标准工具提示仍有一点不同,但会非常相似。它确实有一个appearance 属性,但这是针对NSAppearance 的,它更多地与支持不同的颜色主题有关,例如aquadarkAqua
  • 顺便说一句,我确实实现了自己的解决方案。当我进入它时,我认为它可能是我可以自己使用的东西,所以我把它比 SO 回答真正需要的更进一步。我最终做了方法调配以避免必须子类化任何东西来添加自定义工具提示。该部分无法在您的解决方案中使用,但实际的工具提示本身可以。当我发布它时,请随意撕掉该部分并使其适应您的解决方案。我决定重做我处理延迟以显示工具提示的方式,它还没有处于工作状态,所以我还没有准备好发布。但很快。
  • @unknown 如果您想要一个矩形区域而不是指针三角形,您可以使用浮动 NSPanel 而不是 NSPopOver。您可能可以更好地适应您的进一步要求。我稍微调整了答案。
【解决方案2】:

这个答案的源代码和完整版本在这个GitHub repo

与该存储库分开,我还将代码提取到 Swift 包中,因此我可以在其他项目中使用它。添加到项目的依赖项是“https://github.com/chipjarred/CustomToolTip.git”。使用“from”版本 1.0.0 或分支“main”。

接下来是剪裁到一定长度的版本让我发布。

Stephan 的回答促使我自己实现了工具提示。我的解决方案生成的工具提示看起来像标准工具提示,但您可以在其中放置您喜欢的任何视图,因此不仅是样式化的文本,还有图像......如果您愿意,您甚至可以使用 WebKit 视图。

显然,将某些类型的视图放入其中是没有意义的。任何只对用户交互有意义的东西都是没有意义的,因为一旦他们移动鼠标光标与之交互,工具提示就会消失……尽管这将是一个很好的愚人节笑话。

在开始我的解决方案之前,我想提一下,还有另一种方法可以让 Stephan 的解决方案更易于使用,即通过子类化 NSView 来使用“装饰器”模式来包装另一个视图。您的包装器是连接到工具提示并处理跟踪区域的部分。只要确保你也将这些调用转发到包装视图,以防它也有跟踪区域(也许它会改变光标或其他东西,就像NSTextView 所做的那样。)使用装饰器意味着你不会子类化每个视图......只需将要添加工具提示的视图放在ToolTippableView 或您决定调用它的任何内容中。我认为您不需要覆盖所有NSView 方法,只要您通过将视图添加到subviews 来包装视图即可。视图层次结构和响应者链应该负责将您不感兴趣的事件和消息分派到子视图。您应该只需要转发您处理的工具提示(mouseEnteredmouseExited 等...)

我的解决方案

然而,我走到了一个邪恶的极端……并且花了比我应该做的更多的时间,但它似乎是我在某个时候可能想要使用的东西。我调配(“猴子补丁”)NSView 方法来处理自定义工具提示,结合NSView 上的扩展意味着我没有任何子类来添加自定义工具提示,我可以写:

myView.customToolTip = myCustomToolTipContent

myCustomToolTipContent 是我想在工具提示中显示的任何 NSView

工具提示本身

最主要的是工具提示本身。这只是一个窗口。它会根据您放入的任何内容自行调整大小,因此请确保在设置customToolTip 之前已将提示内容的视图frame 设置为您想要的大小。这是工具提示窗口代码:

// -------------------------------------
/**
 Window for displaying custom tool tips.
 */
class CustomToolTipWindow: NSWindow
{
    // -------------------------------------
    static func makeAndShow(
        toolTipView: NSView,
        for owner: NSView) -> CustomToolTipWindow
    {
        let window = CustomToolTipWindow(toolTipView: toolTipView, for: owner)
        window.orderFront(self)
        return window
    }
    
    // -------------------------------------
    init(toolTipView: NSView, for toolTipOwner: NSView)
    {
        super.init(
            contentRect: toolTipView.bounds,
            styleMask: [.borderless],
            backing: .buffered,
            defer: false
        )
        
        self.backgroundColor = NSColor.windowBackgroundColor
        
        let border = BorderedView.init(frame: toolTipView.frame)
        border.addSubview(toolTipView)
        contentView = border
        contentView?.isHidden = false
        
        reposition(relativeTo: toolTipOwner)
    }
    
    // -------------------------------------
    deinit { orderOut(nil) }
    
    // -------------------------------------
    /**
     Place the tool tip window's frame in a sensible place relative to the
     tool tip's owner view on the screen.
     
     If the current layout direction is left-to-right, the preferred location is
     below and shifted to the right relative to the owner.  If the layout
     direction is right-to-left, the preferred location is below and shift to
     the left relative to the owner.
     
     The preferred location is overridden when any part of the tool tip would be
     drawn off of the screen.  For conflicts with horizontal edges, it is moved
     to be some "safety" distance within the screen bounds.  For conflicts with
     the bottom edge, the tool tip is positioned above the owning view.
     
     Non-flipped coordinates (y = 0 at bottom) are assumed.
     */
    func reposition(relativeTo toolTipOwner: NSView)
    {
        guard let ownerRect =
            toolTipOwner.window?.convertToScreen(toolTipOwner.frame),
            let screenRect = toolTipOwner.window?.screen?.visibleFrame
        else { return }
        
        let hPadding: CGFloat = ownerRect.width / 2
        let hSafetyPadding: CGFloat = 20
        let vPadding: CGFloat = 0
        
        var newRect = frame
        newRect.origin = ownerRect.origin
        
        // Position tool tip window slightly below the onwer on the screen
        newRect.origin.y -= newRect.height + vPadding

        if NSApp.userInterfaceLayoutDirection == .leftToRight
        {
            /*
             Position the tool tip window to the right relative to the owner on
             the screen.
             */
            newRect.origin.x += hPadding
            
            // Make sure we're not drawing off the right edge
            newRect.origin.x = min(
                newRect.origin.x,
                screenRect.maxX - newRect.width - hSafetyPadding
            )
        }
        else
        {
            /*
             Position the tool tip window to the left relative to the owner on
             the screen.
             */
            newRect.origin.x -= hPadding
            
            // Make sure we're not drawing off the left edge
            newRect.origin.x =
                max(newRect.origin.x, screenRect.minX + hSafetyPadding)
        }
        
        
        /*
         Make sure we're not drawing off the bottom edge of the visible area.
         Non-flipped coordinates (y = 0 at bottom) are assumed.
         If we are, move the tool tip above the onwer.
         */
        if newRect.minY < screenRect.minY  {
            newRect.origin.y = ownerRect.maxY + vPadding
        }
        
        self.setFrameOrigin(newRect.origin)
    }
    
    // -------------------------------------
    /// Provides thin border around the tool tip.
    private class BorderedView: NSView
    {
        override func draw(_ dirtyRect: NSRect)
        {
            super.draw(dirtyRect)
            
            guard let context = NSGraphicsContext.current?.cgContext else {
                return
            }
            
            context.setStrokeColor(NSColor.black.cgColor)
            context.stroke(self.frame, width: 2)
        }
    }
}

工具提示窗口是最简单的部分。此实现相对于其所有者(附加工具提示的视图)定位窗口,同时还避免在屏幕外绘制。我不处理工具提示太大以至于无法在屏幕上放置而不会掩盖它作为工具提示的东西的病态情况。我也不处理您要连接工具提示的东西太大以至于即使工具提示本身的尺寸合理,它也不能超出它所连接的视图所占据的区域的情况。这个案子应该不难处理。我只是没有这样做。我确实处理响应当前设置的布局方向。

如果你想将它合并到另一个解决方案中,显示工具提示的代码是

let toolTipWindow = CustomToolTipWindow.makeAndShow(toolTipView: toolTipView, for: ownerView)

其中toolTipView 是要在工具提示中显示的视图。 ownerView 是您要附加工具提示的视图。您需要将 toolTipWindow 存储在某个地方,例如 Stephan 的 ToolTipHandler

隐藏工具提示:

toolTipWindow.orderOut(self)

或者只是将您保留的最后一个引用设置为nil

我认为,如果您愿意,这将为您提供将其合并到另一个解决方案中所需的一切。

工具提示处理代码

为方便起见,我在 NSTrackingArea 上使用了这个扩展

// -------------------------------------
/*
 Convenice extension for updating a tracking area's `rect` property.
 */
fileprivate extension NSTrackingArea
{
    func updateRect(with newRect: NSRect) -> NSTrackingArea
    {
        return NSTrackingArea(
            rect: newRect,
            options: options,
            owner: owner,
            userInfo: nil
        )
    }
}

由于我正在调整NSVew(实际上是您添加工具提示时的子类),因此我没有类似ToolTipHandler 的对象。我只是将它们全部放在NSView 的扩展中并使用全局存储。为此,我有一个 ToolTipControl 结构和一个 ToolTipControls 包装器围绕它们的数组:

// -------------------------------------
/**
 Data structure to hold information used for holding the tool tip and for
 controlling when to show or hide it.
 */
fileprivate struct ToolTipControl
{
    /**
     `Date` when mouse was last moved within the tracking area.  Should be
     `nil` when the mouse is not in the tracking area.
     */
    var mouseEntered: Date?
    
    /// View to which the custom tool tip is attached
    weak var onwerView: NSView?
    
    /// The content view of the tool tip
    var toolTipView: NSView?
    
    /// `true` when the tool tip is currently displayed.  `false` otherwise.
    var isVisible: Bool = false
    
    /**
     The tool tip's window.  Should be `nil` when the tool tip is not being
     shown.
     */
    var toolTipWindow: NSWindow? = nil
    
    init(
        mouseEntered: Date? = nil,
        hostView: NSView,
        toolTipView: NSView? = nil)
    {
        self.mouseEntered = mouseEntered
        self.onwerView = hostView
        self.toolTipView = toolTipView
    }
}

// -------------------------------------
/**
 Data structure for holding `ToolTipControl` instances.  Since we only need
 one collection of them for the application, all its methods and properties
 are `static`.
 */
fileprivate struct ToolTipControls
{
    private static var controlsLock = os_unfair_lock()
    private static var controls: [ToolTipControl] = []
    
    // -------------------------------------
    static func getControl(for hostView: NSView) -> ToolTipControl? {
        withLock { return controls.first { $0.onwerView === hostView } }
    }
    
    // -------------------------------------
    static func setControl(for hostView: NSView, to control: ToolTipControl)
    {
        withLock
        {
            if let i = index(for: hostView) { controls[i] = control }
            else { controls.append(control) }
        }
    }
    
    // -------------------------------------
    static func removeControl(for hostView: NSView)
    {
        withLock
        {
            controls.removeAll {
                $0.onwerView == nil || $0.onwerView === hostView
            }
        }
    }
    
    // -------------------------------------
    private static func index(for hostView: NSView) -> Int? {
        controls.firstIndex { $0.onwerView == hostView }
    }
    
    // -------------------------------------
    private static func withLock<R>(_ block: () -> R) -> R
    {
        os_unfair_lock_lock(&controlsLock)
        defer { os_unfair_lock_unlock(&controlsLock) }
        
        return block()
    }
    
    // -------------------------------------
    private init() { } // prevent instances
}

这些是fileprivate,与我在NSView 上的扩展名在同一个文件中。我还必须有一种方法来区分我的跟踪区域和视图可能具有的其他区域。他们有一个我用的userInfo 字典。我不需要在每一个中存储不同的个性化信息,所以我只需制作一个全局的我可以重复使用。

fileprivate let bundleID = Bundle.main.bundleIdentifier ?? "com.CustomToolTips"
fileprivate let toolTipKeyTag = bundleID + "CustomToolTips"
fileprivate let customToolTipTag = [toolTipKeyTag: true]

我需要一个调度队列:

fileprivate let dispatchQueue = DispatchQueue(
    label: toolTipKeyTag,
    qos: .background
)

NSView 扩展

我的NSView扩展有很多,其中绝大多数是private,包括swizzled方法,所以我将它分解成碎片

为了能够像附加标准工具提示一样轻松地附加自定义工具提示,我提供了一个计算属性。除了实际设置工具提示视图外,它还检查Self(即NSView 的特定子类)是否已经被混合,如果还没有,它会添加鼠标跟踪区域。

// -------------------------------------
/**
 Adds a custom tool tip to the receiver.  If set to `nil`, the custom tool
 tip is removed.
 
 This view's `frame.size` will determine the size of the tool tip window
 */
public var customToolTip: NSView?
{
    get { toolTipControl?.toolTipView }
    set
    {
        Self.initializeCustomToolTips()

        if let newValue = newValue
        {
            addCustomToolTipTrackingArea()
            var current = toolTipControl ?? ToolTipControl(hostView: self)
            current.toolTipView = newValue
            toolTipControl = current
        }
        else { toolTipControl = nil }
    }
}

// -------------------------------------
/**
 Adds a tracking area encompassing the receiver's bounds that will be used
 for tracking the mouse for determining when to show the tool tip.  If a
 tacking area already exists for the receiver, it is removed before the
 new tracking area is set. This method should only be called when a new
 tool tip is attached to the receiver.
 */
private func addCustomToolTipTrackingArea()
{
    if let ta = trackingAreaForCustomToolTip {
        removeTrackingArea(ta)
    }
    addTrackingArea(
        NSTrackingArea(
            rect: self.bounds,
            options:
                [.activeInActiveApp, .mouseMoved, .mouseEnteredAndExited],
            owner: self,
            userInfo: customToolTipTag
        )
    )
}

// -------------------------------------
/**
 Returns the custom tool tip tracking area for the receiver.
 */
private var trackingAreaForCustomToolTip: NSTrackingArea?
{
    trackingAreas.first {
        $0.owner === self && $0.userInfo?[toolTipKeyTag] != nil
    }
}

trackingAreaForCustomToolTip 是我使用全局标记将我的跟踪区域与视图可能具有的任何其他区域进行排序的地方。

当然,我还必须实现updateTrackingAreas,这就是我们开始看到一些混杂的证据的地方。

// -------------------------------------
/**
 Updates the custom tooltip tracking aread when `updateTrackingAreas` is
 called.
 */
@objc private func updateTrackingAreas_CustomToolTip()
{
    if let ta = trackingAreaForCustomToolTip
    {
        removeTrackingArea(ta)
        addTrackingArea(ta.updateRect(with: self.bounds))
    }
    else { addCustomToolTipTrackingArea() }
    
    callReplacedMethod(for: #selector(self.updateTrackingAreas))
}

该方法没有被称为updateTrackingAreas,因为我没有在通常意义上覆盖它。我实际上用我的updateTrackingAreas_CustomToolTip 的实现替换了当前类的updateTrackingAreas 的实现,节省了原始实现以便我可以转发它。 callReplacedMethod 我在哪里进行转发。如果您研究 swizzling,您会发现很多示例,其中人们称其为无限递归,但这并不是因为他们交换 方法实现。这在大多数情况下都有效,但它可能会巧妙地混淆底层的 Objective-C 消息传递,因为用于调用旧方法的选择器不再是原始选择器。我这样做的方式保留了选择器,当某些事情取决于实际选择器保持不变时,这使得它不那么脆弱。我在上面链接到的 GitHub 上的完整答案中有更多关于 swizzling 的内容。现在,将callReplacedMethod 视为类似于调用super,如果我通过子类化来执行此操作。

然后是显示工具提示的计划。我的做法与 Stephan 类似,但我希望在鼠标停止移动一段时间后才会显示工具提示的行为(我目前使用的是 1 秒)。

在我写这篇文章时,我只是注意到,一旦显示工具提示,我确实偏离了标准行为。标准行为是,一旦显示工具提示,即使鼠标移动,只要它保持在跟踪区域中,它就会继续显示工具提示。因此,一旦显示,标准行为不会隐藏工具提示,直到鼠标离开跟踪区域。只要你移动鼠标,我就会隐藏它。以标准方式执行它实际上更简单,但我这样做的方式将允许工具提示显示在大视图(例如大型文档的 NSTextView)上,它实际上必须在屏幕的同一区域中它的所有者占据。我目前没有以这种方式定位工具提示,但如果我这样做,您会希望通过任何鼠标移动来隐藏工具提示,否则工具提示会掩盖您需要与之交互的部分内容。

不管怎样,调度代码是这样的

// -------------------------------------
/**
 Controls how many seconds the mouse must be motionless within the tracking
 area in order to show the tool tip.
 */
private var customToolTipDelay: TimeInterval { 1 /* seconds */ }

// -------------------------------------
/**
 Schedules to potentially show the tool tip after `delay` seconds.
 
 The tool tip is not *necessarily* shown as a result of calling this method,
 but rather this method begins a sequence of chained asynchronous calls that
 determine whether or not to display the tool tip based on whether the tool
 tip is already visible, and how long it's been since the mouse was moved
 withn the tracking area.
 
 - Parameters:
    - delay: Number of seconds to wait until determining whether or not to
        display the tool tip
    - mouseEntered: Set to `true` when calling from `mouseEntered`,
        otherwise set to `false`
 */
private func scheduleShowToolTip(delay: TimeInterval, mouseEntered: Bool)
{
    guard var control = toolTipControl else { return }
    
    if mouseEntered
    {
        control.mouseEntered = Date()
        toolTipControl = control
    }

    let asyncDelay: DispatchTimeInterval = .milliseconds(Int(delay * 1000))
    dispatchQueue.asyncAfter(deadline: .now() + asyncDelay) {
        [weak self] in self?.scheduledShowToolTip()
    }
}

// -------------------------------------
/**
 Display the tool tip now, *if* the mouse is in the tracking area and has
 not moved for at least `customToolTipDelay` seconds.  Otherwise, schedule
 to check again after a short delay.
 */
private func scheduledShowToolTip()
{
    let repeatDelay: TimeInterval = 0.1
    /*
     control.mouseEntered is set to nil when exiting the tracking area,
     so this guard terminates the async chain
     */
    guard let control = self.toolTipControl,
          let mouseEntered = control.mouseEntered
    else { return }
    
    if control.isVisible {
        scheduleShowToolTip(delay: repeatDelay, mouseEntered: false)
    }
    else if Date().timeIntervalSince(mouseEntered) >= customToolTipDelay
    {
        DispatchQueue.main.async
        { [weak self] in
            if let self = self
            {
                self.showToolTip()
                self.scheduleShowToolTip(
                    delay: repeatDelay,
                    mouseEntered: false
                )
            }
        }
    }
    else { scheduleShowToolTip(delay: repeatDelay, mouseEntered: false) }
}

之前我给出了如何显示和隐藏工具提示窗口的代码。以下是该代码与toolTipControl 交互以控制相应循环的函数。

// -------------------------------------
/**
 Displays the tool tip now.
 */
private func showToolTip()
{
    guard var control = toolTipControl else { return }
    defer
    {
        control.mouseEntered = Date.distantPast
        toolTipControl = control
    }
    
    guard let toolTipView = control.toolTipView else
    {
        control.isVisible = false
        return
    }
    
    if !control.isVisible
    {
        control.isVisible = true
        control.toolTipWindow = CustomToolTipWindow.makeAndShow(
            toolTipView: toolTipView,
            for: self
        )
    }
}

// -------------------------------------
/**
 Hides the tool tip now.
 */
private func hideToolTip(exitTracking: Bool)
{
    guard var control = toolTipControl else { return }
    
    control.mouseEntered = exitTracking ? nil : Date()
    control.isVisible = false
    let window = control.toolTipWindow
    
    control.toolTipWindow = nil
    window?.orderOut(self)
    control.toolTipWindow = nil
    
    toolTipControl = control

    print("Hiding tool tip")
}

在进行实际调配之前唯一剩下的就是处理鼠标移动。我使用mouseEnteredmouseExitedmouseMoved 进行此操作,或者更确切地说,它们的混合实现:

// -------------------------------------
/**
 Schedules potentially showing the tool tip when the `mouseEntered` is
 called.
 */
@objc private func mouseEntered_CustomToolTip(with event: NSEvent)
{
    scheduleShowToolTip(delay: customToolTipDelay, mouseEntered: true)
    
    callReplacedEventMethod(
        for: #selector(self.mouseEntered(with:)),
        with: event
    )
}

// -------------------------------------
/**
 Hides the tool tip if it's visible when `mouseExited` is called, cancelling
 further `async` chaining that checks to show it.
 */
@objc private func mouseExited_CustomToolTip(with event: NSEvent)
{
    hideToolTip(exitTracking: true)

    callReplacedEventMethod(
        for: #selector(self.mouseExited(with:)),
        with: event
    )
}

// -------------------------------------
/**
 Hides the tool tip if it's visible when `mousedMoved` is called, and
 resets the time for it to be displayed again.
 */
@objc private func mouseMoved_CustomToolTip(with event: NSEvent)
{
    hideToolTip(exitTracking: false)
    
    callReplacedEventMethod(
        for: #selector(self.mouseMoved(with:)),
        with: event
    )
}

遗憾的是,我的这篇文章的原始版本太长了,所以我不得不删掉那些杂乱无章的细节,但是,我把整个东西放在了 GitHub 上,并附上了完整的源代码,这样你就可以更深入地查看它了。我以前从未达到过长度限制。

所以跳到最后...

这样就可以将所有内容都放在适当的位置(或者如果我可以将整个内容发布在这里),所以现在您只需要使用它即可。

我只是使用 Xcode 的默认 Cocoa App 模板来实现,所以它使用 Storyboard(通常我不喜欢这样做)。我刚刚在情节提要中添加了一个普通的NSButton。这意味着我不会从源代码中任何地方的引用开始,因此在ViewController 中,为了构建示例,我只是通过视图层次结构进行快速递归搜索,以查找NSButton

func findPushButton(in view: NSView) -> NSButton?
{
    if let button = view as? NSButton { return button }
    
    for subview in view.subviews
    {
        if let button = findPushButton(in: subview) {
            return button
        }
    }
    return nil
}

我需要制作一个工具提示视图。我想展示的不仅仅是文字,所以我一起破解了这个


func makeCustomToolTip() -> NSView
{
    let titleText = "Custom Tool Tip"
    let bodyText = "\n\tThis demonstrates that its possible,\n\tand if I can do it, so you can you"
    
    let titleFont = NSFont.systemFont(ofSize: 14, weight: .bold)
    let title = NSAttributedString(
        string: titleText,
        attributes: [.font: titleFont]
    )
    
    let bodyFont = NSFont.systemFont(ofSize: 10)
    let body = NSAttributedString(
        string: bodyText,
        attributes: [.font: bodyFont]
    )
    
    let attrStr = NSMutableAttributedString(attributedString: title)
    attrStr.append(body)
    
    let label = NSTextField(labelWithAttributedString: attrStr)
    
    let imageView = NSImageView(frame: CGRect(origin: .zero, size: CGSize(width: label.frame.height, height: label.frame.height)))
    imageView.image = #imageLiteral(resourceName: "Swift_logo")
    
    let toolTipView = NSView(
        frame: CGRect(
            origin: .zero,
            size: CGSize(
                width: imageView.frame.width + label.frame.width + 15,
                height: imageView.frame.height + 10
            )
        )
    )
    
    imageView.frame.origin.x += 5
    imageView.frame.origin.y += 5
    toolTipView.addSubview(imageView)
    
    label.frame.origin.x += imageView.frame.maxX + 5
    label.frame.origin.y += 5
    toolTipView.addSubview(label)
    
    return toolTipView
}

然后在viewDidLoad()

override func viewDidLoad() 
{
    super.viewDidLoad()
    findPushButton(in: view)?.customToolTip = makeCustomToolTip()
}

【讨论】:

  • 成熟的解决方案。无需对控件进行子类化以附加自定义工具提示。点赞!
  • @StephanSchlecht 谢谢!
  • 我现在已经将代码提取到一个 Swift 包中,所以它可以在任何项目中使用:github.com/chipjarred/CustomToolTip.git
猜你喜欢
  • 2013-06-23
  • 2011-09-28
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-08-19
  • 2023-04-06
相关资源
最近更新 更多