diff --git a/VDS/Components/Tabs/Tabs.swift b/VDS/Components/Tabs/Tabs.swift index 090d6e97..a51e8d40 100644 --- a/VDS/Components/Tabs/Tabs.swift +++ b/VDS/Components/Tabs/Tabs.swift @@ -9,19 +9,11 @@ import Foundation import UIKit import VDSColorTokens -public struct TabModel { - public var text: String - public var onClick: (() -> Void)? - public var width: CGFloat? - - public init(text: String, onClick: (() -> Void)? = nil, width: CGFloat? = nil) { - self.text = text - self.onClick = onClick - self.width = width - } -} - public class Tabs: View { + + //-------------------------------------------------- + // MARK: - Enums + //-------------------------------------------------- public enum Orientation: String, CaseIterable{ case vertical case horizontal @@ -63,26 +55,61 @@ public class Tabs: View { case value(CGFloat) } + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- public var onTabChange: ((Int) -> Void)? - public var orientation: Orientation = .horizontal { didSet { setNeedsUpdate() } } + public var orientation: Orientation = .horizontal { didSet { updateTabItems() } } public var borderLine: Bool = true { didSet { setNeedsUpdate() } } - public var fillContainer: Bool = false { 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 { 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 width: Width = .percentage(0.25) { didSet { setNeedsUpdate() } } - public var tabModels: [TabModel] = [] { didSet { updateTabItems(with: tabModels) } } - + 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) } @@ -95,6 +122,9 @@ public class Tabs: View { super.init(coder: coder) } + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- open override func setup() { super.setup() scrollView = UIScrollView() @@ -116,10 +146,13 @@ public class Tabs: View { 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 { @@ -136,56 +169,95 @@ public class Tabs: View { tabItems.append(tabItem) tabStackView.addArrangedSubview(tabItem) - // Add tap gesture recognizer to handle tab selection - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tabItemTapped(_:))) - tabItem.isUserInteractionEnabled = true - tabItem.addGestureRecognizer(tapGesture) + 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) } - - @objc private func tabItemTapped(_ gesture: UITapGestureRecognizer) { - guard let tabItem = gesture.view as? TabItem else { return } - if let selectedIndex = tabItems.firstIndex(of: tabItem) { - self.selectedIndex = selectedIndex - onTabChange?(selectedIndex) + private func scrollToSelectedIndex(animated: Bool) { + if orientation == .horizontal && self.overflow == .scroll, selectedIndex < tabItems.count { + let selectedTab = tabItems[selectedIndex] + scrollView.scrollRectToVisible(selectedTab.frame, animated: animated) } } - - public override func updateView() { + + open override func updateView() { super.updateView() - if fillContainer { + if orientation == .horizontal && fillContainer { tabStackView.distribution = .fillEqually } else { tabStackView.distribution = .fill } - tabStackView.axis = orientation == .horizontal ? .horizontal : .vertical - tabStackView.alignment = orientation == .horizontal ? .fill : .leading - tabStackView.spacing = orientation == .horizontal ? VDSLayout.Spacing.space6X.value : VDSLayout.Spacing.space4X.value - setNeedsLayout() - } - - public override func layoutSubviews() { - super.layoutSubviews() - - // Apply border line - layer.remove(layerName: "borderLineLayer") - // Update tab appearance based on properties for (index, tabItem) in tabItems.enumerated() { - if selectedIndex == index { - tabItem.selected = true - } else { - tabItem.selected = false - } + 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" @@ -199,150 +271,24 @@ public class Tabs: View { layer.addSublayer(borderLineLayer) } - - // Apply width - if orientation == .vertical { - switch width { - case .percentage(let amount): - contentView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: amount).isActive = true - case .value(let amount): - contentView.widthAnchor.constraint(equalToConstant: amount).isActive = true - } - } else { - // Apply overflow - if orientation == .horizontal && overflow == .scroll { - let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width - contentView.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true - } else { - contentView.widthAnchor.constraint(equalTo: widthAnchor).isActive = true - } - } - - // Enable scrolling if necessary - let contentWidth = contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width - scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height) - addDebugBorder(color: .blue) + 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 + } + } - -public class TabItem: View { - public var orientation: Tabs.Orientation = .horizontal { didSet { setNeedsUpdate() } } - public var size: Tabs.Size = .medium { didSet { setNeedsUpdate() } } - public var indicatorPosition: Tabs.IndicatorPosition = .bottom { didSet { setNeedsUpdate() } } - public var label: Label = Label() - public var onClick: (() -> Void)? { didSet { setNeedsUpdate() } } - public var width: CGFloat? { didSet { setNeedsUpdate() } } - public var selected: Bool = false { didSet { setNeedsUpdate() } } - public var text: String? { didSet { setNeedsUpdate() } } - private var labelMinWidthConstraint: NSLayoutConstraint? - private var labelWidthConstraint: NSLayoutConstraint? - private var labelLeadingConstraint: NSLayoutConstraint? - private var labelTopConstraint: NSLayoutConstraint? - private var labelBottomConstraint: NSLayoutConstraint? - - private var textColorConfiguration: SurfaceColorConfiguration { selected ? textColorSelectedConfiguration : textColorNonSelectedConfiguration } - private var textColorNonSelectedConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight , VDSColor.elementsSecondaryOnlight) - private var textColorSelectedConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) - private var indicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteRed, VDSColor.elementsPrimaryOndark) - private var indicatorWidth: CGFloat = 4.0 - - public override init(frame: CGRect) { - super.init(frame: frame) - addSubview(label) - backgroundColor = .clear - label.backgroundColor = .clear - - label.translatesAutoresizingMaskIntoConstraints = false - label.pinTrailing() - - labelTopConstraint = label.topAnchor.constraint(equalTo: topAnchor, constant: 0) - labelTopConstraint?.isActive = true - - labelBottomConstraint = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0) - labelBottomConstraint?.isActive = true - - labelLeadingConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0) - labelLeadingConstraint?.isActive = true - - labelMinWidthConstraint = label.widthAnchor.constraint(greaterThanOrEqualToConstant: 44.0) - labelMinWidthConstraint?.isActive = true - - labelWidthConstraint = label.widthAnchor.constraint(equalToConstant: 44.0) - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tabItemTapped)) - addGestureRecognizer(tapGesture) - } - - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - required public convenience init() { - self.init(frame: .zero) - } - - @objc private func tabItemTapped() { - onClick?() - } - - public override func updateView() { - if orientation == .horizontal { - label.textPosition = .center - } else { - label.textPosition = .left - } - label.text = text - label.textStyle = size.textStyle - label.textColor = textColorConfiguration.getColor(self) - setNeedsLayout() - } - - public override func setNeedsLayout() { - super.setNeedsLayout() - - if let width { - labelMinWidthConstraint?.isActive = false - labelWidthConstraint?.constant = width - labelWidthConstraint?.isActive = true - } else { - labelWidthConstraint?.isActive = false - labelMinWidthConstraint?.isActive = true - } - - var leadingSpace: CGFloat - if orientation == .horizontal { - leadingSpace = 0 - } else { - leadingSpace = size == .medium ? VDSLayout.Spacing.space4X.value : VDSLayout.Spacing.space6X.value - } - labelLeadingConstraint?.constant = leadingSpace - - var otherSpace: CGFloat - if orientation == .horizontal { - otherSpace = size == .medium ? VDSLayout.Spacing.space3X.value : VDSLayout.Spacing.space4X.value - } else { - otherSpace = VDSLayout.Spacing.space2X.value - } - labelTopConstraint?.constant = otherSpace - labelBottomConstraint?.constant = -otherSpace - - if selected { - var indicator: UIRectEdge = .left - if orientation == .horizontal { - indicator = indicatorPosition.value - } - addBorder(side: indicator, width: indicatorWidth, color: indicatorColorConfiguration.getColor(self)) - } else { - removeBorders() - } - } - - public override func draw(_ rect: CGRect) { - super.draw(rect) - - addDebugBorder(color: .green) - } - -} -