How to get target UITraitCollection as well as target rect - swift

I have a tool pane which in compact H mode, will be at the bottom spanning the full screen, but in compact V mode (or non compact H mode), it will be on the right as a floating pane. How do I get the target UITraitCollection + the target size? They seem to be in 2 different methods:
override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
// need size rect
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// need traits
}
I need both infos for animating things properly! Thanks so much!

You can in fact get both values, the size and the trait collection in both methods, by operating on the UIViewControllerTransitionCoordinator.
let didQueueAnimation = coordinator.animate(alongsideTransition: { context in
guard let view = context.view(forKey: UITransitionContextViewKey.to) else {
return
}
let newScreenSize = view.frame.size
guard let viewController = context.viewController(forKey: UITransitionContextViewKey.to) else {
return
}
let newTraitCollection = viewController.traitCollection // <-- New Trait Collection
// You can also get the new view size from the VC:
// let newTraitCollection = viewController.view.frame.size
// Perform animation if trait collection and size match.
}, completion: { context in
// Perform animation cleanup / other pending tasks.
})
if (didQueueAnimation) {
print("Animation was queued.")
}
Apple's idea here is to simplify the call site with one context parameter that can be queried for multiple properties, and also execute asynchronously so that the values for the final transitioned view or VC can be fetched accurately in one place even before the update occurs.
While you can perform animations in either of the WillTransition methods, I would use the coordinator.animate(alongsideTransition:completion:) method if possible rather than coordinator.notifyWhenInteractionChanges(_:) since it synchronizes the system animation alongside your own custom animation and you can still query the context for the new traitCollection or frame.size using the techniques above.

Related

Cocoa customise NSView's tooltips 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()
}

How, exactly, do I render Metal on a background thread?

This problem is caused by user interface interactions such as showing the titlebar while in fullsreen. That question's answer provides a solution, but not how to implement that solution.
The solution is to render on a background thread. The issue is, the code provided in Apple's is made to cover a lot of content so most of it will extraneous code, so even if I could understand it, it isn't feasible to use Apple's code. And I can't understand it so it just plain isn't an option. How would I make a simple Swift Metal game use a background thread being as concise as possible?
Take this, for example:
class ViewController: NSViewController {
var MetalView: MTKView {
return view as! MTKView
}
var Device: MTLDevice = MTLCreateSystemDefaultDevice()!
override func viewDidLoad() {
super.viewDidLoad()
MetalView.delegate = self
MetalView.device = Device
MetalView.colorPixelFormat = .bgra8Unorm_srgb
Device = MetalView.device
//setup code
}
}
extension ViewController: MTKViewDelegate {
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
}
func draw(in view: MTKView) {
//drawing code
}
}
That is the start of a basic Metal game. What would that code look like, if it were rendering on a background thread?
To fix that bug when showing the titlebar in Metal, I need to render it on a background thread. Well, how do I render it on a background thread?
I've noticed this answer suggests to manually redraw it 60 times a second. Presumably using a loop that is on a background thread? But that seems... not a clean way to fix it. Is there a cleaner way?
The main trick in getting this to work seems to be setting up the CVDisplayLink. This is awkward in Swift, but doable. After some work I was able to modify the "Game" template in Xcode to use a custom view backed by CAMetalLayer instead of MTKView, and a CVDisplayLink to render in the background, as suggested in the sample code you linked — see below.
Edit Oct 22:
The approach mentioned in this thread seems to work just fine: still using an MTKView, but drawing it manually from the display link callback. Specifically I was able to follow these steps:
Create a new macOS Game project in Xcode.
Modify GameViewController to add a CVDisplayLink, similar to below (see this question for more on using CVDisplayLink from Swift). Start the display link in viewWillAppear and stop it in viewWillDisappear.
Set mtkView.isPaused = true in viewDidLoad to disable automatic rendering, and instead explicitly call mtkView.draw() from the display link callback.
The full content of my modified GameViewController.swift is available here.
I didn't review the Renderer class for thread safety, so I can't be sure no more changes are required, but this should get you up and running.
Older implementation with CAMetalLayer instead of MTKView:
This is just a proof of concept and I can't guarantee it's the best way to do everything. You might find these articles helpful too:
I didn't try this idea, but given how much convenience MTKView generally provides over CAMetalLayer, it might be worth giving it a shot:
https://developer.apple.com/forums/thread/89241?answerId=268384022#268384022
Is drawing to an MTKView or CAMetalLayer required to take place on the main thread? and https://developer.apple.com/documentation/quartzcore/cametallayer/1478157-presentswithtransaction
class MyMetalView: NSView {
var displayLink: CVDisplayLink?
var metalLayer: CAMetalLayer!
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupMetalLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupMetalLayer()
}
override func makeBackingLayer() -> CALayer {
return CAMetalLayer()
}
func setupMetalLayer() {
wantsLayer = true
metalLayer = layer as! CAMetalLayer?
metalLayer.device = MTLCreateSystemDefaultDevice()!
// ...other configuration of the metalLayer...
}
// handle display link callback at 60fps
static let _outputCallback: CVDisplayLinkOutputCallback = { (displayLink, inNow, inOutputTime, flagsIn, flagsOut, context) -> CVReturn in
// convert opaque context pointer back into a reference to our view
let view = Unmanaged<MyMetalView>.fromOpaque(context!).takeUnretainedValue()
/*** render something into view.metalLayer here! ***/
return kCVReturnSuccess
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard CVDisplayLinkCreateWithActiveCGDisplays(&displayLink) == kCVReturnSuccess,
let displayLink = displayLink
else {
fatalError("unable to create display link")
}
// pass a reference to this view as an opaque pointer
guard CVDisplayLinkSetOutputCallback(displayLink, MyMetalView._outputCallback, Unmanaged<MyMetalView>.passUnretained(self).toOpaque()) == kCVReturnSuccess else {
fatalError("unable to configure output callback")
}
guard CVDisplayLinkStart(displayLink) == kCVReturnSuccess else {
fatalError("unable to start display link")
}
}
deinit {
if let displayLink = displayLink {
CVDisplayLinkStop(displayLink)
}
}
}

