18 Jan 2019
Belle

Adding a wiggle animation to a UIBarButtonItem

I recently needed to add a wiggle animation to a UIBarButtonItem. It wasn't as simple as animating a standard UIButton, so I had to use a UIButton as the custom view of my bar button. I also used PromiseKit to wrap the animations, so I could chain them together without nesting several completion closures.

Here's the final result:

Exist iOS wiggle animation


In Exist we have an in-app notification system. We use it to alert users to things like major new features, the results of our annual user survey, and anytime their payments fail. In Exist for iOS, I use a UIBarButtonItem to show a small bell icon all the time. Users can tap this to view their past notifications.

If a user has unread notifications, the bell has a red dot on it to encourage them to tap through and read the new notifications.

Lots of users don't read these, though. As I'm currently working on some niceties and polish in Exist for iOS, I decided to try animating the bell icon to do a little wiggle if there are unread notifications, to draw the user's attention a little more.

I ran into a few issues along the way, and didn't find a heap of tutorials online for this that were up-to-date, so I wanted to share how I managed to make this work.

The UIBarButtonItem is set when the main screen of Exist for iOS is first initialised. It's also set based on a Notification that's posted whenever the user's notification status is updated from our server. I was creating the bar button like this:

var bellImg: UIImage?

// First get the bell icon either with or without the unread badge
if unread {
  bellImg = UIImage(named: BELL_UNREAD)?.withRenderingMode(.alwaysOriginal)
} else {
  bellImg = UIImage(named: BELL_DEFAULT)?.withRenderingMode(.alwaysOriginal)
}

// Make sure creating the image works, since it can fail
guard let bell = bellImg?.withRenderingMode(.alwaysOriginal) else { return }
let bellButton = UIBarButtonItem(image: bell, style: .plain, target: self, action: #selector(userTappedNotificationIcon))

I started by adding a variable to my class to track whether the user had unread notifications or not, and adjusted the function above to change this variable, like so:

if unread {
  self.unreadNotifications = true // set the variable we'll be using for animation later
  bellImg = UIImage(named: BELL_UNREAD)?.withRenderingMode(.alwaysOriginal)
} else {
  self.unreadNotifications = false
  bellImg = UIImage(named: BELL_DEFAULT)?.withRenderingMode(.alwaysOriginal)
}

Then I set about creating a function that would animate the button if self.unreadNotifications was true. I knew I wanted to use CGAffineTransform to rotate the bell left and right to create a wiggle effect, but the first hurdle I hit was that UIBarButtonItem doesn't have a transform property like UIView does.

With a bit of research, I found an example of animating a UIBarButtonItem by creating it with a custom UIView, rather than an image, and transforming the custom view.

So that's what I did. I first made an image view to hold the bell image, and set that as the custom view when creating my bar button like so:

let bellImageView = UIImageView(image: bell)
let bellButton = UIBarButtonItem(customView: bellImageView)

Then I could animate the bell back and forth using a transform. I'll explain the animation itself a bit later, but I just used UIView.animate and in the animations closure parameter I set the bell's transform to rotate, something like this:

bell.transform = CGAffineTransform(rotationAngle: CGFloat((5.0 * Double.pi) / 180.0))

(The calculation of CGFloat((-5.0 * Double.pi) / 180.0) is to turn 5 degrees into radians as a CGFloat, which is what the rotationAngle parameter expects.)

To access the bell to rotate it, I just pulled the bar button's custom view from the navigation bar like this:

let bell = self.navigationItem.rightBarButtonItem?.customView

All well and good, right? Alas, this didn't quite work. And the problem took me a long time to figure out (actually, I didn't figure it out myself at all—Josh spotted it for me). While the bell was rotating, it was also getting squashed as it did so. And the more I rotated it, the worse the squashing. It turned out that it was because rotating it made it too wide for the bar button, so it had to be scaled in one direction to fit into its frame, which made the bell looked squashed.

So I thought an easy solution would be to create the bar button with a plain UIView as its custom view that was bigger than the bell image, and set the bell image in the centre of this view. This worked, and the bell finally started wiggling without being squashed!

However, I now had a new problem: the button was no longer tappable.

After a bit of digging, I found this in the Apple docs for the UIBarButtonItem(customView:) initialiser:

The bar button item created by this method does not call the action method of its target in response to user interactions. Instead, the bar button item expects the specified custom view to handle any user interactions and provide an appropriate response.

Ahh! My button wasn't responding to taps anymore, and that was expected behaviour. But making a gesture recogniser isn't too hard, so I thought I could just add one of those to my custom view. I tried this, and couldn't get it to work. I'm still not sure why. But then I came across another solution: set the bar button's custom view to be a normal UIButton, which is created with the bell image as its background image.

And this approach did work. At least, I got my taps back. But I also got the squashed bell problem back. I managed to solve it this time by putting the button inside a custom view, but setting the button, not its parent view as the bar button's custom view. When I tried setting the button's parent view as the bar button's custom view, my UIButton stopped responding to taps again. This seems like a strange set up to me, since my UIButton is a subview of a UIView, but it's also the custom view for my UIBarButtonItem, which knows nothing about the button's parent view as far as I can tell.

