I have a simple UIViewRepresentable wrapper for the live text feature (ImageAnalysisInteraction). It was working without issues until I started updating the UIImage inside the updateUIView(...) function.
I have always been seeing this error in the console which originates from this view:
[api] -[CIImage initWithCVPixelBuffer:options:] failed because the buffer is nil.
When I change the image, it's updating correctly, but the selectableItemsHighlighted overlay stays the same and I can still select the text of the old image (even though it's no longer visible).
import UIKit
import SwiftUI
import VisionKit
#MainActor
struct LiveTextInteraction: UIViewRepresentable {
#Binding var image: UIImage
let interaction = ImageAnalysisInteraction()
let imageView = LiveTextImageView()
let analyzer = ImageAnalyzer()
let configuration = ImageAnalyzer.Configuration([.text])
func makeUIView(context: Context) -> UIImageView {
interaction.setSupplementaryInterfaceHidden(true, animated: true)
imageView.image = image
imageView.addInteraction(interaction)
imageView.contentMode = .scaleAspectFit
return imageView
}
func updateUIView(_ uiView: UIImageView, context: Context) {
Task {
uiView.image = image
do {
if let image = uiView.image {
let analysis = try await analyzer.analyze(image, configuration: configuration)
interaction.analysis = analysis;
interaction.preferredInteractionTypes = .textSelection
interaction.selectableItemsHighlighted = true
interaction.setContentsRectNeedsUpdate()
}
} catch {
// catch
}
}
}
}
class LiveTextImageView: UIImageView {
// Use intrinsicContentSize to change the default image size
// so that we can change the size in our SwiftUI View
override var intrinsicContentSize: CGSize {
.zero
}
}
What am I doing wrong here?
It looks like a bug. Try use dispatch
let highlighted = interaction.selectableItemsHighlighted
interaction.analysis = analysis // highlighted == false
if highlighted
{
DispatchQueue.main.async
{
interaction.selectableItemsHighlighted = highlighted
}
}
You don't need interaction.setContentsRectNeedsUpdate() if the interaction is added to a UIImageView
I'm trying to use a view written in SwiftUI as an accessory view of my NSSavePanel but I struggled to get it working properly.
Here's the implementation for my SwiftUI view:
struct ExportAccessoryView: View {
enum ExportFileType: String, Identifiable {
// ... enum declaration
}
#State var selectedExportFileType: ExportFileType = .png
#State var resolution = 256.0
#Binding var selectedFileTypeBinding: ExportFileType
#Binding var resolutionBinding: Double
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker(selection: $selectedExportFileType, label: Text("Format:")) {
Text("PDF").tag(ExportFileType.pdf)
// ... other items
}
.frame(width: 170)
.padding(.leading, 21)
if [ExportFileType.png, ExportFileType.jpeg, ExportFileType.tiff].contains(selectedExportFileType) {
HStack {
Slider(value: $resolution, in: 128...1024,
label: { Text("Resolution:") })
.frame(width: 200)
Text("\(Int(resolution))")
.frame(width: 40, alignment: .leading)
.padding(.leading, 5)
}
}
}
.padding(10)
.onChange(of: selectedExportFileType) { newValue in
self.selectedFileTypeBinding = newValue
}
.onChange(of: resolution) { newValue in
self.resolutionBinding = newValue
}
}
}
Here's how I implemented my save panel:
class DocumentWindow: NSWindowController {
var exportFileType: ExportAccessoryView.ExportFileType = .pdf
var resolution = 256.0
lazy var exportPanel: NSSavePanel = {
let savePanel = NSSavePanel()
savePanel.message = "Specify where and how you wish to export..."
savePanel.nameFieldLabel = "Export As:"
savePanel.canCreateDirectories = true
savePanel.isExtensionHidden = false
savePanel.showsTagField = true
let fileTypeBinding = Binding {
return self.exportFileType
} set: { newValue in
self.exportFileType = newValue
// update file extension
self.exportPanel.allowedContentTypes = [UTType(newValue.rawValue)!]
}
let resolutionBinding = Binding {
return self.resolution
} set: { newValue in
self.resolution = newValue
}
let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
resolutionBinding: resolutionBinding)
let exportAccessoryView = NSHostingController(rootView: accessoryView)
savePanel.accessoryView = exportAccessoryView.view
savePanel.allowedContentTypes = [UTType(self.exportFileType.rawValue)!]
return savePanel
}()
}
The save panel is presented by invoking beginSheetModal(for:completionHandler:).
It has no problem displaying but the accessory view is exhibiting some bizarre behavior: it seems to be doing its own thing at random (I sought for patterns but I failed to do so).
Sometimes it works properly, sometimes it becomes unclickable (but the function is still accessible via switch control using TAB). The alignment is always different from the last time I expanded/collapsed or opened/closed the panel: sometimes it's left aligned, sometimes it's centered (even if I have explicitly opted for .leading for alignment).
I have absolutely no idea what's going on. I don't know if this is an issue with SwiftUI+AppKit or is it that I'm doing it all wrong, which is highly likely since I'm a total newbie in SwiftUI. What should I do to get it working properly?
I remembered from back in the days when I was using XIB for implementing an accessory view: I used to embed the controls within an NSView and then set up constraints to make it work. So I applied the same idea here of embedding the NSHostingView's view within a custom NSView and after tweaking it for a bit, I made it work:
lazy var exportPanel: NSSavePanel = {
// ... setting up save panel
// instantiate SwiftUI view and its hosting controller
let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
resolutionBinding: resolutionBinding)
let exportAccessoryView = NSHostingController(rootView: accessoryView)
// embed the SwiftUI in a custom view
let customView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 60))
customView.addSubview(exportAccessoryView.view)
// use my own constraints
exportAccessoryView.view.translatesAutoresizingMaskIntoConstraints = false
// top and bottom clipped to custom view
exportAccessoryView.view.topAnchor.constraint(equalTo: customView.topAnchor).isActive = true
exportAccessoryView.view.bottomAnchor.constraint(equalTo: customView.bottomAnchor).isActive = true
// leading and trailing spaces can stretch as far as they need to be, hence ≥0
exportAccessoryView.view.leadingAnchor.constraint(greaterThanOrEqualTo: customView.leadingAnchor).isActive = true
exportAccessoryView.view.trailingAnchor.constraint(greaterThanOrEqualTo: customView.trailingAnchor).isActive = true
// center the SwiftUI view horizontal within custom view
exportAccessoryView.view.centerXAnchor.constraint(equalTo: customView.centerXAnchor).isActive = true
// usually fixed width and height
// can be flexible when SwiftUI view is dynamic
exportAccessoryView.view.widthAnchor.constraint(equalToConstant: customView.frame.width).isActive = true
exportAccessoryView.view.heightAnchor.constraint(greaterThanOrEqualToConstant: customView.frame.height).isActive = true
savePanel.accessoryView = customView
// ... additional setup
return savePanel
}()
Now it works perfectly as expected. Don't know if this is the "proper way" to implement such integration.
How to set window coordinates in SwiftUI on MacOS Desktop? For example, should the window appear always in the center or always in the upper right corner?
Here is my version, however, I shift the code and close it, when I open it, it appears first in the old place, and then jumps to a new place.
import SwiftUI
let WIDTH: CGFloat = 400
let HEIGTH: CGFloat = 200
#main
struct ForVSCode_MacOSApp: App {
#State var window : NSWindow?
var body: some Scene {
WindowGroup {
ContentView(win: $window)
}
}
}
struct WindowAccessor: NSViewRepresentable{
#Binding var window: NSWindow?
func makeNSView(context: Context) -> some NSView {
let view = NSView()
let width = (NSScreen.main?.frame.width)!
let heigth = (NSScreen.main?.frame.height)!
let resWidth: CGFloat = (width / 2) - (WIDTH / 2)
let resHeigt: CGFloat = (heigth / 2) - (HEIGTH / 2)
DispatchQueue.main.async {
self.window = view.window
self.window?.setFrameOrigin(NSPoint(x: resWidth, y: resHeigt))
self.window?.setFrameAutosaveName("mainWindow")
self.window?.isReleasedWhenClosed = false
self.window?.makeKeyAndOrderFront(nil)
}
return view
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
}
and ContentView
import SwiftUI
struct ContentView: View {
#Binding var win: NSWindow?
var body: some View {
VStack {
Text("it finally works!")
}
.font(.largeTitle)
.frame(width: WIDTH, height: HEIGTH, alignment: .center)
.background(WindowAccessor(window: $win))
}
}
struct ContentView_Previews: PreviewProvider {
#Binding var win: NSWindow?
static var previews: some View {
ContentView(win: .constant(NSWindow()))
.frame(width: 250, height: 150, alignment: .center)
}
}
I do have the same issue in one of my projects and thought I will investigate a bit deeper and I found two approaches to control the window position.
So my first approach to influence the window position is by pre-defining the windows last position on screen.
Indirect control: Frame autosave name
When the first window of an app is opened, macOS will try to restore the last window position when it was last closed. To distinguish the different windows, each window has its own frameAutosaveName.
The windows frame is persisted automatically in a text format in the apps preferences (UserDefaults.standard) with the key derived from the frameAutosaveName: "NSWindow Frame <frameAutosaveName>" (see docs for saveFrame).
If you do not specify an ID in your WindowGroup, SwiftUI will derive the autosave name from your main views class name. The first three windows will have the following autosave names:
<ModuleName>.ContentView-1-AppWindow-1
<ModuleName>.ContentView-1-AppWindow-2
<ModuleName>.ContentView-1-AppWindow-3
By setting an ID for example WindowGroup(id: "main"), the following autosave names are used (again for the first three windows):
main-AppWindow-1
main-AppWindow-2
main-AppWindow-3
When you check in your apps preferences directory (where UserDefaults.standard is stored), you will see in the plist one entry:
NSWindow Frame main-AppWindow-1 1304 545 400 228 0 0 3008 1228
There are a lot of numbers to digest. The first 4 integers describe the windows frame (origin and size), the next 4 integers describe the screens frame.
There are a few things to keep in mind when manually setting those value:
macOS coordinate system has it origin (0,0) in the bottom left corner.
the windows height includes the window title bar (28px on macOS Monterey but may be different on other versions)
the screens height excludes the title bar
I don't have documentation on this format and used trial and error to gain knowledge about it...
So to fake the initial position in the center of the screen I used the following function which I run in the apps (or the ContentView) initializer. But keep in mind: with this method only the first window will be centered. All the following windows are going to be put down and right of the previous window.
func fakeWindowPositionPreferences() {
let main = NSScreen.main!
let screenWidth = main.frame.width
let screenHeightWithoutMenuBar = main.frame.height - 25 // menu bar
let visibleFrame = main.visibleFrame
let contentWidth = WIDTH
let contentHeight = HEIGHT + 28 // window title bar
let windowX = visibleFrame.midX - contentWidth/2
let windowY = visibleFrame.midY - contentHeight/2
let newFramePreference = "\(Int(windowX)) \(Int(windowY)) \(Int(contentWidth)) \(Int(contentHeight)) 0 0 \(Int(screenWidth)) \(Int(screenHeightWithoutMenuBar))"
UserDefaults.standard.set(newFramePreference, forKey: "NSWindow Frame main-AppWindow-1")
}
My second approach is by directly manipulating the underlying NSWindow similar to your WindowAccessor.
Direct control: Manipulating NSWindow
Your implementation of WindowAccessor has a specific flaw: Your block which is reading view.window to extract the NSWindow instance is run asynchronously: some time in the future (due to DispatchQueue.main.async).
This is why the window appears on screen on the SwiftUI configured position, then disappears again to finally move to your desired location. You need more control, which involves first monitoring the NSView to get informed as soon as possible when the window property is set and then monitoring the NSWindow instance to get to know when the view is becoming visible.
I'm using the following implementation of WindowAccessor. It takes a onChange callback closure which is called whenever window is changing. First it starts monitoring the NSViews window property to get informed when the view is added to a window. When this happened, it starts listening for NSWindow.willCloseNotification notifications to detect when the window is closing. At this point it will stop any monitoring to avoid leaking memory.
import SwiftUI
import Combine
struct WindowAccessor: NSViewRepresentable {
let onChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
context.coordinator.monitorView(view)
return view
}
func updateNSView(_ view: NSView, context: Context) {
}
func makeCoordinator() -> WindowMonitor {
WindowMonitor(onChange)
}
class WindowMonitor: NSObject {
private var cancellables = Set<AnyCancellable>()
private var onChange: (NSWindow?) -> Void
init(_ onChange: #escaping (NSWindow?) -> Void) {
self.onChange = onChange
}
/// This function uses KVO to observe the `window` property of `view` and calls `onChange()`
func monitorView(_ view: NSView) {
view.publisher(for: \.window)
.removeDuplicates()
.dropFirst()
.sink { [weak self] newWindow in
guard let self = self else { return }
self.onChange(newWindow)
if let newWindow = newWindow {
self.monitorClosing(of: newWindow)
}
}
.store(in: &cancellables)
}
/// This function uses notifications to track closing of `window`
private func monitorClosing(of window: NSWindow) {
NotificationCenter.default
.publisher(for: NSWindow.willCloseNotification, object: window)
.sink { [weak self] notification in
guard let self = self else { return }
self.onChange(nil)
self.cancellables.removeAll()
}
.store(in: &cancellables)
}
}
}
This implementation can then be used to get a handle to NSWindow as soon as possible. The issue we still face: we don't have full control of the window. We are just monitoring what happens and can interact with the NSWindow instance. This means: we can set the position, but we don't know exactly at which instant this should happen. E.g. setting the windows frame directly after the view has been added to the window, will have no impact as SwiftUI is first doing layout calculations to decide afterwards where it will place the window.
After some fiddling around, I started tracking the NSWindow.isVisible property. This allows me to set the position whenever the window becomes visible. Using above WindowAccessor my ContentView implementation looks as follows:
import SwiftUI
import Combine
let WIDTH: CGFloat = 400
let HEIGHT: CGFloat = 200
struct ContentView: View {
#State var window : NSWindow?
#State private var cancellables = Set<AnyCancellable>()
var body: some View {
VStack {
Text("it finally works!")
.font(.largeTitle)
Text(window?.frameAutosaveName ?? "-")
}
.frame(width: WIDTH, height: HEIGHT, alignment: .center)
.background(WindowAccessor { newWindow in
if let newWindow = newWindow {
monitorVisibility(window: newWindow)
} else {
// window closed: release all references
self.window = nil
self.cancellables.removeAll()
}
})
}
private func monitorVisibility(window: NSWindow) {
window.publisher(for: \.isVisible)
.dropFirst() // we know: the first value is not interesting
.sink(receiveValue: { isVisible in
if isVisible {
self.window = window
placeWindow(window)
}
})
.store(in: &cancellables)
}
private func placeWindow(_ window: NSWindow) {
let main = NSScreen.main!
let visibleFrame = main.visibleFrame
let windowSize = window.frame.size
let windowX = visibleFrame.midX - windowSize.width/2
let windowY = visibleFrame.midY - windowSize.height/2
let desiredOrigin = CGPoint(x: windowX, y: windowY)
window.setFrameOrigin(desiredOrigin)
}
}
I hope this solution helps others who want to get more control to the window in SwiftUI.
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()
}
FirebaseUI has a nice pre-buit UI for Swift. I'm trying to position an image view above the login buttons on the bottom. In the example below, the imageView is the "Hackathon" logo. Any logo should be able to show in this, if it's called "logo", since this shows the image as aspectFit.
According to the Firebase docs page:
https://firebase.google.com/docs/auth/ios/firebaseui
You can customize the signin screen with this function:
func authPickerViewController(forAuthUI authUI: FUIAuth) -> FUIAuthPickerViewController {
return FUICustomAuthPickerViewController(nibName: "FUICustomAuthPickerViewController",
bundle: Bundle.main,
authUI: authUI)
}
Using this code & poking around with subviews in the debuggers, I've been able to identify and color code views in the image below. Unfortunately, I don't think that the "true" size of these subview frames is set until the view controller presents, so trying to access the frame size inside these functions won't give me dimensions that I can use for creating a new imageView to hold a log. Plus accessing the views with hard-coded index values like I've done below, seems like a pretty bad idea, esp. given that Google has already changed the Pre-Built UI once, adding a scroll view & breaking the code of anyone who set the pre-built UI's background color.
func authPickerViewController(forAuthUI authUI: FUIAuth) -> FUIAuthPickerViewController {
// Create an instance of the FirebaseAuth login view controller
let loginViewController = FUIAuthPickerViewController(authUI: authUI)
// Set background color to white
loginViewController.view.backgroundColor = UIColor.white
loginViewController.view.subviews[0].backgroundColor = UIColor.blue
loginViewController.view.subviews[0].subviews[0].backgroundColor = UIColor.red
loginViewController.view.subviews[0].subviews[0].tag = 999
return loginViewController
}
I did get this to work by adding a tag (999), then in the completion handler when presenting the loginViewController I hunt down tag 999 and call a function to add an imageView with a logo:
present(loginViewController, animated: true) {
if let foundView = loginViewController.view.viewWithTag(999) {
let height = foundView.frame.height
print("FOUND HEIGHT: \(height)")
self.addLogo(loginViewController: loginViewController, height: height)
}
}
func addLogo(loginViewController: UINavigationController, height: CGFloat) {
let logoFrame = CGRect(x: 0 + logoInsets, y: self.view.safeAreaInsets.top + logoInsets, width: loginViewController.view.frame.width - (logoInsets * 2), height: self.view.frame.height - height - (logoInsets * 2))
// Create the UIImageView using the frame created above & add the "logo" image
let logoImageView = UIImageView(frame: logoFrame)
logoImageView.image = UIImage(named: "logo")
logoImageView.contentMode = .scaleAspectFit // Set imageView to Aspect Fit
// loginViewController.view.addSubview(logoImageView) // Add ImageView to the login controller's main view
loginViewController.view.addSubview(logoImageView)
}
But again, this doesn't seem safe. Is there a "safe" way to deconstruct this UI to identify the size of this button box at the bottom of the view controller (this size will vary if there are multiple login methods supported, such as Facebook, Apple, E-mail)? If I can do that in a way that avoids the hard-coding approach, above, then I think I can reliably use the dimensions of this button box to determine how much space is left in the rest of the view controller when adding an appropriately sized ImageView. Thanks!
John
This should address the issue - allowing a logo to be reliably placed above the prebuilt UI login buttons buttons + avoiding hard-coding the index values or subview locations. It should also allow for properly setting background color (also complicated when Firebase added the scroll view + login button subview).
To use: Create a subclass of FUIAuthDelegate to hold a custom view controller for the prebuilt Firebase UI.
The code will show the logo at full screen behind the buttons if there isn't a scroll view or if the class's private constant fullScreenLogo is set to false.
If both of these conditions aren't meant, the logo will show inset taking into account the class's private logoInsets constant and the safeAreaInsets. The scrollView views are set to clear so that a background image can be set, as well via the private let backgroundColor.
Call it in any signIn function you might have, after setting authUI.providers. Call would be something like this:
let loginViewController = CustomLoginScreen(authUI: authUI!)
let loginNavigationController = UINavigationController(rootViewController: loginViewController)
loginNavigationController.modalPresentationStyle = .fullScreen
present(loginNavigationController, animated: true, completion: nil)
And here's one version of the subclass:
class CustomLoginScreen: FUIAuthPickerViewController {
private var fullScreenLogo = false // false if you want logo just above login buttons
private var viewContainsButton = false
private var buttonViewHeight: CGFloat = 0.0
private let logoInsets: CGFloat = 16
private let backgroundColor = UIColor.white
private var scrollView: UIScrollView?
private var viewContainingButton: UIView?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// set color of scrollView and Button view inside scrollView to clear in viewWillAppear to avoid a "color flash" when the pre-built login UI first appears
self.view.backgroundColor = UIColor.white
guard let foundScrollView = returnScrollView() else {
print("😡 Couldn't get a scrollView.")
return
}
scrollView = foundScrollView
scrollView!.backgroundColor = UIColor.clear
guard let foundViewContainingButton = returnButtonView() else {
print("😡 No views in the scrollView contain buttons.")
return
}
viewContainingButton = foundViewContainingButton
viewContainingButton!.backgroundColor = UIColor.clear
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Create the UIImageView at full screen, considering logoInsets + safeAreaInsets
let x = logoInsets
let y = view.safeAreaInsets.top + logoInsets
let width = view.frame.width - (logoInsets * 2)
let height = view.frame.height - (view.safeAreaInsets.top + view.safeAreaInsets.bottom + (logoInsets * 2))
var frame = CGRect(x: x, y: y, width: width, height: height)
let logoImageView = UIImageView(frame: frame)
logoImageView.image = UIImage(named: "logo")
logoImageView.contentMode = .scaleAspectFit // Set imageView to Aspect Fit
logoImageView.alpha = 0.0
// Only proceed with customizing the pre-built UI if you found a scrollView or you don't want a full-screen logo.
guard scrollView != nil && !fullScreenLogo else {
print("No scrollView found.")
UIView.animate(withDuration: 0.25, animations: {logoImageView.alpha = 1.0})
self.view.addSubview(logoImageView)
self.view.sendSubviewToBack(logoImageView) // otherwise logo is on top of buttons
return
}
// update the logoImageView's frame height to subtract the height of the subview containing buttons. This way the buttons won't be on top of the logoImageView
frame = CGRect(x: x, y: y, width: width, height: height - (viewContainingButton?.frame.height ?? 0.0))
logoImageView.frame = frame
self.view.addSubview(logoImageView)
UIView.animate(withDuration: 0.25, animations: {logoImageView.alpha = 1.0})
}
private func returnScrollView() -> UIScrollView? {
var scrollViewToReturn: UIScrollView?
if self.view.subviews.count > 0 {
for subview in self.view.subviews {
if subview is UIScrollView {
scrollViewToReturn = subview as? UIScrollView
}
}
}
return scrollViewToReturn
}
private func returnButtonView() -> UIView? {
var viewContainingButton: UIView?
for view in scrollView!.subviews {
viewHasButton(view)
if viewContainsButton {
viewContainingButton = view
break
}
}
return viewContainingButton
}
private func viewHasButton(_ view: UIView) {
if view is UIButton {
viewContainsButton = true
} else if view.subviews.count > 0 {
view.subviews.forEach({viewHasButton($0)})
}
}
}
Hope this helps any who have been frustrated trying to configure the Firebase pre-built UI in Swift.