Drawing directly in a NSView without using the draw(_ updateRect: NSRect) function

I would like to draw CGImage pictures directly to a View and with the normal method using the draw func I only get 7 pictures in a second on a new Mac Book Pro. So I decided to use the updateLayer func instead. I have defined wantsUpdateLayer = true and my new updateLayer func is called as expected. But then starts my problem. When using the draw func, I get the current CGContext with "NSGraphicsContext.current?.cgContext" but in my updateLayer func the "NSGraphicsContext.current?.cgContext" is nil. So I do not know where to put my CGImage, that it will be displayed on my screen. Also "self.view?.window?.graphicsContext?.cgContext" and "self.window?.graphicsContext?.cgContext" are nil, too. There are no buttons or other elements in this view and in the window of the view, only one picture, filling the complete window. And this picture must change 30 times in a second. Generating the pictures is done by a separate thread and needs about 1 millisecond for a picture. I think that from "outside" the NSView class it is not possible to write the picture but my updateLayer func is inside the class.
Here is what the func looks like actually:
override func updateLayer ()
{
let updateRect: NSRect = NSRect(x: 0.0, y: 0.0, width: 1120.0, height: 768.0)
let context1 = self.view?.window?.graphicsContext?.cgContext
let context2 = self.window?.graphicsContext?.cgContext
let context3 = NSGraphicsContext.current?.cgContext
}
And all three contexts are nil in the time the function is called automatically after I set the needsDisplay flag.
Any ideas where to draw my CGImages?
The updateLayer func is called automatically by the user interface. I do not call it manually. It is called by the view. My problem is where inside this method to put my picture to be shown on the screen. Perhaps I have to add a layer or use a default layer of the view but I do not know how to do this.
Meanwhile I have found the solution with some tipps from a good friend:
override var wantsUpdateLayer: Bool
{
return (true)
}
override func updateLayer ()
{
let cgimage: CGImage? = picture // Here comes the picture
if cgimage != nil
{
let nsimage: NSImage? = NSImage(cgImage: cgimage!, size: NSZeroSize)
if nsimage != nil
{
let desiredScaleFactor: CGFloat? = self.window?.backingScaleFactor
if desiredScaleFactor != nil
{
let actualScaleFactor: CGFloat? = nsimage!.recommendedLayerContentsScale(desiredScaleFactor!)
if actualScaleFactor != nil
{
self.layer!.contents = nsimage!.layerContents(forContentsScale: actualScaleFactor!)
self.layer!.contentsScale = actualScaleFactor!
}
}
}
}
}
This is the way to directly write into the layer. Depending on the format (CGImage or NSImage) you first must convert it. As soon as the func wantsUpdateLayer returns a true, the func updateLayer() is used instead of the func draw(). Thats all.
For all who want to see my "Normal" draw function:
override func draw (_ updateRect: NSRect)
{
let cgimage: CGImage? = picture // Here comes the picture
if cgimage != nil
{
if #available(macOS 10.10, *)
{
NSGraphicsContext.current?.cgContext.draw(cgimage!, in: updateRect)
}
}
else
{
super.draw(updateRect)
}
}
The additional speed is 2 times or more, depending on what hardware you use. On a modern Mac Pro there is only a little bit more speed but on a modern Mac Book Pro you will get 10 times or more speed. This works with Mojave 10.14.6 and Catalina 10.15.6. I did not test it with older macOS versions. The "Normal" draw function works with 10.10.6 to 10.15.6.

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.

