Collapsing top bar animation with SwiftUI

Introduction

Many modern apps have a top bar, with customized designs and smooth collapsing animations. Since the default NavigationView doesn’t provide much customizations in terms of the UI, let’s see how we can do this with our own implementation in SwiftUI.

Animations in SwiftUI

SwiftUI makes building UI interfaces fun and easy, with animations being an integral part of it. One of the ways to do animations in SwiftUI is by animating the change of view states. Something as simple as adding a view modifier .animation(.default) is enough to animate a transition from an old to a new view state.

Which means, the most important thing that we need to do is to figure out how to compute the new view state, when the user scrolls up / down in the list below.

Simulator Screen Shot - iPhone 11 Pro - 2020-04-17 at 19.01.46

Custom top bar

As you can see, we are not using a default nav bar, but a view that has rounded corners. When the view collapses, it moves to a standard rectangle shape, with no rounded corners.  We should also handle the state changes continuously – not just two states collapsed and expanded, in order to provide smoother user experience. After some threshold is passed, the view goes to the fully expanded / contracted states.

Below the top bar, we have one big vertical scroll view, which contains several horizontal scroll views, with blog posts. The data is dummy and copy pasted in all the rows, since in this post we are mostly focusing on the animations.

The problem we need to solve is the following – detect how much the user scrolled up / down and based on that, provide a formula which will return the new frame and corner radius of the top view.

We need something like an onScroll method, similar to the delegate method in UIScrollView, which will tell us the offset of the scroll view. By comparing the initial offset with the current offset, we can easily determine the next state.

However, SwiftUI doesn’t have such method yet (at the moment of writing, with Xcode 11.3), so we need to find a workaround to this missing functionality.

One option is to use the GeometryReader. With the GeometryReader, we can find out what would be the proposed size of the view. However, when we put it inside a scroll view, and use the frame(in:) method, we can calculate the view’s position in that coordinate space (the scroll view in our case), which is basically what we need.

ScrollView(.vertical, showsIndicators: false) {
GeometryReader { geometry in
Color.clear.preference(key: OffsetKey.self, value: geometry.frame(in: .global).minY)
.frame(height: 0)
}

Since we need to store the preference somehow and return something from the GeometryReader, one option is to return a clear Color, or an EmptyView. It’s not a perfect solution, but while we wait for something better from SwiftUI, it’s a working solution.

Preferences

Another problem we need to solve is to pass the scrolling offset from the scroll view to the top view, so it knows how big it should be. For this, we can use Preferences. Preferences in SwiftUI are used to implicitly pass values from the child views to their superviews. With this, we can find some value we need (the scrolling offset), and pass it in a parent view (the TopView in our case).

Screen Shot 2020-04-17 at 19.20.19

To create our own preference key, we need to implement the PreferenceKey protocol. It’s a simple protocol – we need to provide a default value, as well as implement the reduce function, which decides which value from the children will be passed on to the superview. In our case, since we will be setting this value from one child, it’s enough to look for the first non-nil value.

 

struct OffsetKey: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?,
nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}

Computing the new view state

To read the value set in the preferences, we can hook to the onPreferenceChange method. We will need to keep track of two values, the initial offset and the current offset of the scroll view.

var body: some View {
VStack {
TopView(offset: self.$offset,
initialOffset: self.$initialOffset)

ScrollView(.vertical, showsIndicators: false) {
GeometryReader { geometry in
Color.clear.preference(key: OffsetKey.self, value: geometry.frame(in: .global).minY)
.frame(height: 0)
}
VStack(spacing: 12) {
PostsSection(initialOffset: $initialOffset,
offset: $offset,
color: Color.white)
// removed for brevity
Spacer()
}
}

}
.onPreferenceChange(OffsetKey.self) {
if self.initialOffset == nil || self.initialOffset == 0 {
self.initialOffset = $0
}
self.offset = $0
}
}

