updated tabsModel and Tabs

Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
This commit is contained in:
Matt Bruce 2023-12-11 14:19:00 -06:00
parent 8a90a8ce4a
commit 7c0e8bb106
2 changed files with 66 additions and 412 deletions

View File

@ -8,379 +8,64 @@
import UIKit import UIKit
import VDSColorTokens import VDSColorTokens
import VDS
@objc public protocol TabsDelegate { @objc public protocol TabsDelegate {
func shouldSelectItem(_ indexPath: IndexPath, tabs: Tabs) -> Bool func shouldSelectItem(_ indexPath: IndexPath, tabs: Tabs) -> Bool
func didSelectItem(_ indexPath: IndexPath, tabs: Tabs) func didSelectItem(_ indexPath: IndexPath, tabs: Tabs)
} }
@objcMembers open class Tabs: View, MVMCoreUIViewConstrainingProtocol, MFButtonProtocol { @objcMembers open class Tabs: VDS.Tabs, VDSMoleculeViewProtocol, MVMCoreUIViewConstrainingProtocol, MFButtonProtocol {
public var tabsModel: TabsModel? { //--------------------------------------------------
get { return model as? TabsModel } // MARK: - Public Properties
} //--------------------------------------------------
open var viewModel: TabsModel!
var delegateObject: MVMCoreUIDelegateObject? open var delegateObject: MVMCoreUIDelegateObject?
var additionalData: [AnyHashable: Any]? open var additionalData: [AnyHashable : Any]?
let layout = UICollectionViewFlowLayout()
public var collectionView: CollectionView?
let bottomScrollView = UIScrollView(frame: .zero)
let bottomContentView = View()
let bottomLine = Line()
let selectionLine = View()
var selectionLineLeftConstraint: NSLayoutConstraint?
var selectionLineWidthConstraint: NSLayoutConstraint?
private var widthLabel = Label()
//delegate //delegate
weak public var delegate: TabsDelegate? weak public var delegate: TabsDelegate?
//control var
public var selectedIndex: Int = 0
public var paddingBeforeFirstTab: Bool = true
//constant
let TabCellId = "TabCell"
public let itemSpacing: CGFloat = 20.0
public let cellHeight: CGFloat = 28.0
public let selectionLineHeight: CGFloat = 4.0
public let minimumItemWidth: CGFloat = 32.0
public let selectionLineMovingTime: TimeInterval = 0.2
//------------------------------------------------- //-------------------------------------------------
// MARK: - Layout Views // MARK: - Layout Views
//------------------------------------------------- //-------------------------------------------------
open override func reset() { open func updateView(_ size: CGFloat) {}
super.reset()
selectedIndex = 0
paddingBeforeFirstTab = true
}
open override func updateView(_ size: CGFloat) {
super.updateView(size)
collectionView?.updateView(size)
}
open override func setupView() {
super.setupView()
backgroundColor = VDSColor.backgroundPrimaryLight
addSubview(bottomLine)
setupCollectionView()
setupSelectionLine()
setupConstraints()
}
func setupCollectionView () {
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
let collectionView = CollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.register(TabItemCell.self, forCellWithReuseIdentifier: TabCellId)
collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.dataSource = self
collectionView.delegate = self
addSubview(collectionView)
self.collectionView = collectionView
}
func setupSelectionLine() {
bottomScrollView.translatesAutoresizingMaskIntoConstraints = false
bottomScrollView.delegate = self
addSubview(bottomScrollView)
bottomScrollView.addSubview(bottomContentView)
selectionLine.backgroundColor = VDSColor.paletteRed
bottomContentView.addSubview(selectionLine)
bringSubviewToFront(bottomScrollView)
}
func setupConstraints() {
//collection view
NSLayoutConstraint.constraintPinSubview(collectionView, pinTop: true, pinBottom: false, pinLeft: true, pinRight: true)
collectionView?.heightAnchor.constraint(equalToConstant: cellHeight).isActive = true
//selection line
bottomScrollView.topAnchor.constraint(equalTo: collectionView!.bottomAnchor).isActive = true;
NSLayoutConstraint.constraintPinSubview(bottomScrollView, pinTop: false, pinBottom: false, pinLeft: true, pinRight: true)
bottomScrollView.heightAnchor.constraint(equalToConstant: selectionLineHeight).isActive = true
NSLayoutConstraint.constraintPinSubview(selectionLine, pinTop: true, pinBottom: true, pinLeft: false, pinRight: false)
selectionLine.heightAnchor.constraint(equalToConstant: selectionLineHeight).isActive = true
selectionLineLeftConstraint = selectionLine.leftAnchor.constraint(equalTo: bottomContentView.leftAnchor)
selectionLineLeftConstraint?.isActive = true
selectionLineWidthConstraint = selectionLine.widthAnchor.constraint(equalToConstant: minimumItemWidth)
selectionLineWidthConstraint?.isActive = true
NSLayoutConstraint.constraintPinSubview(toSuperview: bottomContentView)
//bottom line
bottomLine.topAnchor.constraint(equalTo: bottomScrollView.bottomAnchor).isActive = true;
NSLayoutConstraint.constraintPinSubview(bottomLine, pinTop: false, pinBottom: true, pinLeft: true, pinRight: true)
}
open override func layoutSubviews() {
super.layoutSubviews()
// Accounts for any collection size changes
DispatchQueue.main.async {
self.layoutCollection()
}
}
/// Invalidates the layout and ensures we are paged to the correct cell.
open func layoutCollection() {
collectionView?.collectionViewLayout.invalidateLayout()
// Go to current cell. layoutIfNeeded is needed otherwise cellForItem returns nil for peaking logic. The dispatch is a sad way to ensure the collection view is ready to be scrolled.
DispatchQueue.main.async {
self.collectionView?.scrollToItem(at: IndexPath(row: self.selectedIndex, section: 0), at: .left, animated: false)
self.collectionView?.layoutIfNeeded()
}
}
//------------------------------------------------- //-------------------------------------------------
// MARK: - Control Methods // MARK: - Control Methods
//------------------------------------------------- //-------------------------------------------------
public func selectIndex(_ index: Int, animated: Bool) { public func selectIndex(_ index: Int, animated: Bool) {
guard let _ = collectionView, tabsModel?.tabs.count ?? 0 > 0 else { self.selectedIndex = index
selectedIndex = index
tabsModel?.selectedIndex = index
return
}
MVMCoreDispatchUtility.performBlock(onMainThread: {
let currentIndex = self.selectedIndex
self.selectedIndex = index
self.tabsModel?.selectedIndex = index
self.deselect(indexPath: IndexPath(row: currentIndex, section: 0))
self.selectItem(atIndexPath: IndexPath(row: index, section: 0), animated: animated)
})
} }
public func reloadData() { public func reloadData() { setNeedsUpdate() }
collectionView?.reloadData()
}
//------------------------------------------------- //-------------------------------------------------
// MARK: - Molecule Setup // MARK: - Molecule Setup
//------------------------------------------------- //-------------------------------------------------
override open func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable : Any]?) { open func viewModelDidUpdate() {
super.set(with: model, delegateObject, additionalData) orientation = viewModel.orientation
self.delegateObject = delegateObject indicatorPosition = viewModel.indicatorPosition
self.additionalData = additionalData overflow = viewModel.overflow
selectedIndex = tabsModel?.selectedIndex ?? 0 size = viewModel.size
selectionLine.backgroundColor = tabsModel?.selectedBarColor.uiColor selectedIndex = viewModel.selectedIndex
let lineModel = bottomLine.viewModel ?? LineModel(type: .secondary) surface = viewModel.style ?? .light
lineModel.inverted = tabsModel?.style == .dark tabModels = viewModel.tabs.compactMap { TabModel(text: $0.label.text) }
bottomLine.set(with: lineModel, delegateObject, additionalData)
reloadData()
}
}
//------------------------------------------------- if let delegate {
// MARK: - Collection View Methods onTabChange = { [weak self] index in
//------------------------------------------------- guard let self else { return }
delegate.didSelectItem(.init(row: index, section: 0), tabs: self)
}
extension Tabs: UICollectionViewDataSource { onTabShouldSelect = { [weak self] index in
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { guard let self else { return true }
return tabsModel?.tabs.count ?? 0 return delegate.shouldSelectItem(.init(row: index, section: 0), tabs: self)
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TabCellId, for: indexPath) as? TabItemCell else {
return UICollectionViewCell()
}
cell.updateCell(indexPath: indexPath, delegateObject: delegateObject, additionalData: additionalData, selected: indexPath.row == selectedIndex, tabsModel: tabsModel)
updateView(collectionView.bounds.width)
return cell
}
}
extension Tabs: UICollectionViewDelegateFlowLayout {
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
guard self.collectionView(collectionView, numberOfItemsInSection: indexPath.section) != 2 else {
// If two tabs, take up the screen
let insets = self.collectionView(collectionView, layout: collectionViewLayout, insetForSectionAt: indexPath.section)
let width = (collectionView.bounds.width / 2.0) - insets.left - insets.right
return CGSize(width: width, height: cellHeight)
}
guard let labelModel = tabsModel?.tabs[indexPath.row].label else {
return .zero
}
return CGSize(width: max(minimumItemWidth, getLabelWidth(labelModel).width), height: cellHeight)
}
//pre calculate the width of the collection cell
//when user select tabs, it will reload related collectionview, if we use autosize, it would relayout the width, need to keep the cell width constant.
func getLabelWidth(_ labelModel: LabelModel?) -> CGSize {
guard let labelModel = labelModel else { return .zero}
widthLabel.set(with: labelModel, nil, nil)
let cgSize = widthLabel.intrinsicContentSize
widthLabel.reset()
return cgSize
}
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
guard section == 0 else {
return UIEdgeInsets(top: 0, left: itemSpacing, bottom: 0, right: 0)
}
guard paddingBeforeFirstTab else {
return .zero
}
return UIEdgeInsets(top: 0, left: Padding.Component.horizontalPaddingForApplicationWidth, bottom: 0, right: 0)
}
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
// If two tabs, take up the screen, no space between items
guard self.collectionView(collectionView, numberOfItemsInSection: section) != 2 else {
return 0
}
return itemSpacing
}
public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return delegate?.shouldSelectItem(indexPath, tabs: self) ?? true
}
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
selectIndex(indexPath.row, animated: true)
}
public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard let tabCell = cell as? TabItemCell else { return }
if indexPath.row == selectedIndex {
DispatchQueue.main.async {
self.moveSelectionLine(toIndex: indexPath, animated: false, cell: tabCell)
} }
} }
} }
func deselect(indexPath:IndexPath) {
collectionView?.deselectItem(at: indexPath, animated: false)
collectionView?.reloadItems(at: [indexPath])
}
func selectItem(atIndexPath indexPath: IndexPath, animated: Bool) {
guard let collect = collectionView, tabsModel?.tabs.count ?? 0 > 0 else { return }
collect.selectItem(at: indexPath, animated: animated, scrollPosition: .centeredHorizontally)
guard let tabCell = collect.cellForItem(at: indexPath) as? TabItemCell, let tabsModel = tabsModel else { return }
moveSelectionLine(toIndex: indexPath, animated: animated, cell: tabCell)
tabCell.label.textColor = tabsModel.selectedColor.uiColor
tabCell.updateAccessibility(indexPath: indexPath, selected: true, tabsModel: tabsModel)
tabCell.setNeedsDisplay()
tabCell.setNeedsLayout()
tabCell.layoutIfNeeded()
if let delegate = delegate {
delegate.didSelectItem(indexPath, tabs: self)
} else if let action = tabsModel.tabs[selectedIndex].action {
MVMCoreUIActionHandler.performActionUnstructured(with: action, sourceModel: tabsModel, additionalData: nil, delegateObject: delegateObject)
}
if UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .layoutChanged, argument: tabCell)
}
}
} }
extension Tabs: UIScrollViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
/*bottomScrollview is subview of self, it's not belongs to collectionview.
When collectionview is scrolling, bottomScrollView will stay without moving
Adding collectionview's offset to bottomScrollView, will make the bottomScrollview looks like scrolling with the selected tab item.
*/
guard let offsetX = collectionView?.contentOffset.x else { return }
bottomScrollView.setContentOffset(CGPoint(x: offsetX, y: bottomScrollView.contentOffset.y), animated: false)
}
}
//-------------------------------------------------
// MARK: - Bottom Line Methods
//-------------------------------------------------
extension Tabs {
func moveSelectionLine(toIndex indexPath: IndexPath, animated: Bool, cell: TabItemCell) {
guard let collect = collectionView else {return}
let size = collectionView(collect, layout: layout, sizeForItemAt: indexPath)
let animationBlock = {
[weak self] in
self?.selectionLineWidthConstraint?.constant = size.width
self?.selectionLineLeftConstraint?.constant = cell.frame.origin.x
self?.bottomContentView.layoutIfNeeded()
}
if animated {
UIView.animate(withDuration: selectionLineMovingTime, animations: animationBlock)
} else {
animationBlock()
}
}
/// Adjust the line based on the percentage
func progress(from index: Int, toIndex: Int, percentage: CGFloat) {
let fromIndexPath = IndexPath(row: index, section: 0)
let toIndexPath = IndexPath(row: toIndex, section: 0)
guard let collection = collectionView,
let fromCell = collection.cellForItem(at: fromIndexPath),
let toCell = collection.cellForItem(at: toIndexPath) else { return }
// setting the width for percentage
selectionLineWidthConstraint?.constant = (toCell.bounds.width - fromCell.bounds.width) * percentage + fromCell.bounds.width
// setting the x for percentage
let originalX = fromCell.frame.origin.x
let toX = toCell.frame.origin.x
let xDifference = toX - originalX
let finalX = (xDifference * percentage) + originalX
selectionLineLeftConstraint?.constant = finalX
bottomContentView.layoutIfNeeded()
}
}
@objcMembers public class TabItemCell: CollectionViewCell {
public let label = Label()
public override func setupView() {
super.setupView()
contentView.addSubview(label)
NSLayoutConstraint.constraintPinSubview(label, pinTop: false, topConstant: 0, pinBottom: true, bottomConstant: 6, pinLeft: true, leftConstant: 0, pinRight: true, rightConstant: 0)
label.baselineAdjustment = .alignCenters
}
public override func updateView(_ size: CGFloat) {
super.updateView(size)
label.updateView(size)
}
public func updateCell(indexPath: IndexPath, delegateObject: MVMCoreUIDelegateObject?, additionalData: [AnyHashable: Any]?, selected: Bool, tabsModel: TabsModel?) {
guard let tabsModel = tabsModel else { return }
label.reset()
label.set(with: tabsModel.tabs[indexPath.row].label, delegateObject, additionalData)
if selected {
label.textColor = tabsModel.selectedColor.uiColor
} else {
label.textColor = tabsModel.unselectedColor.uiColor
}
updateAccessibility(indexPath: indexPath, selected: selected, tabsModel: tabsModel)
}
public func updateAccessibility(indexPath: IndexPath, selected: Bool, tabsModel: TabsModel?) {
//Accessibility
isAccessibilityElement = false
contentView.isAccessibilityElement = true
let accKey = selected ? "toptabbar_tab_selected" : "AccTab"
let accLabel = "\(label.text ?? "") \(MVMCoreUIUtility.hardcodedString(withKey: accKey) ?? "")"
let accOrder = String(format: MVMCoreUIUtility.hardcodedString(withKey: "AccTabIndex") ?? "", indexPath.row + 1, tabsModel?.tabs.count ?? 0)
contentView.accessibilityLabel = "\(accLabel) \(accOrder)"
contentView.accessibilityHint = selected ? nil : MVMCoreUIUtility.hardcodedString(withKey: "AccTabHint")
}
}

