Chapters

Hide chapters

SwiftUI by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

18. Animations & View Transitions
Written by Bill Morefield

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

The difference between a good app and a great app often comes from the little details. Using the right animations at the right places can delight users and make your app stand out in the crowded App Store.

Animations can make your app more fun to use, and they can play a decisive role in drawing the user’s attention in certain areas. Good animations make your app more appealing and easier to use.

Animation in SwiftUI is much simpler than animation in AppKit or UIKit. SwiftUI animations are higher-level abstractions that handle all the tedious work for you. If you have experience with animations in Apple platforms, a lot of this chapter will seem familiar. You’ll find it a lot less effort to produce animations in your app. You can combine or overlap animations and interrupt them without care. Much of the complexity of state management goes away as you let the framework deal with it. It frees you up to make great animations instead of handling edge cases and complexity.

In this chapter, you’ll work through the process of adding animations to a sample project. Time to get the screen moving!

Animating state changes

First, open the starter project for this chapter. Build and run the project for this chapter. You’ll see an app that shows flight information for an airport. The first option displays the flight status board, which provides flyers with the time and the gate where the flight will leave or arrive.

Flight board
Flight board

Note: Unfortunately, it’s challenging to show animations on a printed page. You’ll need to work through this chapter using the preview, the simulator or on a device. The preview makes tweaking animations a lot easier, but sometimes animations won’t look quite right in the preview. When you don’t see the same thing in the preview described here, try running the app in the simulator or on a device.

Adding animation

To start, open FlightInfoPanel.swift and look for the following code:

if showTerminal {
  FlightTerminalMap(flight: flight)
}
Button(action: {
  showTerminal.toggle()
}, label: {
  HStack(alignment: .center) {
    Text(
      showTerminal ?
      "Hide Terminal Map" :
      "Show Terminal Map"
    )
    Spacer()
    Image(systemName: "airplane.circle")
      .resizable()
      .frame(width: 30, height: 30)
      .padding(.trailing, 10)
      .rotationEffect(.degrees(showTerminal ? 90 : -90))
  }
})
.animation(.linear(duration: 1.0))
.rotationEffect(.degrees(showTerminal ? 90 : 270))

Animation types

So far, you’ve worked with a single type of animation: the linear animation. It provides a linear change at a constant rate from the original state to the final state. If you graphed the change vertically against time horizontally, the transition would look like:

Linear animation
Zajiim ucewoqiad

Image(systemName: "airplane.circle")
  .resizable()
  .frame(width: 30, height: 30)
  .padding(.trailing, 10)
  .rotationEffect(.degrees(showTerminal ? 90 : 270))
  .animation(.linear(duration: 1.0))
Spacer()
Two icons
Kwu imisq

.animation(Animation.default.speed(0.33))

Eased animations

Eased animations might be the most common in apps. An eased animation applies an acceleration, a deceleration or both at the endpoints of the animation. They generally look more natural since something can’t change speed in the real world instantaneously. The animation reflects the acceleration or deceleration of real-world movement.

Ease in out
Oiyi an oot

.animation(.easeInOut(duration: 1.0))
.animation(.easeOut(duration: 1.0))
Ease out
Uozu uit

Ease in
Uuca em

timingCurve
leqerqLitsa

Spring animations

Eased animations always transition between the start and end states in a single direction. They also never pass either end state. The other SwiftUI animations category let you add a bit of bounce at the end of the state change. The physical model for this type of animation gives it the name: a spring.

Why a spring makes a useful animation

Springs resist stretching and compression — the greater the spring’s stretch or compression, the more resistance the spring presents. Imagine a weight attached at one end of a spring. Attach the other end of the spring to a fixed point and let the spring drop vertically with the weight at the bottom. It will bounce several times before coming to a stop.

dampen shm
simget hxk

Creating spring animations

Change the animation for the second icon to:

.animation(
  .interpolatingSpring(
    mass: 1,
    stiffness: 100,
    damping: 10,
    initialVelocity: 0
  )
)
.animation(
  .spring(
    response: 0.55,
    dampingFraction: 0.45,
    blendDuration: 0
  )
)

