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.
The project has an additional group called Supporting Code. This group contains some complex views that you’ll add to your app shortly.
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()
.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 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 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.
➤ 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.
➤ 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.
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.
➤ At the end of SplashScreen.swift, add a new extension where you can improve 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.
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 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.
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, create a new property:
let animation = Animation.interpolatingSpring(
mass: 0.2,
stiffness: 80,
damping: 5,
initialVelocity: 0.0)
This creates a spring animation.
➤ In SplashAnimation, replace the withAnimation(_:_:) closure with:
withAnimation(animation.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 the animation 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.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 {
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 CardsListView():
.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.
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 CardsListView.swift, 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 ListSelection_Previews to pass the initial selection of list:
static var previews: some View {
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:
- 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 updatelistStatewith the specified value. - You tell the
Pickerwhat picker style to use. Other picker styles includemenuandwheel, which displays options in a scrollable wheel.
➤ 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 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 VStack, before Group:
ListSelection(listState: $listState)
➤ 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.
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 navigation 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 group, 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 group, 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
let uiImage = renderer.uiImage ?? UIImage.errorImage
return uiImage
}
}
There’s a lot to unpack here:
-
MainActorensures 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 callsUIImage.screenshot(card:size:)must also be marked withMainActor, otherwise it will not compile. - 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.
- Render the image from the view.
ImageRender<Content>initializes with a view and draws it to aCanvas. You can render shapes or text or any otherViewto an image. - Extract a
UIImagefrom the rendered image, but if there’s an error, use the error image in the asset catalog.
➤ In the Single Card Views group, open CardToolbar.swift and add this code after the Done button ToolbarItem:
ToolbarItem(placement: .navigationBarLeading) {
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 navigation 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")
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.
➤ 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.
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.
➤ 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.
➤ 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: 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:
- Locate the code where you save the card in SingleCardView.swift. First use
UIImage.screenshot(card:size:)to generate aUIImageand then save theUIImageto a file. UIImageExtensions.swift contains a methodUIImage.save(to:)to save the file. Usecard.id.uuidStringas the filename. - In CardThumbnail.swift, load this image file. There’s a
UIImage.load(uuidString:)method in UIImageExtensions.swift. If the load is successful, show the image. If not, show the card’s background color. Enclose the two alternative views in aGroupand 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 will load when you first run the app, but not when you change a card by moving one of the elements. In CardsListView.swift, CardThumbnail will only refresh if there are published changes. CardThumbnail uses card from store.cards, and this is the property that you need to update.
- Add a
uiImage: UIImage?property toCardand update this property when you load the card image in SingleCardView.swift. Updating this property means that you update the published propertycardsinCardStore, and the card thumbnail will redraw.
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’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.
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 theanimation(_:value:)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 SwiftUI’s
ShareLink, you can share any item that conforms toTransferable. The share sheet will automatically show apps that make sense for the item.
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 and has two chapters dedicated to animations and transitions with SwiftUI.
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.