Cocoa customise NSView's tooltips Swift - swift

I am trying to create a tooltip with bold text. Some apple apps on macOS use this behaviour. How do I achieve this?
My code currently
btn.tooltip = "Open Options"
//tooltip doesn't accept attributed strings.
Here is an example (screenshot of Xcode using this behaviour) of what I'm trying to achieve.

It seems there is no built-in default behavior for tooltips with NSAttributedStrings. As a solution, one could implement a floating NSPanel.
As long as the mouse is within the button bounds for at least a certain period of time, you could show a popover with an NSAttributedString. You can use the mouseEntered and mouseExited events for this purpose. Unfortunately, this requires that you subclass the NSButton.
Complete, Self-contained Swift Program
From a ViewController we would most likely to call it like this:
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)
])
}
}
The ToolTipButton class could look like this:
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)
}
}
Finally the ToolTipHandler could look like this:
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
}
}
Finally the 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)
])
}
}
Depending on the actual requirements, adjustments are probably necessary. But it should at least be a starting point.
Demo

The source code and full-length version of this answer are at this GitHub repo.
Separately from that repo I also extracted the code into a Swift Package, so I could use it in other projects. The dependency to add to your project is "https://github.com/chipjarred/CustomToolTip.git". Use "from" version 1.0.0 or branch "main".
What follows is the version trimmed down to a length SO would let me post.
Stephan's answer prompted me to do my own implementation of tool tips. My solution produces tool tips that look like the standard tool tips, except you can put any view you like inside them, so not just styled text, but images... you could even use a WebKit view, if you wanted to.
Obviously it doesn't make sense to put some kinds of views in it. Anything that only makes sense with user interaction would be meaningless since the tool tip would disappear as soon as they move the mouse cursor to interact with it... though that would be good April Fools joke.
Before I get to my solution, I want to mention that there is another way to make Stephan's solution a little easier to use, which is to use the "decorator" pattern by subclassing NSView to wrap another view. Your wrapper is the part that hooks into to the tool tips, and handles the tracking areas. Just make sure you forward those calls to the wrapped view too, in case it also has tracking areas (perhaps it changes the cursor or something, like NSTextView does.) Using a decorator means you don't subclass every view... just put the view you want to add a tool tip inside of a ToolTippableView or whatever you decide to call it. I don't think you'll need to override all NSView methods as long as you wrap the view by adding it to your subviews. The view heirarchy and responder chain should take care of dispatching the events and messages you're not interested in to the subview. You should only need to forward the ones you handle for the tool tips (mouseEntered, mouseExited, etc...)
My solution
However, I went to an evil extreme... and spent way more time on it than I probably should have, but it seemed like something I might want to use at some point. I swizzled ("monkey patched") NSView methods to handle custom tool tips, which combined with an extension on NSView means I don't have subclass anything to add custom tool tips, I can just write:
myView.customToolTip = myCustomToolTipContent
where myCustomToolTipContent is whatever NSView I want to display in the tool tip.
The Tool Tip itself
The main thing is the tool tip itself. It's just a window. It sizes itself to whatever content you put in it, so make sure you've set your tip content's view frame to the size you want before setting customToolTip. Here's the tool tip window code:
// -------------------------------------
/**
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)
}
}
}
The tool tip window is the easy part. This implementation positions the window relative to its owner (the view to which the tool tip is attached) while also avoiding drawing offscreen. I don't handle the pathalogical case where the tool tip is so large that it can't fit onto screen without obscuring the thing it's a tool tip for. Nor do I handle the case where the thing you're attaching the tool tip to is so large that even though the tool tip itself is a reasonable size, it can't go outside of the area occupied by the view to which it's attached. That case shouldn't be too hard to handle. I just didn't do it. I do handle responding to the currently set layout direction.
If you want to incorporate it into another solution, the code to show the tool tip is
let toolTipWindow = CustomToolTipWindow.makeAndShow(toolTipView: toolTipView, for: ownerView)
where toolTipView is the view to be displayed in the tool tip. ownerView is the view to which you're attaching the tool tip. You'll need to store toolTipWindow somewhere, for example in Stephan's ToolTipHandler.
To hide the tool tip:
toolTipWindow.orderOut(self)
or just set the last reference you keep to it to nil.
I think that gives you everything you need to incorporate it into another solution if you like.
Tool Tip handling code
As a small convenience, I use this extension on 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
)
}
}
Since I'm swizzling NSVew (actually its subclasses as you add tool tips), I don't have a ToolTipHandler-like object. I just put it all in an extension on NSView and use global storage. To do that I have a ToolTipControl struct and a ToolTipControls wrapper around an array of them:
// -------------------------------------
/**
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
}
These are fileprivate in the same file as my extension on NSView. I also have to have a way to differentiate between my tracking areas and others the view might have. They have a userInfo dictionary that I use for that. I don't need to store different individualized information in each one, so I just make a global one I reuse.
fileprivate let bundleID = Bundle.main.bundleIdentifier ?? "com.CustomToolTips"
fileprivate let toolTipKeyTag = bundleID + "CustomToolTips"
fileprivate let customToolTipTag = [toolTipKeyTag: true]
And I need a dispatch queue:
fileprivate let dispatchQueue = DispatchQueue(
label: toolTipKeyTag,
qos: .background
)
NSView extension
My NSView extension has a lot in it, the vast majority of which is private, including swizzled methods, so I'll break it into pieces
In order to be able to attach a custom tool tip as easily as you do for a standard tool tip, I provide a computed property. In addition to actually setting the tool tip view, it also checks to see if Self (that is the particular subclass of NSView) has already been swizzled, and does that if it hasn't been, and it's adds the mouse tracking area.
// -------------------------------------
/**
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 is where I use the global tag to sort my tracking area from any others that the view might have.
Of course, I also have to implement updateTrackingAreas and this where we start to see some of evidence of 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))
}
The method isn't called updateTrackingAreas because I'm not overriding it in the usual sense. I actually replace the implementation of the current class's updateTrackingAreas with the implementation of my updateTrackingAreas_CustomToolTip, saving off the original implementation so I can forward to it. callReplacedMethod where I do that forwarding. If you look into swizzling, you find lots of examples where people call what looks like an infinite recursion, but isn't because they exchange method implementations. That works most of the time, but it can subtly mess up the underlying Objective-C messaging because the selector used to call the old method is no longer the original selector. The way I've done it preserves the selector, which makes it less fragile when something depends on the actual selector remaining the same. There's more on swizzling in the full answer on GitHub I linked to above. For now, think of callReplacedMethod as similar to calling super if I were doing this by subclassing.
Then there's scheduling to show the tool tip. I do this kind of similarly to Stephan, but I wanted the behavior that the tool tip isn't shown until the mouse stops moving for a certain delay (1 second is what I currently use).
As I'm writing this, I just noticed that I do deviate from the standard behavior once the tool tip is displayed. The standard behavior is that once the tool tip is shown it continues to show the tool tip even if the mouse is moved as long as it remains in the tracking area. So once shown, the standard behavior doesn't hide the tool tip until the mouse leaves the tracking area. I hide it as soon as you move the mouse. Doing it the standard way is actually simpler, but the way I do it would allow for the tool tip to be shown over large views (for example a NSTextView for a large document) where it has to actually in the same area of the screen that it's owner occupies. I don't currently position the tool tip that way, but if I were to, you'd want any mouse movement to hide the tool tip, otherwise the tool tip would obscure part of what you need to interact with.
Anyway, here's what that scheduling code looks like
// -------------------------------------
/**
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) }
}
Earlier I gave the code for how to show and hide the tool tip window. Here are the functions where that code lives with its interaction with toolTipControl to control the corresponding loop.
// -------------------------------------
/**
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")
}
The only thing that's left before getting to the actual swizzling is handling the mouse movements. I do this with mouseEntered, mouseExited and mouseMoved, or rather, their swizzled implementations:
// -------------------------------------
/**
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
)
}
Sadly my original version of this post was too long, so I had to cut out the swizzling details, however, I put the whole thing on GitHub, with the complete source code, so you can look at it more in depth. I've never reached the length limit before.
So skipping to the end...
That puts everything in place (or would do if I could have posted the whole thing here), so now you just have to use it.
I was just using Xcode's default Cocoa App template to implement, so it uses a Storyboard (which normally I prefer not to). I just added an ordinary NSButton in the Storyboard. That means I don't start with a reference to it anywhere in the source code, so in ViewController, for the sake of building an example I just do a quick recursive search through the view hierarchy looking for an 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
}
And I need to make a tool tip view. I wanted to demonstrate using more than just text, so I hacked this together
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
}
And then in viewDidLoad()
override func viewDidLoad()
{
super.viewDidLoad()
findPushButton(in: view)?.customToolTip = makeCustomToolTip()
}

Related

Highlight NSWindow under mouse cursor

Since this is quite a lot of code and it probably helps if there is a sample project where you can better understand the current problem I made a simple sample project which you can find on GitHub here: https://github.com/dehlen/Stackoverflow
I want to implement some functionality pretty similar what the macOS screenshot tool does. When the mouse hovers over a window the window should be highlighted. However I am having issues only highlighting the part of the window which is visible to the user.
Here is a screenshot of what the feature should look like:
My current implementation however looks like this:
My current implementation does the following:
1. Get a list of all windows visible on screen
static func all() -> [Window] {
let options = CGWindowListOption(arrayLiteral: .excludeDesktopElements, .optionOnScreenOnly)
let windowsListInfo = CGWindowListCopyWindowInfo(options, CGMainDisplayID()) //current window
let infoList = windowsListInfo as! [[String: Any]]
return infoList
.filter { $0["kCGWindowLayer"] as! Int == 0 }
.map { Window(
frame: CGRect(x: ($0["kCGWindowBounds"] as! [String: Any])["X"] as! CGFloat,
y: ($0["kCGWindowBounds"] as! [String: Any])["Y"] as! CGFloat,
width: ($0["kCGWindowBounds"] as! [String: Any])["Width"] as! CGFloat,
height: ($0["kCGWindowBounds"] as! [String: Any])["Height"] as! CGFloat),
applicationName: $0["kCGWindowOwnerName"] as! String)}
}
2. Get the mouse location
private func registerMouseEvents() {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
self.mouseLocation = NSEvent.mouseLocation
return $0
}
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved]) { _ in
self.mouseLocation = NSEvent.mouseLocation
}
}
3. Highlight the window at the current mouse location:
static func window(at point: CGPoint) -> Window? {
// TODO: only if frontmost
let list = all()
return list.filter { $0.frame.contains(point) }.first
}
var mouseLocation: NSPoint = NSEvent.mouseLocation {
didSet {
//TODO: don't highlight if its the same window
if let window = WindowList.window(at: mouseLocation), !window.isCapture {
highlight(window: window)
} else {
removeHighlight()
}
}
}
private func removeHighlight() {
highlightWindowController?.close()
highlightWindowController = nil
}
func highlight(window: Window) {
removeHighlight()
highlightWindowController = HighlightWindowController()
highlightWindowController?.highlight(frame: window.frame, animate: false)
highlightWindowController?.showWindow(nil)
}
class HighlightWindowController: NSWindowController, NSWindowDelegate {
// MARK: - Initializers
init() {
let bounds = NSRect(x: 0, y: 0, width: 100, height: 100)
let window = NSWindow(contentRect: bounds, styleMask: .borderless, backing: .buffered, defer: true)
window.isOpaque = false
window.level = .screenSaver
window.backgroundColor = NSColor.blue
window.alphaValue = 0.2
window.ignoresMouseEvents = true
super.init(window: window)
window.delegate = self
}
// MARK: - Public API
func highlight(frame: CGRect, animate: Bool) {
if animate {
NSAnimationContext.current.duration = 0.1
}
let target = animate ? window?.animator() : window
target?.setFrame(frame, display: false)
}
}
As you can see the window under the cursor is highlighted however the highlight window is drawn above other windows which might intersect.
Possible Solution
I could iterate over the available windows in the list and only find the rectangle which does not overlap with other windows to draw the highlight rect only for this part instead of the whole window.
I am asking myself whether the would be a more elegant and more performant solution to this problem. Maybe I could solve this with the window level of the drawn HighlightWindow? Or is there any API from Apple which I could leverage to get the desired behavior?
I messed around with your code, and #Ted is correct. NSWindow.order(_:relativeTo) is exactly what you need.
Why NSWindow.level wont work:
Using NSWindow.level will not work for you because normal windows (like the ones in your screenshot) all have a window level of 0, or .normal. If you simply adjusted the window level to, say "1" for instance, your highlight view would appear above all the other windows. On the contrary, if you set it to "-1" your highlight view would appear below all normal windows, and above the desktop.
Problems to be introduced using NSWindow.order(_: relativeTo)
No great solution comes without caveats right? In order to use this method you will have to set the window level to 0 so it can be layerd among the other windows. However, this will cause your highlighting window to be selected in your WindowList.window(at: mouseLocation) method. And when it's selected, your if-statement removes it because it believes it's the main window. This will cause a flicker. (a fix for this is included in the TLDR below)
Also, if you attempt to highlight a window that does not have a level of 0, you will run into issues. To fix such issues you need to find the window level of the window you are highlighting and set your highlighting window to that level. (my code didn't include a fix for this problem)
In addition to the above problems, you need to consider what happens when the user hovers over a background window, and clicks on it without moving the mouse. What will happen is the background window will become front.. without moving the highlight window. A possible fix for this would be to update the highlight window on click events.
Lastly, I noticed you create a new HighlightWindowController + window every time the user moves their mouse. It may be a bit lighter on the system if you simply mutate the frame of an already exsisting HighlightWindowController on mouse movement (instead of creating one). To hide it you could call the NSWindowController.close() function, or even set the frame to {0,0,0,0} (not sure about the 2nd idea).
TLDR; Show us some code
Here's what I did.
1. Change your window struct to include a window number:
struct Window {
let frame: CGRect
let applicationName: String
let windowNumber: Int
init(frame: CGRect, applicationName: String, refNumber: Int) {
self.frame = frame.flippedScreenBounds
self.applicationName = applicationName
self.windowNumber = refNumber
}
var isCapture: Bool {
return applicationName.caseInsensitiveCompare("Capture") == .orderedSame
}
}
2. In your window listing function ie static func all() -> [Window], include the window number:
refNumber: $0["kCGWindowNumber"] as! Int
3. In your window highlighting function, after highlightWindowController?.showWindow(nil), order the window relative to the window you are highlighting!
highlightWindowController!.window!.order(.above, relativeTo: window.windowNumber)
4. In your highlight controller, make sure to set the window level back to normal:
window.level = .normal
5. The window will now flicker, to prevent this, update your view controller if-statement:
if let window = WindowList.window(at: mouseLocation) {
if !window.isCapture {
highlight(window: window)
}
} else {
removeHighlight()
}
Best of luck and have fun swifting!
Edit:
I forgot to mention, my swift version is 4.2 (haven't upgraded yet) so the syntax may be ever so slightly different.
I'm not used to Swift, sorry, but it seems to me the natural solution to this would be to use - orderWindow:relativeTo:. In ObjC that would be (added just after the highlight window is shown):
[highlightWindow orderWindow:NSWindowAbove relativeTo:window];
And let the window server handle all the details of hiding obscured portions. Of course, this creates a different headache of keeping the highlight window directly above the target window as users move stuff around on-screen, but...

No-show menuController and can't figure out how to make the View return True to calling .becomeFirstResponder

I am having a no-show menuController and I have checked all of the suggestions in previous questions. It turns out the imageView I have implemented a UILongPressGestureRecognizer on, to show the menu, is returning False on calling .becomeFirstResponder just before setting up the menu controller.
I am coding in swift 4 and can't figure out how to make the imageView return True to calling .becomeFirstResponder. Help!
/*********************************************************/
override func viewDidLoad() {
super.viewDidLoad()
// long tap to show menu that enables deletion of the image.
imageView_1.isUserInteractionEnabled = true
let longPressRecogniser = UILongPressGestureRecognizer(target: self, action: #selector(longPressOnImage(_:)))
//longPressRecogniser.numberOfTapsRequired = 1
//longPressRecogniser.numberOfTouchesRequired = 1
longPressRecogniser.minimumPressDuration = 0.5
imageView_1.addGestureRecognizer(longPressRecogniser)
imageView_1.image = placeHolderImage_1
imageView_2.image = placeHolderImage_2
}
/*********************************************************/
#IBAction func longPressOnImage(_ gestureRecognizer: UILongPressGestureRecognizer) {
print(#function)
if gestureRecognizer.state == .began {
//print("gestureRecognizer.state == .began")
self.tappedView = gestureRecognizer.view!
if tappedView.canResignFirstResponder {
print("can resign first responder")
}
if tappedView.becomeFirstResponder() {
print("returned TRUE to becomeFirstResponder")
} else {
print("returned FALSE to becomeFirstResponder")
}
// Configure the shared menu controller
let menuController = UIMenuController.shared
// Configure the menu item to display
// Create a "delete" menu item
let deleteImage = UIMenuItem(title: "Delete", action: #selector(deleteImage_1))
menuController.menuItems = [deleteImage]
// Set the location of the menu in the view.
let location = gestureRecognizer.location(in: tappedView)
print("location = ", location)
let menuLocation = CGRect(x: location.x, y: location.y, width: 2, height: 2)
menuController.setTargetRect(menuLocation, in: tappedView)
//update the menu settings to force it to display my custom items
menuController.update()
// Show the menu.
menuController.setMenuVisible(true, animated: true)
print("menu should be visible now")
}
}
/*********************************************************/
#objc func deleteImage_1() {
print(#function)
}
My caveman debugging print statements output:
longPressOnImage
can resign first responder
returned FALSE to becomeFirstResponder
location = (207.0, 82.0)
menu should be visible now
Create a custom imageView class and override "canBecomeFirstResponder" property like this:
class ResponsiveImage : UIImageView{
override var canBecomeFirstResponder: Bool{
return true
}
}
Use this ResponsiveImage type and your code will work :)
Thank you to adri. Your answer is the solution to my problem.
I had read in other posts to similar questions about overriding var canBecomeFirstResponder but either overlooked it or it wasn't made explicit that a custom UIImageView class needs to be created.
Just to make it clear to newbies like me, the class of the imageView in storyBoard and its #IBOutlet in its viewController must typed as ResponsiveImage. If only one of these is changed a type casting error is reported.
Many thanks for ending my hours of frustration! :-)

