Scripting with SwiftUI (II) - Scripts Provider
Tutorial13 December 2020
This tutorial is the second one of the series "Scripting with SwiftUI". This series goal is to use SwiftUI in Swift scripts that can be easily shared. As this use case is more common for Mac admins, those tutorials are addressed to this audience. Noneless a Swift developer could find interesting techniques in those tutorials.
If you have not already done it, I advice you to follow the first tutorial Coding a color picker view which explains some basis about SwiftUI required for this tutorial.
Exposition
In this tutorial, we will implement a scripts provider interface. From what I have seen, I believe it could be useful to let an end-user or a technician be able to simply choose a script and execute it through a GUI. A starter project can be found here, with the skeleton needed to start an interface in a script. Also, the final project is available, as well as the final challenge version. In this project, two stub scripts are provided to test: task.sh and long-task.sh. Details will be provided when needed.
Here is what the interface will look like.


On the left will be displayed a list of available scripts that the user can choose. On the right, details of the selected will be provided, as well as the "Run" button to execute it. A spinner will be displayed when the script is running.
Quick project overview
You can find the materials for the tutorial here. Open the starter project, you should see some code already present and which is similar to the one written in the previous tutorial. Here is a quick overview:
- Constants: the properties that will be used in the overall script. The 6 first ones deal with the app, its window and the view proportions. The second ones bridge two colors of AppKit to SwiftUI. You can learn more about the macOS system colors here.
- Models: that's where the data models to be used in the script will be implemented.
- Views: as for the previous tutorial, this section will contain the views. For now, it only contains the
MainView
that will be used to gather all the views in one. - Setup: this last part is the same as the previous tutorial and holds the logic to start the interface in the script.
All right, enough talks. Time to start coding!
Modelling the data
First, we should find a way to store a script informations. If you take a look at the final interface screenshots, you can guess that we will need to store several things:
- The script name that will be displayed in the list and in the script details view
- The script details that will be displayed in the list and in the script details view
- The script path that will be used to find the script to execute
struct Script {
let name: String
let details: String
let path: String
}
class
and struct
If you are wondering what's the difference between a class
and a struct
: a class
is a reference type, which means that when a new class is instantiated with its value, it's the reference (i.e. the memory address) that is copied. For a struct
, its the value at the memory that is copied and not the address itself : it's a value type. Although this is over-simplified since Swift uses Copy on Write technique, it should suffice for this tutorial.
Fo this tutorial, we will only have to work with two scripts and I think it's a good idea to take advantage of Swift powerful extension feature. We will define our two scripts in an extension of the Script
structure as static constants. You can add the following below the Script
structure closing curl bracket.
extension Script {
static let task = Script(name: "Task",
details: "Echo the date in /tmp",
path: "/Users/alexisbridoux/Desktop/task.sh")
static let longTask = Script(name: "Task (long)",
details: "Echo the date in /tmp after 2 seconds",
path: "/Users/alexisbridoux/Desktop/long-task.sh")
}
Be careful to change the path to the script for the one you chose.
This way, we can access the scripts in a logic way which also does not impact any global constant.
let task = Script.task
let longTask: Script = .longTask
Now that we can specify a script, how should we make them available? In the previous tutorial, we stored the colors to be displayed in a global array which is fine in a script. Here we will use another solution: the scripts list and the selected script index will be stored in a class ScriptsProvider
that the views will share. As we will see, using a class
here rather than a struct
will be useful to have every view knows about the same current selected script index.
Here it is with a breakdown.
// 1
final class ScriptsProvider {
// 2
let scripts: [Script] = [.task, .longTask]
var selectedScriptIndex = 0
// 3
var selectedScript: Script {
scripts[selectedScriptIndex]
}
}
- The class is marked as
final
which prevents any subclassing. This is not required but most often when I create a class I prefer to prevent subclassing and change it later if needed. - Since there is no need to modify the scripts list yet, the property is a constant.
- The computed property
selectedIndex
will only be used to more easily access informations about the current selected script. As it is computed, it will not allow any modifications.
Hard-coding the scripts list is simple but not very flexible. In the next tutorial we will see how to import and export data to/from JSON or Plist files.
That's it for the models! We will come back to it later when needed but for now it's enough. Your models should look like this.
// MARK: - Models
struct Script {
let name: String
let details: String
let path: String
}
extension Script {
static let task = Script(name: "Task",
details: "Echo the date in /tmp",
path: "/Users/alexisbridoux/Desktop/task.sh")
static let longTask = Script(name: "Task (long)",
details: "Echo the date in /tmp after 2 seconds",
path: "/Users/alexisbridoux/Desktop/long-task.sh")
}
final class ScriptsProvider {
let scripts: [Script] = [.task, .longTask]
var selectedScriptIndex = 0
var selectedScript: Script {
scripts[selectedScriptIndex]
}
}
// MARK: - Views
Now that the models are ready, it's time for some SwiftUI magic! ๐งโโ๏ธ
Views
As mentioned earlier, the main view is composed of a scripts list view - that will be named ScriptList
- and a script detail view - ScriptDetails
.
Scripts List
If you had used UIKit or AppKit before, you should be familiar with the concept of TableView. It's a simple to use view to display rows of information. And I have good news, it's even easier to use the SwiftUI counterpart: List
.
To see the List
in action, we will start simple. Then we'll flesh it out.
// 1
struct ScriptList: View {
// 2
var scriptsProvider: ScriptsProvider
var body: some View {
// 3
List(0..<scriptsProvider.scripts.count) { index in
// 4
Text(scriptsProvider.scripts[index].name)
}
}
}
- Here the view is declared.
- The structure will need a
ScriptsProvider
to know what scripts should be displayed, and specify that a script has been chosen. This property will be passed in the initialisation so that theScriptsProvider
is the same for everyone (remember: classes are reference types). - Here, we declare a
List
that will display as much elements as they are scripts in thescriptsProvider.scripts
array thanks to the open range operator..<
- Each element that is displayed will be a
Text
view. We will improve that, don't worry!
To use this new view, it's needed to insert it in MainView
. As long as we are here, we will embed the ScriptsList
view in a HStack
now for it will be simpler when inserting the right view - ScriptDetails
. So please update MainView
like so.
struct MainView: View {
// 1
let scriptsProvider = ScriptsProvider()
var body: some View {
HStack {
// 2
ScriptList(scriptsProvider: scriptsProvider)
// 3
.padding(.top, 5)
.frame(width: scriptListWidth, height: windowHeight)
.border(Color.gray)
}
}
}
- The
ScriptsProvider
object that we will share can be declared in the higher ancestor view - hereMainView
. - The property
scriptsProvider
is then passed to theScriptsList
when initialising it. - Here we add some visual content to like the padding or a border. Also, we ensure the
ScriptList
has a fixed width.
You can run the script now, and see the names of the two scripts we declared. Not really interesting yet but it's working! Let's make it nicer.
To display the script name and the script details vertically, you can guess that we will need a VStack
. So if you replace the Text
view in the list by the following, you should get a better result.
VStack(alignment: .leading) {
Text(scriptsProvider.scripts[index].name)
.fontWeight(.semibold)
Text(scriptsProvider.scripts[index].details)
.lineLimit(2)
}
Quite expressive, isn't it? In
SwiftUI, the functions like
fontWeight
or
lineLimit
are called
modifiers as they will transform a
View
into a new one with a modification. As you can guess,
fontWeight
applies a specific weight to the text font and
lineLimit
will prevent the
Text
view from expanding even if it does not completely appear. Thus shrinking the text.
Modifiers really transform a view into a new one, meaning that it's not the same object. It's a new View
that you'll get after applying a modifier. No problems though since View
s are structures and thus are value types.
If you run the app now, you should see some progress.

