Day 41
Day 41 êŽë š
Project 8, part 3
Today weâre going to be completing our Moonshot app by adding two more views plus navigation between them, but here is where youâll start to see what it takes to create custom layouts in SwiftUI â weâll be using GeometryReader
to get the view size exactly the way we want it, for example.
Along the way, weâre also going to be tackling one of the common problems youâll face as programmer: when you have two pieces of separate data that need to be merged somehow. For us, thatâs our astronaut and mission data, but the concept is transferrable enough as youâll see.
At one point in todayâs topics I encourage you to stop and play around with the design. Iâm sure some folks will skip over this wanting to rush towards the end, but I hope you donât. As the astronaut John Glenn said, âI suppose the quality in an astronaut more powerful than any other is curiosity â they have to get to some place nobodyâs ever been.â
Be curious â take the time to explore your skills and it will pay off!
Today you have three topics to work through, in which youâll work with GeometryReader
, ScrollView
, and more.
Showing mission details with ScrollView
and GeometryReader
Showing mission details with ScrollView and GeometryReader
When the user selects one of the Apollo missions from our main list, we want to show information about the mission: its mission badge, its mission description, and all the astronauts that were on the crew along with their roles. The first two of those arenât too hard, but the third requires a little more work because we need to match up crew IDs with crew details across our two JSON files.
Letâs start simple and work our way up: make a new SwiftUI view called MissionView.swift
. Initially this will just have a mission
property so that we can show the mission badge and description, but shortly weâll add more to it.
In terms of layout, this thing needs to have a scrolling VStack
with a resizable image for the mission badge, then a text view. Weâll use GeometryReader
to set the maximum width of the mission image, although through some trial and error I found that the mission badge worked best when it wasnât full width â somewhere between 50% and 70% width looked better, to avoid it becoming weirdly big on the screen.
Put this code into MissionView.swift
now:
struct MissionView: View {
let mission: Mission
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
Image(mission.image)
.resizable()
.scaledToFit()
.frame(maxWidth: geometry.size.width * 0.6)
.padding(.top)
VStack(alignment: .leading) {
Text("Mission Highlights")
.font(.title.bold())
.padding(.bottom, 5)
Text(mission.description)
}
.padding(.horizontal)
}
.padding(.bottom)
}
}
.navigationTitle(mission.displayName)
.navigationBarTitleDisplayMode(.inline)
.background(.darkBackground)
}
}
Placing a VStack
inside another VStack
allows us to control alignment for one specific part of our view â our main mission image can be centered, while the mission details can be aligned to the leading edge.
Anyway, with that new view in place the code will no longer build, all because of the previews struct below it â that thing needs a Mission
object passed in so it has something to render. Fortunately, our Bundle
extension is available here as well:
struct MissionView_Previews: PreviewProvider {
static let missions: [Mission] = Bundle.main.decode("missions.json")
static var previews: some View {
MissionView(mission: missions[0])
.preferredColorScheme(.dark)
}
}
Tip: This view will automatically have a dark color scheme because itâs applied to the NavigationView
in ContentView
, but the MissionView
preview doesnât know that so we need to enable it by hand.
If you look in the preview youâll see thatâs a good start, but the next part is trickier: we want to show the list of astronauts who took part in the mission below the description. Letâs tackle that nextâŠ
Merging Codable
structs
Merging Codable structs
Below our mission description we want to show the pictures, names, and roles of each crew member, which means matching up data that came from two different JSON files.
If you remember, our JSON data is split across missions.json
and astronauts.json
. This eliminates duplication in our data, because some astronauts took part in multiple missions, but it also means we need to write some code to join our data together â to resolve âarmstrongâ to âNeil A. Armstrongâ, for example. You see, on one side we have missions that know crew member âarmstrongâ had the role âCommanderâ, but has no idea who âarmstrongâ is, and on the other side we have âNeil A. Armstrongâ and a description of him, but no concept that he was the commander on Apollo 11.
So, what we need to do is make our MissionView
accept the mission that got tapped, along with our full astronauts dictionary, then have it figure out which astronauts actually took part in the launch.
Add this nested struct inside MissionView
now:
struct CrewMember {
let role: String
let astronaut: Astronaut
}
Now for the tricky part: we need to add a property to MissionView
that stores an array of CrewMember
objects â these are the fully resolved role / astronaut pairings. At first thatâs as simple as adding another property:
let crew: [CrewMember]
But then how do we set that property? Well, think about it: if we make this view be handed its mission and all astronauts, we can loop over the mission crew, then for each crew member look in the dictionary to find the one that has a matching ID. When we find one we can convert that and their role into a CrewMember
object, but if we donât it means somehow we have a crew role with an invalid or unknown name.
That latter case should never happen. To be clear, if youâve added some JSON to your project that points to missing data in your app, youâve made a fundamental mistake â itâs not the kind of thing you should try to write error handling for at runtime, because it should never be allowed to happen in the first place. So, this is a great example of where fatalError()
is useful: if we canât find an astronaut using their ID, we should exit immediately and complain loudly.
Letâs put all that into code, using a custom initializer for MissionView
. Like I said, this will accept the mission it represents along with all the astronauts, and its job is to store the mission away then figure out the array of resolved astronauts.
Hereâs the code:
init(mission: Mission, astronauts: [String: Astronaut]) {
self.mission = mission
self.crew = mission.crew.map { member in
if let astronaut = astronauts[member.name] {
return CrewMember(role: member.role, astronaut: astronaut)
} else {
fatalError("Missing \(member.name)")
}
}
}
As soon as that code is in, our preview struct will stop working again because it needs more information. So, add a second call to decode()
there so it loads all the astronauts, then passes those in too:
struct MissionView_Previews: PreviewProvider {
static let missions: [Mission] = Bundle.main.decode("missions.json")
static let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")
static var previews: some View {
MissionView(mission: missions[0], astronauts: astronauts)
.preferredColorScheme(.dark)
}
}
Now that we have all our astronaut data, we can show this directly below the mission description using a horizontal scroll view. Weâre also going to add a little extra styling to the astronaut pictures to make them look better, using a capsule clip shape and overlay.
Add this code just after the VStack(alignment: .leading)
:
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(crew, id: \.role) { crewMember in
NavigationLink {
Text("Astronaut details")
} label: {
HStack {
Image(crewMember.astronaut.id)
.resizable()
.frame(width: 104, height: 72)
.clipShape(Capsule())
.overlay(
Capsule()
.strokeBorder(.white, lineWidth: 1)
)
VStack(alignment: .leading) {
Text(crewMember.astronaut.name)
.foregroundColor(.white)
.font(.headline)
Text(crewMember.role)
.foregroundColor(.secondary)
}
}
.padding(.horizontal)
}
}
}
}
Why after the VStack
rather than inside? Because scroll views work best when they take full advantage of the available screen space, which means they should scroll edge to edge. If we put this inside our VStack
it would have the same padding as the rest of our text, which means it would scroll strangely â the crew would get clipped as it hit the leading edge of our VStack
, which looks odd.
Weâll make that NavigationLink
do something more useful shortly, but first we need to modify the NavigationLink
in ContentView
â it pushes to Text("Detail View")
right now, but please replace it with this:
MissionView(mission: mission, astronauts: astronauts)
Now go ahead and run the app in the simulator â itâs starting to become useful!
Before you move on, try spending a few minutes customizing the way the astronauts are shown â Iâve used a capsule clip shape and overlay, but you could try circles or rounded rectangles, you could use different fonts or larger images, or even add some way of marking who the mission commander was.
In my project, I think it would be useful to add a little visual separation in our mission view, so that the mission badge, description, and crew are more clearly split up.
SwiftUI does provide a dedicated Divider
view for creating a visual divide in your layout, but itâs not customizable â itâs always just a skinny line. So, to get something a little more useful, Iâm going to draw a custom divider to break up our view.
First, place this directly before the âMission Highlightsâ text:
Rectangle()
.frame(height: 2)
.foregroundColor(.lightBackground)
.padding(.vertical)
Now place another one of those â the same code â directly after the mission.description
text. Much better!
To finish up this view, Iâm going to add a title before our crew, but this needs to be done carefully. You see, although this relates to the scroll view, it needs to have the same padding as the rest of our text. So, the best place for this is inside the VStack
, directly after the previous rectangle:
Text("Crew")
.font(.title.bold())
.padding(.bottom, 5)
You donât need to put it there â if you wanted we could move it outside the VStack
then apply padding individually to that text view. However, if you do that make sure you apply the same amount of padding to keep everything neatly aligned.
Finishing up with one last view
Finishing up with one last view
To finish this program weâre going to make a third and final view to display astronaut details, which will be reached by tapping one of the astronauts in the mission view. This should mostly just be practice for you, but I hope it also shows you the importance of NavigationView
â weâre digging deeper into our appâs information, and the presentation of views sliding in and out really drives that home to the user.
Start by making a new SwiftUI view called AstronautView
. This will have a single Astronaut
property so it knows what to show, then it will lay that out using a similar ScrollView
/VStack
combination as we had in MissionView
. Give it this code:
struct AstronautView: View {
let astronaut: Astronaut
var body: some View {
ScrollView {
VStack {
Image(astronaut.id)
.resizable()
.scaledToFit()
Text(astronaut.description)
.padding()
}
}
.background(.darkBackground)
.navigationTitle(astronaut.name)
.navigationBarTitleDisplayMode(.inline)
}
}
Once again we need to update the preview so that it creates its view with some data:
struct AstronautView_Previews: PreviewProvider {
static let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")
static var previews: some View {
AstronautView(astronaut: astronauts["aldrin"]!)
.preferredColorScheme(.dark)
}
}
Now we can present that from the NavigationLink
inside MissionView
. This points to Text("Astronaut details")
right now, but we can update it to point to our new AstronautView
instead:
AstronautView(astronaut: crewMember.astronaut)
That was easy, right? But if you run the app now youâll see how natural it makes our user interface feel â we start at the broadest level of information, showing all our missions, then tap to select one specific mission, then tap to select one specific astronaut. iOS takes care of animating in the new views, but also providing back buttons and swipes to return to previous views.
Thatâs another complete app built â make sure and share your progress to let others know how youâre getting on!