Image for Navigation Bar with Large Title iOS 11

AppStore app has an icon with an image on the right side of the NabBar with Large Title:
Would really appreciate if anyone knows how to implement it or ideas on how to do it.
BTW: Setting an image for UIButton inside of UIBarButtonItem won't work. Tried already. The button sticks to the top of the screen:
After several hours of coding, I finally managed to make it work. I also decided to write a detailed tutorial: link. Follow it in case you prefer very detailed instructions.
Demo:
Complete project on GitHub: link.
Here are 5 steps to accomplish it:
Step 1: Create an image
private let imageView = UIImageView(image: UIImage(named: "image_name"))
Step 2: Add Constants
/// WARNING: Change these constants according to your project's design
private struct Const {
/// Image height/width for Large NavBar state
static let ImageSizeForLargeState: CGFloat = 40
/// Margin from right anchor of safe area to right anchor of Image
static let ImageRightMargin: CGFloat = 16
/// Margin from bottom anchor of NavBar to bottom anchor of Image for Large NavBar state
static let ImageBottomMarginForLargeState: CGFloat = 12
/// Margin from bottom anchor of NavBar to bottom anchor of Image for Small NavBar state
static let ImageBottomMarginForSmallState: CGFloat = 6
/// Image height/width for Small NavBar state
static let ImageSizeForSmallState: CGFloat = 32
/// Height of NavBar for Small state. Usually it's just 44
static let NavBarHeightSmallState: CGFloat = 44
/// Height of NavBar for Large state. Usually it's just 96.5 but if you have a custom font for the title, please make sure to edit this value since it changes the height for Large state of NavBar
static let NavBarHeightLargeState: CGFloat = 96.5
}
Step 3: setup UI:
private func setupUI() {
navigationController?.navigationBar.prefersLargeTitles = true
title = "Large Title"
// Initial setup for image for Large NavBar state since the the screen always has Large NavBar once it gets opened
guard let navigationBar = self.navigationController?.navigationBar else { return }
navigationBar.addSubview(imageView)
imageView.layer.cornerRadius = Const.ImageSizeForLargeState / 2
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.rightAnchor.constraint(equalTo: navigationBar.rightAnchor,
constant: -Const.ImageRightMargin),
imageView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor,
constant: -Const.ImageBottomMarginForLargeState),
imageView.heightAnchor.constraint(equalToConstant: Const.ImageSizeForLargeState),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor)
])
}
Step 4: create image resizing method
private func moveAndResizeImage(for height: CGFloat) {
let coeff: CGFloat = {
let delta = height - Const.NavBarHeightSmallState
let heightDifferenceBetweenStates = (Const.NavBarHeightLargeState - Const.NavBarHeightSmallState)
return delta / heightDifferenceBetweenStates
}()
let factor = Const.ImageSizeForSmallState / Const.ImageSizeForLargeState
let scale: CGFloat = {
let sizeAddendumFactor = coeff * (1.0 - factor)
return min(1.0, sizeAddendumFactor + factor)
}()
// Value of difference between icons for large and small states
let sizeDiff = Const.ImageSizeForLargeState * (1.0 - factor) // 8.0
let yTranslation: CGFloat = {
/// This value = 14. It equals to difference of 12 and 6 (bottom margin for large and small states). Also it adds 8.0 (size difference when the image gets smaller size)
let maxYTranslation = Const.ImageBottomMarginForLargeState - Const.ImageBottomMarginForSmallState + sizeDiff
return max(0, min(maxYTranslation, (maxYTranslation - coeff * (Const.ImageBottomMarginForSmallState + sizeDiff))))
}()
let xTranslation = max(0, sizeDiff - coeff * sizeDiff)
imageView.transform = CGAffineTransform.identity
.scaledBy(x: scale, y: scale)
.translatedBy(x: xTranslation, y: yTranslation)
}
Step 5:
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let height = navigationController?.navigationBar.frame.height else { return }
moveAndResizeImage(for: height)
}
Hope it's clear and helps you!
Please let me know in comments if you have any additional questions.
If anyone is still looking how to do this in SwiftUI. I made a package named NavigationBarLargeTitleItems to deal with this. It mimics the behavior you see in the AppStore and Messages-app.
Please note to be able to accomplish this behavior we need to add to the '_UINavigationBarLargeTitleView' which is a private class and therefor might get your app rejected when submitting to the App Store.
I'm also including the full relevant source code here for those who dislike links or just want to copy/paste.
Extension:
// Copyright © 2020 Mark van Wijnen
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
public extension View {
func navigationBarLargeTitleItems<L>(trailing: L) -> some View where L : View {
overlay(NavigationBarLargeTitleItems(trailing: trailing).frame(width: 0, height: 0))
}
}
fileprivate struct NavigationBarLargeTitleItems<L : View>: UIViewControllerRepresentable {
typealias UIViewControllerType = Wrapper
private let trailingItems: L
init(trailing: L) {
self.trailingItems = trailing
}
func makeUIViewController(context: Context) -> Wrapper {
Wrapper(representable: self)
}
func updateUIViewController(_ uiViewController: Wrapper, context: Context) {
}
class Wrapper: UIViewController {
private let representable: NavigationBarLargeTitleItems?
init(representable: NavigationBarLargeTitleItems) {
self.representable = representable
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
self.representable = nil
super.init(coder: coder)
}
override func viewWillAppear(_ animated: Bool) {
guard let representable = self.representable else { return }
guard let navigationBar = self.navigationController?.navigationBar else { return }
guard let UINavigationBarLargeTitleView = NSClassFromString("_UINavigationBarLargeTitleView") else { return }
navigationBar.subviews.forEach { subview in
if subview.isKind(of: UINavigationBarLargeTitleView.self) {
let controller = UIHostingController(rootView: representable.trailingItems)
controller.view.translatesAutoresizingMaskIntoConstraints = false
subview.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.bottomAnchor.constraint(
equalTo: subview.bottomAnchor,
constant: -15
),
controller.view.trailingAnchor.constraint(
equalTo: subview.trailingAnchor,
constant: -view.directionalLayoutMargins.trailing
)
])
}
}
}
}
}
Usage:
import SwiftUI
import NavigationBarLargeTitleItems
struct ContentView: View {
var body: some View {
NavigationView {
List {
ForEach(1..<50) { index in
Text("Sample Row \(String(index))")
}
}
.navigationTitle("Navigation")
.navigationBarLargeTitleItems(trailing: ProfileIcon())
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ProfileIcon: View {
var body: some View{
Button(action: {
print("Profile button was tapped")
}) {
Image(systemName: "person.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.red)
.frame(width: 36, height: 36)
}
.offset(x: -20, y: 5)
}
}
Preview
Thanks to #TungFam, I think I have a better solution. check it out
two points:
change button frame according to navigation bar height
// adjust topview height
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let navBar = self.navigationController?.navigationBar else {
return
}
// hardcoded .. to improve
if navBar.bounds.height > 44 + 40 + 10 {
NSLayoutConstraint.deactivate(heightConstraint)
heightConstraint = [topview.heightAnchor.constraint(equalToConstant: 40)]
NSLayoutConstraint.activate(heightConstraint)
} else {
NSLayoutConstraint.deactivate(heightConstraint)
var height = navBar.bounds.height - 44 - 10
if height < 0 {
height = 0
}
heightConstraint = [topview.heightAnchor.constraint(equalToConstant: height)]
NSLayoutConstraint.activate(heightConstraint)
}
}
change button alpha according to pop/push progress
#objc func onGesture(sender: UIGestureRecognizer) {
switch sender.state {
case .began, .changed:
if let ct = navigationController?.transitionCoordinator {
topview.alpha = ct.percentComplete
}
case .cancelled, .ended:
return
case .possible, .failed:
break
}
}
Nice answer about adding it as a subview. I would add the fact that you could use pure auto layout only without the need of CGAffineTransform and all those calculations. If you add vertical constraints as well it will automatically scale. If you still need to use calculations you can use navigationController?.navigationBar.publisher(for: \.frame) publisher instead of doing it inside scroll view. That way you'll be able to do it more globally rather than being dependent on the scroll view.
This is how I did it for example (I needed to do it on leading and have large title hidden but you can change those constraints to add it wherever you'd like):
Add imageView as a property as I also need to hide it in some cases. (e.g., when opening other screen)
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.kf.setImage(with: URL(string: "https://img.buzzfeed.com/buzzfeed-static/static/2021-07/21/15/campaign_images/b4661163b3f8/24-times-michael-scott-from-the-office-made-us-bu-2-7356-1626879661-2_dblbig.jpg?resize=1200:*")!)
imageView.cornerRadiusStyle = .heightFraction(1/2) // This is an extension in the codebase I'm working on but you can set the corner radius normally as you would. Inside layoutSubviews most probably.
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = true
return imageView
}()
Setup custom image (Make sure you call this AFTER navigationController is set and not nil)
func setupCustomImage() {
// Adding imageView inside stackView just for convenience of hiding it later.
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(imageView)
NSLayoutConstraint.activate([
imageView.heightAnchor.constraint(lessThanOrEqualToConstant: 52), // In my case I needed max image size to be 52. You can change that.
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor) // I needed aspect ratio to be 1:1. You can change that also by adding multiplier.
])
guard let navigationBar = navigationController?.navigationBar else { return }
navigationBar.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor, constant: 16), // For leading padding
stackView.centerYAnchor.constraint(equalTo: navigationBar.centerYAnchor),
// You can play around with those constants as well to provide minimum size of the image needed.
navigationBar.bottomAnchor.constraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: 7),
stackView.topAnchor.constraint(greaterThanOrEqualTo: navigationBar.topAnchor, constant: 7)
])
}
It will automatically do all the scaling and stuff.
You could create the UIBarButtonItem using a custom view. This custom view will be a UIView with the actual UIButton (as a subview) placed x pixels from the top (x=the number of pixels you want to move it down).

How to reproduce this Xcode blue drag line

I'd like to reproduce the Xcode blue drag line in my app.
Do you know a way to code this ?
I know how to draw a line using Core Graphics ...
But this line has to be over the top of all other items (on the screen).
I'm posting this after you've posted your own answer, so this is probably a huge waste of time. But your answer only covers drawing a really bare-bones line on the screen and doesn't cover a bunch of other interesting stuff that you need to take care of to really replicate Xcode's behavior and even go beyond it:
drawing a nice connection line like Xcode's (with a shadow, an outline, and big rounded ends),
drawing the line across multiple screens,
using Cocoa drag and drop to find the drag target and to support spring-loading.
Here's a demo of what I'm going to explain in this answer:
In this github repo, you can find an Xcode project containing all the code in this answer plus the remaining glue code necessary to run a demo app.
Drawing a nice connection line like Xcode's
Xcode's connection line looks like an old-timey barbell. It has a straight bar of arbitrary length, with a circular bell at each end:
What do we know about that shape? The user provides the start and end points (the centers of the bells) by dragging the mouse, and our user interface designer specifies the radius of the bells and the thickness of the bar:
The length of the bar is the distance from startPoint to endPoint: length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y).
To simplify the process of creating a path for this shape, let's draw it in a standard pose, with the left bell at the origin and the bar parallel to the x axis. In this pose, here's what we know:
We can create this shape as a path by making a circular arc centered at the origin, connected to another (mirror image) circular arc centered at (length, 0). To create these arcs, we need this mysteryAngle:
We can figure out mysteryAngle if we can find any of the arc endpoints where the bell meets the bar. Specifically, we'll find the coordinates of this point:
What do we know about that mysteryPoint? We know it's at the intersection of the bell and the top of the bar. So we know it's at distance bellRadius from the origin, and at distance barThickness / 2 from the x axis:
So immediately we know that mysteryPoint.y = barThickness / 2, and we can use the Pythagorean theorem to compute mysteryPoint.x = sqrt(bellRadius² - mysteryPoint.y²).
With mysteryPoint located, we can compute mysteryAngle using our choice of inverse trigonometry function. Arcsine, I choose you! mysteryAngle = asin(mysteryPoint.y / bellRadius).
We now know everything we need to create the path in the standard pose. To move it from the standard pose to the desired pose (which goes from startPoint to endPoint, remember?), we'll apply an affine transform. The transform will translate (move) the path so the left bell is centered at startPoint and rotate the path so the right bell ends up at endPoint.
In writing the code to create the path, we want to be careful of a few things:
What if the length is so short that the bells overlap? We should handle that gracefully by adjusting mysteryAngle so the bells connect seamlessly with no weird “negative bar” between them.
What if bellRadius is smaller than barThickness / 2? We should handle that gracefully by forcing bellRadius to be at least barThickness / 2.
What if length is zero? We need to avoid division by zero.
Here's my code to create the path, handling all those cases:
extension CGPath {
class func barbell(from start: CGPoint, to end: CGPoint, barThickness proposedBarThickness: CGFloat, bellRadius proposedBellRadius: CGFloat) -> CGPath {
let barThickness = max(0, proposedBarThickness)
let bellRadius = max(barThickness / 2, proposedBellRadius)
let vector = CGPoint(x: end.x - start.x, y: end.y - start.y)
let length = hypot(vector.x, vector.y)
if length == 0 {
return CGPath(ellipseIn: CGRect(origin: start, size: .zero).insetBy(dx: -bellRadius, dy: -bellRadius), transform: nil)
}
var yOffset = barThickness / 2
var xOffset = sqrt(bellRadius * bellRadius - yOffset * yOffset)
let halfLength = length / 2
if xOffset > halfLength {
xOffset = halfLength
yOffset = sqrt(bellRadius * bellRadius - xOffset * xOffset)
}
let jointRadians = asin(yOffset / bellRadius)
let path = CGMutablePath()
path.addArc(center: .zero, radius: bellRadius, startAngle: jointRadians, endAngle: -jointRadians, clockwise: false)
path.addArc(center: CGPoint(x: length, y: 0), radius: bellRadius, startAngle: .pi + jointRadians, endAngle: .pi - jointRadians, clockwise: false)
path.closeSubpath()
let unitVector = CGPoint(x: vector.x / length, y: vector.y / length)
var transform = CGAffineTransform(a: unitVector.x, b: unitVector.y, c: -unitVector.y, d: unitVector.x, tx: start.x, ty: start.y)
return path.copy(using: &transform)!
}
}
Once we have the path, we need to fill it with the correct color, stroke it with the correct color and line width, and draw a shadow around it. I used Hopper Disassembler on IDEInterfaceBuilderKit to figure out Xcode's exact sizes and colors. Xcode draws it all into a graphics context in a custom view's drawRect:, but we'll make our custom view use a CAShapeLayer. We won't end up drawing the shadow precisely the same as Xcode, but it's close enough.
class ConnectionView: NSView {
struct Parameters {
var startPoint = CGPoint.zero
var endPoint = CGPoint.zero
var barThickness = CGFloat(2)
var ballRadius = CGFloat(3)
}
var parameters = Parameters() { didSet { needsLayout = true } }
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
let shapeLayer = CAShapeLayer()
override func makeBackingLayer() -> CALayer { return shapeLayer }
override func layout() {
super.layout()
shapeLayer.path = CGPath.barbell(from: parameters.startPoint, to: parameters.endPoint, barThickness: parameters.barThickness, bellRadius: parameters.ballRadius)
shapeLayer.shadowPath = CGPath.barbell(from: parameters.startPoint, to: parameters.endPoint, barThickness: parameters.barThickness + shapeLayer.lineWidth / 2, bellRadius: parameters.ballRadius + shapeLayer.lineWidth / 2)
}
private func commonInit() {
wantsLayer = true
shapeLayer.lineJoin = kCALineJoinMiter
shapeLayer.lineWidth = 0.75
shapeLayer.strokeColor = NSColor.white.cgColor
shapeLayer.fillColor = NSColor(calibratedHue: 209/360, saturation: 0.83, brightness: 1, alpha: 1).cgColor
shapeLayer.shadowColor = NSColor.selectedControlColor.blended(withFraction: 0.2, of: .black)?.withAlphaComponent(0.85).cgColor
shapeLayer.shadowRadius = 3
shapeLayer.shadowOpacity = 1
shapeLayer.shadowOffset = .zero
}
}
We can test this in a playground to make sure it looks good:
import PlaygroundSupport
let view = NSView()
view.setFrameSize(CGSize(width: 400, height: 200))
view.wantsLayer = true
view.layer!.backgroundColor = NSColor.white.cgColor
PlaygroundPage.current.liveView = view
for i: CGFloat in stride(from: 0, through: 9, by: CGFloat(0.4)) {
let connectionView = ConnectionView(frame: view.bounds)
connectionView.parameters.startPoint = CGPoint(x: CGFloat(i) * 40 + 15, y: 50)
connectionView.parameters.endPoint = CGPoint(x: CGFloat(i) * 40 + 15, y: 50 + CGFloat(i))
view.addSubview(connectionView)
}
let connectionView = ConnectionView(frame: view.bounds)
connectionView.parameters.startPoint = CGPoint(x: 50, y: 100)
connectionView.parameters.endPoint = CGPoint(x: 350, y: 150)
view.addSubview(connectionView)
Here's the result:
Drawing across multiple screens
If you have multiple screens (displays) attached to your Mac, and if you have “Displays have separate Spaces” turned on (which is the default) in the Mission Control panel of your System Preferences, then macOS will not let a window span two screens. This means that you can't use a single window to draw the connecting line across multiple monitors. This matters if you want to let the user connect an object in one window to an object in another window, like Xcode does:
Here's the checklist for drawing the line, across multiple screens, on top of our other windows:
We need to create one window per screen.
We need to set up each window to fill its screen and be completely transparent with no shadow.
We need to set the window level of each window to 1 to keep it above our normal windows (which have a window level of 0).
We need to tell each window not to release itself when closed, because we don't like mysterious autorelease pool crashes.
Each window needs its own ConnectionView.
To keep the coordinate systems uniform, we'll adjust the bounds of each ConnectionView so that its coordinate system matches the screen coordinate system.
We'll tell each ConnectionView to draw the entire connecting line; each view will clip what it draws to its own bounds.
It probably won't happen, but we'll arrange to be notified if the screen arrangement changes. If that happens, we'll add/remove/update windows to cover the new arrangement.
Let's make a class to encapsulate all these details. With an instance of LineOverlay, we can update the start and end points of the connection as needed, and remove the overlay from the screen when we're done.
class LineOverlay {
init(startScreenPoint: CGPoint, endScreenPoint: CGPoint) {
self.startScreenPoint = startScreenPoint
self.endScreenPoint = endScreenPoint
NotificationCenter.default.addObserver(self, selector: #selector(LineOverlay.screenLayoutDidChange(_:)), name: .NSApplicationDidChangeScreenParameters, object: nil)
synchronizeWindowsToScreens()
}
var startScreenPoint: CGPoint { didSet { setViewPoints() } }
var endScreenPoint: CGPoint { didSet { setViewPoints() } }
func removeFromScreen() {
windows.forEach { $0.close() }
windows.removeAll()
}
private var windows = [NSWindow]()
deinit {
NotificationCenter.default.removeObserver(self)
removeFromScreen()
}
#objc private func screenLayoutDidChange(_ note: Notification) {
synchronizeWindowsToScreens()
}
private func synchronizeWindowsToScreens() {
var spareWindows = windows
windows.removeAll()
for screen in NSScreen.screens() ?? [] {
let window: NSWindow
if let index = spareWindows.index(where: { $0.screen === screen}) {
window = spareWindows.remove(at: index)
} else {
let styleMask = NSWindowStyleMask.borderless
window = NSWindow(contentRect: .zero, styleMask: styleMask, backing: .buffered, defer: true, screen: screen)
window.contentView = ConnectionView()
window.isReleasedWhenClosed = false
window.ignoresMouseEvents = true
}
windows.append(window)
window.setFrame(screen.frame, display: true)
// Make the view's geometry match the screen geometry for simplicity.
let view = window.contentView!
var rect = view.bounds
rect = view.convert(rect, to: nil)
rect = window.convertToScreen(rect)
view.bounds = rect
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
window.isOneShot = true
window.level = 1
window.contentView?.needsLayout = true
window.orderFront(nil)
}
spareWindows.forEach { $0.close() }
}
private func setViewPoints() {
for window in windows {
let view = window.contentView! as! ConnectionView
view.parameters.startPoint = startScreenPoint
view.parameters.endPoint = endScreenPoint
}
}
}
Using Cocoa drag and drop to find the drag target and perform spring-loading
We need a way to find the (potential) drop target of the connection as the user drags the mouse around. It would also be nice to support spring loading.
In case you don't know, spring loading is a macOS feature in which, if you hover a drag over a container for a moment, macOS will automatically open the container without interrupting the drag. Examples:
If you drag onto a window that's not the frontmost window, macOS will bring the window to the front.
if you drag onto a Finder folder icon, and the Finder will open the folder window to let you drag onto an item in the folder.
If you drag onto a tab handle (at the top of the window) in Safari or Chrome, the browser will select the tab, letting you drop your item in the tab.
If you control-drag a connection in Xcode onto a menu item in the menu bar in your storyboard or xib, Xcode will open the item's menu.
If we use the standard Cocoa drag and drop support to track the drag and find the drop target, then we'll get spring loading support “for free”.
To support standard Cocoa drag and drop, we need to implement the NSDraggingSource protocol on some object, so we can drag from something, and the NSDraggingDestination protocol on some other object, so we can drag to something. We'll implement NSDraggingSource in a class called ConnectionDragController, and we'll implement NSDraggingDestination in a custom view class called DragEndpoint.
First, let's look at DragEndpoint (an NSView subclass). NSView already conforms to NSDraggingDestination, but doesn't do much with it. We need to implement four methods of the NSDraggingDestination protocol. The drag session will call these methods to let us know when the drag enters and leaves the destination, when the drag ends entirely, and when to “perform” the drag (assuming this destination was where the drag actually ended). We also need to register the type of dragged data that we can accept.
We want to be careful of two things:
We only want to accept a drag that is a connection attempt. We can figure out whether a drag is a connection attempt by checking whether the source is our custom drag source, ConnectionDragController.
We'll make DragEndpoint appear to be the drag source (visually only, not programmatically). We don't want to let the user connect an endpoint to itself, so we need to make sure the endpoint that is the source of the connection cannot also be used as the target of the connection. We'll do that using a state property that tracks whether this endpoint is idle, acting as the source, or acting as the target.
When the user finally releases the mouse button over a valid drop destination, the drag session makes it the destination's responsibility to “perform” the drag by sending it performDragOperation(_:). The session doesn't tell the drag source where the drop finally happened. But we probably want to do the work of making the connection (in our data model) back in the source. Think about how it works in Xcode: when you control-drag from a button in Main.storyboard to ViewController.swift and create an action, the connection is not recorded in ViewController.swift where the drag ended; it's recorded in Main.storyboard, as part of the button's persistent data. So when the drag session tells the destination to “perform” the drag, we'll make our destination (DragEndpoint) pass itself back to a connect(to:) method on the drag source where the real work can happen.
class DragEndpoint: NSView {
enum State {
case idle
case source
case target
}
var state: State = State.idle { didSet { needsLayout = true } }
public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
guard case .idle = state else { return [] }
guard (sender.draggingSource() as? ConnectionDragController)?.sourceEndpoint != nil else { return [] }
state = .target
return sender.draggingSourceOperationMask()
}
public override func draggingExited(_ sender: NSDraggingInfo?) {
guard case .target = state else { return }
state = .idle
}
public override func draggingEnded(_ sender: NSDraggingInfo?) {
guard case .target = state else { return }
state = .idle
}
public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
guard let controller = sender.draggingSource() as? ConnectionDragController else { return false }
controller.connect(to: self)
return true
}
override init(frame: NSRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
private func commonInit() {
wantsLayer = true
register(forDraggedTypes: [kUTTypeData as String])
}
// Drawing code omitted here but is in my github repo.
}
Now we can implement ConnectionDragController to act as the drag source and to manage the drag session and the LineOverlay.
To start a drag session, we have to call beginDraggingSession(with:event:source:) on a view; it'll be the DragEndpoint where the mouse-down event happened.
The session notifies the source when the drag actually starts, when it moves, and when it ends. We use those notifications to create and update the LineOverlay.
Since we're not providing any images as part of our NSDraggingItem, the session won't draw anything being dragged. This is good.
By default, if the drag ends outside of a valid destination, the session will animate… nothing… back to the start of the drag, before notifying the source that the drag has ended. During this animation, the line overlay hangs around, frozen. It looks broken. We tell the session not to animate back to the start to avoid this.
Since this is just a demo, the “work” we do to connect the endpoints in connect(to:) is just printing their descriptions. In a real app, you'd actually modify your data model.
class ConnectionDragController: NSObject, NSDraggingSource {
var sourceEndpoint: DragEndpoint?
func connect(to target: DragEndpoint) {
Swift.print("Connect \(sourceEndpoint!) to \(target)")
}
func trackDrag(forMouseDownEvent mouseDownEvent: NSEvent, in sourceEndpoint: DragEndpoint) {
self.sourceEndpoint = sourceEndpoint
let item = NSDraggingItem(pasteboardWriter: NSPasteboardItem(pasteboardPropertyList: "\(view)", ofType: kUTTypeData as String)!)
let session = sourceEndpoint.beginDraggingSession(with: [item], event: mouseDownEvent, source: self)
session.animatesToStartingPositionsOnCancelOrFail = false
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
switch context {
case .withinApplication: return .generic
case .outsideApplication: return []
}
}
func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) {
sourceEndpoint?.state = .source
lineOverlay = LineOverlay(startScreenPoint: screenPoint, endScreenPoint: screenPoint)
}
func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) {
lineOverlay?.endScreenPoint = screenPoint
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
lineOverlay?.removeFromScreen()
sourceEndpoint?.state = .idle
}
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { return true }
private var lineOverlay: LineOverlay?
}
That's all you need. As a reminder, you can find a link at the top of this answer to a github repo containing a complete demo project.
Using a transparent NSWindow :
var window: NSWindow!
func createLinePath(from: NSPoint, to: NSPoint) -> CGPath {
let path = CGMutablePath()
path.move(to: from)
path.addLine(to: to)
return path
}
override func viewDidLoad() {
super.viewDidLoad()
//Transparent window
window = NSWindow()
window.styleMask = .borderless
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
//Line
let line = CAShapeLayer()
line.path = createLinePath(from: NSPoint(x: 0, y: 0), to: NSPoint(x: 100, y: 100))
line.lineWidth = 10.0
line.strokeColor = NSColor.blue.cgColor
//Update
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
let newPos = NSEvent.mouseLocation()
line.path = self.createLinePath(from: NSPoint(x: 0, y: 0), to: newPos)
return $0
}
window.contentView!.layer = line
window.contentView!.wantsLayer = true
window.setFrame(NSScreen.main()!.frame, display: true)
window.makeKeyAndOrderFront(nil)
}
Trying to adopt Rob Mayoff's excellent solution above into my own project's interface, which is based around an NSOutlineView, I ran into a few problems. In case it helps anyone trying to achieve the same thing, I'll detail those pitfalls in this answer.
The sample code provided in the solution detects the start of a drag by implementing mouseDown(with:) on the view controller, and then calling hittest() on the window's content view in order to obtain the DragEndpoint subview where the (potential) drag is originating. When using outline views, this causes two pitfalls detailed in the next sections.
1. Mouse-Down Event
It seems that when a table view or outline view is involved, mouseDown(with:) never gets called on the view controller, and we need to instead override that method in the outline view itself.
2. Hit Testing
NSTableView -and by extension, NSOutlineView- overrides the NSResponder method validateProposedFirstResponder(_:for:), and this causes the hittest() method to fail: it always returns the outline view itself, and all subviews (including our target DragEndpoint subview inside the cell) remain inaccessible.
From the documentation:
Views or controls in a table sometimes need to respond to incoming
events. To determine whether a particular subview should receive the
current mouse event, a table view calls
validateProposedFirstResponder:forEvent: in its implementation of
hitTest. If you create a table view subclass, you can override
validateProposedFirstResponder:forEvent: to specify which views can
become the first responder. In this way, you receive mouse events.
At first I tried overriding:
override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool {
if responder is DragEndpoint {
return true
}
return super.validateProposedFirstResponder(responder, for: event)
}
...and it worked, but reading the documentation further suggests a smarter, less intrusive approach:
The default NSTableView implementation of
validateProposedFirstResponder:forEvent: uses the following logic:
Return YES for all proposed first responder views unless they are
instances or subclasses of NSControl.
Determine whether the proposed
first responder is an NSControl instance or subclass. If the control
is an NSButton object, return YES. If the control is not an NSButton,
call the control’s hitTestForEvent:inRect:ofView: to see whether the
hit area is trackable (that is, NSCellHitTrackableArea) or is an
editable text area (that is, NSCellHitEditableTextArea), and return
the appropriate value. Note that if a text area is hit, NSTableView
also delays the first responder action.
(emphasis mine)
...which is weird, because it feels like it should say:
Return NO for all proposed first responder views unless they are
instances or subclasses of NSControl.
, but anyway, I instead modified Rob's code to make DragEndpoint a subclass of NSControl (not just NSView), and that works too.
3. Managing the Dragging Session
Because NSOutlineView only exposes a limited number of drag-and-drop events through its data source protocol (and the drag session itself can not be meaningfully modified from the data source's side), it seems that taking full control of the drag session is not possible unless we subclass the outline view and override the NSDraggingSource methods.
Only by overriding draggingSession(_:willBeginAt:) at the outline view itself can we prevent calling the superclass implementation and starting an actual item drag (which displays the dragged row image).
We could start a separate drag session from the mouseDown(with:) method of the DragEndpoint subview: when implemented, it is called before the same method on the outline view (which in turn is what triggers the dragging session to be started). But if we move the dragging session away from the outline view, it seems like it will be impossible to have springloading "for free" when dragging above an expandable item.
So instead, I discarded the ConnectionDragController class and moved all its logic to the outline view subclass: the tackDrag() method, the active DragEndpoint property, and all methods of the NSDraggingSource protocol into the outline view.
Ideally, I would have liked to avoid subclassing NSOutlineView (it is discouraged) and instead implement this behaviour more cleanly, exclusively through the outline view's delegate/data source and/or external classes (like the original ConnectionDragController), but it seems that it is impossible.
I haven't got the springloading part to work yet (it was working at a moment, but not now so I'm still looking into it...).
I too made a sample project, but I'm still fixing minor issues. I'll post a link to the GiHub repository as soon as it is ready.

Accessibility (Voice Over) with Sprite Kit

I'm attempting to add support for Voice Over accessibility in a puzzle game which has a fixed board. However, I'm having trouble getting UIAccessibilityElements to show up.
Right now I'm overriding accessibilityElementAtIndex, accessibilityElementCount and indexOfAccessibilityElement in my SKScene.
They are returning an array of accessible elements as such:
func loadAccessibleElements()
{
self.isAccessibilityElement = false
let pieces = getAllPieces()
accessibleElements.removeAll(keepCapacity: false)
for piece in pieces
{
let element = UIAccessibilityElement(accessibilityContainer: self.usableView!)
element.accessibilityFrame = piece.getAccessibilityFrame()
element.accessibilityLabel = piece.getText()
element.accessibilityTraits = UIAccessibilityTraitButton
accessibleElements.append(element)
}
}
Where piece is a subclass of SKSpriteNode and getAccessibilityFrame is defined:
func getAccessibilityFrame() -> CGRect
{
return parentView!.convertRect(frame, toView: nil)
}
Right now one (wrongly sized) accessibility element seems to appear on the screen in the wrong place.
Could someone point me in the right direction?
Many thanks
EDIT:
I've tried a hack-ish work around by placing a UIView over the SKView with UIButton elements in the same location as the SKSpriteNodes. However, accessibility still doesn't want to work. The view is loaded as such:
func loadAccessibilityView()
{
view.isAccessibilityElement = false
view.accessibilityElementsHidden = false
skView.accessibilityElementsHidden = false
let accessibleSubview = UIView(frame: view.frame)
accessibleSubview.userInteractionEnabled = true
accessibleSubview.isAccessibilityElement = false
view.addSubview(accessibleSubview)
view.bringSubviewToFront(accessibleSubview)
let pieces = (skView.scene! as! GameScene).getAllPieces()
for piece in pieces
{
let pieceButton = UIButton(frame: piece.getAccessibilityFrame())
pieceButton.isAccessibilityElement = true
pieceButton.accessibilityElementsHidden = false
pieceButton.accessibilityTraits = UIAccessibilityTraitButton
pieceButton.setTitle(piece.getText(), forState: UIControlState.Normal)
pieceButton.setBackgroundImage(UIImage(named: "blue-button"), forState: UIControlState.Normal)
pieceButton.alpha = 0.2
pieceButton.accessibilityLabel = piece.getText()
pieceButton.accessibilityFrame = pieceButton.frame
pieceButton.addTarget(self, action: Selector("didTap:"), forControlEvents: UIControlEvents.TouchUpInside)
accessibleSubview.addSubview(pieceButton)
}
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, nil)
}
The buttons are placed correctly, however accessibility just isn't working at all. Something seems to be preventing it from working.
I've searched in vain for a description of how to implement VoiceOver in Swift using SpriteKit, so I finally figured out how to do it. Here's some working code that converts a SKNode to an accessible pushbutton when added to a SKScene class:
// Add the following code to a scene where you want to make the SKNode variable named “leave” an accessible button
// leave must already be initialized and added as a child of the scene, or a child of other SKNodes in the scene
// screenHeight must already be defined as the height of the device screen, in points
// Accessibility
private var accessibleElements: [UIAccessibilityElement] = []
private func nodeToDevicePointsFrame(node: SKNode) -> CGRect {
// first convert from frame in SKNode to frame in SKScene's coordinates
var sceneFrame = node.frame
sceneFrame.origin = node.scene!.convertPoint(node.frame.origin, fromNode: node.parent!)
// convert frame from SKScene coordinates to device points
// sprite kit scene origin is in lower left, accessibility device screen origin is at upper left
// assumes scene is initialized using SKSceneScaleMode.Fill using dimensions same as device points
var deviceFrame = sceneFrame
deviceFrame.origin.y = CGFloat(screenHeight-1) - (sceneFrame.origin.y + sceneFrame.size.height)
return deviceFrame
}
private func initAccessibility() {
if accessibleElements.count == 0 {
let accessibleLeave = UIAccessibilityElement(accessibilityContainer: self.view!)
accessibleLeave.accessibilityFrame = nodeToDevicePointsFrame(leave)
accessibleLeave.accessibilityTraits = UIAccessibilityTraitButton
accessibleLeave.accessibilityLabel = “leave” // the accessible name of the button
accessibleElements.append(accessibleLeave)
}
}
override func didMoveToView(view: SKView) {
self.isAccessibilityElement = false
leave.isAccessibilityElement = true
}
override func willMoveFromView(view: SKView) {
accessibleElements = []
}
override func accessibilityElementCount() -> Int {
initAccessibility()
return accessibleElements.count
}
override func accessibilityElementAtIndex(index: Int) -> AnyObject? {
initAccessibility()
if (index < accessibleElements.count) {
return accessibleElements[index] as AnyObject
} else {
return nil
}
}
override func indexOfAccessibilityElement(element: AnyObject) -> Int {
initAccessibility()
return accessibleElements.indexOf(element as! UIAccessibilityElement)!
}
Accessibility frames are defined in the fixed physical screen coordinates, not UIView coordinates, and transforming between them is kind of tricky.
The device origin is the lower left of the screen, with X up, when the device is in landscape right mode.
It's a pain converting, I've no idea why Apple did it that way.