vds_ios/VDS/Components/Tabs/Tabs.swift
Matt Bruce ecff7b9686 refactored updateAccessibiltyLabel to updateAccessibility
moved down common isEnabled/isSelected down to base classes

Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2023-08-01 10:23:24 -05:00

342 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Tabs.swift
// VDS
//
// Created by Matt Bruce on 5/18/23.
//
import Foundation
import UIKit
import VDSColorTokens
/// Tabs are organizational components that group content and allow customers to navigate its display. Use them to separate content when the content is related but doesnt need to be compared.
@objc(VDSTabs)
open class Tabs: View {
//--------------------------------------------------
// MARK: - Enums
//--------------------------------------------------
/// Layout Axis of the Tabs
public enum Orientation: String, CaseIterable{
case vertical
case horizontal
}
/// Position for the Hover Border Accent for a Tab.
public enum IndicatorPosition: String, CaseIterable {
case top
case bottom
var value: UIRectEdge {
if self == .top {
return .top
} else {
return .bottom
}
}
}
//Type of behavior for Scrolling
public enum Overflow: String, CaseIterable {
case scroll
case none
}
/// Available size for tabs
public enum Size: String, CaseIterable {
case medium
case large
}
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
///An
/// callback that is called when the selectedIndex changes. Passes parameters (event, tabIndex).
open var onTabChange: ((Int) -> Void)?
//Determines the layout of the Tabs, defaults to horizontal
open var orientation: Orientation = .horizontal { didSet { if oldValue != orientation { updateTabItems() } } }
///When true, Tabs will have border line. If false is passed then the border line won't be visible.
open var borderLine: Bool = true { didSet { setNeedsUpdate() } }
///It will fill the Tabs to the width of the compoent and all Tabs will be in equal width when orientation is horizontal. This is recommended when there are no more than 2-3 tabs.
open var fillContainer: Bool = false { didSet { updateTabItems() } }
///When true, Tabs will be sticky to top of page, when orientation is vertical.
open var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } }
///Sets the Position of the Selected/Hover Border Accent for All Tabs, only for Horizontal Orientation
open var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsUpdate() } }
///Minimum Width for All Tabs, when orientation is horizontal.
open var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } }
///If set to 'scroll', Tabs can be overflow and scrollable. With 'none', tabs will not overflow and labels will be wrapped to multiple lines if the label text is long.
open var overflow: Overflow = .scroll { didSet { updateTabItems() } }
///The initial Selected Tab's index and is set once a Tab is clicked
open var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } }
///Determines the size of the Tabs TextStyle
open var size: Size = .medium { didSet { updateTabItems() } }
///When true, Tabs will be sticky to top of page, when orientation is vertical.
open var sticky: Bool = false { didSet { setNeedsUpdate() } }
///Model of the Tabs you are wanting to show.
open var tabModels: [TabModel] = [] { didSet { updateTabItems() } }
open var tabViews: [Tab] = []
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
private var tabStackView = UIStackView()
private var scrollView = UIScrollView()
private var contentView = View()
private var borderlineView = View()
private let borderlineSize = 1.0
private var borderlineColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark)
private var borderlineViewLeadingConstraint: NSLayoutConstraint?
private var borderlineViewTrailingConstraint: NSLayoutConstraint?
private var borderlineViewTopConstraint: NSLayoutConstraint?
private var borderlineViewBottomConstraint: NSLayoutConstraint?
private var borderlineViewHeightConstraint: NSLayoutConstraint?
private var borderlineViewWidthConstraint: NSLayoutConstraint?
private var contentViewWidthConstraint: NSLayoutConstraint?
//--------------------------------------------------
// MARK: - Configuration Properties
//--------------------------------------------------
private var stackViewAxis: NSLayoutConstraint.Axis {
orientation == .horizontal ? .horizontal : .vertical
}
private var stackViewAlignment: UIStackView.Alignment {
orientation == .horizontal ? .fill : .leading
}
private var stackViewDistribution: UIStackView.Distribution{
if orientation == .horizontal && fillContainer {
return .fillEqually
} else {
return orientation == .horizontal ? .fillProportionally : .fill
}
}
private var stackViewSpacing: CGFloat {
switch orientation {
case .vertical:
return size == .medium ? VDSLayout.Spacing.space4X.value : VDSLayout.Spacing.space6X.value
case .horizontal:
return size == .medium ? VDSLayout.Spacing.space6X.value : VDSLayout.Spacing.space8X.value
}
}
private var scrollIsEnabled: Bool {
orientation == .horizontal && overflow == .scroll
}
private var textPosition: TextPosition {
orientation == .horizontal && fillContainer ? .center : .left
}
//--------------------------------------------------
// 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(borderlineView)
contentView.addSubview(tabStackView)
scrollView.pinToSuperView()
contentView.pinToSuperView()
tabStackView.pinToSuperView()
contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
borderlineViewWidthConstraint = borderlineView.widthAnchor.constraint(equalToConstant: 0)
borderlineViewHeightConstraint = borderlineView.heightAnchor.constraint(equalToConstant: 0)
borderlineViewLeadingConstraint = borderlineView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
borderlineViewTrailingConstraint = borderlineView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
borderlineViewTopConstraint = borderlineView.topAnchor.constraint(equalTo: contentView.topAnchor)
borderlineViewBottomConstraint = borderlineView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
}
private func updateTabItems() {
updateTabItems(with: tabModels)
}
private func updateTabItems(with models: [TabModel]) {
// Clear existing tab items
for tabItem in tabViews {
tabItem.removeFromSuperview()
}
tabViews.removeAll()
// Create new tab items from the models
for model in models {
let tabItem = Tab()
tabItem.size = size
tabItem.text = model.text
tabItem.onClick = model.onClick
tabItem.width = model.width
tabViews.append(tabItem)
tabStackView.addArrangedSubview(tabItem)
tabItem
.publisher(for: UITapGestureRecognizer())
.sink { [weak self] gesture in
guard let self, let tabItem = gesture.view as? Tab else { return }
if let selectedIndex = self.tabViews.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 < tabViews.count {
let selectedTab = tabViews[selectedIndex]
scrollView.scrollRectToVisible(selectedTab.frame, animated: animated)
}
}
open override func updateView() {
super.updateView()
// Update the stackview properties
tabStackView.distribution = stackViewDistribution
tabStackView.axis = stackViewAxis
tabStackView.alignment = stackViewAlignment
tabStackView.spacing = stackViewSpacing
// Update tab appearance based on properties
for (index, tabItem) in tabViews.enumerated() {
tabItem.size = size
tabItem.selected = selectedIndex == index
tabItem.index = index
tabItem.minWidth = minWidth
tabItem.textPosition = textPosition
tabItem.orientation = orientation
tabItem.surface = surface
tabItem.indicatorPosition = indicatorPosition
tabItem.isAccessibilityElement = true
tabItem.accessibilityLabel = tabItem.text
tabItem.accessibilityHint = "Tab Item"
tabItem.accessibilityTraits = tabItem.selected ? [.button, .selected] : .button
tabItem.accessibilityValue = "\(index+1) of \(tabViews.count) Tabs"
}
//update the width based on rules
updateContentView()
setNeedsLayout()
layoutIfNeeded()
}
private func updateContentView() {
// Deactivate old constraint
contentViewWidthConstraint?.isActive = false
// Apply overflow
if orientation == .horizontal && overflow == .scroll && !fillContainer {
let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: contentWidth)
scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height)
} else {
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor)
scrollView.contentSize = bounds.size
}
scrollView.isScrollEnabled = scrollIsEnabled
// Activate old constraint
contentViewWidthConstraint?.isActive = true
scrollToSelectedIndex(animated: true)
}
open override func layoutSubviews() {
super.layoutSubviews()
//borderLine
if borderLine {
var edge: UIRectEdge = .bottom
if orientation == .vertical {
edge = .left
} else if indicatorPosition == .top {
edge = .top
}
borderlineViewLeadingConstraint?.isActive = false
borderlineViewTrailingConstraint?.isActive = false
borderlineViewTopConstraint?.isActive = false
borderlineViewBottomConstraint?.isActive = false
borderlineViewHeightConstraint?.isActive = false
borderlineViewWidthConstraint?.isActive = false
if edge == .left {
borderlineViewWidthConstraint?.constant = borderlineSize
borderlineViewWidthConstraint?.isActive = true
borderlineViewTopConstraint?.isActive = true
borderlineViewLeadingConstraint?.isActive = true
borderlineViewBottomConstraint?.isActive = true
} else if edge == .top {
borderlineViewHeightConstraint?.constant = borderlineSize
borderlineViewHeightConstraint?.isActive = true
borderlineViewTopConstraint?.isActive = true
borderlineViewLeadingConstraint?.isActive = true
borderlineViewTrailingConstraint?.isActive = true
} else {
borderlineViewHeightConstraint?.constant = borderlineSize
borderlineViewHeightConstraint?.isActive = true
borderlineViewLeadingConstraint?.isActive = true
borderlineViewTrailingConstraint?.isActive = true
borderlineViewBottomConstraint?.isActive = true
}
borderlineView.backgroundColor = borderlineColorConfiguration.getColor(self)
borderlineView.isHidden = false
} else {
borderlineView.isHidden = true
}
}
}