View File

@ -8,66 +8,21 @@
import UIKit import UIKit
import VDSColorTokens import VDSColorTokens
import VDS
open class TabsModel: MoleculeModelProtocol { open class TabsModel: MoleculeModelProtocol {
public static var identifier: String = "tabs" public static var identifier: String = "tabs"
public var id: String = UUID().uuidString public var id: String = UUID().uuidString
open var tabs: [TabItemModel] open var tabs: [TabItemModel]
open var style: NavigationItemStyle? open var style: Surface?
private var _backgroundColor: Color? open var orientation: Tabs.Orientation = .horizontal
open var backgroundColor: Color? { open var indicatorPosition: Tabs.IndicatorPosition = .bottom
get { open var overflow: Tabs.Overflow = .scroll
if let backgroundColor = _backgroundColor { return backgroundColor } open var size: Tabs.Size = .medium
if let style = style, public var backgroundColor: Color?
style == .dark { return Color(uiColor: VDSColor.backgroundPrimaryDark) }
return Color(uiColor: VDSColor.backgroundPrimaryLight)
}
set {
_backgroundColor = newValue
}
}
private var _selectedColor: Color?
open var selectedColor: Color {
get {
if let selectedColor = _selectedColor { return selectedColor }
if let style = style,
style == .dark { return Color(uiColor: VDSColor.elementsPrimaryOndark) }
return Color(uiColor: VDSColor.elementsPrimaryOnlight)
}
set {
_selectedColor = newValue
}
}
private var _unselectedColor: Color?
open var unselectedColor: Color {
get {
if let unselectedColor = _unselectedColor { return unselectedColor }
if let style = style,
style == .dark { return Color(uiColor: VDSColor.elementsSecondaryOndark) }
return Color(uiColor: VDSColor.elementsSecondaryOnlight)
}
set {
_unselectedColor = newValue
}
}
private var _selectedBarColor: Color?
open var selectedBarColor: Color {
get {
if let selectedBarColor = _selectedBarColor { return selectedBarColor }
if let style = style,
style == .dark { return Color(uiColor: VDSColor.elementsPrimaryOndark) }
return Color(uiColor: VDSColor.paletteRed)
}
set {
_selectedBarColor = newValue
}
}
// Must be capped to 0...(tabs.count - 1) // Must be capped to 0...(tabs.count - 1)
open var selectedIndex: Int = 0 open var selectedIndex: Int = 0
@ -77,10 +32,11 @@ open class TabsModel: MoleculeModelProtocol {
case moleculeName case moleculeName
case tabs case tabs
case backgroundColor case backgroundColor
case selectedColor
case unselectedColor
case selectedBarColor
case selectedIndex case selectedIndex
case orientation
case indicatorPosition
case overflow
case size
case style case style
} }
@ -92,14 +48,26 @@ open class TabsModel: MoleculeModelProtocol {
let typeContainer = try decoder.container(keyedBy: CodingKeys.self) let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
id = try typeContainer.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString id = try typeContainer.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
tabs = try typeContainer.decode([TabItemModel].self, forKey: .tabs) tabs = try typeContainer.decode([TabItemModel].self, forKey: .tabs)
backgroundColor = try typeContainer.decodeIfPresent(Color.self, forKey: .backgroundColor) style = try typeContainer.decodeIfPresent(Surface.self, forKey: .style)
_selectedColor = try typeContainer.decodeIfPresent(Color.self, forKey: .selectedColor)
_unselectedColor = try typeContainer.decodeIfPresent(Color.self, forKey: .unselectedColor)
_selectedBarColor = try typeContainer.decodeIfPresent(Color.self, forKey: .selectedBarColor)
style = try typeContainer.decodeIfPresent(NavigationItemStyle.self, forKey: .style)
if let index = try typeContainer.decodeIfPresent(Int.self, forKey: .selectedIndex) { if let index = try typeContainer.decodeIfPresent(Int.self, forKey: .selectedIndex) {
selectedIndex = index selectedIndex = index
} }
if let orientation = try typeContainer.decodeIfPresent(VDS.Tabs.Orientation.self, forKey: .orientation) {
self.orientation = orientation
}
if let indicatorPosition = try typeContainer.decodeIfPresent(VDS.Tabs.IndicatorPosition.self, forKey: .indicatorPosition) {
self.indicatorPosition = indicatorPosition
}
if let overflow = try typeContainer.decodeIfPresent(VDS.Tabs.Overflow.self, forKey: .overflow) {
self.overflow = overflow
}
if let size = try typeContainer.decodeIfPresent(VDS.Tabs.Size.self, forKey: .orientation) {
self.size = size
}
} }
open func encode(to encoder: Encoder) throws { open func encode(to encoder: Encoder) throws {
@ -107,12 +75,13 @@ open class TabsModel: MoleculeModelProtocol {
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
try container.encode(moleculeName, forKey: .moleculeName) try container.encode(moleculeName, forKey: .moleculeName)
try container.encode(tabs, forKey: .tabs) try container.encode(tabs, forKey: .tabs)
try container.encodeIfPresent(_backgroundColor, forKey: .backgroundColor)
try container.encodeIfPresent(_selectedColor, forKey: .selectedColor)
try container.encodeIfPresent(_unselectedColor, forKey: .unselectedColor)
try container.encodeIfPresent(_selectedBarColor, forKey: .selectedBarColor)
try container.encode(selectedIndex, forKey: .selectedIndex) try container.encode(selectedIndex, forKey: .selectedIndex)
try container.encodeIfPresent(style, forKey: .style) try container.encodeIfPresent(style, forKey: .style)
try container.encode(orientation, forKey: .orientation)
try container.encode(overflow, forKey: .overflow)
try container.encode(size, forKey: .size)
try container.encode(indicatorPosition, forKey: .indicatorPosition)
} }
} }