When the initialOffset is nil or 0, we set it its value. Afterwards, we only update the offset variable. These two are @State variables of the HomeView, and we are passing them as bindings to the TopView. The TopView reacts to the changes to the values of the offsets, by changing its frame.

struct TopView: View {

@Binding var offset: CGFloat?
@Binding var initialOffset: CGFloat?

var body: some View {
HStack(spacing: 12) {
Text("Martin's blog")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(Color.white)
Spacer()
Button(action: {
print("button pressed")
}) {
Image(systemName: "magnifyingglass")
.renderingMode(.original)
.foregroundColor(Color("navTitle1"))
.font(.system(size: 16, weight: .medium))
.frame(width: 36, height: 36)
.background(Color.white)
.clipShape(Circle())
.shadow(color: Color.black.opacity(0.1), radius: 1, x: 0, y: 1)
.shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 10)
}

}
.padding(.top, 35)
.padding(.leading, 15)
.padding(10)
.background(viewForBackground())
.animation(.linear)
}

private func viewForBackground() -> some View {
let values = heightAndRadiusForBackground()
return RoundedRectangle(cornerRadius: values.1)
.fill(LinearGradient(gradient: Gradient(colors: [Color("navTitle1"), Color.purple]), startPoint: .top, endPoint: .bottom))
.frame(height: values.0)
.animation(.linear)
}

private func heightAndRadiusForBackground() -> (CGFloat, CGFloat) {
let maxHeight: CGFloat = 350
let minHeight: CGFloat = 127
let factor: CGFloat = 5
let radius: CGFloat = 60

guard let initialOffset = initialOffset,
let offset = offset else {
return (maxHeight, radius)
}

if initialOffset > offset {
let diff = initialOffset - offset
if diff > 40 {
return (minHeight, 0)
} else {
let computed = maxHeight - (factor * diff)
let height = (computed > minHeight && computed < maxHeight) ? computed : minHeight
                let returnRadius = height == minHeight ? 0 : radius
return (height, returnRadius)
}
}

return (maxHeight, radius)
}

}

In this view, we do something similar to what we would've done with UIKit. That would be changing the size of the top view, which in this view is implemented by changing its background. In the heightAndRadiusForBackground, we define the thresholds for minimum and maximum height of the view. First, we check if it's the initial state. If yes, we are returning the max height. Next, we check the difference between the offsets. If the initial offset is bigger, that means the user has scrolled down and we need to do some change of the frame. Based on the difference, we compute how big the new height is going to be. For the cornerRadius, we use discrete values in this example, but you can play with continuous values there as well. Afterwards, we return those values and use them to create our RoundedRectangle.

By attaching the .animation modifier, we will have all the transitions (based on our calculations) animated. We are using the .linear animation. Linear animation is working like a linear function, it animates with same duration and progress and any point in time of the animation.

standard_linear_function_2

You can change the duration of the animation, the type (for example .default, easeInOut etc), or even implement your custom animations.

Conclusion

Animations with SwiftUI are deeply integrated in the layout system. There are still some missing APIs, like the scrolling offset, that make us do some hacks to get the needed offset, but in general things will improve over time.

What are your thoughts about animations in SwiftUI? Leave your thoughts in the comment section below.

You can find the complete source of the project here.

3 Comments

  1. Hi Martin,
    You really explained this good! Great article! Would it be possible to use a default nav bar exactly like youtube iOS. So when scrolling up and down it hides and shows?

    Like

    1. Hi graphlogik,

      Thanks, I’m glad you like it. If you want to have a nav bar that hides and shows when scrolling, you can just put a custom nav bar view inside the scroll view or list that you are using. In that way, it will scroll together with all the content, like on YouTube.
      One note, the default NavigationView from SwiftUI doesn’t behave like that – it always stays while scrolling, in a collapsed state. So that’s why a custom view is the better option at the moment.
      Hope that helps.
      Best,
      Martin

      Like

Leave a comment