Last active
July 23, 2024 09:51
-
-
Save demolaf/cec063362418aee07e481e4642d4248d to your computer and use it in GitHub Desktop.
Segmented Control with bottom line indicator (UIKit)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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