Claire
2 years ago
1 changed files with 336 additions and 0 deletions
@ -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…
issues.context.reference_issue