Custom Tab Bar Part 2

Tue Nov 05, 2019

In Part 1 of this series, we talked about how we create a UIBezierPath that’s a curve and then got in sliding from side to side. In this part we’re going to walk through how to use that to make a UITabBarController. In my GitHub you can see that I use the MVP pattern to facilitate testing. However, in this walkthrough I’ll just do most of the work directly in the UITabBarController to make it easier to follow along to. There might also be some magic numbers here or there along with a bang operator now and again because these are just examples. Let’s get going!

First steps

First, we need to make a subclass of UITabBarController that we’ll call WaveTabBarController. Now we’ll create some properties at the top of our file:

// 1
private var circle: UIView?
// 2
private var imageView: UIImageView?
// 3
private lazy var tabBarItems: [UIView] = {
    return tabBar.subviews.filter { String(describing: type(of: $0)) == UITabBarButton }
// 4
private var safeSelectedIndex: Int {
    return selectedIndex < tabBarItems.count ? selectedIndex : tabBarItems.count - 1
// 5
private let waveSubLayer: CAShapeLayer = {
    let subLayer = CAShapeLayer()
    subLayer.strokeColor = UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.3).cgColor
    subLayer.lineWidth = 0.5
    return subLayer
  1. The circle will be our circle view for the selected tab.
  2. This image view will house the image within the circle, this combined with the circle view will create the following: image_1
  3. Here we want to get the tab bar buttons within the tab bar itself so that we can get their coordinates in the future for moving the wave and the circle.
  4. The safeSelectedIndex is here to help with overflowing tab bars. If a tab bar has more items that the view will allow, the other tabs will go into an overflow screen. If we get the index of the current selected tab and it’s in the overflow menu, moving the circle or the wave tab to its position would cause issues. This way we’ll only ever get an index in the range of visible tabs.
  5. This CAShapeLayer will house our wave from Part 1. We can set up some basic parts like the stroke color and line width and we’ll assign it the wave path later.

Now that we have our properties setup, let’s get into building this tab bar.

Building the tab bar

Although it’s advised against, and normally I’d advise against it to, we’re going to set tags on our UITabBarItems. From the viewDidAppear we’ll call the following function:

func setupTabBarTags() {
    viewControllers?.enumerated().forEach { $0.element.tabBarItem.tag = $0.offset + 1 }

There is a reason we added 1 to each of the tags, which will be apparent later on, but for now all you need to know is that it’s important that no tag is 0. Next up, we’ll set up our curve. I added an extension to UIBezierPath to make this a little simpler and more reusable. You can find it here on GitHub. The function we’ll use to create the curve path and assign to to our waveSubLayer is:

func setupCurve() {
    let path = UIBezierPath.createCurve(at: tabBarItems[safeSelectedIndex].center.x, radius: 30.0, on: tabBar)
    waveSubLayer.path = path.cgPath
    tabBar.layer.insertSublayer(waveSubLayer, above: tabBar.layer.sublayers?.first)

Running this now should show us the following


It looks a bit weird, but we’re getting somewhere. We’ve added the wave to our tab bar which is pretty cool. Now we have to add the circle for the currently selected item in the tab bar. We’ll do that with this function:

func setupCircle() {
    circle = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 57.0, height: 57.0))
    circle?.layer.cornerRadius = 28.5
    circle?.center = CGPoint(x: tabBarItems[safeSelectedIndex].center.x, y: 0.0)
    circle?.layer.borderWidth = 0.5
    circle?.layer.borderColor = UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.3).cgColor

I’m aware that I use the bang operator here, which isn’t the nicest. You may ask why I’m doing it this way and don’t just initialise the circle UIView when I declare it up at the top as a property. The reason, which can’t be seen here, is because this will only get calculated with specified size on viewDidAppear where the device orientation will be taken into account. Also, because we can see where the view is being initialised in the same function, I don’t see it as too much of a concern.

Now we have to add the image view to our circle with the tab icon.

func setupImageView() {
    let image = viewControllers?[safeSelectedIndex].tabBarItem.selectedImage?.withRenderingMode(.alwaysTemplate)
    imageView = UIImageView(image: image)
    imageView?.contentMode = UIView.ContentMode.scaleAspectFit
    imageView?.tintColor = tabBar.tintColor
    imageView?.center = CGPoint(x: 57.0, y: 57.0)

The only really interesting part here is how we pull out the image from the tab bar items in the first line. Then we assign it to our image view and then add our image view into the circle as a subview. We now need to set the colour of our floating circle and the wave layer. For this example, I’m just going to hard code them with a specific colour as follows:

func setupTabBarColoring() {
    waveSubLayer.fillColor = UIColor.white.cgColor
    Circle?.backgroundColor = .white

If we run it now, we can see that we’re really close.


We just need to do a bit of clean up with the last of the existing tab bar. This can be achieved with:

func setupTabBarBackground() {
    tabBar.tintColor = .clear
    if #available(iOS 13.0, *) {
        let barAppearance = UIBarAppearance()
        let tabBarAppearance = UITabBarAppearance(barAppearance: barAppearance)
        tabBar.standardAppearance = tabBarAppearance
    } else {
        tabBar.backgroundColor = .clear
        tabBar.backgroundImage = UIImage()
        tabBar.shadowImage = UIImage()

Here we can see there is a special case for iOS 13, but the main idea here is that we want to hide those extra bits of the tab bar that we no longer want to see like the background image and the top border. Running it now and we can see it worked.


It’s looking good, but once we start clicking between tabs, we can see our work isn’t done. Let’s fix that up

Switching tabs and animating the wave

Let’s go ahead and override the tabBar(_: didSelect:) function so that we can start animating our views when a new tab is selected. From there we’ll call two functions, one to animate and move our wave, and one to do the same for our circle. It should look something like this:

override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
    moveCurve(to: item.tag)
    moveCircle(down: true)

Our moveCurve function will be as follows:

func moveCurve(to index: Int) {
    // 1
    let safeIndex = index == 0 ? tabBarItems.count : index
    // 2
    let endPath = UIBezierPath.createCurve(at: tabBar.subviews[safeIndex].center.x, radius: 30.0, on: tabBar)
    // 3
    CATransaction.setCompletionBlock {
        self.waveSubLayer.path = endPath.cgPath
    let pathAnimation = CABasicAnimation(keyPath: path)
    pathAnimation.fromValue = waveSubLayer.path
    pathAnimation.toValue = endPath.cgPath
    pathAnimation.duration = 0.4
    pathAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    pathAnimation.isRemovedOnCompletion = false
    pathAnimation.fillMode = CAMediaTimingFillMode.forwards
    waveSubLayer.add(pathAnimation, forKey: pathAnimation)
  1. Here is why we didn’t want any of our tags that we assigned earlier to be 0. If the selected tab is in the overflow menu, its tag will be set to 0, and we can use that to determine the “More” tab is selected, otherwise we can just get the current index.
  2. Next up we create a new curve where we want the animation to end.
  3. And lastly we create our animation, which should look pretty similar to the animation in Part 1.

Our moveCircle function will look like this:

func moveCircle(down movingDown: Bool) {
    tabBar.isUserInteractionEnabled = false
    guard let circle = circle else { return }
    // 1
    UIView.animate(withDuration: 0.2, animations: {
        // 2
        let verticalPosition = movingDown ? + 30.0 : - 30.0 = CGPoint(x:, y: verticalPosition)
        // 3
        circle.alpha = movingDown ? 0.0 : 1.0
    }, completion: { _ in
        // 4
        self.imageView?.image = self.viewControllers?[self.safeSelectedIndex].tabBarItem.selectedImage?.withRenderingMode(.alwaysTemplate)
        // 5 = CGPoint(x: self.tabBarItems[self.safeSelectedIndex].center.x, y:
        if !movingDown {
            self.tabBar.isUserInteractionEnabled = true
        } else {
            // 6
            self.moveCircle(down: false)
  1. I’m highlighting this point because it’s important to note that the animation duration is half of the wave animation duration.
  2. Based on whether the circle is moving up or down, we’ll either increase or decrease the y coordinate of the circle.
  3. Again, based on the direction the circle is moving in, we’ll either increase or decrease the transparency of the circle.
  4. Next, we update the selected image in the image view in our circle to be that of the newly selected tab.
  5. Now we move the circle’s x coordinate to be that of the updated selected tab.
  6. Lastly if the circle was moving down, it will need to move back up at the new tab location, so we call the same moveCircle function again, this time with the down parameter set to false.

Running it now and we should see the following



To be honest when I set out to build this, it was more work than I thought it would be. However, getting to work with something as visual as CAShapeLayer is always rewarding especially when it’s tied to something so practical and ubiquitous as UITabBarController. You can check out the full code on GitHub where I do some extra things like making it work with dark mode on iOS 13 and handling rotation.

And if you feel like using it in your project it’s also available as a CocoaPod 😃

Spot something wrong? Let me know on Twitter

back · twitter · github · who is matt? · writing · projects · home