Chapters

Hide chapters

SwiftUI Animations by Tutorials

First Edition · iOS 16 · Swift 5.7 · Xcode 14

Section I: SwiftUI Animations by Tutorials

Section 1: 11 chapters
Show chapters Hide chapters

7. Complex Custom Animations
Written by Bill Morefield

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

By now, you can see that creating more complex animations in SwiftUI relies on understanding how the SwiftUI protocols and animation engine work. Done correctly, your custom animations still use SwiftUI to handle as much work as possible.

To create more complex animations, you often need to combine several elements working together. One way to produce a more complex animation is to combine view transitions with animated state changes. Animating the appearance and removal of a view while animating a state change can make a view stand out and clarify the relationship between new elements on a view.

In the previous chapter, you worked on adding animations to your custom views. Up to this point, your animations were limited to relying on a single property, but SwiftUI also supports animating multiple property changes within the same view. In this chapter, you’ll create a view that supports five independently animated values.

First, you’ll look at how to combine transitions and animations to produce a unified animation.

Adding a Popup Button

Open the starter project for this chapter. You’ll see the tea brewing app you worked with in the previous chapter with a few added features. Since tastes in tea can vary, the app now lets users customize the brew settings. They can also record their review of the results of each brew to help them find the perfect process to match their taste for each tea.

Open TimerView.swift. You’ll see the timer is now at the top of the view to make it easier to see. The timer also adds a slider to let the user adjust the brewing length.

Further down, you’ll see the familiar information showing the suggested brewing temperature and a slider that lets the user adjust the amount of water so the app can provide a suggested amount of tea. You’ll now add a button so the user can adjust the suggested ratio of tea to water.

Create a new SwiftUI view file inside the Timer folder named PopupSelectionButton.swift. Add the following properties to the generated view:

@Binding var currentValue: Double?
var values: [Double]

These properties provide a binding that passes the selection back from the view. It also allows passing in an array of Double values that can be selected. Replace the preview body with:

PopupSelectionButton(
  currentValue: .constant(3),
  values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
)

This code provides the view sample settings. Update the view’s body to:

Group {
  if let currentValue {
    Text(currentValue, format: .number)
      .modifier(CircledTextToggle(backgroundColor: Color("Bourbon")))
  } else {
    Text("\(Image(systemName: "exclamationmark"))")
      .modifier(CircledTextToggle(backgroundColor: Color(.red)))
  }
}

This code attempts to unwrap the currentValue binding property. If successful, the value will display using the color bourbon for the background. If not, the view will show an exclamation mark with a red background. You wrap the conditional inside a Group so you can apply additional modifiers to the two view states without repeating code. The CircledTextToggle view modifier is identical to the CircledText view modifier, except it applies a fixed frame to the Text. Without adding this frame, the changing size of the Text view when transitioning from text to a system image would cause the view to shift.

Since you provided the preview a value of 3, you’ll now see the result, which shows the numeral three with the bourbon color background.

Basic button
Basic button

Your button shows the value but doesn’t let the user change it. You’ll implement that in the next section.

Adding Button Options

Add the following property after values:

@State private var showOptions = false
.onTapGesture {
  showOptions.toggle()
}
private func xOffset(for index: Int) -> Double {
  // 1
  let distance = 180.0
  // 2
  let angle = Angle(degrees: Double(90 + 15 * index)).radians
  // 3
  return distance * sin(angle) - distance
}

private func yOffset(for index: Int) -> Double {
  let distance = 180.0
  let angle = Angle(degrees: Double(90 + 15 * index)).radians
  return distance * cos(angle) - 45
}
// 1
if showOptions {
  // 2
  ForEach(values.indices, id: \.self) { index in
    // 3
    Text(values[index], format: .number)
      .modifier(CircledText(backgroundColor: Color("OliveGreen")))
      // 4
      .offset(
        x: xOffset(for: index),
        y: yOffset(for: index)
      )
      // 5
      .onTapGesture {
        currentValue = values[index]
        showOptions = false
      }
  }
  Text("\(Image(systemName: "xmark.circle"))")
    .transition(.opacity.animation(.linear(duration: 0.25)))
    .modifier(CircledTextToggle(backgroundColor: Color(.red)))
}
HStack(alignment: .bottom) {
  Text("\(teaToUse.formatted()) teaspoons")
    .modifier(InformationText())
  Spacer()
  PopupSelectionButton(
    currentValue: $waterTeaRatio,
    values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
  )
}

