Tutorials Ultimate SwiftUI Series Chapter 4

Prototyping the Supplementary Views: Forms, Dates & Layered Stacks

SwiftUIChapter 4 of the Ultimate SwiftUI Series36 minJune 17, 2026Beginner

In the last chapter you built ShipShape's busiest screen — the Step view, with its header, clip player, timer, buttons, and rating row. Once you can lay out the most crowded page, the rest come easy. Three full-screen views are still placeholders:

In this chapter you'll lay out History and Welcome, build the data model behind the history log, then finish the prototype yourself by creating the Ready to Ship! screen as a challenge. Along the way you'll meet Form, DateFormatter, ZStack, and a pair of refactoring tricks that keep your code honest.

Laying Out the History View

You'll start with a hard-coded mock-up and swap in a real data model once the layout looks right. Building UI against fake data first is a good habit: the screen takes shape while the model is still a sketch, and you only commit to a data structure after you've seen what the view actually needs from it.

Create a new SwiftUI View file in the Views group named HistoryView.swift. Above body, add some throwaway sample data — two days' worth of finished steps:

let today = Date()
let yesterday = Date().addingTimeInterval(-86_400)
 
let stepsToday = ["Screenshots", "Metadata", "Keywords", "Final QA"]
let stepsYesterday = ["Screenshots", "Metadata", "Keywords"]

Date() is right now; addingTimeInterval(-86_400) rolls back one day, because there are 86,400 seconds in a day. (The underscores are just digit separators — 86_400 and 86400 are the same number, but the first is easier to read.)

Replace the boilerplate Text("Hello, world!") with a titled VStack:

VStack {
  Text("History")
    .font(.title)
    .padding()
  // Step log goes here
}

That's the header. Before you fill in the log, those two dates need to look like dates a human would write.

Formatting dates with an extension

A raw Date is just a number of seconds, so printing one gives you something like 2026-06-17 09:41:00 +0000. To turn it into Jun 17, you configure a DateFormatter.

Swift tip: what a Date really is

A Date stores a single instant as a number of seconds relative to a fixed reference point (January 1, 2001, UTC). It has no notion of "month" or "time zone" on its own. A DateFormatter is what renders that instant as calendar text in a particular style and zone. Foundation ships built-in styles (.short, .medium, .long, .full), or you can supply your own pattern string.

Back in Chapter 3 a callout mentioned you can extend any type, even ones built into the SDK like Date and Image. Time to do exactly that. Create a new Swift File (not a SwiftUI View — this is a helper, not a view) named DateExtension.swift, and add:

import Foundation
 
extension Date {
  func formatted(pattern: String) -> String {
    let formatter = DateFormatter()
    formatter.dateFormat = pattern
    return formatter.string(from: self)
  }
}

DateFormatter has only an empty initializer: you create one, then configure the properties you care about. Here you set dateFormat to the caller's pattern, then string(from:) renders self — the date you called the method on — as text.

The pattern is a small language of its own. "MMM d" means three letters for the month (Jun, Oct) and one digit for the day with no leading zero (7, 17). Ask for "MM dd" instead and you get zero-padded numbers for both: 06 07. You don't have to think about time zones unless you want to — the formatter defaults to the user's current zone, which is almost always what you want.

Documenting it with Quick Help

Your formatted(pattern:) reads almost like a built-in method now. Make it feel built-in by documenting it. Add a documentation comment directly above the method:

