- SwiftZola2020-03-22T00:00:00+00:00https://www.fullstackstanley.com/tags/swift/atom.xmlReplicating the MacOS Search TextField in SwiftUI2020-03-22T00:00:00+00:002020-03-22T00:00:00+00:00https://www.fullstackstanley.com/articles/replicating-the-macos-search-textfield-in-swiftui/<p>I wanted to add a search text field to a MacOS app that I'm working on and soon discovered it's not available in SwiftUI (as off 5.1). I've put together a quick replication which mostly works, but unfortunately involves some hackiness.</p>
<span id="continue-reading"></span>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/uje3uwqxxogogpex9d5e.gif" alt="Preview of Search TextFIeld" /></p>
<p>Here's the code:</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#65737e;">// This extension removes the focus ring entirely.
</span><span style="color:#b48ead;">extension</span><span> NSTextField {
</span><span> </span><span style="color:#b48ead;">open override var</span><span> focusRingType: NSFocusRingType {
</span><span> </span><span style="color:#b48ead;">get</span><span> { .</span><span style="color:#b48ead;">none</span><span> }
</span><span> </span><span style="color:#b48ead;">set</span><span> { }
</span><span> }
</span><span>}
</span><span>
</span><span style="color:#b48ead;">struct</span><span> SearchTextField: View {
</span><span> </span><span style="color:#b48ead;">@Binding var</span><span> query: String
</span><span> </span><span style="color:#b48ead;">@State var</span><span> isFocused: Bool = </span><span style="color:#b48ead;">false
</span><span> </span><span style="color:#b48ead;">var</span><span> placeholder: String = </span><span style="color:#a3be8c;">"Search..."
</span><span> </span><span style="color:#b48ead;">var</span><span> body: some View {
</span><span> ZStack {
</span><span> RoundedRectangle(cornerRadius: </span><span style="color:#d08770;">5</span><span>, style: .continuous)
</span><span> .fill(Color.white)
</span><span> .frame(width: </span><span style="color:#d08770;">200</span><span>, height: </span><span style="color:#d08770;">22</span><span>)
</span><span> .overlay(
</span><span> RoundedRectangle(cornerRadius: </span><span style="color:#d08770;">5</span><span>, style: .continuous)
</span><span> .stroke(isFocused ? Color.blue.opacity(</span><span style="color:#d08770;">0</span><span>.</span><span style="color:#d08770;">7</span><span>) : Color.gray.opacity(</span><span style="color:#d08770;">0</span><span>.</span><span style="color:#d08770;">4</span><span>), lineWidth: isFocused ? </span><span style="color:#d08770;">3 </span><span>: </span><span style="color:#d08770;">1</span><span>)
</span><span> .frame(width: </span><span style="color:#d08770;">200</span><span>, height: </span><span style="color:#d08770;">21</span><span>)
</span><span> )
</span><span>
</span><span> HStack {
</span><span> Image(</span><span style="color:#a3be8c;">"magnifyingglass"</span><span>).resizable().aspectRatio(contentMode: .fill)
</span><span> .frame(width:</span><span style="color:#d08770;">12</span><span>, height: </span><span style="color:#d08770;">12</span><span>)
</span><span> .padding(.leading, </span><span style="color:#d08770;">5</span><span>)
</span><span> .opacity(</span><span style="color:#d08770;">0</span><span>.</span><span style="color:#d08770;">8</span><span>)
</span><span> TextField(placeholder, text: $query, onEditingChanged: { (editingChanged) </span><span style="color:#b48ead;">in
</span><span> </span><span style="color:#b48ead;">if</span><span> editingChanged {
</span><span> </span><span style="color:#b48ead;">self</span><span>.isFocused = </span><span style="color:#b48ead;">true
</span><span> } </span><span style="color:#b48ead;">else</span><span> {
</span><span> </span><span style="color:#b48ead;">self</span><span>.isFocused = </span><span style="color:#b48ead;">false
</span><span> }
</span><span> })
</span><span> .textFieldStyle(PlainTextFieldStyle())
</span><span> </span><span style="color:#b48ead;">if</span><span> query != </span><span style="color:#a3be8c;">""</span><span> {
</span><span> Button(action: {
</span><span> </span><span style="color:#b48ead;">self</span><span>.query = </span><span style="color:#a3be8c;">""
</span><span> }) {
</span><span> Image(</span><span style="color:#a3be8c;">"xmark.circle.fill"</span><span>)
</span><span> .resizable()
</span><span> .aspectRatio(contentMode: .fit)
</span><span> .frame(width:</span><span style="color:#d08770;">14</span><span>, height: </span><span style="color:#d08770;">14</span><span>)
</span><span> .padding(.trailing, </span><span style="color:#d08770;">3</span><span>)
</span><span> .opacity(</span><span style="color:#d08770;">0</span><span>.</span><span style="color:#d08770;">5</span><span>)
</span><span> }
</span><span> .buttonStyle(PlainButtonStyle())
</span><span> .opacity(</span><span style="color:#b48ead;">self</span><span>.query == </span><span style="color:#a3be8c;">"" </span><span>? </span><span style="color:#d08770;">0 </span><span>: </span><span style="color:#d08770;">0</span><span>.</span><span style="color:#d08770;">5</span><span>)
</span><span> }
</span><span> }
</span><span>
</span><span> }
</span><span> }
</span><span>}
</span></code></pre>
<p>Here's how you use it:</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span>SearchTextField(query: $searchQuery, placeholder: </span><span style="color:#a3be8c;">"Search..."</span><span>)
</span><span> .frame(minWidth: </span><span style="color:#d08770;">60</span><span>.</span><span style="color:#d08770;">0</span><span>, idealWidth: </span><span style="color:#d08770;">200</span><span>.</span><span style="color:#d08770;">0</span><span>, maxWidth: </span><span style="color:#d08770;">200</span><span>.</span><span style="color:#d08770;">0</span><span>, minHeight: </span><span style="color:#d08770;">24</span><span>.</span><span style="color:#d08770;">0</span><span>, idealHeight: </span><span style="color:#d08770;">21</span><span>.</span><span style="color:#d08770;">0</span><span>, maxHeight: </span><span style="color:#d08770;">21</span><span>.</span><span style="color:#d08770;">0</span><span>, alignment: .center)
</span></code></pre>
<h2 id="caveats">Caveats</h2>
<p>There are a few issues with this solution. Hopefully in a future version of SwiftUI this will be fixed.</p>
<h3 id="removing-the-focus-ring-from-all-textfields">Removing the Focus Ring from All TextFields</h3>
<p>This code creates a pretend TextField which holds the magnifying glass, a real TextField, and the close icon inside of it. If you remove the NSTextField extension you'll see the real textfield when focused. It's currently not possible to disable a specific focus ring so you'll have to reimplement them if you need to show them.</p>
<h3 id="fake-focus-ring-doesn-t-update-on-focus">Fake Focus Ring Doesn't Update on Focus</h3>
<p>You'll notice in the GIF above that the focus ring does not show until you start typing. Not ideal but usable. If anyone has a suggestion for fixing this I'd be happy to know!</p>
<h3 id="no-sf-symbols-in-macos">No SF Symbols in MacOS</h3>
<p>Surprisingly, MacOS does not have native support for SF Symbols! I used <a href="https://github.com/knezzy/sfsymbols">this tool</a> to convert them to PNG and import them into my assets catalog. The symbols used are <code>xmark.circle.fill</code> and <code>magnifyingglass</code>.</p>
Creating a global configurable shortcut for MacOS apps in Swift2019-04-17T00:00:00+00:002019-04-17T00:00:00+00:00https://www.fullstackstanley.com/articles/creating-a-global-configurable-shortcut-for-macos-apps-in-swift/<p>I recently released my first MacOS app and after a lot of trial and error, discovered that there are not enough Swift for MacOS tutorials! Consider this my first contribution to the cause š.</p>
<span id="continue-reading"></span>
<p><em>Shameless plug: This tutorial came about from my work on <a href="https://itunes.apple.com/gb/app/snipbar/id1457592516?mt=12" title="Snipbar">Snipbar</a>, a MacOS app Iāve been working on for my shell command app <a href="https://snipline.io/" title="Snipline">Snipline</a>. If you work with shell commands and servers, or SQL then Iād love it if you checked it out.</em></p>
<p>By the end of this tutorial you will have</p>
<ul>
<li>Set up an app with two windows: a Main window and Preferences window</li>
<li>Created a button from the Main window that links to the Preferences window.</li>
<li>Installed and configured <a href="https://github.com/soffes/HotKey" title="HotKey">HotKey</a> via <a href="https://github.com/Carthage/Carthage" title="Carthage">Carthage</a>.</li>
<li>Set up a simple UI for configuring a global keyboard shortcut that opens your Main window.</li>
</ul>
<p>Hereās a preview of how the app works</p>
<div style="max-width:500px;margin:auto;">
<img alt="The finished app" src="https://i.imgur.com/L0UjZm2.gif" style="width:100%;">
</div>
<p>This tutorial uses Xcode 10.2 and Swift 5.</p>
<h2 id="creating-the-app-windows">Creating the app windows</h2>
<p>First things first, letās create a new Mac project in Xcode, I called mine <code>GlobalConfigKeybind</code>. We need to make sure āMacOSā and āCocoa Appā are selected. In the second panel, make sure āUse Storyboardsā is selected.</p>
<p><img src="/images/f82lmoz8ul7y8x55kdg1.png" alt="" /></p>
<p>With the app created we need to create the Preferences window. We can do this by going to <code>Main.storyboard</code>, clicking the Library button, searching for <code>Window View Controller</code> and then drag a new <code>Window Controller</code> next to our Main window.</p>
<div style="margin:auto;max-width:500px;">
<img alt="Selecting the Window Controller from the Library" src="/images/kij3oliz00soiwu2bcdg.png" style="width:100%;">
</div>
<p><img src="/images/79ern54qqe291p8c2k80.png" alt="The layout of the two window view controllers" /></p>
<h2 id="linking-the-main-app-window-to-the-preferences-window">Linking the main app window to the preferences window</h2>
<p>Letās create a button on the Main view controller and set it up so that when itās pressed it shows the preferences window.</p>
<p>Press the library button, search for <code>Push Button</code> and drag it into the Main view controller.</p>
<p>Select the button and go to the <code>Attributes inspector</code>. Change the title to say <code>Preferences</code>.</p>
<div style="margin:auto;max-width:500px;">
<img alt="How the Main Window looks" src="/images/DraggedImage.65591179fdbe43dda79c621bd521ed39.png" style="width:100%;">
</div>
<p>Now we have the button but we need to make it do something. Hold <code>ctrl</code> while click and dragging from the button to the new Preferences window. The Preferences window will become highlighted. Release the mouse and select the <code>Show</code> action segue.</p>
<div style="text-align:center;">
<img src="/images/DraggedImage.7771b07949c24ea8853d23546efb7fd2.jpg" style="width:300px;">
</div>
<p>Now when clicked the button will open the Preferences window. Before we test it though, letās make sure that when the button is pressed more than once it only opens one window. Click on the Window and in the <code>Attributes inspector</code> change <code>Presentation</code> to <code>Single</code>.</p>
<p><img src="/images/DraggedImage.8ef1b4607e5b44a281864509befff350.png" alt="" /></p>
<p>At this point, if we run the app and press the Preferences button the new window will show. Hooray!</p>
<div style="margin:auto;max-width:500px;">
<img src="/images/Screen Recording 2019-04-07 at 13.09.24.27cf15a6a98c4836b6f8cbcb7ff1efee.gif" style="width:100%;">
</div>
<h2 id="installing-hotkey">Installing HotKey</h2>
<p><a href="https://github.com/soffes/HotKey">HotKey</a> is a Swift package that wraps around the Carbon API for dealing with global hot keys.</p>
<p>Weāll use Carthage to install it but If you prefer SPM or CocoaPods feel free to use that instead.</p>
<p>First, make sure you have <a href="https://github.com/Carthage/Carthage">Carthage</a> installed, following their installation instructions if needed. Then in Xcode, create a new <code>Empty</code> file and call it <code>Cartfile</code>. Make sure itās in the base of the project, if youāve accidentally saved it in the wrong place, make sure to drag it below the <code>GlobalConfigKeybind</code> area with the blue page icon.</p>
<p>Inside that file add the following and save it.</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span>github </span><span style="color:#a3be8c;">"soffes/HotKey"
</span></code></pre>
<p><img src="/images/DraggedImage.87a77cb71fa4464097a239ee363776fc.jpg" alt="" /></p>
<p><img src="/images/DraggedImage.62dcdf64d69a4ea0b3c45dff11e381cc.jpg" alt="Location of the Cartfile" /></p>
<p>We need to install HotKey from the Terminal. To do this go to the project directory and run <code>carthage update && carthage build --platform MacOS</code>.</p>
<p>Back in Xcode link the new HotKey binary to our app. </p>
<p>Click the <code>GlobalConfigKeybind</code> with the blue page icon, select the app Target and click the <code>+</code> icon under <code>Embedded Binaries</code>. Click <code>Add Other</code> and navigate to the root directory of your project, then go to <code>Carthage</code> > <code>Build</code> > <code>Mac</code> > highlight <code>HotKey.framework</code> and click <code>Open</code>.</p>
<p>When prompted select <code>Copy items if needed</code>.</p>
<p><img src="/images/DraggedImage.3196fb02ea1f4310a9fd142dc98d26d3.jpg" alt="Embedding HotKey binary" /></p>
<h2 id="creating-the-keybind-options-interface">Creating the Keybind options interface.</h2>
<p>In the <code>Main.storyboard</code> drag a <code>Text Field</code> and a <code>Push Button</code> onto the Preferences window. Give the button a title of <code>Clear</code>, set the <code>State</code> to disabled. For the text field check <code>Refuses First Responder</code> in the attributes inspector. This is because we donāt want the text field to be selected when the window is opened.</p>
<p><img src="/images/DraggedImage.4bbf18458a2f40c689d4e3a5351d177b.png" alt="" /></p>
<h2 id="adding-the-key-bind-configuration-functionality">Adding the key bind configuration functionality</h2>
<p>We need to create three new Cocoa class files. Make sure that XIB is not being created.</p>
<ul>
<li><code>PreferencesViewController</code> which needs to be a subclass of <code>NSViewController</code>.</li>
<li><code>PreferencesWindowController</code> which needs to be a subclass of <code>NSWindowController</code>.</li>
<li><code>MainWindow</code> which needs to be a subclass of <code>NSWindow</code>.</li>
</ul>
<p>Once made, set the class for each in the <code>Main.storyboard</code>. One for the <em>Preferences Window</em> and one for the <em>Preferences View Controller</em>.
<img src="/images/DraggedImage.0f531d5af593430cb5a953df10b102ed.png" alt="" /></p>
<p>The <code>MainWindow</code> needs to be set on the first app window. This will be used later on when we need to target the window to bring to the front. Notice in the below screenshot that <code>Window</code> is highlighted, not <code>Window Controller</code>.</p>
<div style="max-width:500px;margin:auto;">
<img src="/images/DraggedImage.3196fb02ea1f4310a9fd142dc98d26d3.jpg" style="width:100%;">
</div>
<p>Inside of <code>PreferencesWindowController</code> add the following code:</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#65737e;">//
</span><span style="color:#65737e;">// PreferencesWindowController.swift
</span><span style="color:#65737e;">// GlobalConfigKeybind
</span><span style="color:#65737e;">//
</span><span style="color:#65737e;">// Created by Mitch Stanley on 27/01/2019.
</span><span style="color:#65737e;">//
</span><span>
</span><span style="color:#b48ead;">import </span><span>Cocoa
</span><span>
</span><span style="color:#b48ead;">class</span><span> PreferencesWindowController: NSWindowController {
</span><span>
</span><span> </span><span style="color:#b48ead;">override func </span><span>windowDidLoad() {
</span><span> </span><span style="color:#b48ead;">super</span><span>.windowDidLoad()
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">override func </span><span>keyDown(with event: NSEvent) {
</span><span> </span><span style="color:#b48ead;">super</span><span>.keyDown(with: event)
</span><span> </span><span style="color:#b48ead;">if let</span><span> vc = </span><span style="color:#b48ead;">self</span><span>.contentViewController as? PreferencesViewController {
</span><span> </span><span style="color:#b48ead;">if</span><span> vc.listening {
</span><span> vc.updateGlobalShortcut(event)
</span><span> }
</span><span> }
</span><span> }
</span><span>}
</span></code></pre>
<p>The <code>keyDown:with</code> method triggers any time a key is pressed while this window is active. We only want to do this when the configuration button is pressed so we use an if statement that checks the <code>listening</code> state in the Preferences View Controller (Weāll go into more detail on this shortly).</p>
<p>Inside of <code>PreferencesViewController</code> add this code.</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#65737e;">//
</span><span style="color:#65737e;">// PreferencesWindowController.swift
</span><span style="color:#65737e;">// GlobalConfigKeybind
</span><span style="color:#65737e;">//
</span><span style="color:#65737e;">// Created by Mitch Stanley on 27/01/2019.
</span><span style="color:#65737e;">//
</span><span>
</span><span style="color:#b48ead;">import </span><span>Cocoa
</span><span style="color:#b48ead;">import </span><span>HotKey
</span><span style="color:#b48ead;">import </span><span>Carbon
</span><span>
</span><span style="color:#b48ead;">class</span><span> PreferencesViewController: NSViewController {
</span><span>
</span><span> </span><span style="color:#b48ead;">@IBOutlet weak var</span><span> clearButton: NSButton!
</span><span> </span><span style="color:#b48ead;">@IBOutlet weak var</span><span> shortcutButton: NSButton!
</span><span>
</span><span> </span><span style="color:#65737e;">// When this boolean is true we will allow the user to set a new keybind.
</span><span> </span><span style="color:#65737e;">// We'll also trigger the button to highlight blue so the user sees feedback and knows the button is now active.
</span><span> </span><span style="color:#b48ead;">var</span><span> listening = </span><span style="color:#b48ead;">false</span><span> {
</span><span> </span><span style="color:#b48ead;">didSet</span><span> {
</span><span> </span><span style="color:#b48ead;">if</span><span> listening {
</span><span> DispatchQueue.main.async { [</span><span style="color:#b48ead;">weak self</span><span>] </span><span style="color:#b48ead;">in
</span><span> </span><span style="color:#b48ead;">self</span><span>?.shortcutButton.highlight(</span><span style="color:#b48ead;">true</span><span>)
</span><span> }
</span><span> } </span><span style="color:#b48ead;">else</span><span> {
</span><span> DispatchQueue.main.async { [</span><span style="color:#b48ead;">weak self</span><span>] </span><span style="color:#b48ead;">in
</span><span> </span><span style="color:#b48ead;">self</span><span>?.shortcutButton.highlight(</span><span style="color:#b48ead;">false</span><span>)
</span><span> }
</span><span> }
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">override func </span><span>viewDidLoad() {
</span><span> </span><span style="color:#b48ead;">super</span><span>.viewDidLoad()
</span><span>
</span><span> </span><span style="color:#65737e;">// Check to see if the keybind has been stored previously
</span><span> </span><span style="color:#65737e;">// If it has then update the UI with the below methods.
</span><span> </span><span style="color:#b48ead;">if</span><span> Storage.fileExists(</span><span style="color:#a3be8c;">"globalKeybind.json"</span><span>, </span><span style="color:#b48ead;">in</span><span>: .documents) {
</span><span> </span><span style="color:#b48ead;">let</span><span> globalKeybinds = Storage.retrieve(</span><span style="color:#a3be8c;">"globalKeybind.json"</span><span>, from: .documents, as: GlobalKeybindPreferences.</span><span style="color:#b48ead;">self</span><span>)
</span><span> updateKeybindButton(globalKeybinds)
</span><span> updateClearButton(globalKeybinds)
</span><span> }
</span><span>
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">// When a shortcut has been pressed by the user, turn off listening so the window stops listening for keybinds
</span><span> </span><span style="color:#65737e;">// Put the shortcut into a JSON friendly struct and save it to storage
</span><span> </span><span style="color:#65737e;">// Update the shortcut button to show the new keybind
</span><span> </span><span style="color:#65737e;">// Make the clear button enabled to users can remove the shortcut
</span><span> </span><span style="color:#65737e;">// Finally, tell AppDelegate to start listening for the new keybind
</span><span> </span><span style="color:#b48ead;">func </span><span>updateGlobalShortcut(_ event : NSEvent) {
</span><span> </span><span style="color:#b48ead;">self</span><span>.listening = </span><span style="color:#b48ead;">false
</span><span>
</span><span> </span><span style="color:#b48ead;">if let</span><span> characters = event.charactersIgnoringModifiers {
</span><span> </span><span style="color:#b48ead;">let</span><span> newGlobalKeybind = GlobalKeybindPreferences.</span><span style="color:#b48ead;">init</span><span>(
</span><span> function: event.modifierFlags.contains(.function),
</span><span> control: event.modifierFlags.contains(.control),
</span><span> command: event.modifierFlags.contains(.command),
</span><span> shift: event.modifierFlags.contains(.shift),
</span><span> option: event.modifierFlags.contains(.option),
</span><span> capsLock: event.modifierFlags.contains(.capsLock),
</span><span> carbonFlags: event.modifierFlags.carbonFlags,
</span><span> characters: characters,
</span><span> keyCode: UInt32(event.keyCode)
</span><span> )
</span><span>
</span><span> Storage.store(newGlobalKeybind, to: .documents, as: </span><span style="color:#a3be8c;">"globalKeybind.json"</span><span>)
</span><span> updateKeybindButton(newGlobalKeybind)
</span><span> clearButton.isEnabled = </span><span style="color:#b48ead;">true
</span><span> </span><span style="color:#b48ead;">let</span><span> appDelegate = NSApplication.shared.delegate as! AppDelegate
</span><span> appDelegate.hotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: UInt32(event.keyCode), carbonModifiers: event.modifierFlags.carbonFlags))
</span><span> }
</span><span>
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">// When the set shortcut button is pressed start listening for the new shortcut
</span><span> </span><span style="color:#b48ead;">@IBAction func </span><span>register(_ sender: Any) {
</span><span> unregister(</span><span style="color:#d08770;">nil</span><span>)
</span><span> listening = </span><span style="color:#b48ead;">true
</span><span> view.window?.makeFirstResponder(</span><span style="color:#d08770;">nil</span><span>)
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">// If the shortcut is cleared, clear the UI and tell AppDelegate to stop listening to the previous keybind.
</span><span> </span><span style="color:#b48ead;">@IBAction func </span><span>unregister(_ sender: Any?) {
</span><span> </span><span style="color:#b48ead;">let</span><span> appDelegate = NSApplication.shared.delegate as! AppDelegate
</span><span> appDelegate.hotKey = </span><span style="color:#d08770;">nil
</span><span> shortcutButton.title = </span><span style="color:#a3be8c;">""
</span><span>
</span><span> Storage.remove(</span><span style="color:#a3be8c;">"globalKeybind.json"</span><span>, from: .documents)
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">// If a keybind is set, allow users to clear it by enabling the clear button.
</span><span> </span><span style="color:#b48ead;">func </span><span>updateClearButton(_ globalKeybindPreference : GlobalKeybindPreferences?) {
</span><span> </span><span style="color:#b48ead;">if</span><span> globalKeybindPreference != </span><span style="color:#d08770;">nil</span><span> {
</span><span> clearButton.isEnabled = </span><span style="color:#b48ead;">true
</span><span> } </span><span style="color:#b48ead;">else</span><span> {
</span><span> clearButton.isEnabled = </span><span style="color:#b48ead;">false
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">// Set the shortcut button to show the keys to press
</span><span> </span><span style="color:#b48ead;">func </span><span>updateKeybindButton(_ globalKeybindPreference : GlobalKeybindPreferences) {
</span><span> shortcutButton.title = globalKeybindPreference.description
</span><span> }
</span><span>
</span><span>}
</span></code></pre>
<p>Two properties and two methods need to be hooked up here.</p>
<ul>
<li><code>register:sender</code> needs to be connected with the shortcut button.</li>
<li><code>unregister:sender</code> needs to be connected to the clear shortcut button.</li>
<li><code>clearButton</code> property needs to be connected to the clear shortcut button.</li>
<li><code>shortcutButton</code> needs to be connected to the shortcut button.</li>
</ul>
<p>This is quite long a long file but each method is commented. As a general overview, itās letting one button listen and update the app shortcut and another button will clear that shortcut.</p>
<p>Create a new file, <code>GlobalKeybindPreferences.swift</code>. This will be a struct that holds the shortcut state. This includes modifiers and keys that are pressed. It also has a handy computed property called <code>description</code> which is used in <code>PreferencesViewController</code> to set the shortcut button text to look like <code>āāK</code>.</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#65737e;">//
</span><span style="color:#65737e;">// GlobalKeybindPreferences.swift
</span><span style="color:#65737e;">// GlobalConfigKeybind
</span><span style="color:#65737e;">//
</span><span style="color:#65737e;">// Created by Mitch Stanley on 07/04/2019.
</span><span style="color:#65737e;">//
</span><span style="color:#b48ead;">struct</span><span> GlobalKeybindPreferences: Codable, CustomStringConvertible {
</span><span> </span><span style="color:#b48ead;">let</span><span> function : Bool
</span><span> </span><span style="color:#b48ead;">let</span><span> control : Bool
</span><span> </span><span style="color:#b48ead;">let</span><span> command : Bool
</span><span> </span><span style="color:#b48ead;">let</span><span> shift : Bool
</span><span> </span><span style="color:#b48ead;">let</span><span> option : Bool
</span><span> </span><span style="color:#b48ead;">let</span><span> capsLock : Bool
</span><span> </span><span style="color:#b48ead;">let</span><span> carbonFlags : UInt32
</span><span> </span><span style="color:#b48ead;">let</span><span> characters : String?
</span><span> </span><span style="color:#b48ead;">let</span><span> keyCode : UInt32
</span><span>
</span><span> </span><span style="color:#b48ead;">var</span><span> description: String {
</span><span> </span><span style="color:#b48ead;">var</span><span> stringBuilder = </span><span style="color:#a3be8c;">""
</span><span> </span><span style="color:#b48ead;">if self</span><span>.function {
</span><span> stringBuilder += </span><span style="color:#a3be8c;">"Fn"
</span><span> }
</span><span> </span><span style="color:#b48ead;">if self</span><span>.control {
</span><span> stringBuilder += </span><span style="color:#a3be8c;">"ā"
</span><span> }
</span><span> </span><span style="color:#b48ead;">if self</span><span>.option {
</span><span> stringBuilder += </span><span style="color:#a3be8c;">"ā„"
</span><span> }
</span><span> </span><span style="color:#b48ead;">if self</span><span>.command {
</span><span> stringBuilder += </span><span style="color:#a3be8c;">"ā"
</span><span> }
</span><span> </span><span style="color:#b48ead;">if self</span><span>.shift {
</span><span> stringBuilder += </span><span style="color:#a3be8c;">"ā§"
</span><span> }
</span><span> </span><span style="color:#b48ead;">if self</span><span>.capsLock {
</span><span> stringBuilder += </span><span style="color:#a3be8c;">"āŖ"
</span><span> }
</span><span> </span><span style="color:#b48ead;">if let</span><span> characters = </span><span style="color:#b48ead;">self</span><span>.characters {
</span><span> stringBuilder += characters.uppercased()
</span><span> }
</span><span> </span><span style="color:#b48ead;">return </span><span style="color:#a3be8c;">"</span><span>\(stringBuilder)</span><span style="color:#a3be8c;">"
</span><span> }
</span><span>}
</span></code></pre>
<p>In <code>AppDelegate.swift</code> we need to listen for the shortcut if it exists and pull the <code>MainWindow</code> to the front.</p>
<p>We can see in <code>applicationDidFinishLaunching:aNotification</code> we check if the globalKeybind.json file exists, if it does, set the <code>HotKey</code> to what we have stored.</p>
<p>The computed property <code>hotKey</code> checks if hotKey is not nil, and then adds a <code>keyDownHandler</code>. In this closure, we loop through all the windows we have open (Itās possible that the Preferences window is also open, otherwise we could use <code>first</code>). When the <code>MainWindow</code> is found we bring it to the front with <code>makeKeyAndOrderFront</code> and <code>makeKey</code>.</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#65737e;">//
</span><span style="color:#65737e;">// AppDelegate.swift
</span><span style="color:#65737e;">// GlobalConfigKeybind
</span><span style="color:#65737e;">//
</span><span style="color:#65737e;">// Created by Mitch Stanley on 07/04/2019.
</span><span style="color:#65737e;">//
</span><span>
</span><span style="color:#b48ead;">import </span><span>Cocoa
</span><span style="color:#b48ead;">import </span><span>HotKey
</span><span style="color:#b48ead;">import </span><span>Carbon
</span><span>
</span><span style="color:#b48ead;">@NSApplicationMain
</span><span style="color:#b48ead;">class</span><span> AppDelegate: NSObject, NSApplicationDelegate {
</span><span>
</span><span>
</span><span>
</span><span> </span><span style="color:#b48ead;">func </span><span>applicationDidFinishLaunching(_ aNotification: Notification) {
</span><span> </span><span style="color:#65737e;">// Insert code here to initialize your application
</span><span> </span><span style="color:#b48ead;">if</span><span> Storage.fileExists(</span><span style="color:#a3be8c;">"globalKeybind.json"</span><span>, </span><span style="color:#b48ead;">in</span><span>: .documents) {
</span><span>
</span><span> </span><span style="color:#b48ead;">let</span><span> globalKeybinds = Storage.retrieve(</span><span style="color:#a3be8c;">"globalKeybind.json"</span><span>, from: .documents, as: GlobalKeybindPreferences.</span><span style="color:#b48ead;">self</span><span>)
</span><span> hotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: globalKeybinds.keyCode, carbonModifiers: globalKeybinds.carbonFlags))
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">func </span><span>applicationWillTerminate(_ aNotification: Notification) {
</span><span> </span><span style="color:#65737e;">// Insert code here to tear down your application
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">public var</span><span> hotKey: HotKey? {
</span><span> </span><span style="color:#b48ead;">didSet</span><span> {
</span><span> </span><span style="color:#b48ead;">guard let</span><span> hotKey = hotKey </span><span style="color:#b48ead;">else</span><span> {
</span><span> </span><span style="color:#b48ead;">return
</span><span> }
</span><span>
</span><span> hotKey.keyDownHandler = { [</span><span style="color:#b48ead;">weak self</span><span>] </span><span style="color:#b48ead;">in
</span><span> NSApplication.shared.orderedWindows.forEach({ (window) </span><span style="color:#b48ead;">in
</span><span> </span><span style="color:#b48ead;">if let</span><span> mainWindow = window as? MainWindow {
</span><span> print(</span><span style="color:#a3be8c;">"woo"</span><span>)
</span><span> NSApplication.shared.activate(ignoringOtherApps: </span><span style="color:#b48ead;">true</span><span>)
</span><span> mainWindow.makeKeyAndOrderFront(</span><span style="color:#b48ead;">self</span><span>)
</span><span> mainWindow.makeKey()
</span><span> }
</span><span> })
</span><span>
</span><span> }
</span><span> }
</span><span> }
</span><span>}
</span></code></pre>
<p>Finally, the last piece of the puzzle, create a new file called <code>Storage.swift</code>. Weāll use this awesome class created by Saoud M. Rizwan which can be found in more detail <a href="https://medium.com/@sdrzn/swift-4-codable-lets-make-things-even-easier-c793b6cf29e1">here</a>. This class makes working with local JSON storage very simple and I encourage you to read the blog post to understand how it works.</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#b48ead;">import </span><span>Foundation
</span><span>
</span><span style="color:#b48ead;">public class</span><span> Storage {
</span><span>
</span><span> </span><span style="color:#b48ead;">fileprivate init</span><span>() { }
</span><span>
</span><span> </span><span style="color:#b48ead;">enum</span><span> Directory {
</span><span> </span><span style="color:#65737e;">// Only documents and other data that is user-generated, or that cannot otherwise be recreated by your application, should be stored in the <Application_Home>/Documents directory and will be automatically backed up by iCloud.
</span><span> </span><span style="color:#b48ead;">case</span><span> documents
</span><span>
</span><span> </span><span style="color:#65737e;">// Data that can be downloaded again or regenerated should be stored in the <Application_Home>/Library/Caches directory. Examples of files you should put in the Caches directory include database cache files and downloadable content, such as that used by magazine, newspaper, and map applications.
</span><span> </span><span style="color:#b48ead;">case</span><span> caches
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">/// Returns URL constructed from specified directory
</span><span> </span><span style="color:#b48ead;">static fileprivate func </span><span>getURL(</span><span style="color:#b48ead;">for</span><span> directory: Directory) -> URL {
</span><span> </span><span style="color:#b48ead;">var</span><span> searchPathDirectory: FileManager.SearchPathDirectory
</span><span>
</span><span> </span><span style="color:#b48ead;">switch</span><span> directory {
</span><span> </span><span style="color:#b48ead;">case </span><span>.documents:
</span><span> searchPathDirectory = .documentDirectory
</span><span> </span><span style="color:#b48ead;">case </span><span>.caches:
</span><span> searchPathDirectory = .cachesDirectory
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">if let</span><span> url = FileManager.</span><span style="color:#b48ead;">default</span><span>.urls(</span><span style="color:#b48ead;">for</span><span>: searchPathDirectory, </span><span style="color:#b48ead;">in</span><span>: .userDomainMask).first {
</span><span> </span><span style="color:#b48ead;">return</span><span> url
</span><span> } </span><span style="color:#b48ead;">else</span><span> {
</span><span> fatalError(</span><span style="color:#a3be8c;">"Could not create URL for specified directory!"</span><span>)
</span><span> }
</span><span> }
</span><span>
</span><span>
</span><span> </span><span style="color:#65737e;">/// Store an encodable struct to the specified directory on disk
</span><span> </span><span style="color:#65737e;">///
</span><span> </span><span style="color:#65737e;">/// - Parameters:
</span><span> </span><span style="color:#65737e;">/// - object: the encodable struct to store
</span><span> </span><span style="color:#65737e;">/// - directory: where to store the struct
</span><span> </span><span style="color:#65737e;">/// - fileName: what to name the file where the struct data will be stored
</span><span> </span><span style="color:#b48ead;">static func </span><span>store<T: Encodable>(_ object: T, to directory: Directory, as fileName: String) {
</span><span> </span><span style="color:#b48ead;">let</span><span> url = getURL(</span><span style="color:#b48ead;">for</span><span>: directory).appendingPathComponent(fileName, isDirectory: </span><span style="color:#b48ead;">false</span><span>)
</span><span>
</span><span> </span><span style="color:#b48ead;">let</span><span> encoder = JSONEncoder()
</span><span> do {
</span><span> </span><span style="color:#b48ead;">let</span><span> data = </span><span style="color:#b48ead;">try</span><span> encoder.encode(object)
</span><span> </span><span style="color:#b48ead;">if</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.fileExists(atPath: url.path) {
</span><span> </span><span style="color:#b48ead;">try</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.removeItem(at: url)
</span><span> }
</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.createFile(atPath: url.path, contents: data, attributes: </span><span style="color:#d08770;">nil</span><span>)
</span><span> } catch {
</span><span> fatalError(error.localizedDescription)
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">/// Retrieve and convert a struct from a file on disk
</span><span> </span><span style="color:#65737e;">///
</span><span> </span><span style="color:#65737e;">/// - Parameters:
</span><span> </span><span style="color:#65737e;">/// - fileName: name of the file where struct data is stored
</span><span> </span><span style="color:#65737e;">/// - directory: directory where struct data is stored
</span><span> </span><span style="color:#65737e;">/// - type: struct type (i.e. Message.self)
</span><span> </span><span style="color:#65737e;">/// - Returns: decoded struct model(s) of data
</span><span> </span><span style="color:#b48ead;">static func </span><span>retrieve<T: Decodable>(_ fileName: String, from directory: Directory, as type: T.</span><span style="color:#b48ead;">Type</span><span>) -> T {
</span><span> </span><span style="color:#b48ead;">let</span><span> url = getURL(</span><span style="color:#b48ead;">for</span><span>: directory).appendingPathComponent(fileName, isDirectory: </span><span style="color:#b48ead;">false</span><span>)
</span><span>
</span><span> </span><span style="color:#b48ead;">if </span><span>!FileManager.</span><span style="color:#b48ead;">default</span><span>.fileExists(atPath: url.path) {
</span><span> fatalError(</span><span style="color:#a3be8c;">"File at path </span><span>\(url.path) </span><span style="color:#a3be8c;">does not exist!"</span><span>)
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">if let</span><span> data = FileManager.</span><span style="color:#b48ead;">default</span><span>.contents(atPath: url.path) {
</span><span> </span><span style="color:#b48ead;">let</span><span> decoder = JSONDecoder()
</span><span> do {
</span><span> </span><span style="color:#b48ead;">let</span><span> model = </span><span style="color:#b48ead;">try</span><span> decoder.decode(type, from: data)
</span><span> </span><span style="color:#b48ead;">return</span><span> model
</span><span> } catch {
</span><span> fatalError(error.localizedDescription)
</span><span> }
</span><span> } </span><span style="color:#b48ead;">else</span><span> {
</span><span> fatalError(</span><span style="color:#a3be8c;">"No data at </span><span>\(url.path)</span><span style="color:#a3be8c;">!"</span><span>)
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">/// Remove all files at specified directory
</span><span> </span><span style="color:#b48ead;">static func </span><span>clear(_ directory: Directory) {
</span><span> </span><span style="color:#b48ead;">let</span><span> url = getURL(</span><span style="color:#b48ead;">for</span><span>: directory)
</span><span> do {
</span><span> </span><span style="color:#b48ead;">let</span><span> contents = </span><span style="color:#b48ead;">try</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.contentsOfDirectory(at: url, includingPropertiesForKeys: </span><span style="color:#d08770;">nil</span><span>, options: [])
</span><span> </span><span style="color:#b48ead;">for</span><span> fileUrl </span><span style="color:#b48ead;">in</span><span> contents {
</span><span> </span><span style="color:#b48ead;">try</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.removeItem(at: fileUrl)
</span><span> }
</span><span> } catch {
</span><span> fatalError(error.localizedDescription)
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">/// Remove specified file from specified directory
</span><span> </span><span style="color:#b48ead;">static func </span><span>remove(_ fileName: String, from directory: Directory) {
</span><span> </span><span style="color:#b48ead;">let</span><span> url = getURL(</span><span style="color:#b48ead;">for</span><span>: directory).appendingPathComponent(fileName, isDirectory: </span><span style="color:#b48ead;">false</span><span>)
</span><span> </span><span style="color:#b48ead;">if</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.fileExists(atPath: url.path) {
</span><span> do {
</span><span> </span><span style="color:#b48ead;">try</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.removeItem(at: url)
</span><span> } catch {
</span><span> fatalError(error.localizedDescription)
</span><span> }
</span><span> }
</span><span> }
</span><span>
</span><span> </span><span style="color:#65737e;">/// Returns BOOL indicating whether file exists at specified directory with specified file name
</span><span> </span><span style="color:#b48ead;">static func </span><span>fileExists(_ fileName: String, </span><span style="color:#b48ead;">in</span><span> directory: Directory) -> Bool {
</span><span> </span><span style="color:#b48ead;">let</span><span> url = getURL(</span><span style="color:#b48ead;">for</span><span>: directory).appendingPathComponent(fileName, isDirectory: </span><span style="color:#b48ead;">false</span><span>)
</span><span> </span><span style="color:#b48ead;">return</span><span> FileManager.</span><span style="color:#b48ead;">default</span><span>.fileExists(atPath: url.path)
</span><span> }
</span><span>}
</span></code></pre>
<p>And thatās it. Try running the app, bringing up preferences, setting a shortcut, bring some other apps infront of it and press the shortcuts to test it out. Not only this, but if you close the Main window and then press the key bind the app should re-open.</p>
<p>There are a few things that could be improved, such as checking if the shortcut is valid or already used by the system but that's a quest for another day.</p>