vds_ios/VDS/Components/BadgeIndicator/BadgeIndicator.swift
Matt Bruce d6cefaf980 refactoring comments, marks, etc...
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2023-08-28 16:13:13 -05:00

444 lines
17 KiB
Swift

//
// Badge.swift
// VDS
//
// Created by Matt Bruce on 9/22/22.
//
import Foundation
import UIKit
import VDSColorTokens
import VDSFormControlsTokens
import Combine
import VDSTypographyTokens
/// A badge indicator is a visual label used to convey status or highlight supplemental information.
@objc(VDSBadgeIndicator)
open class BadgeIndicator: View {
//--------------------------------------------------
// MARK: - Initializers
//--------------------------------------------------
required public init() {
super.init(frame: .zero)
}
public override init(frame: CGRect) {
super.init(frame: .zero)
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
}
//--------------------------------------------------
// MARK: - Enums
//--------------------------------------------------
/// Enum type for fill color.
public enum FillColor: String, CaseIterable {
case red, yellow, green, orange, blue, gray, grayLowContrast, black, white
}
/// Enum type for kind of BadgeIndicator.
public enum Kind: String, CaseIterable {
case simple, numbered
}
/// Enum type for maximum number of digits.
public enum MaximumDigits: String, CaseIterable {
case one
case two
case three
case four
case five
case six
case none
public var value: Int {
switch self {
case .two:
return 2
case .one:
return 1
case .three:
return 3
case .four:
return 4
case .five:
return 5
case .six:
return 6
case .none:
return 0
}
}
}
/// Enum type describing size of Badge Indicator.
public enum Size: String, CaseIterable {
case xxlarge = "2XLarge"
case xlarge = "XLarge"
case large = "Large"
case medium = "Medium"
case small = "Small"
/// Dynamic TextStyle for the size.
public var textStyle: TextStyle {
let style = TextStyle.bodySmall
var pointSize: CGFloat = VDSTypography.fontSizeBody12
var letterSpacing: CGFloat = 0.0
switch self {
case .xxlarge:
pointSize = VDSTypography.fontSizeTitle24
letterSpacing = VDSTypography.letterSpacingWide
case .xlarge:
pointSize = VDSTypography.fontSizeTitle20
case .large:
pointSize = VDSTypography.fontSizeBody16
letterSpacing = VDSTypography.letterSpacingWide
case .medium:
pointSize = VDSTypography.fontSizeBody14
letterSpacing = VDSTypography.letterSpacingWide
case .small:
pointSize = VDSTypography.fontSizeBody12
}
return TextStyle(rawValue: "\(self.rawValue)BadgeIndicator",
fontFace: style.fontFace,
pointSize: pointSize,
lineHeight: 0,
letterSpacing: letterSpacing)
}
//EdgeInsets for the label.
public var edgeInset: UIEdgeInsets {
var horizontalPadding: CGFloat = VDSLayout.Spacing.space1X.value
let verticalPadding: CGFloat = 0
switch self {
case .xxlarge:
horizontalPadding = VDSLayout.Spacing.space2X.value
case .xlarge, .large,.medium:
horizontalPadding = 6.0
case .small:
break
}
return .init(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding)
}
}
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
/// Label used for the numeric kind.
open var label = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.adjustsFontSizeToFitWidth = false
$0.lineBreakMode = .byTruncatingTail
$0.textPosition = .center
$0.numberOfLines = 1
}
/// BorderColor for Surface.light.
open var borderColorLight: UIColor? {
didSet {
if let borderColorLight {
borderColorConfiguration.lightColor = borderColorLight
} else {
borderColorConfiguration.lightColor = VDSColor.paletteWhite
}
setNeedsUpdate()
}
}
/// BorderColor for Surface.dark.
open var borderColorDark: UIColor? {
didSet {
if let borderColorDark {
borderColorConfiguration.darkColor = borderColorDark
} else {
borderColorConfiguration.darkColor = VDSColor.paletteBlack
}
setNeedsUpdate()
}
}
/// This will render the badges fill color based on the available options.
/// When used in conjunction with the surface prop, this fill color will change its tint automatically based on a light or dark surface.
open var fillColor: FillColor = .red { didSet { setNeedsUpdate() }}
/// Badge Number that will be shown if you are using Kind.numbered.
open var number: Int? { didSet { setNeedsUpdate() }}
/// Type of Badge Indicator, simple is a dot, whereas numbered shows a number.
open var kind: Kind = .simple { didSet { setNeedsUpdate() }}
/// Character that is always at the begging. Accepts any character and if unaffected by maximumDigits.
open var leadingCharacter: String? { didSet { setNeedsUpdate() }}
/// Accepts any text or character. It is unaffected by maximumDigits.
open var trailingText: String? { didSet { setNeedsUpdate() }}
/// Determines the size of the Badge Indicator as well as the textStyle and padding used.
open var size: Size = .xxlarge { didSet { setNeedsUpdate() }}
/// Pixel size of the dot when the kind is set to simple.
open var dotSize: CGFloat? { didSet { setNeedsUpdate() }}
/// Sets the padding at the top/bottom of the label.
open var verticalPadding: CGFloat? { didSet { setNeedsUpdate() }}
/// Sets the padding at the left/right of the label.
open var horitonalPadding: CGFloat? { didSet { setNeedsUpdate() }}
/// Hides the dot when you are in Kind.simple mode.
open var hideDot: Bool = false { didSet { setNeedsUpdate() }}
/// Will not show the border.
open var hideBorder: Bool = false { didSet { setNeedsUpdate() }}
/// When in Kind.numbered this is the amount of digits that will show up when the user adds a number.
open var maximumDigits: MaximumDigits = .two { didSet { setNeedsUpdate() }}
/// The Container's width.
open var width: CGFloat? { didSet { setNeedsUpdate() }}
/// The Container's height.
open var height: CGFloat? { didSet { setNeedsUpdate() }}
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
private let badgeView = View()
private var badgeSize: CGFloat { max(minSize, size.textStyle.font.lineHeight) }
private var labelEdgeInset: UIEdgeInsets {
var newInset = size.edgeInset
if let verticalPadding, let horitonalPadding {
newInset = .init(top: verticalPadding, left: horitonalPadding, bottom: verticalPadding, right: horitonalPadding)
} else if let verticalPadding {
newInset = .init(top: verticalPadding, left: newInset.left, bottom: verticalPadding, right: newInset.right)
} else if let horitonalPadding {
newInset = .init(top: newInset.top, left: horitonalPadding, bottom: newInset.bottom, right: horitonalPadding)
}
return newInset
}
//--------------------------------------------------
// MARK: - Constraints
//--------------------------------------------------
private var widthConstraint: NSLayoutConstraint?
private var heightConstraint: NSLayoutConstraint?
private var labelContraints = NSLayoutConstraint.Container()
//--------------------------------------------------
// MARK: - Configuration
//--------------------------------------------------
private let borderWidth: CGFloat = 1.0
private let minSize: CGFloat = 16.0
private let minDotSize: CGFloat = 4.0
private let dotRatio: CGFloat = 0.24
private var borderColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteBlack)
private var backgroundColorConfiguration: AnyColorable = {
let config = KeyedColorConfiguration<BadgeIndicator, FillColor>(keyPath: \.fillColor)
config.setSurfaceColors(VDSColor.backgroundBrandhighlight, VDSColor.backgroundBrandhighlight, forKey: .red)
config.setSurfaceColors(VDSColor.paletteYellow62, VDSColor.paletteYellow62, forKey: .yellow)
config.setSurfaceColors(VDSColor.paletteGreen26, VDSColor.paletteGreen36, forKey: .green)
config.setSurfaceColors(VDSColor.paletteOrange41, VDSColor.paletteOrange58, forKey: .orange)
config.setSurfaceColors(VDSColor.paletteBlue38, VDSColor.paletteBlue46, forKey: .blue)
config.setSurfaceColors(VDSColor.paletteGray44, VDSColor.paletteGray65, forKey: .gray)
config.setSurfaceColors(VDSColor.paletteGray85, VDSColor.paletteGray20, forKey: .grayLowContrast)
config.setSurfaceColors(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryDark, forKey: .black)
config.setSurfaceColors(VDSColor.backgroundPrimaryLight, VDSColor.backgroundPrimaryLight, forKey: .white)
return config.eraseToAnyColorable()
}()
private var textColorConfiguration = ViewColorConfiguration()
//--------------------------------------------------
// MARK: - Overrides
//--------------------------------------------------
/// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations.
open override func setup() {
super.setup()
addSubview(badgeView)
badgeView.addSubview(label)
accessibilityElements = [label]
heightConstraint = badgeView.heightGreaterThanEqualTo(constant: badgeSize)
widthConstraint = badgeView.widthGreaterThanEqualTo(constant: badgeSize)
//we are insetting the padding to compensate for the border
badgeView.pinToSuperView(.init(top: borderWidth, left: borderWidth, bottom: borderWidth, right: borderWidth))
labelContraints.topConstraint = label.pinTopGreaterThanOrEqualTo(anchor: badgeView.topAnchor)
labelContraints.bottomConstraint = label.pinBottomGreaterThanOrEqualTo(anchor: badgeView.bottomAnchor)
labelContraints.leadingConstraint = label.pinLeadingGreaterThanOrEqualTo(anchor: badgeView.leadingAnchor)
labelContraints.trailingConstraint = label.pinTrailingGreaterThanOrEqualTo(anchor: badgeView.trailingAnchor)
label.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor).isActive = true
label.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor).isActive = true
labelContraints.isActive = true
}
/// Resets to default settings.
open override func reset() {
super.reset()
shouldUpdateView = false
label.reset()
label.lineBreakMode = .byTruncatingTail
label.textPosition = .center
fillColor = .red
number = nil
shouldUpdateView = true
setNeedsUpdate()
}
/// Used to make changes to the View based off a change events or from local properties.
open override func updateView() {
super.updateView()
updateTextColorConfig()
badgeView.backgroundColor = backgroundColorConfiguration.getColor(self)
//height width
heightConstraint?.isActive = false
widthConstraint?.isActive = false
if let width, let height, height > badgeSize, width > badgeSize, height <= width {
heightConstraint = badgeView.heightAnchor.constraint(equalToConstant: height)
widthConstraint = badgeView.widthAnchor.constraint(equalToConstant: width)
} else {
heightConstraint = badgeView.heightAnchor.constraint(greaterThanOrEqualToConstant: badgeSize)
widthConstraint = badgeView.widthAnchor.constraint(greaterThanOrEqualToConstant: badgeSize)
}
heightConstraint?.isActive = true
widthConstraint?.isActive = true
//label constraints
let insets = labelEdgeInset
labelContraints.isActive = false
labelContraints.topConstraint?.constant = insets.top
labelContraints.bottomConstraint?.constant = -insets.bottom
labelContraints.leadingConstraint?.constant = insets.left
labelContraints.trailingConstraint?.constant = -insets.right
labelContraints.isActive = true
//label properties
label.textStyle = size.textStyle
label.textColorConfiguration = textColorConfiguration.eraseToAnyColorable()
label.text = getText()
label.surface = surface
label.isEnabled = isEnabled
label.sizeToFit()
setNeedsLayout()
layoutIfNeeded()
}
open override func layoutSubviews() {
super.layoutSubviews()
//update the cornerRadius
badgeView.layer.cornerRadius = badgeView.frame.size.height / 2
layer.cornerRadius = frame.size.height / 2
//border
if hideBorder {
layer.borderWidth = 0
} else {
layer.borderWidth = borderWidth
layer.borderColor = borderColorConfiguration.getColor(surface).cgColor
}
//dot
badgeView.layer.remove(layerName: "dot")
if kind == .simple && !hideDot {
//frame for the dot to sit inside
let frame = badgeView.frame
//default calculation if a dotSize isn't given
let dotSizeRatio = frame.height * dotRatio
var dot: CGFloat = dotSizeRatio < minDotSize ? minDotSize : dotSizeRatio
if let dotSize, dotSize < frame.width, dotSize < frame.height {
dot = dotSize
}
//get the center of the frame
let centerX = (frame.width - dot) / 2.0
let centerY = (frame.height - dot) / 2.0
//create the layer to draw the dot
let dotLayer = CAShapeLayer()
dotLayer.name = "dot"
dotLayer.frame = .init(x: centerX, y: centerY, width: dot, height: dot)
dotLayer.path = UIBezierPath(ovalIn: .init(origin: .zero, size: .init(width: dot, height: dot))).cgPath
dotLayer.fillColor = textColorConfiguration.getColor(self).cgColor
badgeView.layer.addSublayer(dotLayer)
}
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
private func updateTextColorConfig() {
textColorConfiguration.reset()
switch fillColor {
case .red, .black, .gray, .grayLowContrast:
textColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOndark, forDisabled: false)
textColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOndark, forDisabled: true)
case .yellow, .white:
textColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forDisabled: false)
textColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOnlight, forDisabled: true)
case .orange, .green, .blue:
textColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forDisabled: false)
textColorConfiguration.setSurfaceColors(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight, forDisabled: true)
}
}
private func getText() -> String {
let badgeCount = number ?? 0
var text: String = ""
if kind == .numbered && badgeCount >= 0 {
let maxBadgetCount = maximumDigits == .none ? badgeCount : limitDigits(number: badgeCount, maxDigits: maximumDigits.value)
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
text = formatter.string(from: .init(integerLiteral: maxBadgetCount))!
//leading
if let leadingCharacter, !leadingCharacter.isEmpty {
text = "\(leadingCharacter)\(text)"
}
//maximumDigits
if maximumDigits.value < "\(badgeCount)".count {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
text = "\(text)+"
}
//trailing
if let trailingText, !trailingText.isEmpty {
text = "\(text) \(trailingText)"
}
}
return text
}
private func limitDigits(number: Int, maxDigits: Int) -> Int {
let maxNumber = Int(pow(10.0, Double(maxDigits))) - 1
return min(number, maxNumber)
}
}