unk*_*own 1 cocoa tooltip nsattributedstring swift
我正在尝试创建带有粗体文本的工具提示。macOS 上的一些苹果应用程序使用此行为。我该如何实现这一目标?我目前的代码
btn.tooltip = "Open Options"
//tooltip doesn't accept attributed strings.
Run Code Online (Sandbox Code Playgroud)
该答案的源代码和完整版本位于此GitHub 存储库。
除了该存储库之外,我还将代码提取到 Swift 包中,这样我就可以在其他项目中使用它。要添加到项目中的依赖项是“https://github.com/chipjarred/CustomToolTip.git”。使用“from”版本 1.0.0 或分支“main”。
接下来是修剪到一定长度的版本,这样我就可以发布了。
斯蒂芬的回答促使我自己实现工具提示。我的解决方案生成的工具提示看起来像标准工具提示,只不过您可以将任何您喜欢的视图放入其中,因此不仅仅是样式文本,还包括图像......如果您愿意,您甚至可以使用 WebKit 视图。
显然把某些观点放进去是没有意义的。任何仅对用户交互有意义的内容都是毫无意义的,因为一旦用户移动鼠标光标与之交互,工具提示就会消失……尽管这将是一个很好的愚人节笑话。
在讨论我的解决方案之前,我想提一下,还有另一种方法可以使 Stephan 的解决方案更易于使用,即通过子类化来使用“装饰器”模式NSView来包装另一个视图。您的包装器是挂钩到工具提示并处理跟踪区域的部分。只要确保你也将这些调用转发到包装视图,以防它也有跟踪区域(也许它会改变光标或其他东西,就像那样NSTextView。)使用装饰器意味着你不会子类化每个视图......只需将视图中您想要添加一个工具提示ToolTippableView或任何您决定调用它的内容。我认为您不需要重写所有NSView方法,只要您通过将视图添加到subviews. 视图层次结构和响应者链应该负责将您不感兴趣的事件和消息分派到子视图。您应该只需要转发您处理的工具提示(mouseEntered、mouseExited等...)
然而,我走向了邪恶的极端……并在上面花费了比我应该花的更多的时间,但这似乎是我在某个时候可能想要使用的东西。我使用了 swizzled(“猴子修补”)NSView方法来处理自定义工具提示,它与扩展相结合意味着NSView我没有任何子类来添加自定义工具提示,我可以这样写:
myView.customToolTip = myCustomToolTipContent
Run Code Online (Sandbox Code Playgroud)
我想在工具提示中显示的myCustomToolTipContent内容在哪里 。NSView
最主要的是工具提示本身。这只是一个窗口。它会根据您放入其中的任何内容调整自身大小,因此请确保frame在设置之前将提示内容的视图设置为您想要的大小customToolTip。这是工具提示窗口代码:
// -------------------------------------
/**
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)
}
}
}
Run Code Online (Sandbox Code Playgroud)
工具提示窗口是最简单的部分。此实现相对于其所有者(工具提示所附加的视图)定位窗口,同时还避免在屏幕外绘制。我不处理这种病态的情况,即工具提示太大,以至于无法在不遮挡工具提示的情况下适应屏幕。我也不处理这样的情况:您附加工具提示的东西太大,即使工具提示本身的尺寸合理,它也不能超出其所附加的视图所占据的区域。这个案子应该不难处理。我只是没有这么做。我确实处理对当前设置的布局方向的响应。
如果您想将其合并到另一个解决方案中,显示工具提示的代码是
let toolTipWindow = CustomToolTipWindow.makeAndShow(toolTipView: toolTipView, for: ownerView)
Run Code Online (Sandbox Code Playgroud)
其中toolTipView是要在工具提示中显示的视图。 ownerView是您要附加工具提示的视图。您需要存储toolTipWindow 在某个地方,例如在 Stephan's 中ToolTipHandler。
隐藏工具提示:
toolTipWindow.orderOut(self)
Run Code Online (Sandbox Code Playgroud)
或者将您保留的最后一个引用设置为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
)
}
}
Run Code Online (Sandbox Code Playgroud)
由于我正在混合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
}
Run Code Online (Sandbox Code Playgroud)
这些fileprivate与我的扩展位于同一个文件中NSView。我还必须有一种方法来区分我的跟踪区域和视图可能具有的其他区域。他们有一本userInfo我用的字典。我不需要在每个信息中存储不同的个性化信息,因此我只需创建一个可重复使用的全局信息。
fileprivate let bundleID = Bundle.main.bundleIdentifier ?? "com.CustomToolTips"
fileprivate let toolTipKeyTag = bundleID + "CustomToolTips"
fileprivate let customToolTipTag = [toolTipKeyTag: true]
Run Code Online (Sandbox Code Playgroud)
我需要一个调度队列:
fileprivate let dispatchQueue = DispatchQueue(
label: toolTipKeyTag,
qos: .background
)
Run Code Online (Sandbox Code Playgroud)
我的NSView扩展包含很多内容,其中绝大多数是private,包括 swizzled 方法,所以我将把它分成几部分
为了能够像附加标准工具提示一样轻松地附加自定义工具提示,我提供了一个计算属性。除了实际设置工具提示视图之外,它还检查Self(即 的特定子类NSView)是否已经被 swizzled,如果还没有,则执行此操作,并且添加鼠标跟踪区域。
// -------------------------------------
/**
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
}
}
Run Code Online (Sandbox Code Playgroud)
trackingAreaForCustomToolTip是我使用全局标记将跟踪区域与视图可能具有的任何其他区域进行排序的地方。
当然,我还必须实施updateTrackingAreas,从这里我们开始看到一些 swizzling 的证据。
// -------------------------------------
/**
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))
}
Run Code Online (Sandbox Code Playgroud)
该方法未被调用updateTrackingAreas,因为我没有按照通常的方式重写它。updateTrackingAreas实际上,我用 my 的实现替换了当前类的实现updateTrackingAreas_CustomToolTip,保存了原始实现,以便我可以转发它。 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) }
}
Run Code Online (Sandbox Code Playgroud)
之前我给出了如何显示和隐藏工具提示窗口的代码。以下是该代码所在的函数及其交互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")
}
Run Code Online (Sandbox Code Playgroud)
在进行实际的调整之前剩下的唯一一件事就是处理鼠标移动。我用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
)
}
Run Code Online (Sandbox Code Playgroud)
遗憾的是,我这篇文章的原始版本太长了,所以我不得不删掉一些混乱的细节,但是,我将整个内容放在 GitHub 上,并附有完整的源代码,这样你就可以更深入地查看它。我以前从未达到过长度限制。
所以跳到最后...
这一切都已就位(或者如果我可以将整个内容发布到此处就可以了),所以现在您只需使用它即可。
我只是使用 Xcode 的默认 Cocoa App 模板来实现,因此它使用 Storyboard(通常我不喜欢这样做)。我刚刚NSButton在情节提要中添加了一个普通的。这意味着我不会在源代码中的任何位置开始引用它,因此在 中ViewController,为了构建示例,我只是通过视图层次结构进行快速递归搜索以查找NSButton.
func findPushButton(in view: NSView) ->
| 归档时间: |
|
| 查看次数: |
2159 次 |
| 最近记录: |