Animating the Options

Since an animation requires a state change, your first thought might be to animate using the showOptions already in place. If you try that, you’ll find a problem. Changing showOptions causes SwiftUI to add or remove views. If you recall, you need a special type of animation called a transition to animate the appearance or removal of views.

@State private var animateOptions = false
.offset(
  x: animateOptions ? xOffset(for: index) : 0,
  y: animateOptions ? yOffset(for: index) : 0
)
// 1
withAnimation(.easeOut(duration: 0.25)) {
  animateOptions = !showOptions
}
// 2
withAnimation { showOptions.toggle() }
.onTapGesture {
  currentValue = values[index]
  withAnimation(.easeOut(duration: 0.25)) {
    animateOptions = false
  }
  withAnimation { showOptions = false }
}
.transition(.scale.animation(.easeOut(duration: 0.25)))

Animating Multiple Properties

In Chapter 6: Intro to Custom Animations, you learned about the Animatable protocol and used it to produce views that could handle animations beyond what SwiftUI can handle by default. The changing number and sliding number animations you built in those chapters only dealt with a single changing value. In this section, you’ll create a view with five parameters that are fully animated.

Creating a Radar Chart

Create a new SwiftUI view file named AnimatedRadarChart.swift under the RadarChart group. Add the following properties to the new view:

var time: Double
var temperature: Double
var amountWater: Double
var amountTea: Double
var rating: Double
AnimatedRadarChart(
  time: Double(BrewResult.sampleResult.time),
  temperature: Double(BrewResult.sampleResult.temperature),
  amountWater: BrewResult.sampleResult.amountWater,
  amountTea: BrewResult.sampleResult.amountTea,
  rating: Double(BrewResult.sampleResult.rating)
)
var values: [Double] {
  [
    time / 600.0,
    temperature / 212.0,
    amountWater / 16.0,
    amountTea / 16.0,
    rating / 5.0
  ]
}
// 1
ZStack {
  // 2
  GeometryReader { proxy in
    // 3
    let graphSize = min(proxy.size.width, proxy.size.height) / 2.0
    let xCenter = proxy.size.width / 2.0
    let yCenter = proxy.size.height / 2.0
  }
}
// 4
ForEach(0..<5 id: \.self) { index in
  Path { path in
    path.move(to: .zero)
    path.addLine(to: .init(x: 0, y: -graphSize * values[index]))
  }
  // 5
  .stroke(.black, lineWidth: 2)
  // 6
  .offset(x: xCenter, y: yCenter)
  // 7
  .rotationEffect(.degrees(72.0 * Double(index)))
}

Adding Grid Lines

Now you’ll add a guide to each value. Inside the ForEach loop, in front of the existing Path, add:

Path { path in
  path.move(to: .zero)
  path.addLine(to: .init(x: 0, y: -graphSize))
}
.stroke(.gray, lineWidth: 1)
.offset(x: xCenter, y: yCenter)
.rotationEffect(.degrees(72.0 * Double(index)))
// 1
let chartFraction = Array(stride(
  from: 0.2,
  to: 1.0,
  by: 0.2
))
ForEach(chartFraction, id: \.self) { fraction in
  // 2
  Path { path in
    path.addArc(
      center: .zero,
      radius: graphSize * fraction,
      startAngle: .degrees(0),
      endAngle: .degrees(360),
      clockwise: true
    )
  }
  // 3
  .stroke(.gray, lineWidth: 1)
  .offset(x: xCenter, y: yCenter)
}

Coloring the Radar Chart

The chart looks a little dull in shades of gray. To add some color, add the following code before the body of the view:

let lineColors: [Color] = [.black, .red, .blue, .green, .yellow]
.stroke(lineColors[index], lineWidth: 2)
struct PolygonChartView: View {
  var values: [Double]
  var graphSize: Double
  var colorArray: [Color]
  var xCenter: Double
  var yCenter: Double

  var body: some View {
    Path { path in
    }
  }
}
var gradientColors: AngularGradient {
  AngularGradient(
    colors: colorArray + [colorArray.first ?? .black],
    center: .center,
    angle: .degrees(-90)
  )
}
// 1
for index in values.indices {
  let value = values[index]
  // 2
  let radians = Angle(degrees: 72.0 * Double(index)).radians
  // 3
  let x = sin(radians) * graphSize * value
  let y = cos(radians) * -graphSize * value
  // 4
  if index == 0 {
    path.move(to: .init(x: x, y: y))
  } else {
    path.addLine(to: .init(x: x, y: y))
  }
}
// 5
path.closeSubpath()
.offset(x: xCenter, y: yCenter)
.fill(gradientColors)
.opacity(0.5)
PolygonChartView(
  values: values,
  graphSize: graphSize,
  colorArray: lineColors,
  xCenter: xCenter,
  yCenter: yCenter
)

Using the Radar Chart

Open TeaRatingsView.swift. Now add the following code at the end of the ZStack in place of the comment reading // Add Radar Chart Here:

AnimatedRadarChart(
  time: Double(ratings[selectedRating].time),
  temperature: Double(ratings[selectedRating].temperature),
  amountWater: ratings[selectedRating].amountWater,
  amountTea: ratings[selectedRating].amountTea,
  rating: Double(ratings[selectedRating].rating)
)
.aspectRatio(contentMode: .fit)
.animation(.linear, value: selectedRating)
.padding(20)
.background(
  RoundedRectangle(cornerRadius: 20)
    .fill(Color("QuarterSpanishWhite"))
)

Animating the Radar Chart

When you need to animate a single value, you conform to the Animatable protocol. This protocol uses a property named animatableData that SwiftUI uses to pass the changing value into your view. In Chapter 6: Intro to Custom Animations, you set animatableData to a Double. So how can you manage five Doubles?

AnimatablePair<Double, Double>
struct AnimatedRadarChart: View, Animatable {
// 1
var animatableData: AnimatablePair<
  // 2
  AnimatablePair<Double, Double>,
  // 3
  AnimatablePair<
    // 4
    AnimatablePair<Double, Double>,
    // 5
    Double
  >
>
IyuqoxehkiQuol Boatpu Hueyra AnohomokbaHuod OmemudubciQioq UzijawolmoXiin Duavzi Pooywu Liunte vuyegz rapolt dofps jedwq vimxd lesly konenc bevagw

OwojigecxoSoug Rougje Qaifzi EliriyijwiTuih EresuqajlaTeun AyuwefokmeQuem Zeoxva Yaofvi Viitze yenuxc raxidz qodds xecyk bakjf fesqc hurusn nohepg wawusd aliiqgCie omoijdGudok kiklahasuju dode

> {
  get {
    // 1
    AnimatablePair(
      AnimatablePair(time, temperature),
      AnimatablePair(
        AnimatablePair(amountWater, amountTea),
        rating
      )
    )
  }
  set {
    // 2
    time = newValue.first.first
    temperature = newValue.first.second
    amountWater = newValue.second.first.first
    amountTea = newValue.second.first.second
    rating = newValue.second.second
  }
}

Key Points

  • Transitions are a type of animation. When combining transitions, you’ll find it easier to use different state changes to control each individually.
  • You can apply an animation to a transition that will set the transition’s animation curve and duration.
  • A radar chart provides a way to visualize the relationship between multiple related values.
  • You can use the AnimatablePair type when you need to animate multiple values in a view that conforms to the Animatable protocol.
  • If you need to animate more than two values, you can nest multiple AnimatablePair structures within each other. While this can quickly become complicated, it’ll let you support many values.

Where to Go From Here?

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now