Day 39
Day 39 êŽë š
Project 8, part 1
When I first wrote this course back in 2019, Apple TV+ had just launched. Back then, the show everyone was talking about was âFor All Mankindâ, which dramatized an alternative history of the 1969 moon landing. So, I built todayâs new project around that theme, detailing some of the history behind NASAâs Apollo space program.
I also think it would be fitting if todayâs quote came from Neil Armstrong, the first person to walk on the moon. Back in 2000 he said, âscience is about what is; engineering is about what can be.â I donât know about you, but I find that hugely inspiring: every time we create a new Xcode project we have a blank slate to work with, and that can be whatever we want.
Today weâre learning about the techniques to build Moonshot, but as with all the techniques weâre learning they form part of your larger knowledge for you to mix and remix as you please for years to come.
Today you have five topics to work through, in which youâll learn about GeometryReader
, ScrollView
, NavigationLink
, and more.
Moonshot: Introduction
Moonshot: Introduction
In this project weâre going to build an app that lets users learn about the missions and astronauts that formed NASAâs Apollo space program. Youâll get more experience with Codable
, but more importantly youâll also work with scroll views, navigation, and much more interesting layouts.
Yes, youâll get some practice time with List
, Text
, and more, but youâll also start to solve important SwiftUI problems â how can you make an image fit its space correctly? How can we clean up code using computed properties? How can we compose smaller views into larger ones to help keep our project organized?
As always thereâs lots to do, so letâs get started: create a new iOS app using the App template, naming it âMoonshotâ. Weâll be using that for the project, but first lets take a closer look at the new techniques youâll need to become familiar withâŠ
Resizing images to fit the screen using GeometryReader
Resizing images to fit the screen using GeometryReader
When we create an Image
view in SwiftUI, it will automatically size itself according to the dimensions of its contents. So, if the picture is 1000x500, the Image
view will also be 1000x500. This is sometimes what you want, but mostly youâll want to show the image at a lower size, and I want to show you how that can be done, but also how we can make an image fit some amount of the userâs screen width using a relative frame.
First, add some sort of image to your project. It doesnât matter what it is, as long as itâs wider than the screen. I called mine âExampleâ, but obviously you should substitute your image name in the code below.
Now letâs draw that image on the screen:
struct ContentView: View {
var body: some View {
Image("Example")
}
}
Tip: When you're using fixed image names such as this one, Xcode generates constant names for them all that you can use in place of strings. In this case, that means writing Image(.example)
, which is much safer than using a string!
Even in the preview you can see thatâs way too big for the available space. Images have the same frame()
modifier as other views, so you might try to scale it down like this:
Image(.example)
.frame(width: 300, height: 300)
However, that wonât work â your image will still appear to be its full size. If you want to know why, change Xcode's preview mode from Live to Selectable â look for the three buttons at the bottom left of your Xcode preview, and click the one with a mouse cursor inside.
Important: This stops your preview from running live, so you won't be able to interact with your view until you select the Live option instead.
With Selectable mode enabled, take a close look at the preview window: youâll see your image is full size, but thereâs now a box thatâs 300x300, sat in the middle. The image viewâs frame has been set correctly, but the content of the image is still shown as its original size.
Try changing the image to this:
Image(.example)
.frame(width: 300, height: 300)
.clipped()
Now youâll see things more clearly: our image view is indeed 300x300, but thatâs not really what we wanted.
If you want the image contents to be resized too, we need to use the resizable()
modifier like this:
Image(.example)
.resizable()
.frame(width: 300, height: 300)
Thatâs better, but only just. Yes, the image is now being resized correctly, but itâs probably looking squashed. My image was not square, so it looks distorted now that itâs been resized into a square shape.
To fix this we need to ask the image to resize itself proportionally, which can be done using the scaledToFit()
and scaledToFill()
modifiers. The first of these means the entire image will fit inside the container even if that means leaving some parts of the view empty, and the second means the view will have no empty parts even if that means some of our image lies outside the container.
Try them both to see the difference for yourself. Here is .fit
mode applied:
Image(.example)
.resizable()
.scaledToFit()
.frame(width: 300, height: 300)
And here is scaledToFill()
:
Image(.example)
.resizable()
.scaledToFill()
.frame(width: 300, height: 300)
All this works great if we want fixed-sized images, but very often you want images that automatically scale up to fill more of the screen in one or both dimensions. That is, rather than hard-coding a width of 300, what you really want to say is âmake this image fill 80% of the width of the screen.â
Rather than forcing a specific frame, SwiftUI has a dedicated containerRelativeFrame()
modifier that lets us get exactly the result we want. The "container" part might be the whole screen, but it might also just be the part of the screen that this view's immediate parent occupies â maybe our image is shown inside a VStack
along with other views.
Weâll go into much more detail on container relative frames in project 18, but for now weâre going to use it for one job: to make sure our image fills 80% of the available width of our screen.
For example, we could make an image thatâs 80% the width of the screen:
Image(.example)
.resizable()
.scaledToFit()
.containerRelativeFrame(.horizontal) { size, axis in
size * 0.8
}
Let's break that code down:
- We're saying we want to give this image a frame relative to the horizontal size of its parent. We aren't specifying a vertical size; more on that in a moment.
- SwiftUI then runs a closure where we're given a size and an axis. For us the axis will be
.horizontal
because that's the one we're using, but this matters more when you create relative horizontal and vertical sizes. Thesize
value will be the size of our container, which for this image is the full screen. - We need to return the size we want for this axis, so we're sending back 80% of the container's width.
Again, we don't need to specify a height here. This is because weâve given SwiftUI enough information that it can automatically figure out the height: it knows the original width, it knows our target width, and it knows our content mode, so it understands how the target height of the image will be proportional to the target width.
How ScrollView lets us work with scrolling data
How ScrollView lets us work with scrolling data
Youâve seen how List
and Form
let us create scrolling tables of data, but for times when we want to scroll arbitrary data â i.e., just some views weâve created by hand â we need to turn to SwiftUIâs ScrollView
.
Scroll views can scroll horizontally, vertically, or in both directions, and you can also control whether the system should show scroll indicators next to them â those are the little scroll bars that appear to give users a sense of how big the content is. When we place views inside scroll views, they automatically figure out the size of that content so users can scroll from one edge to the other.
As an example, we could create a scrolling list of 100 text views like this:
ScrollView {
VStack(spacing: 10) {
ForEach(0..<100) {
Text("Item \($0)")
.font(.title)
}
}
}
If you run that back in the simulator youâll see that you can drag the scroll view around freely, and if you scroll to the bottom youâll also see that ScrollView
treats the safe area just like List
and Form
â their content goes under the home indicator, but they add some extra padding so the final views are fully visible.
You might also notice that itâs a bit annoying having to tap directly in the center â itâs more common to have the whole area scrollable. To get that behavior, we should make the VStack
take up more space while leaving the default centre alignment intact, like this:
ScrollView {
VStack(spacing: 10) {
ForEach(0..<100) {
Text("Item \($0)")
.font(.title)
}
}
.frame(maxWidth: .infinity)
}
Now you can tap and drag anywhere on the screen, which is much more user-friendly.
This all seems really straightforward, however thereâs an important catch that you need to be aware of: when we add views to a scroll view they get created immediately. To demonstrate this, we can create a simple wrapper around a regular text view, like this:
struct CustomText: View {
let text: String
var body: some View {
Text(text)
}
init(_ text: String) {
print("Creating a new CustomText")
self.text = text
}
}
Now we can use that inside our ForEach
:
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}
The result will look identical, but now when you run the app youâll see âCreating a new CustomTextâ printed a hundred times in Xcodeâs log â SwiftUI wonât wait until you scroll down to see them, it will just create them immediately.
If you want to avoid this happening, thereâs an alternative for both VStack
and HStack
called LazyVStack
and LazyHStack
respectively. These can be used in exactly the same way as regular stacks but will load their content on-demand â they wonât create views until they are actually shown, and so minimize the amount of system resources being used.
So, in this situation we could swap our VStack
for a LazyVStack
like this:
LazyVStack(spacing: 10) {
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}
}
.frame(maxWidth: .infinity)
Literally all it takes is to add âLazyâ before âVStackâ to have our code run more efficiently â it will now only create the CustomText
structs when they are actually needed.
Although the code to use regular and lazy stacks is the same, there is one important layout difference: lazy stacks always take up as much as room as is available in our layouts, whereas regular stacks take up only as much space as is needed. This is intentional, because it stops lazy stacks having to adjust their size if a new view is loaded that wants more space.
One last thing: you can make horizontal scrollviews by passing .horizontal
as a parameter when you make your ScrollView
. Once thatâs done, make sure you create a horizontal stack or lazy stack, so your content is laid out as you expect:
ScrollView(.horizontal) {
LazyHStack(spacing: 10) {
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}
}
}
Pushing new views onto the stack using NavigationLink
Pushing new views onto the stack using NavigationLink
SwiftUIâs NavigationStack
shows a navigation bar at the top of our views, but also does something else: it lets us push views onto a view stack. In fact, this is really the most fundamental form of iOS navigation â you can see it in Settings when you tap Wi-Fi or General, or in Messages whenever you tap someoneâs name.
This view stack system is very different from the sheets weâve used previously. Yes, both show some sort of new view, but thereâs a difference in the way they are presented that affects the way users think about them.
Letâs start by looking at some code so you can see for yourself â we could show a simple text view inside a navigation stack like this:
struct ContentView: View {
var body: some View {
NavigationStack {
Text("Tap Me")
.navigationTitle("SwiftUI")
}
}
}
That text view is just static text; itâs not a button with any sort of action attached to it, despite what its title says. Weâre going to make it so that when the user taps on it we present them with a new view, and thatâs done using NavigationLink
: give this a destination and something that can be tapped, and it will take care of the rest.
One of the many things I love about SwiftUI is that we can use NavigationLink
with any kind of destination view. Yes, we can design a custom view to push to, but we can also push straight to some text.
To try this out, change your view to this:
NavigationStack {
NavigationLink("Tap Me") {
Text("Detail View")
}
.navigationTitle("SwiftUI")
}
Now run the code and see what you think. You will see that âTap Meâ now looks like a button, and tapping it makes a new view slide in from the right saying âDetail Viewâ. Even better, youâll see that the âSwiftUIâ title animates down to become a back button, and you can tap that or swipe from the left edge to go back.
If you want something other than a simple text view as your label, you can use two trailing closures with your NavigationLink
. For example, we could make a label out of several text views and an image:
NavigationStack {
NavigationLink {
Text("Detail View")
} label: {
VStack {
Text("This is the label")
Text("So is this")
Image(systemName: "face.smiling")
}
.font(.largeTitle)
}
}
So, both sheet()
and NavigationLink
allow us to show a new view from the current one, but the way they do it is different and you should choose them carefully:
NavigationLink
is for showing details about the userâs selection, like youâre digging deeper into a topic.sheet()
is for showing unrelated content, such as settings or a compose window.
The most common place you see NavigationLink
is with a list, and there SwiftUI does something quite marvelous.
Try modifying your code to this:
NavigationStack {
List(0..<100) { row in
NavigationLink("Row \(row)") {
Text("Detail \(row)")
}
}
.navigationTitle("SwiftUI")
}
When you run the app now youâll see 100 list rows that can be tapped to show a detail view, but youâll also see gray disclosure indicators on the right edge. This is the standard iOS way of telling users another screen is going to slide in from the right when the row is tapped, and SwiftUI is smart enough to add it automatically here. If those rows werenât navigation links â if you comment out the NavigationLink
line and its closing brace â youâll see the indicators disappear.
Working with hierarchical Codable data
Working with hierarchical Codable data
The Codable
protocol makes it trivial to decode flat data: if youâre decoding a single instance of a type, or an array or dictionary of those instances, then things Just Work. However, in this project weâre going to be decoding slightly more complex JSON: there will be an array inside another array, using different data types.
If you want to decode this kind of hierarchical data, the key is to create separate types for each level you have. As long as the data matches the hierarchy youâve asked for, Codable
is capable of decoding everything with no further work from us.
To demonstrate this, put this button in to your content view:
Button("Decode JSON") {
let input = """
{
"name": "Taylor Swift",
"address": {
"street": "555, Taylor Swift Avenue",
"city": "Nashville"
}
}
"""
// more code to come
}
That creates a string of JSON in code. In case you arenât too familiar with JSON, itâs probably best to look at the Swift structs that match it â you can put these directly into the button action or outside of the ContentView
struct, it doesnât matter:
struct User: Codable {
let name: String
let address: Address
}
struct Address: Codable {
let street: String
let city: String
}
Hopefully you can now see what the JSON contains: a user has a name string and an address, and addresses are a street string and a city string.
Now for the best part: we can convert our JSON string to the Data
type (which is what Codable
works with), then decode that into a User
instance:
let data = Data(input.utf8)
let decoder = JSONDecoder()
if let user = try? decoder.decode(User.self, from: data) {
print(user.address.street)
}
If you run that program and tap the button you should see the address printed out â although just for the avoidance of doubt I should say that itâs not her actual address!
Thereâs no limit to the number of levels Codable
will go through â all that matters is that the structs you define match your JSON string.
How to lay out views in a scrolling grid
How to lay out views in a scrolling grid
SwiftUIâs List
view is a great way to show scrolling rows of data, but sometimes you also want columns of data â a grid of information, that is able to adapt to show more data on larger screens.
In SwiftUI this is accomplished with two views: LazyHGrid
for showing horizontal data, and LazyVGrid
for showing vertical data. Just like with lazy stacks, the âlazyâ part of the name is there because SwiftUI will automatically delay loading the views it contains until the moment they are needed, meaning that we can display more data without chewing through a lot of system resources.
Creating a grid is done in two steps. First, we need to define the rows or columns we want â we only define one of the two, depending on which kind of grid we want.
For example, if we have a vertically scrolling grid then we might say we want our data laid out in three columns exactly 80 points wide by adding this property to our view:
let layout = [
GridItem(.fixed(80)),
GridItem(.fixed(80)),
GridItem(.fixed(80))
]
Once you have your layout defined, you should place your grid inside a ScrollView
, along with as many items as you want. Each item you create inside the grid is automatically assigned a column in the same way that rows inside a list automatically get placed inside their parent.
For example, we could render 1000 items inside our three-column grid like this:
ScrollView {
LazyVGrid(columns: layout) {
ForEach(0..<1000) {
Text("Item \($0)")
}
}
}
That works for some situations, but the best part of grids is their ability to work across a variety of screen sizes. This can be done with a different column layout using adaptive sizes, like this:
let layout = [
GridItem(.adaptive(minimum: 80)),
]
That tells SwiftUI weâre happy to fit in as many columns as possible, as long as they are at least 80 points in width. You can also specify a maximum range for even more control:
let layout = [
GridItem(.adaptive(minimum: 80, maximum: 120)),
]
I tend to rely on these adaptive layouts the most, because they allow grids that make maximum use of available screen space.
Before weâre done, I want to briefly show you how to make horizontal grids. The process is almost identical, you just need to make your ScrollView
work horizontally, then create a LazyHGrid
using rows rather than columns:
ScrollView(.horizontal) {
LazyHGrid(rows: layout) {
ForEach(0..<1000) {
Text("Item \($0)")
}
}
}
That brings us to the end of the overview for this project, so please go ahead and reset ContentView.swift
to its original state.
Donât forget to post your progress somewhere â stay accountable! (And when youâre done, sit down and watch some For All Mankind.)