Replicating the Safari toolbar collapsing and expanding animation on iOS 15


The iOS 15 update brought us a redesigned Safari app with the address bar being displayed at the bottom of the screen compared to the earlier version where it was on top. The redesign also brought us some delightful animations when creating new tabs or when scrolling web pages where the address bar would expand and collapse. I found the animations quite neat, so I spent some time trying to recreate them.

In this blog post I will explain how I implemented the animation for collapsing and expanding of the toolbar and address bar when the user scrolls a web page. You can take a look at the source code on this GitHub repo. The end result is the following animation:



Toolbar and address bar setup

The Safari toolbar is implemented as a UIToolbar subclass that contains UIBarButtonItems for different actions. When the toolbar is added to the view hierarchy its height is set to 100, so that it can fit the address bar above the UIBarButtonItems.
The constraints setup method for the toolbar is written using SnapKit and looks like this:

func setupToolbar() {
  addSubview(toolbar)
  toolbar.snp.makeConstraints {
    $0.top.equalTo(tabsScrollView.snp.bottom)
    $0.leading.trailing.equalToSuperview()
    $0.height.equalTo(100)
    toolbarBottomConstraint = $0.bottom.equalTo(safeAreaLayoutGuide.snp.bottom).constraint
  }
}

We are keeping the reference to the bottom constraint, so that we can use it later to animate the disappearance of the toolbar.

The address bar is also a custom class that contains, among other properties, a textfield for entering the website that you want to load and a label for showing the domain name of the website. The address bar is added to the root view and placed on top of the toolbar:

func setupAddressBar() {
  addSubview(addressBar)
  addressBar.snp.makeConstraints {
    $0.leading.trailing.equalToSuperview()
    $0.height.equalTo(62)
    addressBarBottomConstraint = $0.bottom.equalTo(safeAreaLayoutGuide).offset(addressBarExpandingFullyBottomOffset).constraint
  }
}

The addressBarExpandingFullyBottomOffset property is a CGFloat constant that we will need later on to show the toolbar expanding animation.


Collapsing animation

For implementing the toolbar collapsing animation we can use the UIViewPropertyAnimator. It is a great way to create dynamic, interactive, interruptable animations and the API is relatively easy to use.

The collapsing animation is composed of two parts. In the first part of the animation the address bar and toolbar are pushed down the screen until they reach a certain threshold. Then the second part of the animation starts which:

  1. pushes the toolbar below the bottom edge of the screen
  2. pushes the address bar to the bottom of the screen
  3. scales down the address bar, so that it gives the effect of it being stretched
  4. scales down the domain label, so it fits the toolbar in the collapsed state

The animator for the collapsing animation would look like this:

func setupCollapsingToolbarAnimator {
  // 1
  addressBarBottomConstraint?.update(offset: addressBarCollapsingHalfwayBottomOffset)
  toolbarBottomConstraint?.update(offset: toolbarCollapsingHalfwayBottomOffset)
  collapsingToolbarAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { [weak self] in
    // 2
    self?.setAddressBarAlpha(0)
    self?.view.layoutIfNeeded()
  }

  // 3
  collapsingToolbarAnimator?.addCompletion { [weak self] _ in
    guard let self = self else { return }
    // 4
    self.addressBarBottomConstraint?.update(offset: self.addressBarCollapsingFullyBottomOffset)
    self.toolbarBottomConstraint?.update(offset: self.toolbarCollapsingFullyBottomOffset)
    UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) { [weak self] in
      guard let self = self else { return }
      self.addressBar.containerView.transform = CGAffineTransform(scaleX: 1.2, y: 0.8)
      self.addressBar.domainLabel.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
      self.view.layoutIfNeeded()
    }.startAnimation()
  }
  // 5
  collapsingToolbarAnimator?.pauseAnimation()
}
  1. We need to update the address bar and toolbar constraints, so that we can prepare the first part of the animation where the address bar and toolbar partially collapse.
  2. We create our animator whose animation block triggers the constraints change animation and address bar alpha animation.
  3. We add a completion block to the animator which will trigger and complete the second part of the collapsing animation.
  4. In the second part of the animation we once again update the address bar and toolbar constraints and animate the change using another independent animator. We also update the transform of the address bar to create the stretching animation and update the transform of the domain label to animate the scaling down of the label text.
  5. We need to call pauseAnimation() to move the animator to the active state. We will later manually control the animation by updating the fractionComplete property.


Expanding animation

