// // Tabs.swift // VDS // // Created by Matt Bruce on 5/18/23. // import Foundation import UIKit import VDSColorTokens public class Tabs: View { //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- public enum Orientation: String, CaseIterable{ case vertical case horizontal } public enum IndicatorPosition: String, CaseIterable { case top case bottom var value: UIRectEdge { if self == .top { return .top } else { return .bottom } } } public enum Overflow: String, CaseIterable { case scroll case none } public enum Size: String, CaseIterable { case medium case large public var textStyle: TextStyle { if self == .medium { return .boldBodyLarge } else { //specs show that the font size shouldn't change however boldTitleSmall does //change point size between iPad/iPhone. This is a "fix" so each device will //load the correct pointSize return UIDevice.isIPad ? .boldTitleSmall : .boldTitleMedium } } } public enum Width { case percentage(CGFloat) case value(CGFloat) } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var onTabChange: ((Int) -> Void)? public var orientation: Orientation = .horizontal { didSet { updateTabItems() } } public var borderLine: Bool = true { didSet { setNeedsUpdate() } } public var fillContainer: Bool = false { didSet { updateTabItems() } } public var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } } public var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsUpdate() } } public var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } } public var overflow: Overflow = .scroll { didSet { updateTabItems() } } public var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } } public var size: Size = .medium { didSet { setNeedsUpdate() } } public var sticky: Bool = false { didSet { setNeedsUpdate() } } public var tabModels: [TabModel] = [] { didSet { updateTabItems() } } //rules for width private var _width: Width = .percentage(0.25) public var width: Width { get { return _width } set { switch newValue { case .percentage(let percentage): if percentage >= 0 && percentage <= 1 { _width = newValue setNeedsUpdate() } else { print("Invalid percentage value. It should be between 0 and 1.") } case .value(let value): if value >= minWidth { _width = newValue setNeedsUpdate() } else { print("Invalid value. It should be greater than or equal to \(minWidth).") } } } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var tabItems: [TabItem] = [] private var tabStackView: UIStackView! private var scrollView: UIScrollView! private var contentView: View! private var borderlineColorConfig = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark) private var contentViewWidthConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- public override init(frame: CGRect) { super.init(frame: frame) } public convenience required init() { self.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open override func setup() { super.setup() scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false addSubview(scrollView) contentView = View() contentView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(contentView) tabStackView = UIStackView() tabStackView.axis = .horizontal tabStackView.distribution = .fill tabStackView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(tabStackView) scrollView.pinToSuperView() contentView.pinToSuperView() tabStackView.pinToSuperView() contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true } private func updateTabItems() { updateTabItems(with: tabModels) } private func updateTabItems(with models: [TabModel]) { // Clear existing tab items for tabItem in tabItems { tabItem.removeFromSuperview() } tabItems.removeAll() // Create new tab items from the models for model in models { let tabItem = TabItem() tabItem.text = model.text tabItem.onClick = model.onClick tabItem.width = model.width tabItems.append(tabItem) tabStackView.addArrangedSubview(tabItem) tabItem .publisher(for: UITapGestureRecognizer()) .sink { [weak self] gesture in guard let self, let tabItem = gesture.view as? TabItem else { return } if let selectedIndex = tabItems.firstIndex(of: tabItem) { self.selectedIndex = selectedIndex self.onTabChange?(selectedIndex) } }.store(in: &tabItem.subscribers) } setNeedsUpdate() scrollToSelectedIndex(animated: false) } private func scrollToSelectedIndex(animated: Bool) { if orientation == .horizontal && self.overflow == .scroll, selectedIndex < tabItems.count { let selectedTab = tabItems[selectedIndex] scrollView.scrollRectToVisible(selectedTab.frame, animated: animated) } } open override func updateView() { super.updateView() if orientation == .horizontal && fillContainer { tabStackView.distribution = .fillEqually } else { tabStackView.distribution = .fill } // Update tab appearance based on properties for (index, tabItem) in tabItems.enumerated() { tabItem.selected = selectedIndex == index tabItem.size = size tabItem.orientation = orientation tabItem.surface = surface tabItem.indicatorPosition = indicatorPosition tabItem.width = tabWidth(for: tabItem) } tabStackView.axis = orientation == .horizontal ? .horizontal : .vertical tabStackView.alignment = orientation == .horizontal ? .fill : .leading tabStackView.spacing = orientation == .horizontal ? VDSLayout.Spacing.space6X.value : VDSLayout.Spacing.space4X.value // Deactivate old constraint contentViewWidthConstraint?.isActive = false // Apply width if orientation == .vertical { scrollView.isScrollEnabled = false switch width { case .percentage(let amount): contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: amount) case .value(let amount): contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: amount) } } else { // Apply overflow if overflow == .scroll && !fillContainer { let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: contentWidth) // Enable scrolling if necessary scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height) } else { contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor) } } scrollView.isScrollEnabled = orientation == .horizontal && overflow == .scroll // Activate old constraint contentViewWidthConstraint?.isActive = true setNeedsLayout() layoutIfNeeded() } open override func layoutSubviews() { super.layoutSubviews() //scrollView Contentsize if orientation == .horizontal && overflow == .scroll && !fillContainer { let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height) } else { scrollView.contentSize = bounds.size } // Apply border line layer.remove(layerName: "borderLineLayer") if borderLine { let borderLineLayer = CALayer() borderLineLayer.name = "borderLineLayer" borderLineLayer.backgroundColor = borderlineColorConfig.getColor(self).cgColor if orientation == .horizontal { borderLineLayer.frame = CGRect(x: 0, y: bounds.height - 1, width: bounds.width, height: 1) } else { borderLineLayer.frame = CGRect(x: 0, y: 0, width: 1, height: bounds.height) } layer.addSublayer(borderLineLayer) } scrollToSelectedIndex(animated: true) } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func tabWidth(for item: TabItem) -> CGFloat? { guard orientation == .vertical else { return item.width } var calculated: CGFloat switch width { case .percentage(let percent): calculated = (bounds.width * percent) - tabStackView.spacing case .value(let value): calculated = value - tabStackView.spacing } return calculated > minWidth ? calculated : minWidth } }