How to create Tabs in iOS using Swift

How to create Tabs in iOS using Swift

In this tutorial, I’m going to show you how to create Tabs without using any 3rd party library.

The view for the tabs (TabsView) will be a UICollectionView, and each cell (TabCell) has an UIImageView for the icon (optional) and an UILabel for the title.

Creating the View for the Tabs

First, add a UIView in the View Controller you want to have the tabs.

Set the height to 55, and make the leading, top, and trailing of the UIView equals to the ViewController

Select the UIView, and add TabsView as a Class

Create a new swift file, give it the name TabsView, and paste the following code inside:

import UIKit

protocol TabsDelegate {
    func tabsViewDidSelectItemAt(position: Int)
}

enum TabMode {
    case fixed
    case scrollable
}

struct Tab {
    var icon: UIImage?
    var title: String
}

class TabsView: UIView {
    var tabMode: TabMode = .scrollable {
        didSet {
            self.collectionView.reloadData()
        }
    }
    
    var tabs: [Tab] = [] {
        didSet {
            self.collectionView.reloadData()
        }
    }
    
    var titleColor: UIColor = .black {
        didSet {
            self.collectionView.reloadData()
        }
    }
    
    var titleFont: UIFont = UIFont.systemFont(ofSize: 20, weight: .regular) {
        didSet {
            self.collectionView.reloadData()
        }
    }
    
    var iconColor: UIColor = .black {
        didSet {
            self.collectionView.reloadData()
        }
    }
    
    var indicatorColor: UIColor = .black {
        didSet {
            self.collectionView.reloadData()
        }
    }
    
    var collectionView: UICollectionView!
    
    var delegate: TabsDelegate?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        createView()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        createView()
    }
    
    private func createView() {
        // Create Flow Layout
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        
        // Create CollectionView
        collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.backgroundColor = .clear
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(TabCell.self, forCellWithReuseIdentifier: "TabCell")
        addSubview(collectionView)
        
        // ColletionView Constraints
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: self.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
            collectionView.trailingAnchor.constraint(equalTo: self.trailingAnchor)
        ])
    }
}

extension TabsView: UICollectionViewDelegate, UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return tabs.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TabCell", for: indexPath) as? TabCell else {
            return UICollectionViewCell()
        }
        cell.tabViewModel = Tab(icon: tabs[indexPath.item].icon, title: tabs[indexPath.item].title)
        
        // Change Icon Color
        cell.tabIcon.image = cell.tabIcon.image?.withRenderingMode(.alwaysTemplate)
        cell.tabIcon.tintColor = iconColor
        
        // Change Title Color
        cell.tabTitle.font = titleFont
        cell.tabTitle.textColor = titleColor
        cell.indicatorColor = indicatorColor
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        delegate?.tabsViewDidSelectItemAt(position: indexPath.item)
    }
}

extension TabsView: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        switch tabMode {
        case .scrollable:
            let tabSize = CGSize(width: 500, height: self.frame.height)
            let tabTitle = tabs[indexPath.item].title
            
            // Add more space left and right the tab
            var addSpace: CGFloat = 20
            if tabs[indexPath.item].icon != nil {
                // Icon exist, add space for the icon width
                addSpace += 40
            }
            // Calculate the width of the Tab Title string
            let titleWidth = NSString(string: tabTitle).boundingRect(with: tabSize, options: .usesLineFragmentOrigin, attributes: [.font: titleFont], context: nil).size.width
            
            let tabWidth = titleWidth + addSpace
            
            return CGSize(width: tabWidth, height: self.frame.height)
        case .fixed:
            return CGSize(width: self.frame.width / CGFloat(tabs.count), height: self.frame.height)
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
    }
}

Creating the Tab

The next step is to create the cell (tab) for the UICollectionView.

Create a new swift file, name it TabCell, and paste the following code:

import UIKit

class TabCell: UICollectionViewCell {
    private var tabSV: UIStackView!
    
    var tabTitle: UILabel!
    
    var tabIcon: UIImageView!
    
    var indicatorView: UIView!
    
    var indicatorColor: UIColor = .black
    
    override var isSelected: Bool {
        didSet {
            DispatchQueue.main.async {
                UIView.animate(withDuration: 0.3) {
                    self.indicatorView.backgroundColor = self.isSelected ? self.indicatorColor : UIColor.clear
                    self.layoutIfNeeded()
                }
            }
        }
    }
    