The expanding animation is implemented the same way as the collapsing animation - using the UIViewPropertyAnimator. The expanding animation is also composed of two parts. In the first part of the animation the address bar and the toolbar are pushed up the screen until they reach a certain threshold. Then the second part of the animation starts which:

  1. sets the toolbar and address bar constraints to the initial value when the toolbar was not collapsed
  2. reverts the transforms of the address bar and domain label to identity.
  3. sets the alpha of the address bar back to 1.

The animator would look like this:

func setupExpandingToolbarAnimator() {
  addressBarBottomConstraint?.update(offset: addressBarExpandingHalfwayBottomOffset)
  toolbarBottomConstraint?.update(offset: toolbarExpandingHalfwayBottomOffset)
  expandingToolbarAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { [weak self] in
    self?.view.layoutIfNeeded()
  }
  expandingToolbarAnimator?.addCompletion { [weak self] _ in
    guard let self = self else { return }
    self.toolbarBottomConstraint?.update(offset: self.toolbarExpandingFullyBottomOffset)
    self.addressBarBottomConstraint?.update(offset: self.addressBarExpandingFullyBottomOffset)
    UIViewPropertyAnimator(duration: 0.2, curve: .easeIn) { [weak self] in
      self?.addressBar.containerView.transform = .identity
      self?.addressBar.domainLabel.transform = .identity
      self?.setAddressBarContainersAlpha(1)
      self?.view.layoutIfNeeded()
    }.startAnimation()
  }
  expandingToolbarAnimator?.pauseAnimation()
}


Controlling animations using pan gesture tracking

The collapsing and expanding animations wouldn’t look so good if they weren’t interactive. To achieve that the animation runs dynamically based on the scrolling distance of the web view, we can track the scrolling behaviour of web view’s scroll view using its pan gesture recognizer. The pan gesture handling method would look like this:

@objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) {
  let yOffset = webView.scrollView.contentOffset.y
  switch panGestureRecognizer.state {
  case .began:
    startYOffset = yOffset
  case .changed:
    webViewDidScroll(yOffsetChange: startYOffset - yOffset)
  case .failed, .ended, .cancelled:
    webViewDidEndDragging()
  default:
    break
  }
}

When the pan gesture begins we store the initial y offset. Once the gesture changes we need to calculate the y offset difference and use it to activate the right animation and calculate its progress. The webViewDidScroll(yOffsetChange:) method would look like this:

func webViewDidScroll(yOffsetChange: CGFloat) {
  // 1
  let offsetChangeBeforeFullAnimation = CGFloat(30)
  let animationFractionComplete = abs(yOffsetChange) / offsetChangeBeforeFullAnimation
  let thresholdBeforeAnimationCompletion = CGFloat(0.6)
  let isScrollingDown = yOffsetChange < 0
  
  // 2
  if isScrollingDown {
    // 3
    guard !isCollapsed else { return }
    
    // 4
    if collapsingToolbarAnimator == nil || collapsingToolbarAnimator?.state == .inactive {
      setupCollapsingToolbarAnimator()
    }
    
    // 5
    if animationFractionComplete < thresholdBeforeAnimationCompletion {
      collapsingToolbarAnimator?.fractionComplete = animationFractionComplete
    } else {
      isCollapsed = true
      collapsingToolbarAnimator?.continueAnimation(withTimingParameters: nil, durationFactor: 0)
    }
  } else {
    // 6
    guard isCollapsed else { return }
    if expandingToolbarAnimator == nil || expandingToolbarAnimator?.state == .inactive {
      setupExpandingToolbarAnimator()
    }
    
    if animationFractionComplete < thresholdBeforeAnimationCompletion {
      expandingToolbarAnimator?.fractionComplete = animationFractionComplete
    } else {
      isCollapsed = false
      expandingToolbarAnimator?.continueAnimation(withTimingParameters: nil, durationFactor: 0)
    }
  }
}
  1. We calculate the animation completion fraction using the y offset. We also set the threshold after which the animation should complete automatically.
  2. If the user is scrolling down we need to trigger the collapsing animation.
  3. We track the state of the toolbar and address bar using the isCollapsed flag. If the toolbar is already in the collapsed state then we skip the animation.
  4. If an animator does not exist (e.g. we just started the animation) we need to create it. Also, if the animation has already completed and the animator moved to the inactive state, but user keeps scrolling without ending the pan gesture then we need to stop and recreate the animator.
  5. If the animationFractionComplete value below the thresholdBeforeAnimationCompletion then we just update the animation completion fraction. But, if the threshold is reached then we update the isCollapsed flag and complete the animation by calling the continueAnimation(withTimingParameters:, durationFactor:) method on the animator.
  6. The same logic for collapsing is also used for expanding animation.


