SwiftUI

Want to learn it to some non-trivial extent (which I never have before) so that I can start actually using it in anger for apps and look for jobs using it.

Initial Apple tutorial

Starting with Apple’s tutorial (the Landmarks app), 2024-02-16. Actually, it’s the “Introducing SwiftUI tutorial” (there are a few different tutorials, others e.g. more principles-based).

SwiftUI essentials

Creating and combining views

Now we’re going to add an image and put some nice effects on it (mask, border, drop shadow), creating a CircleView

Asset catalogs are still the way of getting images into project

Next we add a map.

Building lists and navigation

After some initial faffing around…

Now we’re going to add navigation.

Notice that they pass an Image directly into a view; this suggests that Image is a thing you can use as a view or as a piece of data?

(How do we preview what a view will look like in a navigation stack?)

Now we look at previews a bit more.

Handling user input

We’re going to add favouriting of places: a switch (sorry, a Toggle) to filter the list by favourites, and a button to favourite an item.

OK, so the filtering of the list is the first place we see state (showFavoritesOnly on list, accompanied by a filteredLandmarks computed property)

(Note that SwiftLint has a rule that makes sure that state is private, in line with the guidance given in this tutorial)

And now we see bindings (“a binding acts as a reference to a mutable state”). “You use the $ prefix to access a binding to a state variable, or one of its properties.” We pass it as the Toggle’s isOn.

Notice that there’s no animation of the list when we flip the switch. To solve this, they add a .animation(.default, value: filteredLandmarks) modifier to the list. Hopefully we’ll learn more about this modifier in the Eidhof SwiftUI book, so won’t think much about it now; but it’s something to do with applying the animation when given value changes.

So, now we change our static list of landmarks, stored inside the list, into an external data source. To do this, we create a ModelData class and annotate it with the @Observable macro (well, it says it’s a macro, but I can’t figure out how to expand it…).

SwiftUI updates a view only when an observable property changes and the view’s body reads the property directly.

(emphasis mine here — important to bear in mind that SwiftUI is paying attention to what gets accessed whilst a view’s body is executing)

And now (for reasons I still need to understand — i.e. what might the alternatives be, why is this the right way to do things, how would it be in React?) we tell the LandmarkList to grab this data from the environment:

@Environment(ModelData.self) var modelData

No idea why ModelData.self can be used as the environment key here — think about it again after reading the environment chapter in the book.

At this point, the ContentView preview just started crashing — with a macOS crash report dialog, with no useful information. Adding .environment(ModelData()) fixed this.

And now we do the same in the app, except there we create the ModelData instance as part of the app’s state, and then pass it down through .environment(modelData) (again, why does this not require a specific key — are types a special kind of key?). Before doing this, running the app crashes with:

SwiftUI/Environment+Objects.swift:32: Fatal error: No Observable object of type ModelData found. A View.environmentObject(_:) for ModelData may be missing as an ancestor of this view.

(I need to understand better why it’s important that this be @State in the app.)

Just like SwiftUI initializes state in a view only once during the lifetime of the view, it initializes state in an app only once during the lifetime of the app.

Now we see how to manipulate model data in a reusable view, by passing a binding to a property of the observable ModelData object.

So, inside the LandmarkDetail view, in order to get the binding to pass to FavoriteButton, we find the index of the current landmark inside modelData.landmarks. Then inside body we declare an @Bindable var modelData = modelData (the latter coming from the environment). No idea what this @Bindable step is nor why we do it. And then we pass $modelData.landmarks[landmarkIndex].isFavorite to the FavoriteButton.

(What is @Bindable?)

(It would be instructive to some time understand why this feels so much more complicated than React, in which you basically just have useState and useContext. Is it because I’ve not yet seen a full app created in React?)

OK, that concludes “SwiftUI essentials”. Lots of information there at the end. Now we’ll move back away from the data side of things and do some more presentation stuff.

Drawing and animation

Drawing paths and shapes

We’re going to generate a nice-looking badge that the user gets when they visit a landmark.

Then to get the size of the path we’re meant to be drawing, we wrap the Path in a GeometryReader, which takes a closure that provides a geometry object; we grab its size.width and size.height (again, one to understand better after seeing the layout chapter in the book)

