Chapters

Hide chapters

SwiftUI Apprentice

Third Edition · iOS 18 · Swift 5.9 · Xcode 16.2

Section I: Your First App: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your Second App: Cards

Section 2: 9 chapters
Show chapters Hide chapters

21. Delightful UX — Final Touches
Written by Caroline Begbie

An iOS app is not complete without some snazzy animation. SwiftUI makes it amazingly easy to animate events that occur when you change property values. Transition animations are a breeze.

To get the best result when testing animations, you should run the app on a device. Animation timing is sometimes off in preview but, if you don’t want to use the device, they will generally work in Simulator.

The Starter Project

➤ Open the starter project for this chapter.

The project has an additional folder called Supporting Code. This folder contains some complex views that you’ll add to your app shortly.

In CardsApp.swift, the project uses the default preview data.

Animated Splash Screen

Skills you’ll learn in this section: set up properties for animation

Sometimes in a more complex app, after showing the launch screen, your app will take a few seconds to do all the loading housekeeping. To prevent the UI from appearing to stall, the app can perform an animation to distract the user. Apps such as Duolingo and Uber use fancy animation when you open the app.

You’ll create an animated splash screen where the letters C-A-R-D-S will drop down from the top and, when that animation is complete, the animation view will slide to the main cards view.

Final animation
Final animation

➤ Create a new folder inside the Cards folder called App Startup and drag CardsApp.swift into the new folder.

➤ Inside App Startup, create two new SwiftUI View files named AppLoadingView.swift and SplashScreen.swift.

➤ Open AppLoadingView.swift. This view will determine whether you’re showing the animation or not.

➤ Create a new property in AppLoadingView:

@State private var showSplash = true

➤ Change body to:

var body: some View {
  if showSplash {
    SplashScreen()
      .ignoresSafeArea()
  } else {
    CardsListView()
  }
}

When showSplash is true, you’ll show the splash animation, otherwise you’ll show the main CardsListView. At the moment, you never set showSplash to false, so CardsListView will never show. Sometimes the live preview doesn’t show animations correctly — or at all — so in order to see the animation on the simulator, you’ll keep it this way until you perfect your splash animation.

➤ In #Preview, add this modifier to AppLoadingView:

.environmentObject(
  CardStore(defaultData: true))

This sets up the card store so that the app will still work in Live Preview.

➤ Open CardsApp.swift and change CardsListView() to:

AppLoadingView()

You show the intermediate view which contains the splash screen.

➤ Build and run, and you’ll see the default “Hello World” from SplashScreen.

Hello, World
Hello, World

➤ Open SplashScreen.swift and add this new method to SplashScreen:

func card(letter: String, color: String) -> some View {
  ZStack {
    RoundedRectangle(cornerRadius: 25)
      .shadow(radius: 3)
      .frame(width: 120, height: 160)
      .foregroundStyle(.white)
    Text(letter)
      .fontWeight(.bold)
      .scalableText()
      .foregroundStyle(Color(color))
      .frame(width: 80)
  }
}

Here you create a view, with a shadow, that takes in a letter and a color.

➤ In body, change Text("Hello, World!") to:

card(letter: "C", color: "appColor7")

Here you create the view with the letter “C” and the name of a color set up in your asset catalog.

➤ Preview the view.

The card
The card

You now have a stationary card. You’ll separate out the animation movement into a new view modifier.

➤ At the end of SplashScreen.swift, add a new structure:

private struct SplashAnimation: ViewModifier {
  @State private var animating = true
  let finalYPosition: CGFloat
  let delay: Double

  func body(content: Content) -> some View {
    content
      .offset(y: animating ? -700 : finalYPosition)
      .onAppear {
        animating = false
      }
  }
}

To drop the card from the top, you’ll animate content‘s offset. If animating is true, then the card’s offset is off the top of the screen at -700 points. When false, the offset will be the final designated position. You change animating to false when the view appears.

You’ll use the delay property shortly.

➤ At the end of SplashScreen.swift, add a new extension on View that improves the modifier’s ease-of-use:

private extension View {
  func splashAnimation(
    finalYposition: CGFloat,
    delay: Double
  ) -> some View {
    modifier(SplashAnimation(
      finalYPosition: finalYposition,
      delay: delay))  }
}

This is simply a pass through method to make your code prettier.

➤ In SplashScreen, replace body with:

var body: some View {
  card(letter: "C", color: "appColor7")
    .splashAnimation(finalYposition: 200, delay: 0)
}

Here, you call the view modifier with the final Y position of the card.

➤ Live Preview the view, and you’ll see your card 200 points below the center, but not animated yet.

The card before animation
The card before animation

SwiftUI Animation

Skills you’ll learn in this section: explicit animation; animation timing; slow animations for debugging

SwiftUI makes animating any view parameter that depends on a property incredibly easy. You simply surround the dependent property with a closure:

withAnimation {
  property.toggle()
}

And that’s it! SwiftUI will automatically animate any parameter in your entire app that depends on property.

In SplashAnimation, the offset of your card depends on animating.

➤ In onAppear(_:), change animating = false to:

withAnimation {
  animating = false
}

➤ Live preview the view. Your card now animates from the top and ends up at a Y offset of 200.

➤ Build and run the app in Simulator and choose Debug ▸ Slow Animations.

This menu option is a debug feature to slow down animations, so that you can see them properly. You’ll now see a check mark next to the menu item.

➤ Build and run the app again to see the animation in slow motion.

➤ In SplashScreen, change the contents of body to:

ZStack {
  Color("background")
    .ignoresSafeArea()
  card(letter: "S", color: "appColor1")
    .splashAnimation(finalYposition: 240, delay: 0)
  card(letter: "D", color: "appColor2")
    .splashAnimation(finalYposition: 120, delay: 0.2)
  card(letter: "R", color: "appColor3")
    .splashAnimation(finalYposition: 0, delay: 0.4)
  card(letter: "A", color: "appColor6")
    .splashAnimation(finalYposition: -120, delay: 0.6)
  card(letter: "C", color: "appColor7")
    .splashAnimation(finalYposition: -240, delay: 0.8)
}

This sets up all the card letters with their final positions and colors. The delay parameter doesn’t do anything yet, but you’ll use it shortly. The background color is in your asset catalog.

➤ Live Preview or run in Simulator. In this animation, all the cards animate downwards with the same timing, which isn’t aesthetically pleasing.

Animating with the same timing
Animating with the same timing

When you use withAnimation(_:_:), you can specify what sort of Animation you want to use. You can specify the timing of the animation, the duration and whether it has a delay.