Reverting incomplete animations

One neat thing about these animations is that they revert back to the previous state if the animation completion fraction doesn’t reach the threshold before the user stops scrolling.
Initially, I thought that I could implement this behaviour by using the animator’s isReversed property. Unfortunatelly, this doesn’t work when the animation is implemented using constraints. That’s why I had to use a different approach.
We need to track when the user ends dragging and reverse the animation if it hasn’t completed. As shown in the pan gesture handler above, the webViewDidEndDragging() method gets called when the gesture ends. Its implementation would look like this:

func webViewDidEndDragging() {
  // 1
  if let collapsingToolbarAnimator = collapsingToolbarAnimator,
      collapsingToolbarAnimator.state == .active,
      !isCollapsed {
    reverseCollapsingToolbarAnimation()
  }
  
  // 2
  if let expandingToolbarAnimator = expandingToolbarAnimator,
      expandingToolbarAnimator.state == .active,
      isCollapsed {
    reverseExpandingToolbarAnimation()
  }
  
  // 3
  collapsingToolbarAnimator = nil
  expandingToolbarAnimator = nil
}
  1. If the collapsing animator is active, but the toolbar is not fully collapsed then we need to revert the animation.
  2. If the expanding animator is active, but the toolbar is not fully expanded then we need to revert the animation.
  3. Set the animators to nil so that they are recreated when the user starts dragging again.

The collapsing animation reversal is triggered by calling the reverseCollapsingToolbarAnimation() method:

func reverseCollapsingToolbarAnimation() {
  guard let collapsingToolbarAnimator = collapsingToolbarAnimator else { return }
  // 1
  addressBarBottomConstraint?.update(offset: addressBarCollapsingHalfwayBottomOffset * collapsingToolbarAnimator.fractionComplete)
  toolbarBottomConstraint?.update(offset: toolbarCollapsingHalfwayBottomOffset * collapsingToolbarAnimator.fractionComplete)
  view.layoutIfNeeded()

  // 2
  collapsingToolbarAnimator.stopAnimation(true)
  collapsingToolbarAnimator.finishAnimation(at: .current)

  // 3
  addressBarBottomConstraint?.update(offset: addressBarExpandingFullyBottomOffset)
  toolbarBottomConstraint?.update(offset: 0)
  UIViewPropertyAnimator(duration: 0.1, curve: .linear) { [weak self] in
    self?.setAddressBarContainersAlpha(1)
    self?.view.layoutIfNeeded()
  }.startAnimation()
}
  1. We need to update the constraints so that their value corresponds to the current layout visible on the screen. We need to use the fractionComplete of the animator to determine the completion state of the animation and use it to calculate the value for our constraints. After that we need to trigger the layout of the updated constraints.
  2. We stop and finish the current animator.
  3. We update the constraints and alpha to their previous value (in this case the expanded state value) and trigger the reverse animation.

The expanding animation reversal method is implemented in the same way:

func reverseExpandingToolbarAnimation() {
  guard let expandingToolbarAnimator = expandingToolbarAnimator else { return }
  addressBarBottomConstraint?.update(offset: addressBarExpandingHalfwayBottomOffset * expandingToolbarAnimator.fractionComplete)
  toolbarBottomConstraint?.update(offset: toolbarExpandingHalfwayBottomOffset * expandingToolbarAnimator.fractionComplete)
  view.layoutIfNeeded()
  expandingToolbarAnimator.stopAnimation(true)
  expandingToolbarAnimator.finishAnimation(at: .current)
  addressBarBottomConstraint?.update(offset: addressBarCollapsingFullyBottomOffset)
  toolbarBottomConstraint?.update(offset: toolbarCollapsingFullyBottomOffset)
  UIViewPropertyAnimator(duration: 0.1, curve: .linear) { [weak self] in
    self?.setAddressBarContainersAlpha(0)
    self?.view.layoutIfNeeded()
  }.startAnimation()
}


Conclusion

Working on this project was quite interesting. In this post, I’ve described how the toolbar animations are implemented through a couple code snippets. If you are interested in how the project was organized in more detail you can check out my GitHub repo which contains a lot more content.