From 47f43b566462b502a5c85cbbf5e04814a7b7ee24 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 29 Sep 2022 03:36:49 +0000 Subject: [PATCH] Update 'Swift Windows' --- Swift-Windows.md | 336 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 Swift-Windows.md diff --git a/Swift-Windows.md b/Swift-Windows.md new file mode 100644 index 0000000..dae8e40 --- /dev/null +++ b/Swift-Windows.md @@ -0,0 +1,336 @@ +# Windows with Swift + +I'm just going to preface this by saying: holy hell, making windows work in Xcode and Swift is no small feat. + +This project features two windows: one that shows some basic information about the application, and another that shows the application's changelog, which will be loaded from a text file packaged with the application. + +## The "About" window + + + +### Building the window + +Hit **⌘+N** or right-click in the project navigator to add a new file to your project. Select **Window** in the **User Interface** section. Click **Next**, give your new file a name, and click **Create**. My file is named `InfoBox.xib`. + +Now we have an empty window we need to customize. You can resize the window using the handles on the corners and sides. + +We need to make a few customizations to the window's title bar. + +Make sure you've selected the **Window** (not the **View** under the window), and find the **Attribute Inspector** icon in the righthand pane of your Xcode window. You can also find this pane in the application menu under **View** > **Inspectors** > **Show Attribute Inspector**. + +Under **Title**, enter a display title (mine is "About LunaMac"), and under **Controls**, uncheck the boxes next to **Minimize** and **Resize**. Finally, make sure **Visible at launch** is checked. This refers the launch of the *window*, not the *application*. + +### Adding the controls + +To add content to the window, we need to bring up the **Objects Library** - you can find this under **View** > **Libraries** > **Show Library** from the application menu, hit **Shift+⌘+L**, or click the **Libraries** icon in the toolbar of your Xcode window (it looks like a square inside a circle). Search for the word **label**, and drag a **Label** to the window. Change the **Title** attribute to your application name. Drag a **Multi-line Label** to the window, and position it below the label we just created. Change the **Title** attribute to a short summary (or other information) about your application. + +The application icon in this window also serves as a button to open the changelog window. Open the **Objects Library** again, and search for and add an **Image Button**. Change the **Image** attribute of this button to the name of your application icon (in my case, `LunaMac`). Resize and position this button to your liking. + +### Activating the window + +We're going to go back to our main `AppDelegate.swift` file. In your `buildMenu()` function, add a new menu item above the **Quit application** menu item. This will activate a new `showAbout()` function when clicked. + +```swift +@objc func buildMenu(key: String = "default") { + ... + statusBarMenu.addItem( + withTitle: "About LunaMac...", + action: #selector(AppDelegate.showAbout), + keyEquivalent: "" + ) + ... +} +``` + +`showAbout()` needs to load this window. To accomplish this, we need to use an instance of [`NSWindowController`](https://developer.apple.com/documentation/appkit/nswindowcontroller) using `InfoBox.xib`. + +```swift +@objc func showAbout() { + // create infobox window object + let aboutWin = NSWindowController(windowNibName: "InfoBox") + + aboutWin.loadWindow() +} +``` + +To make sure your window shows up on top of any other open windows, add a call to [`NSApp.activate`](https://developer.apple.com/documentation/appkit/nsapplication/1428468-activate). + +```swift +@objc func showAbout() { + ... + NSApp.activate(ignoringOtherApps: true) +} +``` + +Launch your application, show the "About" window from your icon's menu, and bask in your success. But wait...there's more. Hit that menu item again, and note that you now have a duplicate window. We need to find a way to make sure we're only loading one instance of this window, and resurfacing the same window, rather than duplicating it. + +This took awhile to figure out, so maybe I can save you the headache of blindly searching for answers and details. + +### Preventing window duplication + +Swift follows the MVC model, in which models, views, and controls are separate components of your application. Because of this, we need to use an instance [`NSViewController`](https://developer.apple.com/documentation/appkit/nsviewcontroller), which will give us access to window triggers that fire when the window is opened, closed, changed, etc. + +We'll start with adding a [`Bool`](https://developer.apple.com/documentation/swift/bool) scoped to our `AppDelegate` class named `infoOpen`. This is going to keep track of whether or not the "About" window is already open. + +```swift +class AppDelegate: NSObject, NSApplicationDelegate { + ... + // is the info box open? + var infoOpen = false + ... +} +``` + +Now we need to add an [`NSViewController`](https://developer.apple.com/documentation/appkit/nsviewcontroller) to `InfoBox.xib`. Open the **Object Library**, and add a **View Controller** to your `xib.` + +To make this controller useful, we're going to create a class that inherits from [`NSViewController`](https://developer.apple.com/documentation/appkit/nsviewcontroller). + +Add a new Swift file to your project named `ViewController.swift`. Remove the `import Foundation` line at the top, and create your ViewController class. We also need a reference to our `AppDelegate` class, so we can access our `infoOpen` [`Bool`](https://developer.apple.com/documentation/swift/bool). + +```swift +import Cocoa +class ViewController: NSViewController, NSWindowDelegate { + let appDelegate = NSApplication.shared.delegate as! AppDelegate +} +``` + +To handle setting `infoOpen`, we need to override two methods in [`NSViewController`](https://developer.apple.com/documentation/appkit/nsviewcontroller): [`viewDidLoad()`](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434476-viewdidload) and [`viewWillDisappear()`](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434483-viewwilldisappear). + +In our [`viewDidLoad()`](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434476-viewdidload) override, we need to call `super.viewDidLoad()`, which ensures the inherited method will still run as expected. + +Then, we're going to call a custom function that will load the window, or show it if it's already loaded. + +```swift +override func viewDidLoad() { + super.viewDidLoad() + + toggleWindow(boolShow: true) +} +``` + +Our `toggleWindow()` function looks at the title of the window that triggered it, and sets the value of `infoOpen` based on the [`Bool`](https://developer.apple.com/documentation/swift/bool) passed by the original function call. This allows us to use the same function to either show or hide the window. + +```swift +func toggleWindow(boolShow: Bool = false) { + // get the window title to set the right bool + // window title is in self.view.window.title + let winTitle = self.view.window?.title + + if (winTitle?.contains("About") ?? false) { + appDelegate.infoOpen = boolShow + } +} +``` + +We need something similar when the window closes, which triggers [`viewWillDisappear()`](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434483-viewwilldisappear). + +```swift +// triggers when window is closed +override func viewWillDisappear() { + toggleWindow(boolShow: false) +} +``` + + + +Now that we have our custom `ViewController` class, we need to assign it to the **View Controller** we created in `InfoBox.xib`. + +In the `xib` file, select the **View Controller**, and open the **Identity Inspector** in the righthand pane of your Xcode window. In the **Custom Class** section, set the **Class** value to **ViewController** (the name of our class). + +
+ +This is where things get more than slightly confusing. Xcode relies on a mouse-driven graphical interface for connecting code to windows and controls in windows. To associate the **View Controller** with our window, hold down `Ctrl` while click-and-dragging from **View Controller** in the tree to the **View** in the window - the whole window area excluding the title bar. A little menu will appear; choose **View** under **Outlets**. + + + +If you look at the source code (XML) view for `InfoBox.xib`, there's now a `` section that references the automatically-generated `id` of the ``. You can manually set these element `id` values, and set up the connection between view and controller directly in the XML. + +```xml + + ... + + ... + + ... + + + + + + +``` + +Launch your app, and verify that everything works. Now, when you select "About..." from the status bar icon's menu, if the window's already open, it will display, rather than creating a new instance of the window. + +### Adding an action to the button + +Recall when we created `InfoBox`, we included an **Image Button** using the application's icon. Now we're going to associate a function with this button. First, we'll create a special kind of function using [`@IBAction`](https://developer.apple.com/documentation/uikit/uibutton) in our `AppDelegate` class. *Note: this documentation relates to UIKit, which we aren't using in this application. The `@IBAction` concept is the same.* + +When the button is clicked, it calls `clickClick()`, which in turn calls `showLog()`. + +```swift +@IBAction func clickClick(_ sender: NSButtonCell) { + // show changelog + showLog() +} +``` + +`showLog()` is a simple function that behaves just like `showAbout()`. Add the function to your `AppDelegate` class definition. + +```swift +@objc func showLog() { + if (!logOpen) { + // create changelog window object + // this creates duplicate windows + let logWin = NSWindowController(windowNibName: "Changelog") + + logWin.loadWindow() + } + NSApp.activate(ignoringOtherApps: true) +} +``` + +To connect this action to your button, you can either edit the XML, or use the mouse-driven Interface Builder. The XML is simple: + +```xml + + ... + + ... + +``` + +Alternatively, you can `Ctrl`-and-drag from the button's **Button Cell** to the **First Responder** you'll find under **Placeholders**, and select your function `clickClick:` from the list that appears. + +Now we need to make a changelog dialog, and the code to load it and its contents. + +## The Changelog window + +Add a new **Window** document to your project named `Changelog.xib`. Next, add an **Empty** file - this file type can be found in the **Other** section under **MacOS** templates. Name this file `changelog.txt` - we'll load the contents of this file into the changelog window, making it easy to maintain the changelog separate from the application. + +Change the **Window** attributes to remove the minimize and maximize buttons, and set the title to `Changelog`. Add a **Scrollable Text View**, and resize it to fill out the window. Navigate through the window tree to the **Text View** that sits under **Scroll View**, and uncheck the box next to **Editable** in the **Attributes Inspector**. + +To interact with this window and its contents, we need a **View Controller**. Add one from the **Object Library**. As we did with `InfoBox.xib`, change this **View Controller** to use our custom class named `ViewController`. Connect this **View Controller** to your changelog window's **View**, either through the `Ctrl`-and-drag, or by editing the XML directly in `Changelog.xib`. + +Since we're also going to be changing the contents of our **Scrollable Text View**, we need to add an outlet for it, as well. + +```xml + + ... + + ... + + ... + + + + + + + +``` + +### Tracking the window status + +Displaying the Changelog window also requires a [`Bool`](https://developer.apple.com/documentation/swift/bool) value to track whether it's already open, so add one to your `AppDelegate` class definition in `AppDelegate.swift`. + +```swift +class AppDelegate: NSObject, NSApplicationDelegate { + ... + // is the info box open? + var logOpen = false + ... +} +``` + +### Handling the view controller + +Then, add the code to `toggleWindow()` in `ViewController.swift` to handle showing the changelog window, similar to how we handled the info window. + +```swift +func toggleWindow(boolShow: Bool = false) { + ... + if (winTitle?.contains("About") ?? false) { + appDelegate.infoOpen = boolShow + } + else if (winTitle?.contains("Changelog") ?? false) { + appDelegate.logOpen = boolShow + } +} +``` + +Now you can launch your application and access both the info and changelog windows. We're in the home stretch! + +### Loading the changelog file + +In our `ViewController` class, we're going to override [`NSViewController.viewDidAppear()`](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434455-viewdidappear) to load and display the contents of `changelog.txt` in our changelog window. + +Since `changelog.txt` will be packaged with our application, we can access it as a resource in [`NSBundle.main`](https://developer.apple.com/documentation/foundation/bundle/1410786-main). We'll catch any failures with `?? ""`. + +```swift +override func viewDidAppear() { + // use this to load the changelog + if (self.view.window?.title.contains("Changelog") ?? false) { + // get log file path + let logPath = Bundle.main.path(forResource: "changelog.txt", ofType: nil) ?? "" + ... + } +} +``` + +We'll use Swift's [`String.contentsOfFile()`](https://developer.apple.com/documentation/swift/string/init(contentsoffile:)) method to read the contents of `changelog.txt` into a variable. If the file can't be read for any reason, the window will show a simple error message. + +Note the `encoding:` argument ensures the file is read with the right encoding. The full list of supported formats can be found in Apple's documentation for [`String.Encoding`](https://developer.apple.com/documentation/swift/string/encoding). + +```swift +override func viewDidAppear() { + // use this to load the changelog + if (self.view.window?.title.contains("Changelog") ?? false) { + ... + // get contents of log file + let logText = (try? String(contentsOfFile: logPath, encoding: String.Encoding.utf8)) ?? "Changelog couldn't be read." + ... + } +} +``` + +To actually change the contents of our scrolling text control, we need to create an [`NSTextStorage`](https://developer.apple.com/documentation/uikit/nstextstorage) object that contains both the string and some formatting. Since the log is formatted as a plain text file, we'll use Apple's standard fixed width font, Monaco. + +```swift +override func viewDidAppear() { + // use this to load the changelog + if (self.view.window?.title.contains("Changelog") ?? false) { + ... + // create an object to hold the text and its formatting + let textStorage = NSTextStorage(string: logText) + textStorage.font = NSFont(name: "Monaco", size: 11) + textStorage.foregroundColor = NSColor.textColor + ... + } +} +``` + +We made it to the finish line! To refer to our text control, we'll use that special syntax again, this time using `@IBOutlet` + +```swift +class ViewController: NSViewController, NSWindowDelegate { + @IBOutlet var textView: NSTextView! +} +``` + +We'll assign our `textStorage` object to our `textView`, and we're done! + +```swift +override func viewDidAppear() { + ... + // replace textView's textStorage contents + self.textView?.layoutManager?.replaceTextStorage(textStorage) +} +``` + +Congratulations. You can now make simple Swift applications for MacOS with the concepts illustrated in this project. For the complete project, just clone this repo. \ No newline at end of file