Update 'Swift Windows'

master
Claire 2 years ago
parent
commit
47f43b5664
  1. 336
      Swift-Windows.md

336
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
<img src="https://abettergeek.com/_media/wiki/git/lunamac/aboutwindow.png" align="left">
### 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)
}
```
<img src="https://abettergeek.com/_media/wiki/git/lunamac/viewcontroller.png" align="right">
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).
<br clear="right"/>
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**.
<img src="https://abettergeek.com/_media/wiki/git/lunamac/viewcontroller2.png">
If you look at the source code (XML) view for `InfoBox.xib`, there's now a `<viewController>` section that references the automatically-generated `id` of the `<view>`. You can manually set these element `id` values, and set up the connection between view and controller directly in the XML.
```xml
<window title="About LunaMac" ... id="lunaWindow">
...
<view key="contentView" wantsLayer="YES" id="lunaView">
...
</view>
...
</window>
<viewController id="lunaViewController" customClass="ViewController" customModule="LunaMac" customModuleProvider="target">
<connections>
<outlet property="view" destination="lunaView" id="lunaViewOutlet"/>
</connections>
</viewController>
```
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
<window ... >
...
<button ... >
...
<connections>
<action selector="clickClick:" target="-1" id="clickAction"/>
</connections>
</button>
...
</window>
```
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
<window title="Changelog" ... id="lunaWindow">
...
<view key="contentView" wantsLayer="YES" id="lunaView">
...
</view>
...
</window>
<viewController id="lunaViewController" customClass="ViewController" customModule="LunaMac" customModuleProvider="target">
<connections>
<outlet property="view" destination="lunaView" id="lunaViewOutlet"/>
<outlet property="textView" destination="lunaText" id="lunaTextOutlet"/>
</connections>
</viewController>
```
### 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.
Loading…
Cancel
Save