Anyway, this convoluted approach works. My bell wiggles, and it doesn't look squashed. Here's how I create the bar button now:

// Make sure creating the image works, since it can fail
guard let bell = bellImg?.withRenderingMode(.alwaysOriginal) else { return }

// Create the UIButton's parent view
let view = UIView(frame: CGRect(x: 0, y: 0, width: bell.size.width * 2, height: bell.size.height * 2))
// Create the UIButton
let button = UIButton(type: .custom)
button.setBackgroundImage(bell, for: .normal)
button.addTarget(self, action: #selector(self.userTappedNotificationIcon), for: .touchUpInside)
// Add the button to its parent view
view.addSubview(button)
// Make the button with the bell image centred inside the custom view, which is double the size of the bell
// I use SnapKit for these constraints
button.snp.makeConstraints { (make) in
  make.center.equalToSuperview()
}
// Use the UIButton as the custom view for the UIBarButton
let bellButton = UIBarButtonItem(customView: button)

Now, on to the animation. This isn't hard if you know what you're doing, but for the sake of completeness, I'll go over how I'm doing it. I'm actually using a PromiseKit wrapper around UIView animation functions, since I use PromiseKit a lot in Exist for iOS already. But initially I did it this way, without promises, which forced me to nest several completion blocks:

// Only animate the bell if the user has unread notifications
// and we can access the bell from the UIBarButtonItem
if self.unreadNotifications,
  let bell = self.navigationItem.rightBarButtonItem?.customView {
  // 1. Animate rotating the bell to the left
  UIView.animate(duration: 0.1,
    delay: 1.0,
    animations: {
      bell.transform = CGAffineTransform(rotationAngle: CGFloat((-5.0 * Double.pi) / 180.0))
    }) { (_) in
      // 2. After the bell has rotated left,
      // Start a repeating animation that takes it from
      // left to right several times by using autoreverse
      UIView.animate(duration: 0.1,
        delay: 0.0,
        options: [.autoreverse, .repeat],
        animations: {
          // Set how many times to repeat
          // the animation
          UIView.setAnimationRepeatCount(4)
          // Rotate to the right
          bell.transform = CGAffineTransform(rotationAngle: CGFloat((5.0 * Double.pi) / 180.0))
        }) { (_) in
          // 3. Animate returning the bell to its normal position
          UIView.animate(duration: 0.1,
          animations: {
            bell.transform = CGAffineTransform.identity
          }
        }
    }
}

This is a pretty crazy looking bit of code, but not nearly as bad as my initial attempt, before I realised I could make an animation repeat! So we start with an animation to rotate the bell to the left. If we don't do this, when the repeating animation starts, which animates from left to right, it'll jump the bell to be rotated left as it begins, which will look jerky.

Once we've animated the bell to the left, we start a repeating animation by using the .repeat animation option. Inside the animations closure we set the repeat count on UIView. To make the animation go back and forth, rather than just repeating in the same direction over and over, we use the .autoreverse animation option. This animation rotates the bell to the right by the same amount as we animated it left. Since the bell is rotated to the left when this animation starts, the animation is taking the bell from -5 degrees to +5 degrees. .autoreverse makes it then take the bell from +5 to -5, and .repeat makes it keep doing that process as many times as we've asked it to.

Finally, we add an animation in the completion closure of the repeating animation to take the bell back to its normal state, with CGAffineTransform.identity. If we didn't do this, the bell would end up rotated to the right when the repeating animation was done.

Using PromiseKit makes this a bit more readable, though the steps are the same. PromiseKit has lots of extensions available, including one for UIKit, which makes these functions return promises, rather than taking completion closures. This means you can chain a bunch of animations together without nesting completion blocks, which makes it easier to read.

Here's how the same code looks when using PromiseKit:

if self.unreadNotifications,
let bell = self.navigationItem.rightBarButtonItem?.customView {
  UIView.animate(.promise,
  duration: 0.1,
  delay: 1.0) {
    bell.transform = CGAffineTransform(rotationAngle: CGFloat((-5.0 * Double.pi) / 180.0))
  }.then { (_) -> Guarantee<Bool> in
    return UIView.animate(.promise,
    duration: 0.1,
    delay: 0.0,
    options: [.autoreverse, .repeat],
    animations: {
      UIView.setAnimationRepeatCount(4)
      bell.transform = CGAffineTransform(rotationAngle: CGFloat((5.0 * Double.pi) / 180.0))
    }
  )}.done { (_) in
    UIView.animate(.promise,
    duration: 0.1,
    animations: {
      bell.transform = CGAffineTransform.identity
    })
  }
}

And here's the finished wiggle animation:

Exist iOS wiggle animation

Phew! It was more tricky than I thought it would be to get this working, but I'm happy with the result. Hopefully sharing this will help anyone who might end up down the dead-ends I did, so you can learn from my mistakes.