Select a script
In order to clearly see the selection, we will first add a coloured background to the selected row. To do so, we will cheat a bit, because where would be the fun if playing by the rules? To have a coloured background on a single row, we will add a background to all rows, but make only the one for the selected row no transparent. This is done by changing the Color.opacity
property from 1 to 0. This logic will be placed inside an opacity(for:)
function. This is what it looks like.
struct ScriptList: View {
var scriptsProvider: ScriptsProvider
var body: some View {
List(0..<scriptsProvider.scripts.count) { index in
VStack(alignment: .leading) {
Text(scriptsProvider.scripts[index].name)
.fontWeight(.semibold)
Text(scriptsProvider.scripts[index].details)
.lineLimit(2)
}
// 1
.padding(.vertical, 5)
.padding(.horizontal, 20)
// 2
.frame(maxWidth: scriptsListWidth,
minHeight: 60, maxHeight: 60,
alignment: .leading)
// 3
.background(Color.selectedText.opacity(opacity(for: index)))
// 4
.cornerRadius(5)
}
}
// 5
func opacity(for index: Int) -> Double {
scriptsProvider.selectedScriptIndex == index ? 1 : 0
}
}
- We add some padding for the background color to not stick to the text.
- As the
Text
views have a line limit, they will try expand horizontally, which will not be nice because they will not have the same width. Here we ensure the width is the same for everyText
by setting its maximum width to the one of its parent. - The background color is set, with its opacity set to the result function
opacity(for:)
- The corner radius is set after the background color. That's because modifiers are applied in their specification order, and it sometimes has an impact on the view. Explaining the all concept of the views in SwiftUI is beyond the scope of this tutorial, but if you want to see that in action, try to put the
cornerRadius
modifier before thebackground
one. - This function simply returns 1 if the test
scriptsProvider.selectedScriptIndex == index
istrue
and 0 otherwise. Here, we use the ternary operator.
// 1
.contentShape(Rectangle())
// 2
.onTapGesture {
scriptsProvider.selectedScriptIndex = index
}
- In a stack, only the content that is drawn on the screen can be interacted with. But the framework allows us to declare that all a specific frame of the stack should be made tappable - here its rectangle.
- To watch for a tap (i.e a click on macOS), we simply have to add a
tapGesture
modifier with a function to be ran each time the event happens. Trust me that's really nicer than the AppKit version!
What ?!
Nothing is happening?
Now wait a minute. Remember what we had to do in the previous tutorial to observe a variable modification and propagate it in the views? We declared State
property! Unfortunately, the State
property wrapper will not work with a class or a structure and is not suitable for the scriptsProvider
property. Its purpose is to observe a simple type or value. And here it's a property inside the scriptsProvider
which is changed. Fortunately enough, SwiftUI provides a way to resolve this matter: the ObservableObject
protocol.
This protocol can only work with reference types, and you might start to think that I have an obsession on this topic! In fact, it's an huge topic and it's normal to have to deal with it on a daily basis. Here, ObservableObject
will allow us to observe the properties of an object. If this object was a value, like a struct
, each time a property would be modified, the whole object would change. Thus it would not really make sense to observe that. Do not worry though if it does not seem really clear yet, it's not required to fully understand it to use it. And you'll get used to it in Swift.
To use ObservableObject
we have two steps to perform. First, the ScriptsProvider
class has to declare that it conforms to the protocol. And then it should specify which properties should be observed with the Published
property wrapper, which is somehow equivalent to State
but it will make the ScriptsProvider
object notifies SwiftUI that a property has changed and that the view should be redrawn. Here it is.
final class ScriptsProvider: ObservableObject {
let scripts: [Script] = [.task, .longTask]
@Published var selectedScriptIndex = 0
var selectedScript: Script {
scripts[selectedScriptIndex]
}
}
Our second task is to make the views aware that the property scriptsProvider
can now be observed. To do so, the view that instantiates it should add a StateObject
property wrapper. So in MainView
change the line
scriptsProvider = ScriptsProvider()
to
@StateObject var scriptsProvider = ScriptsProvider()
Now that
MainView
declares an
StateObject
, how should it let its children view know that there is an object to be shared? Once again,
SwiftUI brings the answer with the
environmentObject
modifier. By using it, we can pass an
ObservableObject
to a child view. When this child view modifies this object, the changes will be propagated back to its parent view.
This is how it can be done: first, replace the ScriptList
view in MainView
from
ScriptList(scriptsProvider: scriptsProvider)
to
ScriptList()
.environmentObject(scriptsProvider)
Then we also have to change the declaration of
scriptProvider
property in
ScriptsList
. It's simple though: replace the declaration of
scriptsProvider
to
@EnvironmentObject var scriptsProvider: ScriptsProvider
That's enough for
SwiftUI to link the
ScriptsList.scriptsProvider
property to the environment object
scriptsProvider
.
Finally (for real this time), you can run the script and see the background color changing when you click on the other row. Hurrah!
If you wonder, SwiftUI matches the environment objects with types. Here, the type of ScriptsList.scriptProvider
and the environment object passed with .environmentObject(scriptProvider)
matches. This technique has some limits. For example, it's not possible yet to pass two objects with the same type, because two object will match. Also, an oversight of an .environmentObject
and the program will exit with a fatalError
with no warnings at build time (you can try that by commenting out the line with .environmentObject(scriptsProvider)
.
That's if for the left view! You can take a break now. The right view should be easier with your knowledge of the environment objects.
Scripts Details
Now that theScriptsList
view is implemented, let's think about a way to implement the
ScriptDetails
one. And there are several possibilities. I do not know a way to find the best views composition yet so we will a solution that I find simple. And we will use stacks.
Stacks in SwiftUI are really a simple yet powerful way to layout views. This is why they are used so often. Even with UIKit Apple was already recommending the usage of the UIStackView
because they take care of some layout work for us - almost for free most often.
To get the expected result, two stacks will be needed here. (The spinner will be added in final part so feel free to ignore it for now).

HStack
to push the "Run" button to the right edge of the window. The orange one will be a
VStack
to layout the row "Task + Run" and below it the script details.
If you want, you can try to code this part yourself. I'll be waiting with the solution below. Here are some hints:
- Don't forget to add a
scriptsProvider
property as an environment object. - It's possible to specify how the text should be aligned in a
VStack
with thealignment
parameter. - You can use a
Button
with a label and an empty action function. We shall fill this action soon. - When the
ScriptDetails
view is ready, you can add it to theMainView
below the last modifier ofScriptsList
.
๐
๐
๐
Finished? Let's compare. Here is my solution with a breakdown.
struct ScriptDetail: View {
// 1
@EnvironmentObject var scriptsProvider: ScriptsProvider
var body: some View {
// 2
VStack(alignment: .leading) {
// 3
HStack {
Text(scriptsProvider.selectedScript.name)
.font(.title)
Spacer()
Button("Run") {
// 4
print("Run script at \(scriptsProvider.selectedScript.path)")
}
}
// 5
Text(scriptsProvider.selectedScript.details)
Spacer()
}
.padding()
}
}
- A
scriptsProvider
property will be passed as en environment object, similarly with theScriptsList
view. - The
VStack
is the higher view in the hierarchy, and will align the text on the leading side (left when the language is read from left to right). - The
HStack
holds the script title and the "Run" button. TheSpacer
will push the button to the right. - When the "Run" button is clicked or tapped, the code inside that closure will be executed. We will take part of this in the next part. For now, it simply prints a message in the console. To see it, tap โงโY.
- The script details text is specified below the
HStack
, and aSpacer
will push those views to the top.
Then to add this view to the MainView
struct MainView: View {
@StateObject var scriptsProvider = ScriptsProvider()
var body: some View {
HStack {
ScriptList()
.environmentObject(scriptsProvider)
.frame(width: scriptListWidth, height: windowHeight)
.border(Color.gray)
ScriptDetail()
.environmentObject(scriptsProvider)
.frame(width: scriptsDetailWidth, height: windowHeight)
.background(Color.controlBackground) // 1
.border(Color.gray)
}
}
}
- The background color has to be specified here but not for the
ScriptsList
view becauseList
views already have a specific translucent background.
Execute the selected script
The logic that will run the selected script should be put somewhere, and I find the ScriptsProvider
class to be a perfect fit. Thus we will add a runSelectedScript
function to this class. But before, I would like you to add another property to the Script
structure. This property will be a boolean named isRunning
and will hold the information whether the script is running or not. This will let us know for example if the spinner should be visible or not.
So please add this property to the Script
structure.
struct Script {
let name: String
let details: String
let path: String
var isRunning = false
}
Now we can add the
runSelectedScript
function in the
ScriptsProvider
class. Here it is.
func runSelectedScript() {
// 1
let selectedIndex = selectedScriptIndex
let selectedScript = self.selectedScript
let url = URL(fileURLWithPath: selectedScript.path)
// 2
let task: NSUserScriptTask
do {
task = try NSUserScriptTask(url: url)
} catch {
print("โ๏ธ Error while loading the script at \(selectedScript.path). \(error), \(error.localizedDescription)\n")
return
}
// 3
scripts[selectedIndex].isRunning = true
// 4
task.execute { [unowned self] error in if let error = error {
print("โ๏ธ The script at \(selectedScript.path) finished with an error: \(error), \(error.localizedDescription)\n")
} else {
print("โ The script at \(selectedScript.path) finished successfully\n")
}
// 5
DispatchQueue.main.async {
self.scripts[selectedIndex].isRunning = false
}
}
}
- Some local constants are set for an easier usage. The selected script index in the
scripts
array, the selected script informations and the URL from the selected script path. - Apple provides an API to execute a script on macOS:
NSUserScriptTask
. It's quite simple to use although it does not allow to retrieve the script output, and will throw an error if the script has no shebang. This is the reason of the do-try-catch usage here: to print an error if the script cannot be executed. - Before running the script, we have to think about setting the selected
Script
isRunning
property to let the rest of the program know that this script is being executed. This is done by targeting the script at the index and not theself.selectedScript
property since this one is computed only and we cannot change it. - When executing a script, Apple provides a way to let us handle the script completion with a callback function - a very common pattern in Apple's APIs. This function will take an optional error as parameter to let us know if an error occurred. If the error is not nil, an error occurred and we can print its description in the console to debug. Otherwise, we print a successful message.
- As the script completed - successfully or not - we can set the property
isRunning
back to false. But wait, what isDispatchQueue.main.async
?
Thanks for asking! The script execution is performed on another thread, meaning that the system will not execute it synchronously with the rest of our code. As soon as the execution enters thetask.execute
function, it will jump to the end of the closure, and the script execution will be performed elsewhere then will call the callback function to let us know the script has been executed - with or without an error. The reason of doing so is that the script could be long to run, and if the program had to wait for the script to complete, it would not be able to update the interface as only one instruction can be executed at at time. Thus, the user would think the UI is frozen, which is not good.
Finally, when the callback function is called, it is executed from the thread where the script was run. However, the UI should always be updated on the main thread and changing the propertyisRunning
will have an impact on the interface, since we want to update a spinner. This is Apple requirement for the interface and it's a golden rule. Here we ensure that the change is performed on the main thread using theDispatchQueue
API.
There is a lot of interesting stuff to deal with threads and UI but I think that's enough about this topic in this tutorial. Just remember that the script is executed on another thread and that the UI should be always updated on the main thread.
The compiler should complain that we modify the scripts
property of the ScriptsProvider
although it's a constant so we have to change it from a constant to a variable. Please do that by changing let scripts:
to var scripts:
Now we can use this function inside the Button
view in ScriptsDetails
. You can replace the line
print("Run script at \(scriptsProvider.selectedScript.path)")
with
scriptsProvider.runSelectedScript()
When you click on the "Run" button now, it should execute the script at the provided path.
You can use the provided task.sh and long-task.sh to test the behavior. Those script echo the date in /tmp/script-provider-test.log
You might remark something though: when the long task is executed, there is now way to know about it. The task script execute immediately but the second one takes some time to execute. It's done artificially here but it's common to have some long execution script. The purpose of the final part is to show that in the interface.
Please wait - Display a spinner
Alright, I got some bad news for this last part. The view that we could use - ProgressView
- is not available on macOS 10.15
, only on macOS 11.0
. The good news is that Apple provides a way to use NSView
s or UIView
s as SwiftUI views. Thus, we can use the NSProgressIndicator
from AppKit. As a benefit, this method will give us more granularity on the view.
The make this bridge with AppKit, SwiftUI asks us to provide an object implementing the NSViewRepresentable
. This protocol has two requirements (three in fact but the third one is on a type and can be inferred): we have to declare a function to make the NSView
and another one to update it. This way, SwiftUI has everything it needs to make and update the view as any other of the standard views.
It's possible to deeply customise those bridge functions with the passed Context
but this topic will not be tackled in this tutorial.
Here is our Spinner
.
struct Spinner: NSViewRepresentable {
// 1
@EnvironmentObject var scriptsProvider: ScriptsProvider
let size: NSControl.ControlSize
// 2
func makeNSView(context: Context) -> NSProgressIndicator {
let indicatorView = NSProgressIndicator()
indicatorView.isDisplayedWhenStopped = false
indicatorView.controlSize = size
indicatorView.style = .spinning
return indicatorView
}
// 3
func updateNSView(_ nsView: NSProgressIndicator, context: Context) {
scriptsProvider.selectedScript.isRunning ?
nsView.startAnimation(nil) :
nsView.stopAnimation(nil)
}
}
- Here again we need the shared
scriptProvider
as en environment object. Also, asize
property is used to customise the size of theNSProgressIndicator
view - Here, the function will simply make a new
NSProgressIndicator
view, do some setup and return it. - This function will be called when the
scriptsProvider
is modified. This is our opportunity to start or stop the spinner. Once again we use the ternary operator: if the current selected script is running, the spinner is started, otherwise it will be stopped.
Button
view in the
ScriptDetails
since we want it to be on the left of the button. So between the
Spacer()
and
Button
please insert the
ProgressIndicator
.
Spacer()
Spinner(size: .small)
Button("Run") {
As you can notice, no
environmentObject
is used here.
SwiftUI is smart enough to do it for you if the current view already has a
EnvironmentObject
with the same type.
You can now run the long task to the see the spinner... well... not appearing. Come on now SwiftUI! That's the end of the tutorial you can't let me down.
Unless ๐ค
We update the ScriptsProvider.script
property, right? Because each time a script is running, we update the array with the selected index:
scripts[selectedIndex].isRunning = true
But how
SwiftUI is supposed to know that this property should be observed and update the views? Good question! In fact, the only thing we have to do in order to fix this is to mark the
scripts
property as
@Published
similarly with the
selectedScriptIndex
:
@Published var scripts: [Script] = [.task, .longTask]
And now it should work! (but I keep an eye on you SwiftUI ๐ง)
And that's the end of this (quite long!) tutorial. You should have a working scripts provider interface now. In the next tutorial, we will see how to store and retrieve objects like Scripts
from a Plist or JSON file. A proposed challenge will then be to come back to this tutorial and implemented this feature.
But before that, I already have two challenges to offer.
Challenges
You can find the challenges starter project and the solutions (final) in the Challenges folder.
Disable the "Run" button (easy)
When the user clicks the "Run" button, the spinner starts animating, but the button is not disabled. Thus, it's possible to click on it again. It would be great if the button was disabled while the script is running. You can use the disabled
modifier.
Display the script output (hard)
As mentioned in the tutorial, Apple NSUserScriptTask
API does not allow to retrieve the output. In the challenge starter project, you can find a new function runSelectedScriptWithOutput
to execute the current script which lets retrieve the output in a Result
variable.
For this challenge, you should add a new view to display the script output below the script details. You are free to use any view you want. The solution shows the output in a Text
with a background color. You should use the function runSelectedScriptWithOutput
to run the script and retrieve the output to then update a State
property (on the main thread ๐).