Removing and combining animations

There are times that you may apply modifications to a view, but you only want to animate some of them. You do this by passing a nil to the animation() method.

.scaleEffect(showTerminal ? 1.5 : 1.0)
.animation(nil)
 .animation(.linear(duration: 1))
Image(systemName: "airplane.circle")
  .resizable()
  .frame(width: 30, height: 30)
  .padding(.trailing, 10)
  .rotationEffect(.degrees(showTerminal ? 90 : 270))
  .animation(
    .spring(
      response: 0.55,
      dampingFraction: 0.45,
      blendDuration: 0
    )
  )

Animating from state changes

To this point in the chapter, you’ve applied animations at the element of the view that changed. You can also apply the animation where the state change occurs. When doing so, the animation applies to all changes that occur because of the state change.

@State private var showBars = CGFloat(0)
func minuteLength(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  let pointsPerMinute = proxy.size.width / minuteRange
  return CGFloat(abs(minutes)) * pointsPerMinute * showBars
}
.onAppear {
  withAnimation(Animation.default.delay(0.5)) {
    showBars = CGFloat(1)
  }
}
func minuteOffset(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  let pointsPerMinute = proxy.size.width / minuteRange
  let offset = minutes < 0 ? 15 + minutes * Int(showBars) : 15
  return CGFloat(offset) * pointsPerMinute
}

Cascading animations

The delay() method allows you to specify a time in seconds to pause before the animation occurs. You used it in the previous section so the view fully displayed before the bars animated.

@State private var showBars = false
.frame(
  width: showBars ?
    minuteLength(history.timeDifference, proxy: proxy) :
    0
)
.offset(
  x: showBars ?
    minuteOffset(history.timeDifference, proxy: proxy) :
    minuteOffset(0, proxy: proxy)
)
.onAppear {
  showBars = true
}
.animation(
  Animation.easeInOut.delay(Double(history.day) * 0.1)
)

Extracting animations from the view

To this point, you’ve defined animations directly within the view. For exploring and learning, that works well. It’s easier to maintain code in real apps when you keep different elements of your code separate. Doing so also lets you reuse them. In DelayBarChart.swift, add the following code above the body structure:

func barAnimation(_ barNumber: Int) -> Animation {
  return Animation.easeInOut.delay(Double(barNumber) * 0.1)
}
.animation(barAnimation(history.day))

Animating paths

Run the app and tap on Flight Status and then tap on a flight. Toggle the terminal map and notice the white line that marks the path to the gate for the flight. Open FlightTerminalMap.swift and you’ll see the line is determined using a set of fixed points that are scaled to the size of the view. The code below draws the path:

Path { path in
  // 1
  let walkingPath = gatePath(proxy)
  // 2
  guard walkingPath.count > 1 else { return }
  // 3
  path.addLines(walkingPath)
}.stroke(Color.white, lineWidth: 3.0)

Making a state change

To animate this path, you need a state change on a property that SwiftUI knows how to animate. Animations function because of the Animatable protocol. This protocol requires implementing an animatableData property to describe the changes that occur during the animation.

@State private var showPath = false
struct WalkPath: Shape {
  var points: [CGPoint]

  func path(in rect: CGRect) -> Path {
    return Path { path in
      guard points.count > 1 else { return }
      path.addLines(points)
    }
  }
}
var walkingAnimation: Animation {
  Animation
    .linear(duration: 3.0)
    .repeatForever(autoreverses: false)
}
WalkPath(points: gatePath(proxy))
  .trim(to: showPath ? 1.0 : 0.0)
  .stroke(Color.white, lineWidth: 3.0)
  .animation(walkingAnimation)
.onAppear {
  showPath = true
}

Animating view transitions

Note: Transitions often render incorrectly in the preview. If you do not see what you expect, try running the app in the simulator or on a device.

