SwiftUI: Layout & Interfaces

Nov 18 2021 Swift 5.5, iOS 15, Xcode 13

Part 2: Aligning Views

14. Challenge: Custom Alignment Guides

Episode complete

Play next episode

Next
Save for later
About this episode
See versions

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 13. Custom Alignment Next episode: 15. ZStacks

This course was originally recorded in 2019. It has been reviewed and all content and materials updated as of October 2021.

This is a screenshot from the language learning app Duolingo. Duo is its owl mascot, and your coach. When using the app regularly, you amass gems. And in the Shop tab, you can trade them in for outfits for Duo to wear. In this episode, you’ll be recreating a layout inspired by this shop.

On the right, we’ve got what looks like a VStack. But to my eyes, the images on the left don’t look like they’re aligned in a deliberate way. I think there’s an opportunity to tidy things up for Duo.

Your challenge for this episode will be to use custom alignment guides to position some creature Images to match up with some text. —Based on the center of their eyes.

The creature data will be represented with an array of this structure: the “Razémon”. It’s in a Swift file with the same name. A Razémon has a name, a description, and a cost.

It’s also got an eye position, and this documentation of what that means will be helpful to you when you position your guides. A collection of 4 Razémon is in this all array, below.

To start off with, in ContentView, we’re displaying a List of HStacks, based on that array. But we’re not making use of the razémon instance yet. So first, let’s get the right image, based on the creature’s name.

Image(razémon.name)

These are all just coming from the Assets folder, if you’re wondering. Then, let’s restrict the width of the image.

          .scaledToFit()
          .frame(width: 120)

        Text("name")

Next, let’s use the name again, for the first Text View.

Text(razémon.name) 

And for the next one, use the description.

razémon.description

For the last text view, keep that gem emoji, but use String interpolation to incorporate the razémon’s cost.

Text("💎 \(razémon.cost)")

And now you’ve got all your individual views ready for the challenge. You just need to stack the three Text Views.

Then, leading-align the VStack…

…and add 15 points of space.

VStack(alignment: .leading, spacing: 15) {

Just like with Duolingo, by using one alignment for all of the images — vertical center— we’re getting a slightly haphazard look.

Instead, let’s try to guide the viewer’s eye. First to the razémon’s name. Then, to its eyes, and finally, to the top of its description.

That user eye movement pattern should be the same shape for every creature, regardless of where its eyes are in its image. There’s no reason for users to have to dart their eyes down, and then back up, to take in this information.

Instead, enforce that a creature’s eyes line up vertically with, again, the top of its description. Then, the user’s eye will always travel in the same horizontal direction, when scanning the most important Razémon details.

To complete this challenge, use a custom Alignment, based on your own AlignmentID.

You’ll also need to incorporate each creature’s eyePosition. If its eyes were at the very top, the eyePosition value would be zero. And at the bottom? 1. The place to make use of that data is the closure for an alignment guide. Which means you’ll be combining what you learned in the last two episodes. Have fun!

If you had success with this challenge, you may have come up with a solution that utilized a nested enumeration, like in the last video. And if so, that’s awesome. Congratulations on learning the pattern!

But there’s nothing stopping you from using the Razémon type itself as an AlignmentID. I thought that was the simplest way to go, so I did it like this:

}

extension Razémon: AlignmentID {

}

struct ContentView_Previews: PreviewProvider {

And, because I had four views —the description text views— that were all going to use the same top guide, that’s what I used as my default.

extension Razémon: AlignmentID {
  static func defaultValue(in context: ViewDimensions) -> CGFloat {
    context[.top]
  }
}

Then, I used the pattern from the last episode, where you extend an Alignment type to have a static property with the name of an alignment guide.

extension VerticalAlignment {
  static let raz
}

If you want to type e-acute, you can hit option-E, and then E, to spell “Razémon” right.

static let razémon 

…even though, like with “Pokémon”, people usually turn the “é” into a “uh”, when speaking. (That’s called a schwa if you’re interested. I’m interested!) But, back to it! That static property had to return a VerticalAlignment instance, based on the ID.

static let razémon = Self(Razémon.self)

And I used that on the parent HStack.

      HStack(alignment: .razémon) {

Now, that aligned everything at the top, which wasn’t the right result. But at least that’s different than the default center, so I knew it was working. Then, I started off with aligning the description Text, which I suspected was going to be simpler than the Images.

          Text(razémon.description)
            .alignmentGuide(<#T##g: VerticalAlignment##VerticalAlignment#>, computeValue: <#T##(ViewDimensions) -> CGFloat#>)

First, I matched up to the outer parent with the razémon alignment.

            .alignmentGuide(.razémon, computeValue: <#T##(ViewDimensions) -> CGFloat#>)

And the function I needed was exactly the same as Razémon’s defaultValue I just wrote. So, I just used it directly.

            .alignmentGuide(
              .razémon,
              computeValue: Razémon.defaultValue
            )

And then I had a slightly-improved intermediate result: where the tops of the images were aligning with the tops of the descriptions. The last step was one more alignment guide, based on razémon alignment.

          .frame(width: 120)
          .alignmentGuide(.razémon) {

          }

But, what to do for the calculation? Well, the description said eyePosition described how far “down” the eyes were. If we’re talking all the way down, that’s the bottom guide.

          .alignmentGuide(.razémon) {
            $0[.bottom]
          }

And if we have a 0-1 value, describing a “portion” of that full bottom value, we’re talking multiplication.

razémon.eyePosition * $0[.bottom]

And that’s it! The key here is achieving that result. You didn’t need to write exactly the code I did to get there, and I wouldn’t have expected you to! I’m here to teach you about your available options.

And if you’re like me, you may have been wondering if there was an easier way to use your AlignmentIDs’ default guides. This code isn’t too complicated, but I think it’s a little redundant.

I was able to get it down to this, but I did have to write my own extension to do that. I’ll show you what that entailed, but the complexity of the code is beyond what I expect most students taking this course will understand.

So feel free to move right along to the next episode. But I always find that a handful of students ask advanced questions, even for beginner content. I don’t judge — sometimes the questions lead me to learning something new, myself. So, if you fit that profile, this might be for you.

Here’s the file with the extensions. Explaining them is outside the scope of the course. Dig in and feel free to ask me clarifying questions on the forum.

I put that file in a Swift package, to avoid cluttering things up for people who didn’t care about this part of the episode.

So, to use the code, you’d have to import the AlignmentExtensions library in your ContentView file.

import AlignmentExtensions

The next step would be to adopt SingleAxisAlignmentID, which inherits from SwiftUI’s AlignmentID.

extension Razémon: SingleAxisAlignmentID {

The only extra thing it does is define an Alignment type.

typealias Alignment = VerticalAlignment

And with that, you can use your AlignmentID in one of the extension alignmentGuide overloads.

.alignmentGuide(Razémon.self)