    var tabViewModel: Tab? {
        didSet {
            tabTitle.text = tabViewModel?.title
            tabIcon.image = tabViewModel?.icon
            
            // Remove stackView spacing if icon is nil
            (tabViewModel?.icon != nil) ? (tabSV.spacing = 10) : (tabSV.spacing = 0)
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        tabSV = UIStackView()
        tabSV.axis = .horizontal
        tabSV.distribution = .equalCentering
        tabSV.alignment = .center
        tabSV.spacing = 10.0
        addSubview(tabSV)
        
        // Tab Icon
        tabIcon = UIImageView()
        tabIcon.clipsToBounds = true
        self.tabSV.addArrangedSubview(tabIcon)
        
        // Tab Title
        tabTitle = UILabel()
        tabTitle.textAlignment = .center
        self.tabSV.addArrangedSubview(tabTitle)
        
        // TabIcon Constaints
        tabIcon.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            self.tabIcon.heightAnchor.constraint(equalToConstant: 18),
            self.tabIcon.widthAnchor.constraint(lessThanOrEqualToConstant: 18)
        ])
        
        // TabSv Constraints
        tabSV.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tabSV.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            tabSV.centerYAnchor.constraint(equalTo: self.centerYAnchor)
        ])
        
        setupIndicatorView()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        tabTitle.text = ""
        tabIcon.image = nil
    }
    
    func setupIndicatorView() {
        indicatorView = UIView()
        addSubview(indicatorView)
        
        indicatorView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            indicatorView.heightAnchor.constraint(equalToConstant: 3),
            indicatorView.widthAnchor.constraint(equalTo: self.widthAnchor),
            indicatorView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
        ])
    }
}

Creating the Page View Controller

In the Storyboard (e.g Main.storyboard), add a Page View Controller, add TabsPageViewController as a Class and type TabsPageViewController as a Storyboard ID

Next, change the Transition Style of the Page View Controller to Scroll

Create a new swift file, give the name TabsPageViewController, and paste the following code in:

import UIKit

class TabsPageViewController: UIPageViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()

    }
}

Creating the View Controllers

Now it’s time to create the View Controllers that contain the content we want to display.

In this example, we create three different View Controllers (Demo1ViewController, Demo2ViewController, and Demo3ViewController)

Go to your Storyboard, add as many View Controllers you want, and type the same name of your UIViewController class to the Storyboard ID field

Create a new UIViewController swift file, give it a name (e.g. Demo1ViewController), and add the variable pageIndex.

Page View Controller doesn’t return the current page position, so we use this variable to keep track.

import UIKit

class Demo1ViewController: UIViewController {

    var pageIndex: Int!
    
    override func viewDidLoad() {
        super.viewDidLoad()

    }
}

Setting up Tabs

Go back to your View Controller you added the TabsView at the beginning, and create an IBOutlet:

Now, create the list of tabs and customize them the way you like:

class ViewController: UIViewController {

    @IBOutlet var tabsView: TabsView!

    var currentIndex: Int = 0
    
    var pageController: UIPageViewController!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupTabs()
       
    }
    
    func setupTabs() {
        // Add Tabs (Set 'icon'to nil if you don't want to have icons)
        tabsView.tabs = [
            Tab(icon: UIImage(named: "music"), title: "Music"),
            Tab(icon: UIImage(named: "movies"), title: "Movies"),
            Tab(icon: UIImage(named: "books"), title: "Books")
        ]
        
        // Set TabMode to '.fixed' for stretched tabs in full width of screen or '.scrollable' for scrolling to see all tabs
        tabsView.tabMode = .fixed
        
        // TabView Customization
        tabsView.titleColor = .white
        tabsView.iconColor = .white
        tabsView.indicatorColor = .white
        tabsView.titleFont = UIFont.systemFont(ofSize: 20, weight: .semibold)
        tabsView.collectionView.backgroundColor = .cyan
        
        // Set TabsView Delegate
        tabsView.delegate = self
        
        // Set the selected Tab when the app starts
        tabsView.collectionView.selectItem(at: IndexPath(item: 0, section: 0), animated: true, scrollPosition: .centeredVertically)
    }
}

If you have rotation enabled in your app, you need to update the UICollectionView Layout every time you rotate:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    // Refresh CollectionView Layout when you rotate the device
    tabsView.collectionView.collectionViewLayout.invalidateLayout()
}

At the bottom of your swift file, extend your View Controller, and add the TabsDelegate.

The following method (tabsViewDidSelectItemAt) called every time you tap on a tab.