How to to determinate in Swift the current width of the app when in Split View?

EDIT: I have a project with a row of buttons on top on it. Usually the buttons are 5 in Compact view and 6 in Regular view. I would like to remove a button when the app runs in 1/3 Split View. How can I determine the width of the app?
I'm using this code to determinate the current width of the app when in Split View (multitasking):
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
// works but it's deprecated:
let currentWidth = UIScreen.mainScreen().applicationFrame.size.width
print(currentWidth)
}
It works, but unfortunately applicationFrame is deprecated in iOS 9, so I'm trying to replace it with this:
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
// gives you the width of the screen not the width of the app:
let currentWidth = UIScreen.mainScreen().bounds.size.width
print(currentWidth)
}
The problem is that the first statement gives you the effective width of the app and it's fine, instead the second one, gives you the width of the screen, so you can't use it to learn the real width of the app when it is in Split View.
Would someone know what code would be necessary to replace this deprecated statement?
let currentWidth = UIScreen.mainScreen().applicationFrame.size.width // deprecated
#TheValyreanGroup's answer will work if there are no intervening view controllers mucking with sizes. If that possibility exists you should be able to use self.view.window.frame.size.width
You can just get the size of the parent view.
let currentSize = self.view.bounds.width
That will return the width accurately even in split view.
You can do something like this to determine whether to show or hide a button.
let totalButtonWidth: Int
for b in self.collectionView.UIViews{
let totalButtonWidth += b.frame.width + 20 //Where '20' is the gap between your buttons
}
if (currentSize < totalButtonWidth){
self.collectionView.subviews[self.collectionView.subviews.count].removeFromSuperview()
}else{
self.collectionView.addSubview(buttonViewToAdd)
}
Something like that, but i think you can get the idea.
Thanks to the replay of TheValyreanGroup and David Berry on this page I made a solution that can respond to the interface changes without using the deprecate statement UIScreen.mainScreen().applicationFrame.size.width I post it here with its context to made more clear what is the problem and the (surely improvable) solution. Please post any suggestion and comment you think could improve the code.
// trigged when app opens and when other events occur
override func traitCollectionDidChange(previousTraitCollection: UITraitCollection?) {
let a = self.view.bounds.width
adaptInterface(Double(a))
}
// not trigged when app opens or opens in Split View, trigged when other changes occours
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
adaptInterface(Double(size.width))
}
func isHorizontalSizeClassCompact () -> Bool {
if (view.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClass.Compact) {
return true // Comapact
} else {
return false // Regular
}
}
func adaptInterface(currentWidth: Double) {
if isHorizontalSizeClassCompact() { // Compact
// do what you need to do when sizeclass is Compact
if currentWidth <= 375 {
// do what you need when the width is the one of iPhone 6 in portrait or the one of the Split View in 1/3 of the screen
} else {
// do what you need when the width is bigger than the one of iPhone 6 in portrait or the one of the Split View in 1/3 of the screen
}
} else { // Regular
// do what you need to do when sizeclass is Regular
}
}