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. Animations often won’t work 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.
- This project has an additional group called Supporting Code. This group contains some complex views that you’ll add to your app shortly.
-
Cardcontains two extra properties. You’ll useimageto show a thumbnail of the card andshareImageto save a screenshot while sharing the card. -
ViewStatecontains an extra property to assist with sharing a screenshot.
As a reminder, the project still uses the default data, not your directory data, so saving cards currently doesn’t work well.
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 Twitter and Uber use animation to reflect their branding.
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.
➤ In the Cards group, under CardsApp.swift, 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()
.edgesIgnoringSafeArea(.all)
} else {
CardsView()
}
}
When showSplash is true, you’ll show the splash animation, otherwise you’ll show the main CardsView. At the moment, you never set showSplash to false, so CardsView 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 your splash animation is perfected.
➤ In AppLoadingView_Previews, 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 CardsView() 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.
➤ 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)
.foregroundColor(.white)
Text(letter)
.fontWeight(.bold)
.scalableText()
.foregroundColor(Color(color))
.frame(width: 80)
}
}
Here you create a view with a shadow, that takes in a letter and a color.
➤ 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.
You now have a stationary card. You’ll separate out the animation movement into a new view modifier.
➤ In 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.
➤ In SplashScreen, replace body with:
var body: some View {
card(letter: "C", color: "appColor7")
.modifier(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.
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! Any parameter in your entire app that depends on property, will animate automatically.
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.
➤ In Simulator, choose Debug ▸ Slow Animations. This 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")
.edgesIgnoringSafeArea(.all)
card(letter: "S", color: "appColor1")
.modifier(SplashAnimation(finalYPosition: 240, delay: 0))
card(letter: "D", color: "appColor2")
.modifier(SplashAnimation(finalYPosition: 120, delay: 0.2))
card(letter: "R", color: "appColor3")
.modifier(SplashAnimation(finalYPosition: 0, delay: 0.4))
card(letter: "A", color: "appColor6")
.modifier(SplashAnimation(finalYPosition: -120, delay: 0.6))
card(letter: "C", color: "appColor7")
.modifier(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.
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.
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.
A more interesting Animation is a spring, where the view bounces like a spring. You can specify how stiff it is and how fast the bouncing stops.
➤ In SplashAnimation, replace the withAnimation(_:_:) closure with:
withAnimation(
Animation.interpolatingSpring(
mass: 0.2,
stiffness: 80,
damping: 5,
initialVelocity: 0.0)
.delay(delay)) {
animating = false
}
➤ Live Preview this, and you’ll see that each card bounces as it hits its offset position. Experiment with the values of each of these spring properties to see how they affect the 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.
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.interpolatingSpring(
mass: 0.2,
stiffness: 80,
damping: 5,
initialVelocity: 0.0)
.delay(delay))
This adds an implicit animation to the view. Whenever any animatable property affects the view, you describe the animation to use for this view.
➤ 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 CardsView. SwiftUI makes this easy with built-in transition effects, but you can also have complete control about how the view transitions.
➤ Open AppLoadingView.swift.
➤ After edgesIgnoringSafeArea(.all), add:
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.linear(duration: 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.
Slowing the animation in Simulator doesn’t work well when testing this transition, so you give the transition animation a slow duration of 5 seconds to see what’s happening.
➤ In Simulator, choose Debug ▸ Slow Animations to turn off the slow animations.
➤ As Live Preview doesn’t work well with transition animations, build and run the app.
The default transition does an opacity fade from one view to another.
➤ In AppLoadingView, add a modifier to CardsView():
.transition(.slide)
➤ Build and run to see the slide transition over the specified five second duration.
As well as opacity and slide, there are a couple more automatic transitions:
-
move: allows you to specify the edge that the new view moves in from. -
scale: the new view scales up.
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.
➤ Replace withAnimation(.linear(duration: 5)) { with:
withAnimation {
This replaces the five second duration with the default transition duration.
➤ Build and run to see your completed splash screen animation and transition.
Transition from card list to single card
Skills you’ll learn in this section: correct transition view layer order
When you tap a card in the scrolling list of cards, the transition is very abrupt.
➤ Open CardsView.swift and add a new modifier to SingleCardView:
.transition(.move(edge: .bottom))
This transition will slide the new view in from the bottom edge. If you build and run the application, no transition animation takes place yet, because you haven’t configured which property to animate.
The Boolean property ViewState.showAllCards controls which view shows, so you’ll locate where this property toggles.
➤ Open CardsListView.swift and, in onTapGesture(count:perform:), change viewState.showAllCards.toggle() to:
withAnimation {
viewState.showAllCards = false
}
When viewState.showAllCards changes to false, it will now trigger animation in all places that use this property.
➤ Similarly, open CardsView.swift, and in createButton, change viewState.showAllCards = false to:
withAnimation {
viewState.showAllCards = false
}
Creating a new card now also performs the transition.
➤ In the Views/Single Card Views group, open CardToolbar.swift and locate the Done button.
➤ Replace viewState.showAllCards.toggle() with:
withAnimation {
viewState.showAllCards = true
}
➤ Build and run and choose a card. Although the initial slide transition takes place, the transition when you press Done does not work well. The card view transitions behind the list of cards. You can slow the simulator animations to see this better.
➤ Open CardsView.swift and add a new modifier to SingleCardView:
.zIndex(1)
zIndex controls the order of views when they are on top of each other. A view with zIndex of 1 will show in front of a view with zIndex of 0.
The transition moves the new view behind the old view, so to keep the card view in front, you change SingleCardView’s zIndex to higher than CardsListView’s.
➤ Build and run, and the transition animation from card to list now takes place in front.
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.
The carousel
Carousel.swift, included in the starter project in the Supporting Code group, is an alternative view for listing the cards. It’s a 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.
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 group, under CardsView.swift, create a new SwiftUI View file named ListSelectionView.swift.
➤ Add a new Binding to ListSelectionView:
@Binding var selection: CardListState
CardListState is an enumeration in ViewState.swift that can take one of two values: list and carousel. selection holds the current picker selection.
➤ Update ListSelectionView_Previews to pass the initial selection of list:
static var previews: some View {
ListSelectionView(selection: .constant(.list))
}
➤ In ListSelectionView, replace body with:
var body: some View {
// 1
Picker(selection: $selection, label: Text("")) {
// 2
Image(systemName: "square.grid.2x2.fill")
.tag(CardListState.list)
Image(systemName: "rectangle.stack.fill")
.tag(CardListState.carousel)
}
// 3
.pickerStyle(SegmentedPickerStyle())
.frame(width: 200)
}
Going through this code:
- You use a
Picker, passing in the selection property to update. - You assign SFSymbols for each option. When the user chooses an option, the
tag(_:)modifier will updateselectionwith the specified value. - You tell the
Pickerwhat picker style to use.
➤ Preview the 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 CardsView.swift and embed ZStack in a VStack.
VStack {
ZStack {
...
}
}
➤ At the top of VStack, add this code:
if viewState.showAllCards {
ListSelectionView(selection: $viewState.cardListState)
}
If you’re showing all the cards, show the picker so that you can decide how to view them.
➤ Change CardsListView() to:
switch viewState.cardListState {
case .list:
CardsListView()
case .carousel:
Carousel()
}
You show the scrolling list or the carousel depending on the view state.
➤ Build and run to see the picker in action.
Sharing the card
Skills you’ll learn in this section: share sheet;
UIActivityViewController; photo library permissions
At the moment, when you create a card, you’re the only person that can admire it. As a final feature, you’ll add sharing.
You’ll create a share button on the navigation bar. On tapping this button, you’ll screen capture the card. You’ll then use this screenshot in the built-in view controller that provides standard services, commonly called a Share view, for sharing to other apps such as email or your Photos library.
To make it easy to keep track of the sharing state, the starter project added two new properties.
- In Card.swift,
shareImagewill temporarily store the screenshot image for sharing. - In ViewState.swift,
shouldScreenshotwill trigger a screenshot when set totrue.
Currently in SwiftUI, there’s not an easy way to create a screenshot, so you’ll use a pre-made RenderableView with code in the starter project’s Supporting Code group.
➤ Open CardDetailView.swift and, in body, embed GeometryReader in a new Container by Command-clicking GeometryReader and choosing Embed… from the resulting menu.
➤ Rename Container to:
RenderableView(card: $card)
RenderableView is a @ViewBuilder, where you send the content view in a closure. You already created a simple container view in Chapter 10, “Refining Your App”, and other examples of @ViewBuilders are: VStack, Button and GeometryReader. With ViewModifiers, one view passes through the modifier to create a new view. With @ViewBuilders, however, you can supply multiple views inside the closure and create one new view.
You embed the card content view in RenderableView, and this view will take a screenshot when viewState.shouldScreenshot is true. RenderableView will also save a thumbnail image of the card to disk when the view disappears. You’ll use this thumbnail later in this chapter.
➤ Locate:
.modifier(CardToolbar(currentModal: $currentModal))
.cardModals(card: $card, currentModal: $currentModal)
➤ Cut these two lines and paste them at the end of body, so that they are modifiers on RenderableView rather than on content(size:).
Shortly, you’ll create a Share button in CardModalViews, which you call from cardModals(card:currentModal:). You should generally create modal views from as high a level as possible. In this case, if you leave the modifiers on content(size:), the system will get confused and present a second share sheet on top of the first share sheet. You’ll also get an uncomfortable message in the debug console: Presenting view controller from detached view controller…is discouraged.
➤ In the Model group, open CardModal.swift and add a new case to CardModal:
case shareSheet
➤ In the Views ▸ Single Card Views group, open CardToolbar.swift.
➤ Add this code to toolbar(content:) with the other ToolbarItems:
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
viewState.shouldScreenshot = true
currentModal = .shareSheet
}) {
Image(systemName: "square.and.arrow.up")
}
}
Here you create a share button on the leading edge of the navigation bar. When the user taps this button, viewState.shouldScreenshot triggers a screenshot in RenderableView, which saves the screenshot in card.shareImage. The button also sets the current modal to be a share sheet.
➤ In the Views ▸ Single Card Views group, open CardModalViews.swift.
➤ In body, add a new case to the switch statement:
case .shareSheet:
if let shareImage = card.shareImage {
ShareSheetView(
activityItems: [shareImage],
applicationActivities: nil)
.onDisappear {
card.shareImage = nil
}
}
Here you pass the screenshot to ShareSheetView and show the modal. This view controls a UIActivityViewController inside a UIViewControllerRepresentable and is created for you in ShareSheetView.swift in Supporting Code.
➤ Open SingleCardView.swift and preview the view to see your share button.
➤ Build and run the app and choose a card. Tap the share sheet icon at the top left. The card view now renders to a screenshot image, which is passed to the share sheet.
➤ Choose Save Image to save the image to the photo library.
The app will crash with an error:
This app has crashed because it attempted to access privacy-sensitive data without a usage description. The app’s Info.plist must contain an NSPhotoLibraryAddUsageDescription key with a string value explaining to the user how the app uses this data.
Whenever your app first adds an image to the photo library, you must get permission from the user and let them know how you will use the library data.
➤ In the Project navigator, choose the top Cards group. Choose the target Cards and Info along 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.
➤ Build and run the app again and choose a card. Share the card and save the image to the photo library again. This time, the app asks for permission to save to photos, showing the message you entered in the Info key.
➤ Tap OK and the card will save to the photo library. Check out the Photos app on the simulator to see your photo 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: Load the thumbnail image
When you tap Done on the card, RenderView disappears and saves a thumbnail file with the same name as the card id in Documents. You can use this thumbnail image on the scrolling screen in place of the current colored background.
In CardThumbnailView.swift, load this image file. There’s a load(uuidString:) method in UIImageExtensions. If the load is successful, 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.
Challenge 2: Change the text entry modal view
In the Supporting Code group, you’ll find an enhanced Text Entry view called TextView.swift, that lets users pick fonts and colors when they enter text. There is 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 TextPicker 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.
Run the app in Simulator to test the view, as the text element does not update with the font and color in preview.
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 per view with theanimation(_:)modifier. - Transitions are also easy with the
transition(_:)modifier. Remember to usewithAnimation(_:_:)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 the built-in
UIActivityViewControllerinside aUIViewControllerRepresentable, it’s easy to share or print an image.
Where to go from here?
You probably want to animate everything possible now. The book iOS Animation by Tutorials is available with the Pro subscription at https://bit.ly/3roiqMa and has two chapters fully dedicated to animations and transitions with SwiftUI.
A great example of an app with complex layout and animation is Apple’s Fruta sample app at https://apple.co/2XE8tNF. This is a fully featured app where “Users can order smoothies, save favorite drinks, collect rewards, and browse recipes.” Fruta also has various features, such as widgets, which you’ll learn about in Section 3. Download the app and see if you can work out how it all fits together.