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 VDSColorTokens
import VDS
@objc public protocol TabsDelegate {
func shouldSelectItem(_ indexPath: IndexPath, tabs: Tabs) -> Bool
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 }
}
var delegateObject: MVMCoreUIDelegateObject?
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()
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
open var viewModel: TabsModel!
open var delegateObject: MVMCoreUIDelegateObject?
open var additionalData: [AnyHashable : Any]?
//delegate
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
//-------------------------------------------------
open override func reset() {
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
open func updateView(_ size: CGFloat) {}
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
//-------------------------------------------------
public func selectIndex(_ index: Int, animated: Bool) {
guard let _ = collectionView, tabsModel?.tabs.count ?? 0 > 0 else {
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)
})
self.selectedIndex = index
}
public func reloadData() {
collectionView?.reloadData()
}
public func reloadData() { setNeedsUpdate() }
//-------------------------------------------------
// MARK: - Molecule Setup
//-------------------------------------------------
override open func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable : Any]?) {
super.set(with: model, delegateObject, additionalData)
self.delegateObject = delegateObject
self.additionalData = additionalData
selectedIndex = tabsModel?.selectedIndex ?? 0
selectionLine.backgroundColor = tabsModel?.selectedBarColor.uiColor
let lineModel = bottomLine.viewModel ?? LineModel(type: .secondary)
lineModel.inverted = tabsModel?.style == .dark
bottomLine.set(with: lineModel, delegateObject, additionalData)
reloadData()
}
}
//-------------------------------------------------
// MARK: - Collection View Methods
//-------------------------------------------------
extension Tabs: UICollectionViewDataSource {
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return tabsModel?.tabs.count ?? 0
}
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)
open func viewModelDidUpdate() {
orientation = viewModel.orientation
indicatorPosition = viewModel.indicatorPosition
overflow = viewModel.overflow
size = viewModel.size
selectedIndex = viewModel.selectedIndex
surface = viewModel.style ?? .light
tabModels = viewModel.tabs.compactMap { TabModel(text: $0.label.text) }
if let delegate {
onTabChange = { [weak self] index in
guard let self else { return }
delegate.didSelectItem(.init(row: index, section: 0), tabs: self)
}
onTabShouldSelect = { [weak self] index in
guard let self else { return true }
return delegate.shouldSelectItem(.init(row: index, section: 0), tabs: self)
}
}
}
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,67 +8,22 @@
import UIKit
import VDSColorTokens
import VDS
open class TabsModel: MoleculeModelProtocol {
public static var identifier: String = "tabs"
public var id: String = UUID().uuidString
open var tabs: [TabItemModel]
open var style: NavigationItemStyle?
private var _backgroundColor: Color?
open var backgroundColor: Color? {
get {
if let backgroundColor = _backgroundColor { return backgroundColor }
if let style = style,
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
}
}
open var style: Surface?
open var orientation: Tabs.Orientation = .horizontal
open var indicatorPosition: Tabs.IndicatorPosition = .bottom
open var overflow: Tabs.Overflow = .scroll
open var size: Tabs.Size = .medium
public var backgroundColor: Color?
// Must be capped to 0...(tabs.count - 1)
open var selectedIndex: Int = 0
@ -77,10 +32,11 @@ open class TabsModel: MoleculeModelProtocol {
case moleculeName
case tabs
case backgroundColor
case selectedColor
case unselectedColor
case selectedBarColor
case selectedIndex
case orientation
case indicatorPosition
case overflow
case size
case style
}
@ -92,14 +48,26 @@ open class TabsModel: MoleculeModelProtocol {
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
id = try typeContainer.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
tabs = try typeContainer.decode([TabItemModel].self, forKey: .tabs)
backgroundColor = try typeContainer.decodeIfPresent(Color.self, forKey: .backgroundColor)
_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)
style = try typeContainer.decodeIfPresent(Surface.self, forKey: .style)
if let index = try typeContainer.decodeIfPresent(Int.self, forKey: .selectedIndex) {
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 {
@ -107,12 +75,13 @@ open class TabsModel: MoleculeModelProtocol {
try container.encode(id, forKey: .id)
try container.encode(moleculeName, forKey: .moleculeName)
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.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)
}
}