/// Renders the date as text using a custom pattern string.
/// - Parameter pattern: A date pattern like `"MMM d"` or `"MM dd"`.
/// - Returns: The formatted date, in the user's current time zone.
func formatted(pattern: String) -> String {

Those triple-slash /// comments are special. Option-click the method name anywhere you call it and Xcode shows your summary in Quick Help, formatted exactly like Apple's own API docs — parameters, return value, and all. It's worth documenting every non-obvious method you write this way; future-you reading the call site will thank present-you. Apple's Markup Formatting Reference lists everything you can put in one.

A Form with sections

SwiftUI has a container that styles its contents into the neat, grouped rows you see on a Settings screen — perfect for a dated log. It's called Form. Inside the VStack, replace the // Step log goes here comment with:

Form {
  Section(
    header:
      Text(today.formatted(pattern: "MMM d"))
        .font(.headline)) {
    // today's steps
  }
  Section(
    header:
      Text(yesterday.formatted(pattern: "MMM d"))
        .font(.headline)) {
    // yesterday's steps
  }
}

Each Section gets a header — the day's date in headline font, thanks to the extension you just wrote. Refresh the preview and you'll see two dated sections, empty for now, with whatever dates today and yesterday happen to be.

Looping over a collection

To list the steps under each date, loop over the arrays with ForEach. In Chapter 3 you looped over a range (ForEach(0 ..< 5)) and over an array's indices (ForEach(Step.steps.indices, id: \.self)). Here you'll loop over the array's elements. Replace // today's steps with:

ForEach(stepsToday, id: \.self) { step in
  Text(step)
}

This uses the ForEach initializer that takes the data, a key path to each element's identifier, and a closure that builds a view per element. stepsToday is the data; \.self is the key path — it tells ForEach that each String is its own unique identifier. As the loop visits each element it binds it to step, which you show in a Text.

Do the same for the other section — replace // yesterday's steps with the near-identical loop over stepsYesterday:

ForEach(stepsYesterday, id: \.self) { step in
  Text(step)
}

Refresh the preview to admire your two-day log.

Two hard-coded sections are fine for a mock-up, but the real log has any number of days, each with any set of steps. Hard-coded today/yesterday properties can't grow. You need a data structure you can loop over: for each day, a Section; inside it, a loop over that day's steps.

Modeling the History Data

Time to replace the loose dates and string arrays with a proper model.

Mini-exercise

Before reading on, jot down what one entry in the history log actually holds. A single day of shipping work carries which pieces of data? (You already saw them in the mock-up — a date, and the steps finished that day.) Naming them yourself first makes the struct below feel inevitable rather than handed to you.

A ShipDay and a HistoryStore

Create a new Swift File named HistoryStore.swift — a model, not a view, so plain Swift. Then select it together with Step.swift and group them into a new folder named Model (right-click ▸ New Group from Selection). Models living apart from Views keeps the project easy to navigate.

Below import Foundation, add:

struct ShipDay: Identifiable {
  let id = UUID()
  let date: Date
  var steps: [String] = []
}
 
struct HistoryStore {
  var shipDays: [ShipDay] = []
}

A ShipDay is one day's record: the date, plus the names of the steps the user finished that day. HistoryStore holds the whole log — an array of ShipDay values you'll loop over in HistoryView.

Notice ShipDay conforms to Identifiable. That protocol is the easy way to make a type loopable.

Why Identifiable matters for ForEach

When ForEach loops over a collection, it needs a stable way to tell the elements apart — so it can animate insertions, track selections, and redraw only what changed. The simplest way to provide that is to conform the element's type to Identifiable and give it an id property. UUID() mints a brand-new unique value every time a ShipDay is created, so no two days ever collide. With Identifiable in place, you can loop over [ShipDay] without the id: \.self argument you needed for the String arrays.

In a later chapter you'll extend HistoryStore with methods to save the log to disk and load it back. For now you just need realistic data to design against.

Sample data in an extension

Add a method that fills the store with a couple of sample days. Put it in an extension, outside HistoryStore's braces:

extension HistoryStore {
  mutating func seedSampleData() {
    shipDays = [
      ShipDay(
        date: Date().addingTimeInterval(-86_400),
        steps: [
          Step.steps[0].stepName,
          Step.steps[1].stepName,
          Step.steps[2].stepName
        ]),
      ShipDay(
        date: Date().addingTimeInterval(-86_400 * 2),
        steps: [
          Step.steps[1].stepName,
          Step.steps[0].stepName
        ])
    ]
  }
}

Three things to notice here:

Debug-only, with a compiler directive

You want this fake data while you're building, but never in the version users download. Add an initializer to the main HistoryStore that seeds data only in debug builds:

init() {
  #if DEBUG
  seedSampleData()
  #endif
}

#if DEBUG is a compiler directive: the code between #if and #endif is only compiled when the active build configuration is Debug. Run from Xcode and you're in Debug, so you get sample data; an exported Release build skips it entirely.

Where the build configuration lives

Debug and Release are build configurations. To see which one a run uses, click the scheme menu (next to the run-destination picker), choose Edit Scheme…, and open the Info tab — the Build Configuration row shows Debug for Run and Release for Archive. Compiler directives like #if DEBUG let one codebase behave differently in each.

Move dev code into Preview Content

Better still: keep development-only code out of the app target entirely. Xcode gives you a group for exactly this — Preview Content — and nothing in it ships in your release build.

In the Preview Content group, create a new Swift File named HistoryStore+SampleData.swift, and move the whole extension HistoryStore { … } block into it.

That's the second reason seedSampleData() lived in an extension: extensions can sit in separate files. You never have to scroll past sample data to find the real store, and because the file is under Preview Content, the seeding code physically can't end up in what users install. Your #if DEBUG initializer and the Preview-Content extension reinforce each other — belt and suspenders.

Drive HistoryView from the store

Back in HistoryView.swift, delete the four mock properties (today, yesterday, stepsToday, stepsYesterday) and add the store in their place:

let history = HistoryStore()

The store now owns everything those loose properties used to. Replace the entire Form with a loop over the store's days:

Form {
  ForEach(history.shipDays) { day in
    Section(
      header:
        Text(day.date.formatted(pattern: "MMM d"))
          .font(.headline)) {
      ForEach(day.steps, id: \.self) { step in
        Text(step)
      }
    }
  }
}

Look closely at the two ForEach calls. The outer one loops over history.shipDays with no id: argument — it doesn't need one, because ShipDay is Identifiable. The inner one loops over day.steps, which are plain Strings with no built-in identity, so it still needs id: \.self. Same loop, different identity story.

Refresh the preview: the same two-day log, now produced by your model instead of hand-typed properties — and ready to display ten days or a hundred without another line of layout code.

Dismissing the History Sheet

In the next chapter you'll present HistoryView as a modal sheet, which means it needs a way to close itself. The convention is a small ✕ in the top-right corner. The trick is to add it without disturbing the title-and-Form layout you just built — and for that, you reach for ZStack.

A button in its own layer

HStack arranges views along the x-axis; VStack along the y-axis; ZStack stacks them along the z-axis, perpendicular to the screen — think of it as a depth stack, one layer on top of another. Putting the dismiss button in its own layer lets it float over the corner while the title and form lay themselves out as if it weren't there.

Right-click the VStack and choose Embed in ZStack, then add the button as the first child, above the VStack:

ZStack {
  Button(action: {}) {
    Image(systemName: "xmark.circle")
  }
  VStack {
    // title + Form
  }
}

Switch the preview to Selectable mode and you'll see the button's outline — but only the outline. Because you declared it first, it's on the bottom layer, hidden behind the VStack. (Counter-intuitive at first: picture laying the first view flat on a table, then stacking each later view on top.) It's also dead-center, since the default ZStack alignment is .center. You're about to fix both.

Stack alignment

Every stack takes an alignment, but each kind expects different values:

Push the button into the top-right corner by setting the ZStack's alignment:

ZStack(alignment: .topTrailing) {

Then size it up and ease it off the edge with two modifiers on the Button:

Button(action: {}) {
  Image(systemName: "xmark.circle")
}
.font(.title)
.padding(.trailing)

The button is now a visible ✕ in the corner. The ZStack's .topTrailing only positions children that don't set their own alignment, so the VStack of title and form is unaffected — exactly the isolation you wanted. HistoryView is done for now.

ShipShape's History screen on a simulated iPhone: a 'History' title with a circled dismiss button in the top-right corner, and a grouped Form with two dated sections — 'Jun 16' listing Screenshots, Metadata, and Keywords, and 'Jun 15' listing Metadata and Screenshots.

Laying Out the Welcome View

Open WelcomeView.swift. Welcome is the first page of the paged TabView, so it should wear the same header as the Step pages. Replace the boilerplate Text with:

HeaderView(titleText: "Welcome")

Wait — titleText? Your Chapter 3 HeaderView takes stepName. Using it here surfaces two problems:

  1. There's no page indicator for the Welcome page — the header shows numbers 1–4 for the four steps, but Welcome isn't one of them.
  2. stepName is a poor label for the word "Welcome." Welcome isn't a step.

You'll fix both by refactoring HeaderView.

Refactoring HeaderView

The missing indicator. Open HeaderView.swift. The app has exactly one non-step page, so just add one more "page number" — a friendly wave. Duplicate the first Image in the page-number HStack, then change the now-first one:

HStack {
  Image(systemName: "hand.wave")
  Image(systemName: "1.circle")
  Image(systemName: "2.circle")
  Image(systemName: "3.circle")
  Image(systemName: "4.circle")
}
.font(.title2)

The wave reads as "you are here, at the start."

The misnamed parameter. stepName should really be a generic page title. You could hunt down every stepName by hand and decide case by case whether to change it — and in a bigger app you'd miss one, or change one you shouldn't. Let Xcode do it safely instead. Right-click the first stepName, choose Refactor ▸ Rename…, and type titleText. Xcode highlights every affected spot across three files — the declaration, the Text(stepName) usage, the #Preview, and the call site in StepView.swift — and changes them together when you click Rename.

After the rename, HeaderView reads:

let titleText: String
// …
Text(titleText)

In StepView.swift the call updated itself to HeaderView(titleText: step.stepName), and the WelcomeView line you wrote a moment ago — HeaderView(titleText: "Welcome") — now matches. The user sees a wave icon; the programmer sees a parameter that means what it says.

Refactor ▸ Rename beats find-and-replace

Rename understands Swift. It changes the symbol — the declaration and every reference to it — and leaves unrelated text that merely contains the same letters alone. A blind find-and-replace can't tell a parameter named stepName from the word "stepName" in a comment or an unrelated property. Reach for Rename whenever you change a name that's used in more than one place.

Layering the Welcome screen

Welcome wants three things at once: the header pinned to the top, a History button at the bottom, and a marketing message floating in the center, independent of how tall the other two are. Same move as HistoryView: put the header and button in one layer and shove them apart with a Spacer; put the centered content in a second layer.

Embed HeaderView in a VStack, embed that in a ZStack, then add the spacer and button:

ZStack {
  VStack {
    HeaderView(titleText: "Welcome")
    Spacer()
    Button("History") { }
      .padding(.bottom)
  }
}

The Spacer expands to fill the gap, pushing the header up and the History button down; the bottom padding keeps the button off the very edge.

Adding an image

Now fill the middle. Add a second layer to the ZStack (above or below the first VStack — order doesn't matter, the two layers don't overlap):

VStack {
  HStack {
    VStack(alignment: .leading) {
      Text("Ship it")
        .font(.largeTitle)
      Text("your App Store release, one calm step at a time")
        .font(.headline)
    }
  }
}

The inner VStack holds two Text views at different sizes, left-justified by alignment: .leading. It sits in an HStack because you're about to put an image beside the text, and that HStack sits in an outer VStack because a call-to-action button is coming below.

Add a welcome image to Assets

For the prototype you need one squarish image named launch in Assets.xcassets — a screenshot of your own app, your app icon, a stock illustration, anything. Open the Library with Shift-Option-Command-L, switch to the media tab, and drag launch straight into the code editor, dropping it just below the VStack of two Text views so it lands inside the HStack. You'll swap in something polished later.

After the drop, the HStack reads:

HStack {
  VStack(alignment: .leading) {
    Text("Ship it")
      .font(.largeTitle)
    Text("your App Store release, one calm step at a time")
      .font(.headline)
  }
  Image("launch")
}

Modifying the image

The raw image is the wrong size, and stretching it would distort it. Three modifiers fix that, in order:

Image("launch")
  .resizable()
  .aspectRatio(contentMode: .fill)
  .frame(width: 240, height: 240)
  .clipShape(Circle())

.resizable() lets the image scale at all. .aspectRatio(contentMode: .fill) makes it fill its frame while keeping its proportions (no squashing). .frame(width: 240, height: 240) sets that frame. .clipShape(Circle()) trims it to a circle — a nice, friendly touch for a welcome screen.

A custom image modifier

You'll reach for that resizableaspectRatioframe trio constantly — only the numbers change. Bundle it into a custom modifier so you Don't Repeat Yourself. Create a new Swift File named ImageExtension.swift:

import SwiftUI
 
extension Image {
  func filled(width: CGFloat, height: CGFloat) -> some View {
    self
      .resizable()
      .aspectRatio(contentMode: .fill)
      .frame(width: width, height: height)
  }
}

It extends Image, so self is whichever image you call it on. Now collapse the three modifiers into one:

Image("launch")
  .filled(width: 240, height: 240)
  .clipShape(Circle())

Same result, less noise — and the next time you need a sized, aspect-correct image, it's one call away.

You've now extended two built-in types

Remember the Chapter 3 note that you can add methods to types you didn't write, even Apple's? You've just done it twice: formatted(pattern:) on Date and filled(width:height:) on Image. Once an extension is in scope, its methods are indistinguishable from the built-in ones — autocompletion offers them, Quick Help documents them, the dot syntax just works.

A button with text and an image

The last piece of the center layer is a call-to-action. Its label needs a Get Started prompt and a right-arrow to hint that you can also swipe to the first step. Add it inside the center VStack, below the HStack:

Button(action: { }) {
  Text("Get Started")
  Image(systemName: "arrow.right.circle")
}
.font(.title2)
.padding()

This looks different from the plain Button("History") { } buttons you've written — and it's worth understanding why.

Two ways to make a Button

The full Button signature is:

Button(action: () -> Void, label: () -> Label)

Every button you'd written before this used the simplest form, Button("History") { }. That's syntactic sugar for the special case where the label is just a string: the string is the label, and the trailing closure is the action. It reverses the official parameter order, and it only works when the label is a lone String.

The moment you want more than a string in the label — here, a Text and an Image — you use the general form: the action goes in the first parameter, and the label content goes in the trailing closure (where the Text and Image become an implicit HStack).

Swift tip: trailing closures

When the last argument to a function is a closure, you can move it outside the parentheses. Button(action: { }) { Text("Get Started") } is the same call as Button(action: { }, label: { Text("Get Started") }) — the label closure just trails behind. That's why both Button forms look the way they do.

The Label view

SwiftUI also has a purpose-built view for icon-plus-text pairs: Label. Comment out (Command-/) the Text and Image and try it in the label closure:

Label("Get Started", systemImage: "arrow.right.circle")

Look closely at the result: the arrow jumps to the left of the text. Label always leads with the icon (unless the language reads right-to-left). That's ideal for an icon list where you want the symbols aligned down the leading edge — but wrong for a "next →" affordance, where the arrow belongs after the words. Delete the Label and uncomment the Text and Image.

Tip: labelStyle

You can change what a Label shows with the labelStyle modifier — icon only, title only, or both. It's the right tool when you want consistent icon-text rows; it's just not the tool for an arrow that has to trail its text.

A border for the button

For a little polish, outline the button. Add this below .padding():

.background(
  RoundedRectangle(cornerRadius: 20)
    .stroke(Color.gray, lineWidth: 2))

A rounded rectangle is stroked (outline only, not filled) around the padded label — corner radius, color, and line width all yours to tune.

One last nicety: the headline text would sit better aligned with the bottom of the circular image. Change the enclosing HStack:

HStack(alignment: .bottom) {

And there's your Welcome page — header up top, History button at the bottom, and a centered pitch with a circular image and a bordered call-to-action.

ShipShape's Welcome screen on a simulated iPhone: the 'Welcome' header with a wave icon and page numbers 1–4, a centered pitch reading 'Ship it / your App Store release, one calm step at a time' beside a circular rocket illustration, a bordered 'Get Started' button with a right-arrow icon, and a History button at the bottom.

Challenge: The Ready-to-Ship View

When the user finishes the final step, ShipShape congratulates them with a modal Ready to Ship! screen. You now have every tool you need to build it yourself. Here's the target you're aiming for:

ShipShape's Ready to Ship! screen on a simulated iPhone: a large green filled checkmark-seal symbol, a bold 'Cleared for launch!' headline, a centered line of gray supporting text, and a 'Continue' button hugging the bottom edge.
  1. Create a new SwiftUI View file named ReadyView.swift. Set its preview to #Preview(traits: .sizeThatFitsLayout) so you see just the view, not a whole device.
  2. Replace the boilerplate Text with a VStack containing a celebratory SF Symbol on top and a few lines of text below.
  3. Use a fillable symbol such as checkmark.seal.fill (a nod to the rating seals on the Step view), in a 75 × 75 frame, tinted green. Hint: your new filled(width:height:) modifier works on any Image, including an SF Symbol — then add .foregroundColor(.green).
  4. Give the headline — say, "Cleared for launch!" — extra emphasis with the fontWeight modifier on a largeTitle.
  5. For the supporting copy, use a single multi-line string and multilineTextAlignment(.center), colored gray. The triple-quote """ literal in the Swift Style Guide is the clean way to write it across several lines.
  6. Like HistoryView, this screen needs a dismiss control — center a Continue button at the bottom. Hint: wrap everything in a ZStack(alignment: .bottom) so the "Cleared for launch!" stack stays vertically centered while the button hugs the bottom edge.

Build it, preview it, and your prototype is complete — Welcome, Step, History, and Ready to Ship!, all laid out. You'll wire the Continue button (and History's ✕) to real actions in the next chapter, once you've learned how views hand data back to one another.

Here's the finished prototype, paging from Welcome through all four steps:

A sequence of frames showing ShipShape paging from the Welcome screen through the Step screens — the swipeable page interface in action.

Key Points

Every screen is laid out, but nothing does anything yet: the buttons have empty actions, the timer is a stand-in, and the rating seals don't fill. To bring ShipShape to life, your views need to share and change data — the Step view has to tell HistoryStore "I finished this step," and HistoryView has to redraw when it does. You already know how to pass data into a view; next you'll learn how views hand data back and stay in sync with @State, @Binding, and observable models. That's where a prototype turns into an app.

Ultimate iOS Bootcamp: Master Swift & SwiftUI App THE HARD WAY
Companion video course

Ultimate iOS Bootcamp: Master Swift & SwiftUI App THE HARD WAY

★ 4.5 · 210 students
Master this on Udemy →
Taught by LIPAI WANG · contains an affiliate link
Ch 3: Prototyping the Main ViewComing Soon →
SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.TeardownApp Teardown SeriesApp business and design teardowns covering onboarding, paywalls, ASO, retention, and monetization.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access