Skip to content

Instantly share code, notes, and snippets.

@demolaf
Last active July 23, 2024 09:51
Show Gist options
  • Save demolaf/cec063362418aee07e481e4642d4248d to your computer and use it in GitHub Desktop.
Save demolaf/cec063362418aee07e481e4642d4248d to your computer and use it in GitHub Desktop.
Segmented Control with bottom line indicator (UIKit)
//
// CustomSegmentedControl.swift
// gogetit
//
// Created by Ademola Fadumo on 17/11/2023.
//
import UIKit
import SnapKit
struct CustomSegmentedControlItems {
let title: String?
let icon: UIImage?
init(title: String? = nil, icon: UIImage? = nil) {
guard title != nil || icon != nil else {
fatalError("Either title or icon should have a value")
}
self.title = title
self.icon = icon
}
}
struct CustomSegmentedControlConfiguration {
// MARK: - Title Text Font
let selectedTextStyle: UIFont?
let unselectedTextStyle: UIFont?
// MARK: - Icon Color
let selectedIconColor: UIColor?
let unselectedIconColor: UIColor?
// MARK: - Indicator Color
let selectedIndicatorColor: UIColor?
let unselectedIndicatorColor: UIColor?
// MARK: - Text Color
let selectedTextColor: UIColor?
let unselectedTextColor: UIColor?
init(
selectedTextStyle: UIFont? = nil,
unselectedTextStyle: UIFont? = nil,
selectedIconColor: UIColor? = nil,
unselectedIconColor: UIColor? = nil,
selectedIndicatorColor: UIColor? = nil,
unselectedIndicatorColor: UIColor? = nil,
selectedTextColor: UIColor? = nil,
unselectedTextColor: UIColor? = nil
) {
self.selectedTextStyle = selectedTextStyle
self.unselectedTextStyle = unselectedTextStyle
self.selectedIconColor = selectedIconColor
self.unselectedIconColor = unselectedIconColor
self.selectedIndicatorColor = selectedIndicatorColor
self.unselectedIndicatorColor = unselectedIndicatorColor
self.selectedTextColor = selectedTextColor
self.unselectedTextColor = unselectedTextColor
}
}
protocol CustomSegmetedControlDelegate: AnyObject {
func didTapTab(index: Int)
}
class CustomSegmetedControl: UIView {
private let rootView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.distribution = .fillEqually
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private let indicatorView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground.flipped()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
// MARK: - Internal Variables
private var tabs: [UIButton] = []
private var currentIndex: Int = 0
private var previousIndex: Int = -1
private var indicatorViewLeadingConstraint: Constraint?
// MARK: - External Variables
private let items: [CustomSegmentedControlItems]
var configuration: CustomSegmentedControlConfiguration?
weak var delegate: CustomSegmetedControlDelegate?
// MARK: - Initialization
init(
frame: CGRect = .zero,
items: [CustomSegmentedControlItems]
) {
self.items = items
super.init(frame: frame)
// Must be called first
initializeTabs()
initializeSubviews()
updateTabAppearance()
}
required init?(coder: NSCoder) {
fatalError()
}
override func layoutSubviews() {
super.layoutSubviews()
applyConstraints()
updateViewLayouts(currentIndex)
}
private func initializeSubviews() {
self.addSubview(rootView)
rootView.addSubview(stackView)
rootView.addSubview(indicatorView)
tabs.forEach {
stackView.addArrangedSubview($0)
}
}
private func applyConstraints() {
rootView.snp.makeConstraints { make in
make.edges.equalTo(self)
make.top.equalTo(stackView)
make.bottom.equalTo(indicatorView)
}
stackView.snp.makeConstraints { make in
make.top.trailing.leading.equalTo(rootView)
}
indicatorView.snp.makeConstraints { make in
make.top.equalTo(stackView.snp.bottom).offset(6)
make.height.equalTo(2)
make.width.equalTo(tabs[0])
indicatorViewLeadingConstraint = make.leading.equalTo(tabs[0]).constraint
}
}
// Creates the buttons from the buttonTitles array
private func initializeTabs() {
tabs = items.map { item in
let button: UIButton = UIButton(type: .custom)
if let title = item.title {
button.setTitle(title, for: .normal)
}
if let icon = item.icon {
button.setImage(
icon.withTintColor(
.label,
renderingMode: .alwaysOriginal
),
for: .normal
)
}
button.addAction(
UIAction(handler: {[weak self] _ in
self?.didTapTabItem(sender: button)
}),
for: .touchUpInside
)
button.tintColor = configuration?.unselectedTextColor ?? .systemBackground.flipped()
button.configuration = .plain()
button.configuration?.imagePadding = 8
return button
}
}
private func didTapTabItem(sender: UIButton) {
guard let index = tabs.firstIndex(of: sender) else {
return
}
delegate?.didTapTab(
index: index
)
updateViewLayouts(index)
}
// Perform the UI/logic changes when a button is pressed
func updateViewLayouts(_ index: Int) {
previousIndex = currentIndex
currentIndex = index
updateTabAppearance()
animateBarViewPosition()
}
// Change the color and font when a button is pressed
private func updateTabAppearance() {
let selectedTab = tabs[currentIndex]
tabs.forEach { tab in
// If button is selected
if tab == selectedTab {
if let configuration = self.configuration {
tab.setImage(
tab.currentImage?.withTintColor(
configuration.selectedIconColor ?? .systemBackground.flipped(),
renderingMode: .alwaysOriginal
),
for: .normal
)
tab.titleLabel?.font = configuration.selectedTextStyle
tab.setTitleColor(configuration.selectedTextColor, for: .normal)
}
} else {
if let configuration = self.configuration {
tab.setImage(
tab.currentImage?.withTintColor(
configuration.unselectedIconColor ?? .systemBackground.flipped(),
renderingMode: .alwaysOriginal
),
for: .normal
)
tab.titleLabel?.font = configuration.unselectedTextStyle
tab.setTitleColor(configuration.unselectedTextColor, for: .normal)
}
}
}
}
// Change and animate the barView position
private func animateBarViewPosition() {
let selectedTab = tabs[currentIndex]
// NOTES:
// Using snapkit I tried animating the indicator view but doesn't work
// if you try to:
// 1. deactivate constraints
// 2. then make your constraints update
// 3. activate constraints
//
// This doesn't work why?
// I then chose to read the snap kit docs and use remakeConstraints instead
// which then allows me to update the entire constraints like removing and adding it
// back.
if indicatorViewLeadingConstraint != nil {
indicatorView.snp.remakeConstraints { make in
make.top.equalTo(stackView.snp.bottom).offset(6)
make.height.equalTo(2)
make.width.equalTo(selectedTab)
indicatorViewLeadingConstraint = make.leading.equalTo(selectedTab).constraint
}
if previousIndex != currentIndex {
UIView.animate(withDuration: 0.3) {
self.layoutIfNeeded()
}
}
}
}
}
private let tabBarView: CustomSegmetedControl = {
let selectedColor: UIColor = .systemBackground.flipped()
let unselectedColor: UIColor = .systemGray
let items = [
CustomSegmentedControlItems(
title: "Incomplete",
icon: UIImage(systemName: "figure.disc.sports")
),
CustomSegmentedControlItems(
title: "Complete",
icon: UIImage(systemName: "checkmark.seal.fill")
)
]
let segmentedControl = CustomSegmetedControl(items: items)
segmentedControl.configuration = .init(
selectedTextStyle: FontFamily.Aeonik.medium.font(size: 16),
unselectedTextStyle: FontFamily.Aeonik.regular.font(size: 14),
selectedIconColor: selectedColor,
unselectedIconColor: unselectedColor,
// selectedIndicatorColor: selectedColor,
// unselectedIndicatorColor: unselectedColor,
selectedTextColor: selectedColor,
unselectedTextColor: unselectedColor
)
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
return segmentedControl
}()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment