这个答案的源代码和完整版本在这个GitHub repo。
与该存储库分开,我还将代码提取到 Swift 包中,因此我可以在其他项目中使用它。添加到项目的依赖项是“https://github.com/chipjarred/CustomToolTip.git”。使用“from”版本 1.0.0 或分支“main”。
接下来是剪裁到一定长度的版本让我发布。
Stephan 的回答促使我自己实现了工具提示。我的解决方案生成的工具提示看起来像标准工具提示,但您可以在其中放置您喜欢的任何视图,因此不仅是样式化的文本,还有图像......如果您愿意,您甚至可以使用 WebKit 视图。
显然,将某些类型的视图放入其中是没有意义的。任何只对用户交互有意义的东西都是没有意义的,因为一旦他们移动鼠标光标与之交互,工具提示就会消失……尽管这将是一个很好的愚人节笑话。
在开始我的解决方案之前,我想提一下,还有另一种方法可以让 Stephan 的解决方案更易于使用,即通过子类化 NSView 来使用“装饰器”模式来包装另一个视图。您的包装器是连接到工具提示并处理跟踪区域的部分。只要确保你也将这些调用转发到包装视图,以防它也有跟踪区域(也许它会改变光标或其他东西,就像NSTextView 所做的那样。)使用装饰器意味着你不会子类化每个视图......只需将要添加工具提示的视图放在ToolTippableView 或您决定调用它的任何内容中。我认为您不需要覆盖所有NSView 方法,只要您通过将视图添加到subviews 来包装视图即可。视图层次结构和响应者链应该负责将您不感兴趣的事件和消息分派到子视图。您应该只需要转发您处理的工具提示(mouseEntered、mouseExited 等...)
我的解决方案
然而,我走到了一个邪恶的极端……并且花了比我应该做的更多的时间,但它似乎是我在某个时候可能想要使用的东西。我调配(“猴子补丁”)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")
}
在进行实际调配之前唯一剩下的就是处理鼠标移动。我使用mouseEntered、mouseExited 和mouseMoved 进行此操作,或者更确切地说,它们的混合实现:
// -------------------------------------
/**
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()
}