A spotlight-inspired quick action bar for macOS.
I've seen this in other mac applications (particularly Spotlight and Boop) and it's very useful and convenient.
- macOS AppKit Swift Support
- macOS AppKit SwiftUI Support
- Completely keyboard navigable
- Optional keyboard shortcuts
- Asynchronous API to avoid beachballing on complex queries.
You can present a quick action bar in the context of a window (where it will be centered above and within the bounds of the window as is shown in the image above) or centered in the current screen (like Spotlight currently does).
You can find macOS demo apps in the Demos
subfolder.
Simple Demo
- a simple AppKit application demonstrating a synchronous quick action bar using AppKit, SwiftUI and custom cell typesDoco Demo
- AppKit demo used for generating images for the websiteFaux Spotlight
- An AppKit demo showing asynchronous searching support using MDItemQuery()SwiftUI Demo
- A SwiftUI demonstrationStatusBar Item Demo
- Demonstrates displaying a quick action bar from a statusbar item (in the menu).
- Present the quick action bar, automatically focussing on the edit field so your hands can stay on the keyboard
- User starts typing in the search field
- For each change to the search term -
- The contentSource will be asked for the item(s) that 'match' the search term (
itemsForSearchTerm
). Theitems
request is asynchronous, and can be completed at any point in the future (as long as it hasn't been cancelled by another search request) - For each item, the contentSource will be asked to provide a view which will appear in the result table for that item (
viewForItem
) - When the user either double-clicks on, or presses the return key on a selected item row, the contentSource will be provided with the item (
didActivateItem
)
- The contentSource will be asked for the item(s) that 'match' the search term (
- The quick action bar will automatically dismiss if
- The user clicks outside the quick action bar (ie. it loses focus)
- The user presses the escape key
- The user double-clicks an item in the result table
- The user selects a row and presses 'return'
You present a quick action bar by :-
- creating an instance of
DSFQuickActionBar
- set the content source on the instance
- call the
present
method.
Call the present
method on the quick action bar instance.
Name | Type | Description |
---|---|---|
parentWindow | NSWindow |
The window to present the quick action bar over, or nil to display for the current screen (ala Finder Spotlight) |
placeholderText | String |
The placeholder text to display in the edit field |
searchImage | NSImage |
The image to display on the left of the search edit field. If nil, uses the default magnifying glass image |
initialSearchText | String |
Provide an initial search string to appear when the bar displays |
width | CGFloat |
Force the width of the action bar |
showKeyboardShortcuts | Bool |
Display keyboard shortcuts (↩︎, ⌘1 -> ⌘9) for the first 10 selectable items |
didClose | callback | Called when the quick action bar closes |
The contentSource (DSFQuickActionBarContentSource
) provides the content and feedback for the quick action bar. The basic mechanism is similar to NSTableViewDataSource
/NSTableViewDelegate
in that the control will :-
- query the contentSource for items matching a search term (itemsForSearchTerm)
- ask the contentSource for a view for each displayed item (viewForItem)
- indicate that the user has pressed/clicked a selection in the results.
- (optional) indicate to the contentSource that the quick action bar has been dismissed.
func quickActionBar(_ quickActionBar: DSFQuickActionBar, itemsForSearchTermTask task: DSFQuickActionBar.SearchTask)
Called when the control needs a array of items to display within the control that match a search term. The definition of 'match' is entirely up to you - you can perform any check you want.
The task
object contains the search term and a completion block to call when the search results become
available. If the search text changes during an asynchronous search call the task is marked as invalid and the
result will be ignored.
If you have code using the old synchronous API, it's relatively straightforward to convert your existing code to the new api.
func quickActionBar(_ quickActionBar: DSFQuickActionBar, itemsForSearchTermTask task: DSFQuickActionBar.SearchTask)
let results = countryNames.filter { $0.name.startsWith(task.searchTerm) }
task.complete(with: results)
}
var currentSearch: SomeRemoteSearchMechanism?
func quickActionBar(_ quickActionBar: DSFQuickActionBar, itemsForSearchTermTask task: DSFQuickActionBar.SearchTask)
currentSearch?.cancel()
currentSearch = SomeRemoteSearchMechanism(task.searchTerm) { [weak self] results in
task.complete(with: results)
self?.currentSearch = nil
}
}
func quickActionBar(_ quickActionBar: DSFQuickActionBar, viewForItem item: AnyHashable, searchTerm: String) -> NSView?
Return the view to be displayed in the row for the item. The search term is also provided to allow the view to be customized for the search term (eg. highlighting the match in the name)
func quickActionBar(_ quickActionBar: DSFQuickActionBar, canSelectItem item: AnyHashable) -> Bool
Called when a item will be selected (eg. by keyboard navigation or clicking). Return false if this row should not be selected (eg. it's a separator)
func quickActionBar(_ quickActionBar: DSFQuickActionBar, didSelectItem item: AnyHashable)
Called when an item is selected within the list.
// Swift
func quickActionBar(_ quickActionBar: DSFQuickActionBar, didActivateItem item: AnyHashable)
Indicates the user activated an item in the result list. The 'item' parameter is the item that was selected by the user
func quickActionBarDidCancel(_ quickActionBar: DSFQuickActionBar)
Called if the user cancels the quick action bar (eg. by hitting the esc
key or clicking outside the bar)
Swift Example
A simple AppKit example using Core Image Filters as the contentSource.
class ViewController: NSViewController {
let quickActionBar = DSFQuickActionBar()
override func viewDidLoad() {
super.viewDidLoad()
// Set the content source for the quick action bar
quickActionBar.contentSource = self
}
@IBAction func selectFilter(_ sender: Any) {
// Present the quick action bar
quickActionBar.present(placeholderText: "Search for filters…")
}
}
// ContentSource delegate calls
extension ViewController: DSFQuickActionBarContentSource {
func quickActionBar(_ quickActionBar: DSFQuickActionBar, itemsForSearchTerm searchTerm: String) -> [AnyHashable] {
return Filter.search(searchTerm)
}
func quickActionBar(_ quickActionBar: DSFQuickActionBar, viewForItem item: AnyHashable, searchTerm: String) -> NSView? {
guard let filter = item as? Filter else { fatalError() }
// For the demo, just return a simple text field with the filter's name
return NSTextField(labelWithString: filter.userPresenting)
}
func quickActionBar(_ quickActionBar: DSFQuickActionBar, didActivateItem item: AnyHashable) {
Swift.print("Activated item \(item as? Filter)")
}
func quickActionBarDidCancel(_ quickActionBar: DSFQuickActionBar) {
Swift.print("Cancelled!")
}
}
// the datasource for the Quick action bar. Each filter represents a CIFilter
struct Filter: Hashable, CustomStringConvertible {
let name: String // The name is unique within our dataset, thus the default equality will be enough to uniquely identify
var userPresenting: String { return CIFilter.localizedName(forFilterName: self.name) ?? self.name }
var description: String { name }
// All of the available filters
static var AllFilters: [Filter] = {
let filterNames = CIFilter.filterNames(inCategory: nil).sorted()
return filterNames.map { name in Filter(name: name) }
}()
// Return filters matching the search term
static func search(_ searchTerm: String) -> [Filter] {
if searchTerm.isEmpty { return AllFilters }
return Filter.AllFilters
.filter { $0.userPresenting.localizedCaseInsensitiveContains(searchTerm) }
.sorted(by: { a, b in a.userPresenting < b.userPresenting })
}
}
The SwiftUI implementation is a View. You 'install' the quick action bar just like you would any other SwiftUI view.
The QuickActionBar
view is zero-sized, and does not display content within the view its installed on.
QuickActionBar<IdentifyingObject, IdentifyingObjectView>
The QuickActionBar template parameters represent
IdentifyingObject
is the type of the object (eg.URL
)IdentifyingObjectView
is the type of View used to representIdentifyingObject
in the results list (eg.Text
)
You present the quick action bar by setting the visible
parameter to true.
For example :-
@State var quickActionBarVisible = false
@State var selectedItem: URL = URL(...)
...
VStack {
Button("Show Quick Action Bar") {
quickActionBarVisible = true
}
QuickActionBar<URL, Text>(
location: .window,
visible: $quickActionBarVisible,
selectedItem: $selectedItem,
placeholderText: "Open Quickly",
itemsForSearchTerm: { searchTask in
let results = /* array of matching URLs */
searchTask.complete(with: results)
},
viewForItem: { url, searchTerm in
Text(url.path)
}
)
.onChange(of: selectedItem) { newValue in
Swift.print("Selected item \(newValue)")
}
}
...
Parameter | Description |
---|---|
location |
Where to locate the quick action bar (.window, .screen) |
visible |
If true, presents the quick action bar on the screen |
showKeyboardShortcuts |
Display keyboard shortcuts for the first 10 selectable items |
requiredClickCount |
If .single , only requires the user to single-click a row to activate it (defaults to .double ) |
barWidth |
The width of the presented bar |
searchTerm |
The search term to use, updated when the quick action bar is closed |
selectedItem |
The item selected by the user |
placeholderText |
The text to display in the quick action bar when the search term is empty |
itemsForSearchTerm |
A block which returns the item(s) for the specified search term |
viewForItem |
A block which returns the View to display for the specified item |
SwiftUI Example
A simple macOS SwiftUI example using Core Image Filters as the content.
struct DocoContentView: View {
// Binding to update when the user selects a filter
@State var selectedFilter: Filter?
// Binding to show/hide the quick action bar
@State var quickActionBarVisible = false
var body: some View {
VStack {
Button("Show Quick Action Bar") {
quickActionBarVisible = true
}
QuickActionBar<Filter, Text>(
location: .screen,
visible: $quickActionBarVisible,
selectedItem: $selectedFilter,
placeholderText: "Open Quickly...",
itemsForSearchTerm: { searchTask in
let results = filters__.search(searchTask.searchTerm)
searchTask.complete(with: results)
},
viewForItem: { filter, searchTerm in
Text(filter.userPresenting)
}
)
}
}
}
/// The unique object used as the quick action bar item
struct Filter: Hashable, CustomStringConvertible {
let name: String // The name is unique within our dataset, therefore it will be our identifier
var userPresenting: String { return CIFilter.localizedName(forFilterName: self.name) ?? self.name }
var description: String { name }
}
class Filters {
// If true, displays all of the filters if the search term is empty
var showAllIfEmpty = true
// All the filters
var all: [Filter] = {
let filterNames = CIFilter.filterNames(inCategory: nil).sorted()
return filterNames.map { name in Filter(name: name) }
}()
// Return filters matching the search term
func search(_ searchTerm: String) -> [Filter] {
if searchTerm.isEmpty && showAllIfEmpty { return all }
return all
.filter { $0.userPresenting.localizedCaseInsensitiveContains(searchTerm) }
.sorted(by: { a, b in a.userPresenting < b.userPresenting })
}
}
let filters__ = Filters()
- [BREAKING API] Move to using an asynchronous API for the item search to allow background searching without locking up the UI.
- Added optional keyboard shortcuts. Defaults to off to make sure existing projects don't change their behaviour.
- Added ability to mark a row as not selectable (eg. if you want to add a separator item in the results).
canSelectItem
added to the delegate callback. By default, all rows are selectable.
UI changes to move the UX implementation closer to that of Spotlight/Open Quickly (Xcode).
- First search result is selected by default.
- Typing in the edit field and using the up/down arrows does not change the UI focus to the result view, the result selection moves but the focus is not removed from the edit field
- Set the number of clicks required to 'activate' an item (
requiredClickCount
). Default to double-click.
There have been some breaking changes for those moving up from v2 or earlier to v3. This changes were to make the codebase more generic and to improve the SwiftUI support. Please be aware your existing code will need changes to support the new v3 codebase.
- The UUID identifier has been replaced with a
Hashable
type. This allows other types to be used as an identifier (eg.URL
or even structs/classes). - 'Identifier' used within the API has been changed to
Item
(eg.viewForIdentifier
->viewForItem
) - The
didSelectItem
delegate callback is now used when the item is selected, not activated.didActivateItem
is called when the user 'activates' (ie. double-clicks or keyboard selects) a row - The SwiftUI implementation has been changed to be much more SwiftUI-y. Please see the Implementing for SwiftUI section for details.
- Updated demo for updated DSFAppKitBuilder
- Updated demo for updated DSFAppKitBuilder
Note the delegate API has changed for this version, hence moving to 2.0.0 to avoid automatic breakages
- Changed
viewForItem
delegate method to also pass the current search term. - Changed the code to use
searchTerm
(instead ofterm
) consistently throughout the library
- Fixed silly runtime error for dynamic rows
- Changed the demo apps data from using 'Mountains' to using Core Image Filter definitions.
- Added initial SwiftUI support
- Changed 'delegate' to 'contentSource'
- Fixed bugs in documentation
- Initial release
MIT. Use it and abuse it for anything you want, just attribute my work. Let me know if you do use it somewhere, I'd love to hear about it!
MIT License
Copyright © 2022 Darren Ford
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.