Text(
  showTerminal ?
  "Hide Terminal Map" :
  "Show Terminal Map"
)
if showTerminal {
  Text("Hide Terminal Map")
} else {
  Text("Show Terminal Map")
}
Group {
  if showTerminal {
    Text("Hide Terminal Map")
  } else {
    Text("Show Terminal Map")
  }
}
.transition(.slide)
Button(action: {
  withAnimation {
    showTerminal.toggle()
  }
}, label: {

View transition types

The default transition type changes the opacity of the view when adding or removing it. The view goes from transparent to opaque on insertion and from opaque to transparent on removal. You can create a more customized version using the .opacity transition.

.transition(.move(edge: .bottom))

Extracting transitions from the view

You can extract your transitions from the view as you did with animations. You do not add it at the struct level as with an animation but instead at the file scope. At the top of FlightInfoPanel.swift add the following:

extension AnyTransition {
  static var buttonNameTransition: AnyTransition {
    AnyTransition.slide
  }
}
if showTerminal {
  FlightTerminalMap(flight: flight)
    .transition(.buttonNameTransition)
}

Async transitions

SwiftUI lets you specify separate transitions when adding and removing a view. Change the static property to:

extension AnyTransition {
  static var buttonNameTransition: AnyTransition {
    let insertion = AnyTransition.move(edge: .trailing)
      .combined(with: .opacity)
    let removal = AnyTransition.scale(scale: 0.0)
      .combined(with: .opacity)
    return .asymmetric(insertion: insertion, removal: removal)
  }
}

Linking view transitions

The second release of SwiftUI added many features. The one you’ll use in this section is the matchedGeometryEffect method. It allows you to synchronize the animations of multiple views. Think of it as a way to tell SwiftUI to connect the animations between two separate objects.

@State var selectedAward: AwardInformation?
@Binding var selected: AwardInformation?
AwardCardView(award: award)
  .foregroundColor(.black)
  .aspectRatio(0.67, contentMode: .fit)
  .onTapGesture {
    selected = award
  }
AwardGrid(
  title: "Test",
  awards: AppEnvironment().awardList,
  selected: .constant(nil)
)
ZStack {
  // 1
  if let award = selectedAward {
    // 2
    AwardDetails(award: award)
      .background(Color.white)
      .shadow(radius: 5.0)
      .clipShape(RoundedRectangle(cornerRadius: 20.0))
      // 3
      .onTapGesture {
        selectedAward = nil
      }
  } else {
    ScrollView {
      LazyVGrid(columns: awardColumns) {
        AwardGrid(
          title: "Awarded",
          awards: activeAwards,
          selected: $selectedAward
        )
        AwardGrid(
          title: "Not Awarded",
          awards: inactiveAwards,
          selected: $selectedAward
        )
      }
    }
  }
}
.onTapGesture {
  withAnimation {
    selectedAward = nil
  }
}
.onTapGesture {
  withAnimation {
    selected = award
  }
}
@Namespace var cardNamespace
.matchedGeometryEffect(
  id: award.hashValue,
  in: cardNamespace,
  anchor: .topLeading
)
AwardGrid(
  title: "Awarded",
  awards: activeAwards,
  selected: $selectedAward,
  namespace: cardNamespace
)
AwardGrid(
  title: "Not Awarded",
  awards: inactiveAwards,
  selected: $selectedAward,
  namespace: cardNamespace
)
var namespace: Namespace.ID
@Namespace static var namespace
AwardGrid(
  title: "Test",
  awards: AppEnvironment().awardList,
  selected: .constant(nil),
  namespace: namespace
)
.matchedGeometryEffect(
  id: award.hashValue,
  in: namespace,
  anchor: .topLeading
)

Key points

  • Don’t use animations only for the sake of doing so. Have a purpose for each animation.
  • Keep animations between 0.25 and 1.0 second in length. Shorter animations are often not noticeable. Longer animations risk annoying your user wanting to get something done.
  • Keep animations consistent within an app and with platform usage.
  • Animations should be optional. Respect accessibility settings to reduce or eliminate application animations.
  • Make sure animations are smooth and flow from one state to another.
  • Animations can make a huge difference in an app if used wisely.
  • Using matchedGeometryEffect let’s you link view transitions into a single animation.

Where to go from here?

You can read more about animations in Getting Started with SwiftUI Animations at https://www.raywenderlich.com/5815412-getting-started-with-swiftui-animations

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.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now