➤ In SplashAnimation, in onAppear(_:), change withAnimation { to:

withAnimation(Animation.default.delay(delay)) {

Here you’re using the default animation with a delay modifier. You’ve already set up the cards with their delay. Each card has a 0.2 second delay greater than the previous card.

➤ Live Preview the result. With the delays, the card animation is staggered.

Animation delay
Animation delay

An Animation can have various qualities. The most common are:

  • easeIn: where the animation starts slowly but speeds up to the end.
  • easeOut: where the animation starts at speed but slows down toward the end.
  • easeInOut: a combination of the previous two.
  • linear: where the animation speed is constant all the way through.

➤ Replace withAnimation(Animation.default.delay(delay)) { with:

withAnimation(Animation.easeOut(duration: 1.5).delay(delay)) {

This animation lasts for 1.5 seconds and slows gradually at the end of the animation.

➤ Live Preview first to see the animation in 1.5 seconds. Then build and run on the simulator with slow animations. You can see that the cards fall closer together toward the end of the animation.

Ease out animation timing
Ease out animation timing

A more interesting Animation is a spring, where the view bounces like a spring. You can specify the spring’s mass, stiffness and damping, but Animation has a few predefined springs:

  • smooth (the default)
  • bouncy
  • snappy

➤ In SplashAnimation, replace the withAnimation(_:_:) closure with:

withAnimation(Animation.bouncy.delay(delay)) {
  animating = false
}

➤ Live Preview this, and you’ll see that each card bounces as it hits its offset position.

➤ Change Animation.bouncy.delay(delay) to:

Animation.bouncy(
  duration: 1.5,
  extraBounce: 0.4)
  .delay(delay)

The animation is now slow and very bouncy.

➤ Experiment with the bouncy parameters, and changing bouncy to smooth and snappy until you find a pleasing animation.

To finish off this animation, add a random rotation to each card.

➤ In SplashAnimation, after offset(y:), add this:

.rotationEffect(
  animating ? .zero
    : Angle(degrees: Double.random(in: -10...10)))

The card animates to a random rotation between -10 and 10 degrees as it drops.

➤ Live Preview, and you’ll see your final animation.

Random rotation
Random rotation

Explicit and Implicit Animation

Skills you’ll learn in this section: implicit animation

withAnimation(_:_:) explicitly causes animations with parameters affected by the property within its closure. If you have multiple properties changing, you can explicitly change the animation for each of them.

For implicit animation, you animate any view with an animatable parameter automatically.

➤ In SplashAnimation, remove the withAnimation(_:_:) closure, so that onAppear(_:) is:

.onAppear {
  animating = false
}

This removes all animation.

➤ After the rotation effect modifier add this:

.animation(
 Animation.snappy(
   duration: 0.5,
   extraBounce: 0.2)
 .delay(delay),
 value: animating)

This adds an implicit animation to the view. The view watches the property animating, and whenever animating changes, the view animates with the Animation provided.

➤ Live Preview the animation.

In this case, as you are only animating views with one animatable property, the implicit animation will appear exactly the same as the explicit animation. Explicit animations can be less code, but implicit animations give you more control by being able to animate each view depending on the animated property with different animations.

Animated Transitions

Skills you’ll learn in this section: transitions

You’ll now transition your splash screen to the main CardsListView. SwiftUI makes this easy with built-in transition effects, but you can also have complete control over how the view transitions.

➤ Open AppLoadingView.swift. After ignoresSafeArea(), add:

.onAppear {
  withAnimation(
    .linear(duration: 1.0)
    .delay(1.5)) {
    showSplash = false
  }
}

Here you set showSplash to false after a delay and use explicit animation. showSplash controls which view shows. You want the splash screen to show for a second or two and then transition to the main view.

➤ Live Preview the transition.

Fade transition
Fade transition

The default transition does an opacity fade from one view to another.

➤ In AppLoadingView, add a modifier to CardsListView():

.transition(.slide)

➤ Live Preview to see the slide transition.

Slide transition
Slide transition

As well as opacity and slide, there are a couple more automatic transitions:

  • move(edge:): allows you to specify the edge that the new view moves in from.
  • scale: the new view scales up.
  • push(from:) pushes the view in from a specified edge while fading in.

You can also have a different transition for each direction by using:

.transition(.asymmetric(insertion: .slide, removal:.scale))

➤ Change the transition to:

.transition(.scale(scale: 0, anchor: .top))

This will scale the new view in from the top.

➤ Build and run to see your completed splash screen animation and transition.

Scale transition
Scale transition

The Zoom Transition

Skills you’ll learn in this section: zoom transition

Currently you transition from the list of cards to the card detail using the full screen cover that appears from the bottom edge.

A very common transition is to zoom from the source view to the destination view, then reverse the zoom when navigating back.

In the Views folder, open CardsListView.swift and refresh your memory on how the transition currently works.

The full screen cover transition
The full screen cover transition

When you tap a card, you change the state property selectedCard. The view observes selectedCard, and when true, fullScreenCover(item:onDismiss:content:) performs its default transition animation and slides up from the bottom.

SingleCardView has a toolbar with a Done button. When you tap this button, you call the Environment’s dismiss action to dismiss SingleCardView. The full screen cover then slides down to disappear.

You’ll create a transition where you define the source and destination linked by the card id. Your source view will be CardThumbnail and your destination will be SingleCardView.

➤ In CardsListView, create a new property:

@Namespace private var namespace

The namespace holds the identity of the current view so that you can match source and destination views.

➤ Add a new modifier to SingleCardView inside the full screen cover modifier:

.navigationTransition(
  .zoom(
    sourceID: card.id,
    in: namespace))

The navigation transition should zoom. You specify the card ID and the namespace that will match the source’s transition.

➤ In list (not initialView), add a modifier to CardThumbnail:

.matchedTransitionSource(
  id: card.id,
  in: namespace)

You match the transition with the same card ID in the same namespace.

➤ Live Preview your transition.

The zoom transition
The zoom transition

The selected card zooms from the list. This is an interactive zoom, so you can swipe down to dismiss the card during the transition, as well as when the card is fully loaded.

The interactive zoom is generally a nice feature, but because you use gestures to move and resize the card elements, the swipe to dismiss is inconvenient.

➤ In CardsListView.swift, add this modifier to SingleCardView:

.interactiveDismissDisabled(true)

If you haven’t done this already, you can revert slow animations in Simulator by choosing Debug ▸ Slow Animations and unchecking the menu item.

Supporting Multiple View Types

Skills you’ll learn in this section: picker control

You’ll add a picker view to the top of the list of cards to choose how you view the cards. You can either view them in the scrolling list or in a carousel. When you have a set of mutually exclusive values, you can use a picker control to decide between them.

There are various picker styles for mutually exclusive picking. For example, WheelPickerStyle shows the options in a scrollable wheel. Apple’s Clock app uses a wheel picker for the Timer. You’ll use a SegmentedPickerStyle, which is a horizontal control that holds one value at a time.

Picker with two segments
Picker with two segments

The Carousel

Carousel.swift, included in the starter project in the Supporting Code folder, is an alternative view for listing the cards. It’s an example of a TabView, similar to the one you created in Section 1.

➤ Open Carousel.swift and Live Preview the view. Swipe to view each card.

Carousel
Carousel

Each card should take up most of the device’s screen, so the code uses GeometryReader to determine the size. There should be nothing new to you in this code. One of SwiftUI’s great advantages is that you can be given a view like this, and it’s an easy matter to slot it into your own code.

Adding a Picker

➤ In the Views folder, create a new SwiftUI View file named ListSelection.swift.

➤ At the top of the file, after import SwiftUI, create a new enumeration that describes how you are viewing the list of cards:

enum ListState {
  case list, carousel
}

You’ll either view the cards as a list or as a carousel.

➤ Add a new Binding to ListSelection:

@Binding var listState: ListState

listState holds the current picker selection and you’ll pass this in from CardsListView.

➤ Update #Preview to pass the initial selection of list:

ListSelection(listState: .constant(.list))

➤ In ListSelection, replace body with:

var body: some View {
  // 1
  Picker(selection: $listState, label: Text("")) {
  // 2
    Image(systemName: "square.grid.2x2.fill")
      .tag(ListState.list)
    Image(systemName: "rectangle.stack.fill")
      .tag(ListState.carousel)
  }
  // 3
  .pickerStyle(.segmented)
  .frame(width: 200)
}

Going through this code:

  1. You use a Picker, passing in the selection property to update.
  2. You assign SFSymbols for each option. When the user chooses an option, the tag(_:) modifier will update listState with the specified value.
  3. You tell the Picker what picker style to use. Other picker styles include menu and wheel, which displays options in a scrollable wheel.

➤ Preview the picker.

Segmented picker
Segmented picker

In the app, when you tap the right segment, the cards should display in the carousel; tapping the left segment will display them in the scrolling list.

➤ Open CardsListView.swift and add a new property to CardsListView:

@State private var listState = ListState.list

This property controls how you view the cards.

➤ In body, add the picker to the top of VStack, before list:

ListSelection(listState: $listState)

The picker in place
The picker in place

➤ Change list to:

Group {
  switch listState {
  case .list:
    list
  case .carousel:
    Carousel(selectedCard: $selectedCard)
  }
}

You show the scrolling list or the carousel depending on listState. Similar to the list, when you select a card from the carousel, changing the value of selectedCard will run the full screen modal.

➤ Live Preview to see the picker in action.

The two card list views
The two card list views

Sharing the Card

Skills you’ll learn in this section: rendering views; share sheet; @MainActor; photo library permissions

At the moment, when you create a card, you’re the only person who can admire it. As a final feature, you’ll add sharing.

You’ll create a share button on the top bar. On tapping this button, you’ll screen capture the card. You’ll then use this screenshot in the built-in Share sheet for sharing to other apps such as email or your Photos library.

➤ In the Supporting Code folder, open ShareCardView.swift.

ShareCardView is a cut-down version of CardDetailView, without any of the modifiers that make the card interactive. You’ll be able to render this view to an image and then share the image.

Rendering a View to an Image

➤ In the Extensions folder, open UIImageExtensions.swift. Add a new extension at the end of the file:

extension UIImage {
  // 1
  @MainActor static func screenshot(
    card: Card,
    size: CGSize
  ) -> UIImage {
    // 2
    let cardView = ShareCardView(card: card)
    let content = cardView.content(size: size)
    // 3
    let renderer = ImageRenderer(content: content)
    // 4
    return renderer.uiImage ?? UIImage.error
  }
}

There’s a lot to unpack here:

  1. MainActor ensures that a method is performed on the main dispatch queue. Any time you are dealing with views, you should be on the main thread. Note that any method that calls UIImage.screenshot(card:size:) must also be marked with MainActor, otherwise it will not compile.
  2. Load the card into a view and extract the content. Specifying the size of the content, means that you can scale it to any size preview you want.
  3. Render the image from the view. ImageRender<Content> initializes with a view and draws it to a Canvas. You can render shapes or text or any other View to an image.
  4. Extract a UIImage from the rendered image, but if there’s an error, use the error image in the asset catalog.

➤ In the Single Card Views folder, open CardToolbar.swift and add this code after the Done button ToolbarItem:

ToolbarItem(placement: .topBarLeading) {
  let uiImage = UIImage.screenshot(
    card: card,
    size: Settings.cardSize)
  let image = Image(uiImage: uiImage)
  // Add ShareLink here
}

You create a new toolbar item at the leading edge of the top bar and load an Image ready for sharing.

Sharing Images

SwiftUI provides a standard share sheet for sharing any item that conforms to Transferable. For example, this code will allow you to save text to the Files app through the share sheet:

ShareLink("Share Text", item: "Hello world")

Sharing text from your app
Sharing text from your app

ShareLink will add an icon, seen here at the top left of the screen, where you can start the share. A sheet will pop up, and you’ll see a preview of the text at the top left of the sheet. The share sheet determines what apps to show from the type of the item.

➤ Replace // Add ShareLink here with this code:

ShareLink(
  item: image,
  preview: SharePreview(
    "Card",
    image: image)) {
      Image(systemName: "square.and.arrow.up")
}

Here you use a longer ShareLink initializer. In place of text, you share your screen-capture image. You create your own preview image and provide a custom icon.

➤ Build and run your app in Simulator. Open the first card and tap the share icon at the top left. Pull up the sheet to see where you can share the card.

Sharing your card from your app
Sharing your card from your app

➤ As no option appears to save your card to Photos, tap Save to Files, then Save, and then open the Files app in Simulator. In the Files app, locate your card. Long press your card and choose Get Info to see the properties of the imported file.

Your card in the Files app
Your card in the Files app

Notice that the dimensions of the PNG file are 1300 x 2000, which is what you specified for your card size.

There’s only one problem. You’d much rather have it in Photos than Files.

Configuring Your App to Save Photos

Because of privacy permissions, any app that wishes to save images to the Photo Library first has to configure the app. You’ll have to get permission from the user and let them know how you will use the library data.

App properties are held in your app’s Info.plist. You’ll save a property here to allow Photo Library additions, and the option to save a photo will automatically appear in the share sheet’s list of actions.

In the Project navigator, select the topmost Cards and choose the Cards target, then choose Info from the options across the top.

➤ Add a new key NSPhotoLibraryAddUsageDescription, or Privacy - Photo Library Additions Usage Description.

➤ In the Value field, add:

Cards will save your card to the Photo Library

This is the message your users will see, so you might add something soothing about not using their personal data for nefarious purposes.

Key to ask user for permission to use photo library
Key to ask user for permission to use photo library

➤ Build and run the app again and choose a card. Share the card and this time, Save Image appears as an option. Save the image to the Photo Library.

The app asks for permission to save to photos, showing the message you entered in the Info key.

Asking user for permission to use photo library
Asking user for permission to use photo library

➤ Tap Allow and the card will save to the photo library. Check out the Photos app in Simulator to see your photo library.

Your shared card in the Photos Library
Your shared card in the Photos Library

If you run the app on a device with Mail, Messages or any sharing app installed, you can share the image through those, too.

Challenges

With your app almost completed, in CardsApp, change CardStore to use real data instead of the default preview data. Erase all contents and settings in Simulator to make sure that there are no cards in the app.

Challenge 1: Save & Load the Card Thumbnail

Currently, the list of cards doesn’t show a preview of the card. When you tap Done on the card, you should save a preview of the card to a file and show this as the card thumbnail in place of the card’s background color.

To achieve this:

  1. In CardsApp.swift, set up CardStore without the default data. Erase the data in Simulator.

  2. Locate the code where you save the card in SingleCardView.swift. First use UIImage.screenshot(card:size:) to generate a UIImage. Use Settings.cardSize * 0.2 as the size. Then save the UIImage to a file using card.id.uuidString as the filename. UIImageExtensions.swift contains a method UIImage.save(to:) to save the file.

  3. In CardThumbnail.swift, load this image file. There’s a UIImage.load(uuidString:) method in UIImageExtensions.swift. If the load is successful (not UIImage.error), show the image. If not, show the card’s background color. Enclose the two alternative views in a Group and place the modifiers on the group, rather than on the background color.

If you have done this part correctly, when testing this in Simulator, the card thumbnail image doesn’t automatically refresh when you dismiss the card, but will load when you restart the app. In CardsListView.swift, CardThumbnail will only refresh if there are published changes. When you save the card in SingleCardView, you don’t change the observed property cards in CardStore, so there are no published changes.

Add a uiImage: UIImage? property to Card and update this property when you save the card image in SingleCardView.swift. Updating this property means that you update the published property cards in CardStore, and the card thumbnail will redraw.

The thumbnail image
The thumbnail image

Challenge 2: Change the Text Entry Modal View

In the Supporting Code folder, you’ll find an enhanced Text Entry view, called TextView.swift, that lets users pick fonts and colors when they enter text. There’s a list of some of the fonts available on iOS in AppFonts.swift.

First, preview and examine TextView and make sure you understand it. SwiftUI views look complicated, but you have encountered almost everything in this file before.

Your challenge is to add this view to the modal view TextModal under the current TextField.

With the new font and color, style the text currently being entered in the TextField. Use .font(.custom(textElement.textFont, size: 30)) to style the font.

To test the view, run the app in Simulator or Live Preview SingleCardView.

Text entry with fonts and colors
Text entry with fonts and colors

When you’ve completed these challenges, you should be well pleased with yourself. You’ve worked hard to construct an app with some very tricky features. Don’t rest on your laurels, though. You still have Section 3 to work through!

Key Points

  • Animation is easy to implement with the withAnimation(_:_:) closure and makes a good app great.
  • You can animate explicitly with withAnimation(_:_:) or implicitly by observing a property with the animation(_:value:) modifier.
  • Transitions are also easy with the transition(_:) modifier. Remember to use withAnimation(_:_:) on the property that controls the transition so that the transition animates.
  • Picker views allow the user to pick one of a set of values. You can have a wheel style picker or a segmented style picker.
  • Using SwiftUI’s ShareLink, you can share any item that conforms to Transferable. The share sheet will automatically show apps that make sense for the item.

Where to Go From Here?

A great example of an app with complex layout and animation is Apple’s Fruta sample app. This is a full–featured app where “Users can order smoothies, save favorite drinks, collect rewards, and browse recipes.” Fruta also has various features, such as widgets. Download the app and see if you can work out how it all fits together.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.