DSFQuickActionBar
A spotlight-inspired quick action bar for macOS.
Why?
I've seen this in other mac applications (particularly spotlight) and it's very useful. I have a project where it needs to allow the user to quickly access a large set of actions and hey presto!
Integration
Swift package manager
Add https://github.com/dagronf/DSFQuickActionBar
to your project.
Features
- macOS AppKit Swift Support
- macOS AppKit SwiftUI Support
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).
Name | Type | Description |
---|---|---|
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, or nil for no image |
initialSearchText | String (optional) |
Provide an initial search string to appear when the bar displays |
width | CGFloat (optional) |
Force the width of the action bar |
NOTE: Due to limitations of my understanding of integrating SwiftUI into macOS, the SwiftUI implementation doesn't support placing a quick action bar in front of a specific window. It (currently) only supports a global quick action bar (like Spotlight).
Process
- 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 identifiers that 'match' the search term (
itemsForSearchTerm
) - For each item, the contentSource will be asked to provide a view which will appear in the result table for that identifier (
viewForIdentifier
) - When the user either double-clicks on, or presses the return key on a selected identifier row, the contentSource will be provided with the identifier (
didSelectIdentifier
)
- The contentSource will be asked for the identifiers 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'
Implementing
The contentSource (DSFQuickActionBarContentSource
/DSFQuickActionBarSwiftUIContentSource
) 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 to display each item (viewForIdentifier)
- 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.
identifiersForSearchTerm
Returns an array of the unique identifiers for items that match the search term. The definition of 'match' is entirely up to you - you can perform any check you want
// Swift
func quickActionBar(_ quickActionBar: DSFQuickActionBar, identifiersForSearchTerm searchTerm: String) -> [DSFQuickActionBar.ItemIdentifier]
// SwiftUI
func identifiersForSearch(_ term: String) -> [DSFQuickActionBar.ItemIdentifier]
viewForIdentifier
Return the view to be displayed in the row for the item that matches the identifier. The search term is also provided to allow the view to be customized for the search term (eg. highlighting the match in the name)
// Swift
func quickActionBar(_ quickActionBar: DSFQuickActionBar, viewForIdentifier identifier: DSFQuickActionBar.ItemIdentifier, searchTerm: String) -> NSView?
// SwiftUI
func viewForIdentifier<RowContent: View>(_ identifier: DSFQuickActionBar.ItemIdentifier, searchTerm: String) -> RowContent?
didSelectIdentifier
Indicates the user activated an item in the result list.
// Swift
func quickActionBar(_ quickActionBar: DSFQuickActionBar, didSelectIdentifier identifier: DSFQuickActionBar.ItemIdentifier)
// SwiftUI
func didSelectIdentifier(_ identifier: DSFQuickActionBar.ItemIdentifier)
Examples
Swift Example
Swift Example
A simple AppKit example using Core Image Filters as the contentSource.
class QuickActions: DSFQuickActionBarContentSource {
/// DATA
struct Filter {
let identifier = DSFQuickActionBar.ItemIdentifier()
let name: String
var userPresenting: String { return CIFilter.localizedName(forFilterName: self.name) ?? self.name }
var description: String { return CIFilter.localizedDescription(forFilterName: self.name) ?? "" }
}
let AllFilters: [Filter] = {
let filterNames = CIFilter.filterNames(inCategory: nil).sorted()
return filterNames.map { name in Filter(name: name) }
}()
/// ACTIONS
let quickActionBar = DSFQuickActionBar()
func displayQuickActionBar() {
self.quickActionBar.contentSource = self
self.quickActionBar.presentOnMainScreen(
placeholderText: "Quick Actions",
width: 600
)
}
/// CALLBACKS
// Get all the identifiers for the actions that 'match' the term
func quickActionBar(_: DSFQuickActionBar, identifiersForSearchTerm searchTerm: String) -> [DSFQuickActionBar.ItemIdentifier] {
return self.actions
.filter { $0.userPresenting.localizedCaseInsensitiveContains(term) }
.sorted(by: { a, b in a.userPresenting < b.userPresenting })
.map { $0.identifier }
}
// Get the row's view for the action with the specified identifier
func quickActionBar(_: DSFQuickActionBar, viewForIdentifier identifier: DSFQuickActionBar.ItemIdentifier, searchTerm: String) -> NSView? {
// Find the item with the specified item identifier
guard let filter = self.actions.filter({ $0.identifier == identifier }).first else {
fatalError()
}
return FilterRowView(filter) // FilterRowView() is a NSView-derived class
}
// Perform a task with the selected action
func quickActionBar(_: DSFQuickActionBar, didSelectIdentifier identifier: DSFQuickActionBar.ItemIdentifier) {
guard let filter = self.actions.filter({ $0.identifier == identifier }).first else {
fatalError()
}
self.performAction(filter) // Do something with the selected filter
}
}
SwiftUI Example
SwiftUI Example
A simple macOS SwiftUI example using Core Image Filters as the contentSource.
Data
struct Filter {
let identifier = DSFQuickActionBar.ItemIdentifier()
let name: String
var userPresenting: String { return CIFilter.localizedName(forFilterName: self.name) ?? self.name }
var description: String { return CIFilter.localizedDescription(forFilterName: self.name) ?? "" }
}
let AllFilters: [Filter] = {
let filterNames = CIFilter.filterNames(inCategory: nil).sorted()
return filterNames.map { name in Filter(name: name) }
}()
SwiftUI View
struct ContentView: View {
// Binding to update when the user selects a filter
@State var selectedFilter: Filter?
// A quick action bar that uses FilterViewCell to display each row in the results
let quickActionBar = DSFQuickActionBar.SwiftUI<FilterRowCell>()
var body: some View {
Button("Show Quick Action Bar") {
self.quickActionBar.present(
placeholderText: "Search Core Image Filters",
contentSource: CoreImageFiltersContentSource(selectedFilter: $selectedFilter)
)
}
}
}
Content Source Definition
class CoreImageFiltersContentSource: DSFQuickActionBarSwiftUIContentSource {
@Binding var selectedFilter: Filter?
init(selectedFilter: Binding
?>) {
self.
_selectedFilter
= selectedFilter
}
func
identifiersForSearch(
_
searchTerm:
String)
-> [DSFQuickActionBar.ItemIdentifier] {
if term.
isEmpty {
return [] }
return AllFilters
.
filter {
$0.
userPresenting.
localizedCaseInsensitiveContains(term) }
.
sorted(
by: { a, b
in a.
userPresenting
< b.
userPresenting } )
.
prefix(
100)
.
map {
$0.
id }
}
func
viewForIdentifier<
RowContent>(
_
identifier: DSFQuickActionBar.ItemIdentifier,
searchTerm:
String)
-> RowContent
?
where RowContent
:
View {
guard
let filter
= AllFilters.
filter({
$0.
id
== identifier }).
first
else {
return
nil
}
return
FilterViewCell(
filter: filter)
as? RowContent
}
func
didSelectIdentifier(
_
identifier: DSFQuickActionBar.ItemIdentifier) {
guard
let filter
= AllFilters.
filter({
$0.
id
== identifier }).
first
else {
return
}
selectedFilter
= filter
}
func
didCancel() {
selectedFilter
=
nil
}
}
Filter Row View
struct FilterViewCell: View {
let filter: Filter
var body: some View {
HStack {
Image("filter-color").resizable()
.frame(width: 42, height: 42)
VStack(alignment: .leading) {
Text(filter.userPresenting).font(.title)
Text(filter.description).font(.callout).foregroundColor(.gray).italic()
}
}
}
}
Screenshots
Releases
2.0.2
- Updated demo for updated DSFAppKitBuilder
2.0.1
- Updated demo for updated DSFAppKitBuilder
2.0.0
Note the delegate API has changed for this version, hence moving to 2.0.0 to avoid automatic breakages
- Changed
viewForIdentifier
delegate method to also pass the current search term. - Changed the code to use
searchTerm
(instead ofterm
) consistently throughout the library
1.1.1
- Fixed silly runtime error for dynamic rows
1.1.0
- Changed the demo apps data from using 'Mountains' to using Core Image Filter definitions.
1.0.0
- Added initial SwiftUI support
- Changed 'delegate' to 'contentSource'
0.5.1
- Fixed bugs in documentation
0.5.0
- Initial release
License
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 (c) 2021 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.