Building a Swipeable UITabBar from Scratch

You’re aware that Apple’s iOS SDK provides a wide range of built-in UI components. Buttons, containers, navigation elements, tabbed layouts—it’s all there, ready to use. Or is it?

While these basic components are great for creating standard UIs, what happens when you need to think outside the box? What if an iOS developer wants to create a specific behavior that the SDK doesn’t support out of the box?

This is where UITabBar becomes a challenge, as it lacks the functionality to swipe between tabs and doesn’t provide animations for tab switching.

Finding a Simple UITabBar Solution

After searching extensively, I only found one somewhat useful library on Github. However, despite initially seeming like a good solution, it introduced a number of problems when I tried using it in my application.

In simpler terms, the library was user-friendly but quite buggy, which unfortunately made it more trouble than it was worth. If you’re curious, you can find this library link.

After much thought and research, I decided to develop my own solution. I thought, “What if we combine a page view controller for swiping with a native UITabBar? Could we link these two, managing the page index during both swiping and tab bar taps?”

This led me to a solution, although it wasn’t without its challenges, as you’ll see later.

A More Complex UITabBar Solution

Let’s say you want to create three tab bar items. This implies having three pages/controllers that will be displayed for each corresponding item.

To achieve this, you’ll need to instantiate these three view controllers along with two placeholder/empty view controllers for the tab bar itself. This is necessary to create the tab bar items, manage their state when tapped, and enable programmatic tab index changes.

Let’s jump into Xcode and build some classes to illustrate how this works in practice.

Example: Swiping Through Tabs

Example of swipeable tabs in iOS

These screenshots demonstrate how the first tab bar item is initially blue. When the user swipes to the right, the yellow tab is selected. The last screenshot shows the third item selected, resulting in the entire page turning yellow.

Using the Swipeable Tab Bar Programmatically

Let’s dive into the code and create a straightforward example of a swipeable tab bar for iOS. Start by creating a new project.

The prerequisites are simple: Xcode and its command-line tools installed on your Mac.

To initiate a new project, launch Xcode and choose “Create a new Xcode project.” Give your project a name and then choose “Single View App” as the application type. Click Next.

Xcode Screenshot

The following screen will ask for some project details:

  • Product Name: I went with SwipeableTabbar.
  • Team: For running on a physical device, a developer account is required. I’ll be using my own.

Important: If you don’t have a developer account, you can still run the project on the Simulator.

  • Organization Name: I used Toptal.
  • Organization Identifier: I set this to com.toptal.
  • Language: Select Swift.
  • Uncheck: “Use Core Data,” “Include Unit Tests,” and “Include UI Tests.”

Click Next, and you’re ready to begin building your swipeable tab bar.

Basic Structure

When you create a new app, you automatically get a Main ViewController class and Main.Storyboard.

Before we design the UI, let’s create the required classes and files to ensure a smooth development process.

Within your project, create new files named TabbarController.swift, NavigationController.swift, and PageViewController.swift.

Here’s how it looks in my project.

Screenshot: Xcode Controllers

In the AppDelegate file, remove all methods except for didFinishLaunchingWithOptions.

Within the didFinishLaunchingWithOptions method, paste the following code:

1
2
3
4
5
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = NavigationController(rootViewController: TabbarController())
window?.makeKeyAndVisible()

return true

Delete all content from the file ViewController.swift. We’ll revisit this file later.

Let’s start by writing the code for NavigationController.swift.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import Foundation
import UIKit

class NavigationController: UINavigationController {
    override func viewDidLoad() {
        super.viewDidLoad()
        navigationBar.isTranslucent = true
        navigationBar.tintColor = .gray
    }
}

This code defines a basic UINavigationController with a translucent navigation bar that has a gray tint color.

Next, let’s move on to the PageViewController.

This file requires a bit more code than the previous ones. It includes a class, a protocol, some UIPageViewController data source methods, and delegate methods.

Here’s what the complete file should look like:

Xcode Screenshot: Methods

You’ll notice that we’ve declared a custom protocol called PageViewControllerDelegate, which is used to inform the tab bar controller about page index changes after a swipe is completed.

1
2
3
4
5
6
import Foundation
import UIKit

protocol PageViewControllerDelegate: class {
    func pageDidSwipe(to index: Int)
}

Next, we create a class called PageViewController responsible for managing our view controllers, selecting pages at a specific index, and handling swipes.

Let’s assume we want the center view controller to be selected by default on the first run. We achieve this by setting the default index value to 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class PageViewController: UIPageViewController {
    
    weak var swipeDelegate: PageViewControllerDelegate?
    
    var pages = [UIViewController]()
    
    var prevIndex: Int = 1
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self
        self.delegate = self
    }
    
    func selectPage(at index: Int) {
        self.setViewControllers(
            [self.pages[index]],
            direction: self.direction(for: index),
            animated: true,
            completion: nil
        )
        self.prevIndex = index
    }

    private func direction(for index: Int) -> UIPageViewController.NavigationDirection {
        return index > self.prevIndex ? .forward : .reverse
    }

}

Here, the pages variable stores references to all our view controllers.

The prevIndex variable keeps track of the last selected index.

To set the selected index, you can call the selectPage method.

To track page index changes, subscribe to the swipeDelegate. Each time a page swipe occurs, you’ll be notified, and you’ll receive the current index.

The direction method returns the swipe direction of the UIPageViewController. The final part of this class involves implementing delegate and data source methods.

These implementations are quite simple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
extension PageViewController: UIPageViewControllerDataSource {
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let viewControllerIndex = pages.firstIndex(of: viewController) else { return nil }
        let previousIndex = viewControllerIndex - 1
        guard previousIndex >= 0 else { return nil }
        guard pages.count > previousIndex else { return nil }
        
        return pages[previousIndex]
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let viewControllerIndex = pages.firstIndex(of: viewController) else { return nil }
        let nextIndex = viewControllerIndex + 1
        guard nextIndex < pages.count else { return nil }
        guard pages.count > nextIndex else { return nil }
        
        return pages[nextIndex]
    }
    
}

extension PageViewController: UIPageViewControllerDelegate {
    
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if completed {
            guard let currentPageIndex = self.viewControllers?.first?.view.tag else { return }
            self.prevIndex = currentPageIndex
            self.swipeDelegate?.pageDidSwipe(to: currentPageIndex)
        }
    }
    
}

The code above demonstrates three methods:

  • The first method locates the index and returns the previous view controller.
  • The second method locates the index and returns the next view controller.
  • The last method determines if the swipe has ended, sets the local property prevIndex to the current index, and then notifies the parent view controller about the successful swipe completion by calling the delegate method.

Now we can finally implement our UITabBarController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import UIKit

class TabbarController: UITabBarController {
    
    let selectedColor = UIColor.blue
    let deselectedColor = UIColor.gray
    
    let tabBarImages = [
        UIImage(named: "ic_music")!,
        UIImage(named: "ic_play")!,
        UIImage(named: "ic_star")!
    ]
    
    override func viewDidLoad() {
        
        view.backgroundColor = .gray
        
        self.delegate = self
        tabBar.isTranslucent = true
        tabBar.tintColor = deselectedColor
        tabBar.unselectedItemTintColor = deselectedColor
        tabBar.barTintColor = UIColor.white.withAlphaComponent(0.92)
        tabBar.itemSpacing = 10.0
        tabBar.itemWidth = 76.0
        tabBar.itemPositioning = .centered
        
        setUp()
        
        self.selectPage(at: 1)
    }
    
}

Here, we create the TabbarController with default properties and style. We define two colors for selected and deselected bar items. I’ve also included three images for the tab bar items.

In the viewDidLoad method, we set up the default configuration for our tab bar and select page #1 as the starting page.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    private func setUp() {
        
        guard let centerPageViewController = createCenterPageViewController() else { return }
        
        var controllers: [UIViewController] = []
        
        controllers.append(createPlaceholderViewController(forIndex: 0))
        controllers.append(centerPageViewController)
        controllers.append(createPlaceholderViewController(forIndex: 2))
        
        setViewControllers(controllers, animated: false)
        
        selectedViewController = centerPageViewController
    }
    
    private func selectPage(at index: Int) {
        guard let viewController = self.viewControllers?[index] else { return }
        self.handleTabbarItemChange(viewController: viewController)
        guard let PageViewController = (self.viewControllers?[1] as? PageViewController) else { return }
        PageViewController.selectPage(at: index)
    }
    

Within the setUp method, you’ll see that we create two placeholder view controllers. These are required for the UITabBar because the number of tab bar items must match the number of view controllers you have.

We’re using UIPageViewController to display controllers, but for the UITabBar to function correctly, we need to have all view controllers instantiated. This ensures that the bar items respond to taps. In this example, placeholderviewcontroller #0 and #2 are these empty view controllers.

For the center view controller, we create a PageViewController with three view controllers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
    private func createPlaceholderViewController(forIndex index: Int) -> UIViewController {
        let emptyViewController = UIViewController()
        emptyViewController.tabBarItem = tabbarItem(at: index)
        emptyViewController.view.tag = index
        return emptyViewController
    }
    
    private func createCenterPageViewController() -> UIPageViewController? {
        
        let leftController = ViewController()
        let centerController = ViewController2()
        let rightController = ViewController3()
        
        leftController.view.tag = 0
        centerController.view.tag = 1
        rightController.view.tag = 2
        
        leftController.view.backgroundColor = .red
        centerController.view.backgroundColor = .blue
        rightController.view.backgroundColor = .yellow
        
        let storyBoard = UIStoryboard.init(name: "Main", bundle: nil)
        
        guard let pageViewController = storyBoard.instantiateViewController(withIdentifier: "PageViewController") as? PageViewController else { return nil }
        
        pageViewController.pages = [leftController, centerController, rightController]
        pageViewController.tabBarItem = tabbarItem(at: 1)
        pageViewController.view.tag = 1
        pageViewController.swipeDelegate = self
        
        return pageViewController
    }
    
    private func tabbarItem(at index: Int) -> UITabBarItem {
        return UITabBarItem(title: nil, image: self.tabBarImages[index], selectedImage: nil)
    }

The first two methods shown above are the initialization methods for our pageview controller.

The tabbar item method simply returns the tabbar item at the specified index.

Notice that I’m using tags for each view controller within the createCenterPageViewController() method. This helps me track which controller is currently displayed on the screen.

Now let’s move on to our most important method: handleTabbarItemChange.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    private func handleTabbarItemChange(viewController: UIViewController) {
        guard let viewControllers = self.viewControllers else { return }
        let selectedIndex = viewController.view.tag
        self.tabBar.tintColor = selectedColor
        self.tabBar.unselectedItemTintColor = selectedColor
        
        for i in 0..<viewControllers.count {
            let tabbarItem = viewControllers[i].tabBarItem
            let tabbarImage = self.tabBarImages[i]
            tabbarItem?.selectedImage = tabbarImage.withRenderingMode(.alwaysTemplate)
            tabbarItem?.image = tabbarImage.withRenderingMode(
                i == selectedIndex ? .alwaysOriginal : .alwaysTemplate
            )
        }
        
        if selectedIndex == 1 {
            viewControllers[selectedIndex].tabBarItem.selectedImage = self.tabBarImages[1].withRenderingMode(.alwaysOriginal)
        }
    }

This method takes a view controller as a parameter and uses its tag to determine the selected index. We set the selected and unselected colors for the tab bar.

We loop through all controllers and check if i == selectedIndex.

If the condition is true, we render the image using the original rendering mode; otherwise, we use the template mode.

When an image is rendered in template mode, it inherits the item’s tint color.

We’re almost there! We just need to implement two crucial methods from UITabBarControllerDelegate and PageViewControllerDelegate.

1
2
3
4
5
6
7
8
9
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        self.selectPage(at: viewController.view.tag)
        return false
    }
    
    func pageDidSwipe(to index: Int) {
        guard let viewController = self.viewControllers?[index] else { return }
        self.handleTabbarItemChange(viewController: viewController)
    }

The first method is called when a tab item is pressed, while the second one is triggered when swiping between tabs.

Conclusion

By combining this code, you can achieve smooth scrolling and swiping between tab bar items without having to implement custom gesture handlers or write a lot of code.

This particular implementation may not be suitable for all use cases, but it offers a quick, clever, and relatively simple solution for adding these features with minimal coding effort.

Feel free to explore my approach further using my GitHub repo. Happy coding!

Licensed under CC BY-NC-SA 4.0