(This seems like a rather bizarre part of an introduction to SwiftUI — there’s a lot of geometry and maths to plough through)

Now to make the background pretty, we replace the .fill(.black) with a .fill(.linearGradient(…)), passing a Gradient (look into this)

Finally — in another step I don’t really want to try and understand now — they do .aspectRatio(1, contentMode: .fit)

Then another view, BadgeSymbol, which is more drawing with Path.

Next RotatedBadgeSymbol. Note there’s a type called Angle (why?). Also applies some negative padding to make the badge symbol not quite fit inside (is it meant to be artistic?). And we apply .rotationEffect(angle, anchor: .bottom) modifier.

Then we combine the background and multiple RotatedBadgeSymbol (with different angles) into a Badge.

Hmm: why do you have to write some View for return type of methods that return View? one to look into

We also see the .opacity(0.5) modifier

We put the symbol on top of the background with a ZStack. The sizes of the two views are completely mismatched (not sure why) so we use a GeometryReader and scale the badge symbols using (new to us, look into) .scaleEffect(1.0 / 4.0, anchor: .top) and .position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height) modifiers.

Also we see the .scaledToFit() modifier; another mystery.

Animating views and transitions

So, we are going to look at some hike data that it gives us, and a view that it gives us, which is a graph that represents ranges of values over time using blobby capsules.

So, that showed us how to animate a change to a single view. Now we see how to animate all the results of a given state change, by wrapping the state change in the withAnimation function. Specifially, we do it inside the button action’s showDetail.toggle().

Now we’re going to think about transitions, which I think means when a view is added to or removed from the screen. The default is a fade in/out. We see how we can customise it by using the .transition(_:) modifier on the view in question, and passing e.g. .slide or .move(edge:) or .asymmetric(insertion:removal:). We also see the .combined(with:) transition for combining them.

Now we’ll work on animation the capsules on the graph. We create a custom animation by adding a static property to Animation. We start by returning .default and go on from there.

Oh, so I notice that the GraphCapsule view we’re now animating does indeed conform to Equatable (see my ‘what is an “equatable view”?’ above), and we just use .animation(_:) but no further explanation.

So, just adding .animation(_:) modifier to each GraphCapsule has made clicking between the different metrics (elevation, heart rate, pace) to do a nice animation between the graph states, which is nice.

Then they switch it to use .spring(dampingFraction: 0.5) — this dampingFraction change seems to make it, to my eyes, actually what I’d call “springy” (compare to before when I said I couldn’t tell what .spring() was doing).

Then we do .speed(2) (which I guess can generically speed up any animation).

(Something to look into sometime — why does the colour not also go springy between the two values?)

Then they do a cool thing where they add a .delay(:_) based on the index of the capsule, giving a ripple effect.

(Note: this area of animation design is something I know nothing about, but it’s cool.)

(Note: I see that HikeGraph uses a var path: KeyPath<Hike.Observation, Range<Double>> to represent the current choice of data. I like KeyPath and would like to know explore its uses. I see that they can often be used in place of functions.)

Another thing I missed earlier:

A nil animation for a particular value counteracts a non-nil animation for the same value that appears higher in the view hierarchy.

App design and layout

Composing complex interfaces

(I just noticed that Xcode now has a Vim mode; that’s nice.)

(My understanding of how something like .listRowInsets (and most other modifiers) work is that this will put something into the environment, and then List will read it)

Then some more navigation stuff. Note that, for example, they put the push from CategoryRow to LandmarkDetail directly inside CategoryRow. Presumably there is some sort of architectural kinda-better practice for this?

Now, after putting a CategoryItem as the NavigationLink’s label, we go and undo some of the automatic changes that this causes:

Text that you pass as the label for a navigation link renders using the environment’s accent color, and images may render as template images. You can modify either behavior to best suit your design.

(I don’t even know what a template image is.)

We do this by:

Next we see how to use a TabView. You pass it a binding to the selection (i.e. a piece of state in the view, e.g. some Tab enum of your creation)

We use the tabItem(label:) modifier to set the tab button for each view — they use a Label("Featured", systemImage: "star"), and both the text and image get displayed (I think that there are contexts, possibly some we’ve seen already, in which the label’s text is only used for accessibility).

