In this article, let’s see how to make some horizontal scrollable buttons (tabs) like the ones you see on Youtube App.
Originally, I thought that I don’t have that much buttons so I am just going to hard code all my buttons in storyboards and embed them in a UIScrollView.
However, I ran into number of problems with this approach.
- You cannot configure button style for each state (normal, selected, highlighted) individually from storyboard
- I tried to configure button title styles programmatically by using
setAttributedTitle
,setTitleColor
,titleLabel?.textColor
, but none of those work… The only way I found to actually change the title color with not exceptions and corner cases is to use the following.
var config = button.configuration
config?.baseForegroundColor = .white
button.configuration = config
- However, if you are trying to change your button background color,
config.baseBackgroundColor
will not work and you will have to usebutton.backgroundColor = someColor
instead. - You don’t have much control over the scrolling behavior. In order to scroll to a specific button, you will need to first store the buttons in a variable, figure out which button you are trying to scroll to, get the frame/bounds of the button, scroll to that rectangle! Too much work for me!
Okay, end of my complain on my first try using UIButton + UIScrollView. I hated it so let’s see how we can make it using UICollectionView! I will also be adding a left and right indicator to show that there are more buttons to come!
I have also added the final project here so feel free to grab it and check it out.
Let’s skip the set up for a project and jump right in!
Custom Button Cell
First of all, Create a new subClass of UICollectionViewCell. Make sure to also create a XIB file for it.
TabButtonCell.xib
Go to your XIB file and set up the basic appearance and constraints for each part like following.
Since we do want dynamic button (cell) width based on the content of the label, make sure that
- you do not add any constant constraints to the label nor its container
- container’s leading and trailing constraints should be relative to that of the label
Hook up the Container View and Button Label as outlets.
TabButtonCell.swift
Now, let’s head to TabButtonCell.swift and write some code to configure the button (cell) appearance.
You should already have your outlets declared like following.
@IBOutlet weak var containerView: UIView!
@IBOutlet weak var buttonLabel: UILabel!
First of all, let’s give it a round corner just because I like it. We will do this in func layoutSubviews()
like following.
override func layoutSubviews() {
super.layoutSubviews()
containerView.layer.borderColor = UIColor.gray.cgColor
containerView.layer.borderWidth = 0.6
containerView.layer.cornerRadius = 16.0
containerView.layer.masksToBounds = true
}
Next, in order to set custom UI for selected
and highlighted
states, we will be overriding the isSelected
and isHighlighted
properties.
override var isSelected: Bool {
didSet {
if isSelected {
containerView.backgroundColor = UIColor.link
containerView.layer.borderColor = UIColor.clear.cgColor
buttonLabel.textColor = .white
} else {
containerView.backgroundColor = UIColor.white
containerView.layer.borderColor = UIColor.systemGray5.cgColor
buttonLabel.textColor = .black
}
}
}
override var isHighlighted: Bool {
didSet {
if isHighlighted {
containerView.backgroundColor = UIColor.systemGray6
containerView.layer.borderColor = UIColor.gray.cgColor
buttonLabel.textColor = .black
} else {
containerView.backgroundColor = UIColor.white
containerView.layer.borderColor = UIColor.gray.cgColor
buttonLabel.textColor = .black
}
}
}
I am making my cell blue with white text on selected, and gray with a gray border on highlight. Feel free to change it to whatever you like.
Main ViewController
That’s it for the custom cell. Now let’s get started on building the main part, the actually collection view.
Main.storyboard
Let’s first set up our Main View.
Really simple! Only three elements.
- Button Collection View: the collection view in which we will be displaying our selection buttons (tabs)
- Left Scroll Button: allow us to scroll left if we are not at the beginning
- Right Scroll Button: scroll right if not at the end
Couple notes here.
- To change spacing between cells , use Min Spacing For Lines.
- To change where the first button starts and last button ends relative to the collection View, use Left and Right under Section Insets.
Hook it all three elements to ViewController and let’s get our hands dirty on writing some code.
ViewController.swift
Configure Collection View
First of all, we will need our viewController to conform to UICollectionViewDelegate and UICollectionViewDataSource. Add those two protocols and the required functions, as well as register the UINib for the Subclass we created above. I have also specified that I don’t want to enable multiple Selections. However, this should be the default anyway.
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
@IBOutlet weak var buttonCollectionView: UICollectionView!
@IBOutlet weak var leftScrollButton: UIButton!
@IBOutlet weak var rightScrollButton: UIButton!
// ...
override func viewDidLoad() {
super.viewDidLoad()
buttonCollectionView.delegate = self
buttonCollectionView.dataSource = self
buttonCollectionView.register(UINib(nibName: TabButtonCell.tableCellIdentifier, bundle: nil), forCellWithReuseIdentifier: TabButtonCell.tableCellIdentifier)
buttonCollectionView.allowsMultipleSelection = false
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
}
}
Leave the functions empty for right now and we will come back in a second.
Let’s declare two variables here. One indicating which button is selected (I will be selecting the one at position 0
in this case ), and one showing the total number of buttons we will have.
var selectedButtonIndex = 0
var totalButtonCount = 10
The reason we need the total count is that we actually don’t have a way to retrieve the total number of cells from collection view (you can only get that for visible cells).
Let’s head back to the required collectionView functions.
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
totalButtonCount
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TabButtonCell.tableCellIdentifier, for: indexPath) as! TabButtonCell
if indexPath.item == selectedButtonIndex {
cell.isSelected = true
} else {
cell.isSelected = false
}
cell.buttonLabel.text = "\(indexPath)"
return cell
}
I am simply displaying the indexPath
as the cell Button text because I am too lazy…
Note that how we are setting the isSelected
property within cellForItemAt
? This will ensure that the cell has the correct appearance.
If you try to set the cell styles within didSelectItemAt
, didDeselectItemAt
, didHighlightItemAt
, and didUnhighlightItemAt
without changing the isSelected
property of the cell, you might run into some weird selection problems (like I did…).
For example, in some corner cases, I ended up having two cells with background being blue (showing that they are selected) even though I am only allowing single selection. And when I try to select another cell, my app will crash at didDeselectItemAt
.
Now, we will have to update the selectedButtonIndex
when user did select a specific cell.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
selectedButtonIndex = indexPath.row
}
I am also adding a scrollToItem
so that the selected cell can be positioned at the center of the screen when possible.
To have a cell initially selected, we actually have to do something extra. We will have to actually selectItem in viewDidAppear
(or viewWillAppear
) like following.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
buttonCollectionView.selectItem(at: IndexPath(item: selectedButtonIndex, section: 0), animated: true, scrollPosition: .centeredHorizontally)
}
Also, to ensure that the highlighted appearance is not overwriting the selected appearance, and we will not accidentally deselect selected item, we will also need the following.
func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
if indexPath.item == selectedButtonIndex {
return false
}
return true
}
func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
if indexPath.item == selectedButtonIndex {
return false
}
return true
}
That’s it for the collection view part. If you run your app now, you should see button 0
being initially selected, being able to scroll and select with the given style, when button selected, it should scroll to the center if possible.
Now let’s head to configuring our indicator buttons.
Configure Indicator Buttons
Let’s first add the half opaque background to the buttons within viewWillAppear
.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let leftLayer = CAGradientLayer()
leftLayer.colors = [
UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor,
UIColor(red: 1, green: 1, blue: 1, alpha: 0).cgColor
]
leftLayer.locations = [0, 1]
leftLayer.startPoint = CGPoint(x: 0.25, y: 0.5)
leftLayer.endPoint = CGPoint(x: 0.75, y: 0.5)
leftLayer.transform = CATransform3DMakeAffineTransform(CGAffineTransform(a: 0.51, b: 0, c: 0, d: 2, tx: 0.49, ty: -1))
leftLayer.bounds = leftScrollButton.bounds.insetBy(dx: -0.5*leftScrollButton.bounds.size.width, dy: -0.5*leftScrollButton.bounds.size.height)
leftLayer.frame = leftScrollButton.bounds
leftScrollButton.backgroundColor = .clear
leftScrollButton.layer.addSublayer(leftLayer)
let rightLayer = CAGradientLayer()
rightLayer.colors = [
UIColor(red: 1, green: 1, blue: 1, alpha: 0).cgColor,
UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor
]
rightLayer.locations = [0, 1]
rightLayer.startPoint = CGPoint(x: 0.25, y: 0.5)
rightLayer.endPoint = CGPoint(x: 0.75, y: 0.5)
rightLayer.transform = CATransform3DMakeAffineTransform(CGAffineTransform(a: 0.51, b: 0, c: 0, d: 2, tx: 0.49, ty: -1))
rightLayer.bounds = rightScrollButton.bounds.insetBy(dx: -0.5*rightScrollButton.bounds.size.width, dy: -0.5*rightScrollButton.bounds.size.height)
rightLayer.frame = rightScrollButton.bounds
rightScrollButton.backgroundColor = .clear
rightScrollButton.layer.addSublayer(rightLayer)
rightScrollButton.backgroundColor = .clear
rightScrollButton.layer.addSublayer(rightLayer)
}
To change where your opaque part starts and end, you can adjust the .startPoint
and the .endPoint
properties.
And after we selected our initial button (tab / cell), we also need to check the collection view position to see which button or buttons we should display. If we are at the beginning, we will hide left button, and if we are at the end, hide the right button.
To do so, add the following to viewDidAppear
.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
buttonCollectionView.selectItem(at: IndexPath(item: selectedButtonIndex, section: 0), animated: true, scrollPosition: .centeredHorizontally)
if buttonCollectionView.contentOffset.x <= 0 {
leftScrollButton.isHidden = true
rightScrollButton.isHidden = false
} else if buttonCollectionView.contentOffset.x >= buttonCollectionView.contentSize.width - buttonCollectionView.bounds.width - 10 {
rightScrollButton.isHidden = true
leftScrollButton.isHidden = false
} else {
rightScrollButton.isHidden = false
leftScrollButton.isHidden = false
}
}
In a similar manner, we will also perform the same check every time the user scrolls the collection view within function scrollViewDidScroll
.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.contentOffset.x <= 0 {
leftScrollButton.isHidden = true
rightScrollButton.isHidden = false
} else if scrollView.contentOffset.x >= scrollView.contentSize.width - scrollView.bounds.width - 10 {
rightScrollButton.isHidden = true
leftScrollButton.isHidden = false
} else {
rightScrollButton.isHidden = false
leftScrollButton.isHidden = false
}
}
Now, let’s head to configure the behaviors of the indicator buttons. It will consist of the following steps:
- check which cells are on screen and which cells are off screen.
- Is the last or first cell already on screen?
- If yes, return.
- If not, scroll to the previous or the next item.
And this is how we will do it.
@IBAction func onRightScrollButtonPressed(_ sender: Any) {
var cellsOnScreen: [UICollectionViewCell] = []
for i in 0..<totalButtonCount {
guard let cell = buttonCollectionView.cellForItem(at: IndexPath(item: i, section: 0))
else {continue}
if buttonCollectionView.bounds.contains(cell.frame) {
cellsOnScreen.append(cell)
}
}
if cellsOnScreen.count == 0{
return
}
guard let lastCellOnScreen = cellsOnScreen.last, let lastIndex = buttonCollectionView.indexPath(for: lastCellOnScreen) else {return}
print("last index: ", lastIndex)
print("number of items: ", buttonCollectionView.numberOfItems(inSection: 0) )
if lastIndex.item == buttonCollectionView.numberOfItems(inSection: 0) - 1 {
return
}
buttonCollectionView.scrollToItem(at: IndexPath(item: lastIndex.item + 1, section: 0), at: .centeredHorizontally, animated: true)
}
@IBAction func onLeftScrollButtonPressed(_ sender: Any) {
var cellsOnScreen: [UICollectionViewCell] = []
for i in 0..<totalButtonCount {
guard let cell = buttonCollectionView.cellForItem(at: IndexPath(item: i, section: 0))
else {continue}
if buttonCollectionView.bounds.contains(cell.frame) {
cellsOnScreen.append(cell)
}
}
if cellsOnScreen.count == 0{
return
}
guard let firstCellOnScreen = cellsOnScreen.first, let firstIndex = buttonCollectionView.indexPath(for: firstCellOnScreen) else {return}
print("last index: ", firstIndex)
print("number of items: ", buttonCollectionView.numberOfItems(inSection: 0) )
if firstIndex.item == 0 {
return
}
buttonCollectionView.scrollToItem(at: IndexPath(item: firstIndex.item - 1, section: 0), at: .centeredHorizontally, animated: true)
}
That’s it! Run your app and you should get the result I show you at the beginning!
You can find the entire project here!
Thank you for reading and have a nice day!