extension ViewController: TabsDelegate {
    func tabsViewDidSelectItemAt(position: Int) {
        // Check if the selected tab cell position is the same with the current position in pageController, if not, then go forward or backward
        if position != currentIndex {
            if position > currentIndex {
                self.pageController.setViewControllers([showViewController(position)!], direction: .forward, animated: true, completion: nil)
            } else {
                self.pageController.setViewControllers([showViewController(position)!], direction: .reverse, animated: true, completion: nil)
            }
            tabsView.collectionView.scrollToItem(at: IndexPath(item: position, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
}

Next, setup the UIPageViewController:

class ViewController: UIViewController {

    @IBOutlet var tabsView: TabsView!
    
    var currentIndex: Int = 0
    
    var pageController: UIPageViewController!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // ...
        
        setupPageViewController()
    }
    
    func setupPageViewController() {
        // PageViewController
        self.pageController = storyboard?.instantiateViewController(withIdentifier: "TabsPageViewController") as! TabsPageViewController
        self.addChild(self.pageController)
        self.view.addSubview(self.pageController.view)
        
        // Set PageViewController Delegate & DataSource
        pageController.delegate = self
        pageController.dataSource = self
        
        // Set the selected ViewController in the PageViewController when the app starts
        pageController.setViewControllers([showViewController(0)!], direction: .forward, animated: true, completion: nil)
        
        // PageViewController Constraints
        self.pageController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            self.pageController.view.topAnchor.constraint(equalTo: self.tabsView.bottomAnchor),
            self.pageController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            self.pageController.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            self.pageController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])
        self.pageController.didMove(toParent: self)
    }

}

Again, at the bottom of your swift file, extend your View Controller, and add the UIPageViewControllerDataSource & UIPageViewControllerDelegate.

extension ViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
    // return ViewController when go forward
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        let vc = pageViewController.viewControllers?.first
        var index: Int
        index = getVCPageIndex(vc)
        // Don't do anything when viewpager reach the number of tabs
        if index == tabsView.tabs.count {
            return nil
        } else {
            index += 1
            return self.showViewController(index)
        }
    }
    
    // return ViewController when go backward
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let vc = pageViewController.viewControllers?.first
        var index: Int
        index = getVCPageIndex(vc)
        
        if index == 0 {
            return nil
        } else {
            index -= 1
            return self.showViewController(index)
        }
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if finished {
            if completed {
                guard let vc = pageViewController.viewControllers?.first else { return }
                let index: Int
                
                index = getVCPageIndex(vc)
                
                tabsView.collectionView.selectItem(at: IndexPath(item: index, section: 0), animated: true, scrollPosition: .centeredVertically)
                // Animate the tab in the TabsView to be centered when you are scrolling using .scrollable
                tabsView.collectionView.scrollToItem(at: IndexPath(item: index, section: 0), at: .centeredHorizontally, animated: true)
            }
        }
    }
    
    // Return the current position that is saved in the UIViewControllers we have in the UIPageViewController
    func getVCPageIndex(_ viewController: UIViewController?) -> Int {
        switch viewController {
        case is Demo1ViewController:
            let vc = viewController as! Demo1ViewController
            return vc.pageIndex
        case is Demo2ViewController:
            let vc = viewController as! Demo2ViewController
            return vc.pageIndex
        case is Demo3ViewController:
            let vc = viewController as! Demo3ViewController
            return vc.pageIndex
        default:
            let vc = viewController as! Demo1ViewController
            return vc.pageIndex
        }
    }
}

The viewControllerAfter method returns the next viewController when you go forward on the Page View Controller, and the viewControllerBefore when you go backward.

The didFinishAnimating called after a user swipes to a new UIViewController, and the animation has completed.

Last, add the showViewController method. This method returns the right View Controller for the current position.

// Show ViewController for the current position
func showViewController(_ index: Int) -> UIViewController? {
    if (self.tabsView.tabs.count == 0) || (index >= self.tabsView.tabs.count) {
        return nil
    }
    
    currentIndex = index
    
    if index == 0 {
        let contentVC = storyboard?.instantiateViewController(withIdentifier: "Demo1ViewController") as! Demo1ViewController
        contentVC.pageIndex = index
        return contentVC
    } else if index == 1 {
        let contentVC = storyboard?.instantiateViewController(withIdentifier: "Demo2ViewController") as! Demo2ViewController
        contentVC.pageIndex = index
        return contentVC
    } else if index == 2 {
        let contentVC = storyboard?.instantiateViewController(withIdentifier: "Demo3ViewController") as! Demo3ViewController
        contentVC.pageIndex = index
        return contentVC
    } else {
        let contentVC = storyboard?.instantiateViewController(withIdentifier: "Demo1ViewController") as! Demo1ViewController
        contentVC.pageIndex = index
        return contentVC
    }
}

Done!

You can find the final project here

Did you find this tutorial helpful?



If you have any questions, please feel free to leave a comment below

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments