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
}()
- The
circle
will be our circle view for the selected tab. - This image view will house the image within the circle, this combined with the
circle
view will create the following: - 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.
- 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. - 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 UITabBarItem
s. 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
tabBar.addSubview(circle!)
}
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
circle?.addSubview(imageView!)
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()
barAppearance.configureWithTransparentBackground()
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.begin()
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”)
CATransaction.commit()
}
- 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.
- Next up we create a new curve where we want the animation to end.
- 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 ? circle.center.y + 30.0 : circle.center.y - 30.0
circle.center = CGPoint(x: circle.center.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
circle.center = CGPoint(x: self.tabBarItems[self.safeSelectedIndex].center.x, y: circle.center.y)
if !movingDown {
self.tabBar.isUserInteractionEnabled = true
} else {
// 6
self.moveCircle(down: false)
}
})
}
- I’m highlighting this point because it’s important to note that the animation duration is half of the wave animation duration.
- Based on whether the circle is moving up or down, we’ll either increase or decrease the y coordinate of the circle.
- Again, based on the direction the circle is moving in, we’ll either increase or decrease the transparency of the circle.
- Next, we update the selected image in the image view in our circle to be that of the newly selected tab.
- Now we move the circle’s x coordinate to be that of the updated selected tab.
- 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 thedown
parameter set to false.
Running it now and we should see the following
Conclusion
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