Day 62
Day 62 êŽë š
Project 13, part 1
This is the first of two projects that start looking at how we push beyond the boundaries of SwiftUI so that we can connect it to Appleâs other frameworks. Apple has provided approaches for us to do this, but it takes a little thinking â itâs very different from the regular SwiftUI code youâve been writing so far, and itâs even quite different from the kind of code UIKit developers are used to!
Donât worry, weâll be tackling it step by step. Iâll provide the tutorials and a project to work towards; all you need to do is bring your brain and the willpower to push through. Remember, as the writer John Ortberg says, âif you want to walk on water, you have to get out of the boatâ!
Today you have four topics to work through, in which youâll learn about responding to state changes, showing confirmation dialogs, and more.
Instafilter: Introduction
Instafilter: Introduction
In this project weâre going to build an app that lets the user import photos from their library, then modify them using various image effects. Weâll cover a number of new techniques, but at the center of it all are one useful app development skill â using Appleâs Core Image framework â and one important SwiftUI skill â integrating with UIKit. There are other things too, but those two are the big takeaways.
Core Image is Appleâs high-performance framework for manipulating images, and itâs extraordinarily powerful. Apple has designed dozens of example image filters for us, providing things like blurs, color shifts, pixellation, and more, and all are optimized to take full advantage of the graphics processing unit (GPU) on iOS devices.
Tip: Although you can run your Core Image app in the simulator, donât be surprised if most things are really slow â youâll only get great performance when you run on a physical device.
As for integrating with UIKit, you might wonder why this is needed â after all, SwiftUI is designed to replace UIKit, right? Well, sort of. Before SwiftUI launched, almost every iOS app was built with UIKit, which means that there are probably several billion lines of UIKit code out there. So, if you want to integrate SwiftUI into an existing project youâll need to learn how to make the two work well together.
But thereâs another reason, and Iâm hoping it wonât always be a reason: many parts of Appleâs frameworks donât have SwiftUI wrappers yet, which means if you want to integrate MapKit, Safari, or other important APIs, you need to know how to wrap their code for use with SwiftUI. Iâll be honest, the code required to make this work isnât pretty, but at this point in your SwiftUI career youâre more than ready for it.
As always we have some techniques to cover before we get into the project, so please create a new iOS app using the App template, naming it âInstafilterâ.
How property wrappers become structs
How property wrappers become structs
Youâve seen how SwiftUI lets us store changing data in our view structs by using the @State
property wrapper, how we can bind that state to the value of a UI control using $
, and how changes to that state automatically cause SwiftUI to reinvoke the body
property of our view.
All that combined lets us write code such as this:
struct ContentView: View {
@State private var blurAmount = 0.0
var body: some View {
VStack {
Text("Hello, World!")
.blur(radius: blurAmount)
Slider(value: $blurAmount, in: 0...20)
Button("Random Blur") {
blurAmount = Double.random(in: 0...20)
}
}
}
}
If you run that, youâll find that dragging the slider left and right adjusts the blur amount for the text label, exactly as you would expect, and tapping the button immediately jumps to a random blur amount.
Now, letâs say we want that binding to do more than just handle the radius of the blur effect. Perhaps we want to run a method, or just print out the value for debugging purposes. You might try updating the property like this:
@State private var blurAmount = 0.0 {
didSet {
print("New value is \(blurAmount)")
}
}
If you run that code, youâll be disappointed: as you drag the slider around youâll see the blur amount change, but you wonât see our print()
statement being triggered â in fact, nothing will be output at all. But if you try pressing the button you will see a message printed.
To understand whatâs happening here, I want you to explore a little of how @State
functions, and what property wrappers are actually doing for us.
Property wrappers have that name because they wrap our property inside another struct. What this means is that when we use @State
to wrap a string, the actual type of property we end up with is a State<String>
. Similarly, when we use @Environment
and others we end up with a struct of type Environment
that contains some other value inside it.
Previously I explained that we canât modify properties in our views because they are structs, and are therefore fixed. However, now you know that @State
itself produces a struct, so we have a conundrum: how come that struct can be modified?
Xcode has a really helpful command called âOpen Quicklyâ (accessed using Cmd+Shift+O), which lets you find any file or type in your project or any of the frameworks you have imported. Activate it now, and type âStateâ â hopefully the first result says SwiftUI below it, but if not please find that and select it.
Youâll be taken to a generated interface for SwiftUI, which is essentially all the parts that SwiftUI exposes to us. Thereâs no implementation code in there, just lots of definitions for protocols, structs, modifiers, and such.
We asked to see State
, so you should have been taken to this line:
@propertyWrapper public struct State<Value> : DynamicProperty {
That @propertyWrapper
attribute is what makes this into @State
for us to use.
Now look a few lines further down, and you should see this:
public var wrappedValue: Value { get nonmutating set }
That wrapped value is the actual value weâre trying to store, such as a string. What this generated interface is telling us is that the property can be read (get
), and written (set
), but that when we set the value it wonât actually change the struct itself. Behind the scenes, it sends that value off to SwiftUI for storage in a place where it can be modified freely, so itâs true that the struct itself never changes.
Now you know all that, letâs circle back to our problematic code:
@State private var blurAmount = 0.0 {
didSet {
print("New value is \(blurAmount)")
}
}
On the surface, that states âwhen blurAmount
changes, print out its new value.â However, because @State
actually wraps its contents, what itâs actually saying is that when the State
struct that wraps blurAmount
changes, print out the new blur amount.
Still with me? Now letâs go a stage further: youâve just seen how State
wraps its value using a non-mutating setter, which means neither blurAmount
or the State
struct wrapping it are changing â our binding is directly changing the internally stored value, which means the property observer is never being triggered.
So, changing the property directly using a button works fine, because it goes through the nonmutating setter and triggers the didSet
observer, but using a binding doesnât because it bypasses the setter and adjusts the value directly.
How then can we solve this â how can we ensure some code is run whenever a binding is changed, no matter how that change happens? Well, thereâs a modifier just for that purposeâŠ
Responding to state changes using onChange()
Responding to state changes using onChange()
Because of the way SwiftUI sends binding updates to property wrappers, property observers used with property wrappers often wonât work the way you expect, which means this kind of code wonât print anything even as the blur radius changes:
struct ContentView: View {
@State private var blurAmount: CGFloat = 0.0 {
didSet {
print("New value is \(blurAmount)")
}
}
var body: some View {
VStack {
Text("Hello, World!")
.blur(radius: blurAmount)
Slider(value: $blurAmount, in: 0...20)
}
}
}
To fix this we need to use the onChange()
modifier, which tells SwiftUI to run a function of our choosing when a particular value changes. SwiftUI will automatically pass in both the old and new value to whatever function you attach, so we'd use it like this:
struct ContentView: View {
@State private var blurAmount = 0.0
var body: some View {
VStack {
Text("Hello, World!")
.blur(radius: blurAmount)
Slider(value: $blurAmount, in: 0...20)
.onChange(of: blurAmount) { oldValue, newValue in
print("New value is \(newValue)")
}
}
}
}
Now that code will correctly print out values as the slider changes, because onChange()
is watching it. Notice how most other things have stayed the same: we still use @State private var
to declare the blurAmount
property, and we still use blur(radius: blurAmount)
as the modifier for our text view.
Tip: You can attach onChange()
wherever you want in your view hierarchy, but I prefer to put it near the thing that's actually changing.
What all this means is that you can do whatever you want inside the onChange()
function: you can call methods, run an algorithm to figure out how to apply the change, or whatever else you might need.
The onChange()
modifier has two other common variants:
- One that accepts no parameters at all, for times when you just want to run a function when a value changes but you don't actually care what the new value is.
- One that accepts only the new value, without also passing in the old value. This is deprecated as of iOS 17, which is Apple's way of saying "please don't use this unless you need to support iOS 16 and earlier."
Showing multiple options with confirmationDialog()
Showing multiple options with confirmationDialog()
SwiftUI gives us alert()
for presenting important choices, and sheet()
for presenting whole views on top of the current view, but it also gives us confirmationDialog()
: an alternative to alert()
that lets us add many buttons.
Visually alerts and confirmation dialogs are very different: on iPhones, alerts appear in the center of the screen and must actively be dismissed by choosing a button, whereas confirmation dialogs slide up from the bottom, can contain lots of buttons, and can be dismissed by tapping on Cancel or by tapping outside of the options.
Although they look very different, confirmation dialogs and alerts are created almost identically:
- Both are created by attaching a modifier to our view hierarchy â
alert()
for alerts andconfirmationDialog()
for confirmation dialogs. - Both get shown automatically by SwiftUI when a condition is true.
- Both can be filled with buttons to take various actions.
- Both can have a second closure attached to provide an extra message. To demonstrate confirmation dialogs being used, we first need a basic button that toggles some sort of condition:
struct ContentView: View {
@State private var showingConfirmation = false
@State private var backgroundColor = Color.white
var body: some View {
Button("Hello, World!") {
showingConfirmation = true
}
.frame(width: 300, height: 300)
.background(backgroundColor)
}
}
Now for the important part: we need to add another modifier to the button, creating and showing a confirmation dialog when weâre ready.
Just like alert()
, we have a confirmationDialog()
modifier that accepts three parameters: a title, a binding that decides whether the dialog is currently presented or not, and a closure that provides the buttons that should be shown â usually provided as a trailing closure.
We provide our confirmation dialog with a title and optionally also a message, then an array of buttons. These are stacked up vertically on the screen in the order you provide, and itâs generally a good idea to include a cancel button at the end â yes, you can cancel by tapping elsewhere on the screen, but itâs much better to give users the explicit option.
So, add this modifier to your text view:
.confirmationDialog("Change background", isPresented: $showingConfirmation) {
Button("Red") { backgroundColor = .red }
Button("Green") { backgroundColor = .green }
Button("Blue") { backgroundColor = .blue }
Button("Cancel", role: .cancel) { }
} message: {
Text("Select a new color")
}
When you run the app, you should find that tapping the text causes the confirmation dialog to slide over, and tapping its options should cause the textâs background color to change.