They also show using the .tag(_:) modifier on each of the tab’s views — not sure yet how this is useful.

Working with UI controls

Now we’re going to add a profile view and let the user edit (not sure exactly what that means yet) their profile.

You’ll work with a variety of common user interface controls for data entry, and update the Landmarks model types whenever the user saves their changes.

(emphasis mine)

(Aside: I was curious to see what happened if I tried to modify some non-@State property of a view inside a button’s action. I get Cannot assign to property: 'self' is immutable. Fair enough. So what’s special about @State that we can assign to it even though we’re not in a mutating context?)

Hmm, they have one Text to which they do .bold() and .font(.title). Is this something you’d do or would you normally trust the style to get it right? Should I care?

Another interesting thing I just saw was adding two Text views together (?!):

Text("Goal Date: ") + Text(profile.goalDate, style: .date)

(and the style: .date is interesting, too.)

Some sort of trick that’s used to show a badge at a certain size (I don’t know if this is a useful technique or some defect in the way that the badge code was written):

The badge’s drawing logic produces a result that depends on the size of the frame in which it renders. To ensure the desired appearance, render in a frame of 300 x 300 points. To get the desired size for the final graphic, then scale the rendered result and place it in a comparably smaller frame.

We also see the .hueRotation(_:) modifier, whatever that does — seems to be use here as just a way to quickly vary the colours of a badge

OK, now we’re going to see how to:

  1. add a toolbar button:

    We set the .toolbar modifier on one of our views (I’m not sure the impact of which view you set this on). Its initializer just takes content, and we set it to a Button (I guess another one of those appearance-depends-on-context things that I don’t know how they are implemented, is it environment?)

  2. make that present the profile details view:

    use the .sheet(isPresented:) modifier. This takes a binding. The body of the modifier is the content you want to show.

Now we look at “edit mode”, whatever that is.

We grab the built-in @Environment(\.editMode), which gives a property of type Binding<EditMode>?. How mysterious. Why is this a binding? What’s going on?

And to make things more confusing, we then use the built-in EditButton view, which controls the same \.editMode enironment value. (Based on what I saw, this button appears to change to “Done” during editing.)

(Note on data flow: We add a profile object to the environment, but we pass the profile page a copy of this profile (to avoid unintentional edits until confirmed). We store this draft in state in the profile page, and pass it as a binding to the ProfileEditor that we’ll see in a second.)

Our view then reads editMode?.wrappedValue === .inactive (this ?.wrappedValue just looks wrong, so I need to understand why it’s not) to decide whether to show the profile or the editor.

Now we build our ProfileEditor view:

(It would be worth at some point understanding exactly what a Binding is and how SwiftUI interacts with one; I remember reading somewhere that it’s really just a pair of set/get functions.)

OK, we’re finally going to see how to delay edit propagation (well, we’ve already been doing that; I guess what we’re actually doing is seeing how to commit once done).

Right, first we add a cancel button, which has (…, role: .cancel) (what’s that?), and when tapped resets the draft profile: draftProfile = modelData.profile. It also does editMode?.animation().wrappedValue = .inactive.

(What’s editMode?.animation()? Something to look into when understanding Binding.)

OK, and then the final step, where I expected something cool, was a bit odd. The profile page applies the (new to us) .onAppear and .onDisappear modifiers to its ProfileEditor, and in onDisappear (which it assumes it called because the user has tapped the Done button — i.e. it relies on some effect of EditButton which is a bit odd) it updates the global model with the draftProfile.

Framework integration

TODO: OK, all that’s left for this tutorial is to do:

Interfacing with UIKit

We”re going to embed a UIPageViewController inside our SwiftUI view.

To implement the page view controller’s dataSource, we implement UIViewControllerRepresentable’s makeCoordinator() method. This will return an object of our new class Coordinator: NSObject, which will implement the Cocoa pattern.

This coordinator is then accessible inside the aforementioned make/updateUIViewController via the context.coordinator property.

So, we implement, on our coordinator, the pageViewController(_:viewControllerBefore/After:) methods.

(Note: I’m not going to think about this UIKit stuff in too much detail right now, especially how the coordinator maintains a reference to the SwiftUI view that created it; it seems like a mix of a long-lived object with a throwaway template and it’s a mix that doesn’t seem immediately intuitive.)

OK, now we add a piece of state on the view that holds this view controller, in order to control the current page number. We then see how to update this state inside the page view controller delegate so that the binding is two way.

Next we see, similarly, how to wrap a UIPageControl in a UIViewRepresentable, and we similarly hook up target-action to the coordinator.

Creating a watchOS app

TIL you can have multiple asset catalogs in a target.

(TODO again we see use of the landmark.image.resizable() and the .scaledToFill() modifier; get comfortable with those)

Then there’s something I don’t understand to do with fiddling with the image so it behaves well in a scroll view

Note that the NavigationSplitView on watchOS presents things in what I’d call a modal style, with a little list button to return to the list.

Now we’ll take a look at how to create a notification view (not sure exactly what that means; let’s see).

So, we first just create a normal SwiftUI view, and then we subclass WKUserNotificationHostingController (parameterised by our view type) from the WatchKit framework. We implement its body to return our view, and we implement didReceive(_ notification: UNNotification) (where UNNotification is from the UserNotifications framework) which populate some properties that somehow find their way into that SwiftUI view.

Then to hook this thing up to the app, seems like you have to include a WKNotificationScene in your view hierarchy (I don’t know why it’s a view and not a modifier, shrug). They include it inside the app’s Scene (is the exact location important?) using an #if os(watchOS).

Then we see how to request notification permissions, which we just do by calling UNUserNotifcationCenter#requestAuthorization in the usual way. This is an async method and we call it inside a .task modifier.

It then tries to show us how to view the notification by creating a “Notification Simulation File” and dragging it on the simulator watch face, but this doesn’t work for me and I shan’t investigate it now.

Creating a macOS app

We start by adding a macOS target.

Now,

The larger display gives you more room for additional features.

For the row, using conditional compilation is appropriate because the differences are small.

OK, we’re going to now move the favourites toggle to a menu on macOS.

(Oh, I see, I think that they mean a dropdown menu that pops up when you click a toolbar button, not a menu bar button.)

OK, I think now we’re going to look at menu bar menus.

We’re going to start by adding a menu command for restoring the sidebar (if the user accidentally closes it and ignoring there’s a button for doing so).

We create a LandmarkCommands structure that extends SwiftUI’s Commands. It has a var body: some Commands. And then in this body, we include SidebarCommands, which is a built-in command set, including a command for toggling the sidebar.

To make use of commands in an app, you have to apply them to a scene, which you’ll do next.

(What is a scene?) — I don’t know, but we apply the .commands modifier to the WindowGroup in the app.

Scene modifiers work like view modifiers, except that you apply them to scenes instead of views.

And now, magically, the View > Show Sidebar menu has appeared!

Now, having seen how to add a built-in menu command, we’ll add a custom one for toggling the favourited-ness of the selected landmark.

Now begins something that I’ll vaguely follow.

OK, so we’ve got the menu in place, and it’ll do stuff with the selectedLandmark. How do we populate that based on what the user’s selected?

(**Aside: ** I think I was wondering earlier about how to pass things up view hierarchy, e.g. how .tag gets interrogated. Someone asked the same thing in this Reddit question. Sounds like Preferences is the general mechanism for passing stuff up the view hierarchy.)

Finally, we’re going to add an in-app “MacLandmarks > Settings” menu (i.e. the standard settings menu). We’ll use it to control the initial map zoom level.

You typically use a Form to arrange controls in your settings view.

(Why only in the settings view?) (Also, I notice that the Picker appears as a list of radio buttons; is this Form’s doing? Or is that because we’ve used .pickerStyle(.inline)? Or both? Ah, yeah, it’s because of .inline; without that then the form shows a <select>-style thingy.)

And then, finally, we add the new-to-us Settings scene inside the app’s body, with content of our settings view.

From the test at the end, note that the picker’s selection type must be Hashable.

Well, that’s it for this tutorial! There was a lot to take in, and I definitely switched off for a decent chunk of it. But working with SwiftUI and discovering all the different APIs is something that seems very exciting and cool to me, so that’s a good thing I guess.