458 lines
18 KiB
Swift
458 lines
18 KiB
Swift
//
|
|
// Badge.swift
|
|
// VDS
|
|
//
|
|
// Created by Matt Bruce on 9/22/22.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import VDSTokens
|
|
import Combine
|
|
|
|
/// 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 used to describe the primary color for the view.
|
|
public enum FillColor: String, CaseIterable {
|
|
case red, yellow, green, orange, blue, grayHighContrast, grayLowContrast, black, white
|
|
}
|
|
|
|
/// Enum used to describe the kind of BadgeIndicator.
|
|
public enum Kind: String, CaseIterable {
|
|
case simple, numbered
|
|
}
|
|
|
|
/// Enum used to describe the 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
|
|
case xlarge
|
|
case large
|
|
case medium
|
|
case small
|
|
|
|
/// Dynamic TextStyle for the size.
|
|
public var textStyle: TextStyle {
|
|
var font: Font = .edsRegular
|
|
var pointSize: CGFloat = VDSTypography.fontSizeBody12
|
|
var letterSpacing: CGFloat = 0.0
|
|
|
|
switch self {
|
|
case .xxlarge:
|
|
pointSize = VDSTypography.fontSizeTitle24
|
|
|
|
case .xlarge:
|
|
pointSize = VDSTypography.fontSizeTitle20
|
|
|
|
case .large:
|
|
pointSize = VDSTypography.fontSizeBody16
|
|
letterSpacing = VDSTypography.letterSpacingWide
|
|
|
|
case .medium:
|
|
pointSize = VDSTypography.fontSizeBody14
|
|
letterSpacing = VDSTypography.letterSpacingWide
|
|
|
|
case .small:
|
|
font = .etxRegular
|
|
pointSize = VDSTypography.fontSizeBody12
|
|
}
|
|
|
|
return TextStyle(rawValue: "\(self.rawValue)BadgeIndicator",
|
|
fontFace: font,
|
|
pointSize: pointSize,
|
|
lineHeight: 0,
|
|
letterSpacing: letterSpacing)
|
|
}
|
|
|
|
//EdgeInsets for the label.
|
|
public var edgeInset: UIEdgeInsets {
|
|
var horizontalPadding: CGFloat = 0.0
|
|
let verticalPadding: CGFloat = 0.0
|
|
|
|
switch self {
|
|
case .xxlarge:
|
|
horizontalPadding = VDSLayout.space2X
|
|
case .xlarge, .large, .medium:
|
|
horizontalPadding = 6.0
|
|
case .small:
|
|
horizontalPadding = VDSLayout.space1X
|
|
}
|
|
return .axis(horizontal: horizontalPadding, vertical: verticalPadding)
|
|
}
|
|
|
|
}
|
|
|
|
//--------------------------------------------------
|
|
// 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.textAlignment = .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 horizontalPadding: 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() } }
|
|
|
|
open var accessibilityText: String? { 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 horizontalPadding {
|
|
newInset = .init(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding)
|
|
newInset = .axis(horizontal: horizontalPadding, vertical: verticalPadding)
|
|
} else if let verticalPadding {
|
|
newInset = .init(top: verticalPadding, left: newInset.left, bottom: verticalPadding, right: newInset.right)
|
|
} else if let horizontalPadding {
|
|
newInset = .init(top: newInset.top, left: horizontalPadding, bottom: newInset.bottom, right: horizontalPadding)
|
|
}
|
|
|
|
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.badgesBackgroundRedOnlight, VDSColor.badgesBackgroundRedOndark, forKey: .red)
|
|
config.setSurfaceColors(VDSColor.badgesBackgroundYellowOnlight, VDSColor.badgesBackgroundYellowOndark, forKey: .yellow)
|
|
config.setSurfaceColors(VDSColor.badgesBackgroundGreenOnlight, VDSColor.badgesBackgroundGreenOndark, forKey: .green)
|
|
config.setSurfaceColors(VDSColor.badgesBackgroundOrangeOnlight, VDSColor.badgesBackgroundOrangeOndark, forKey: .orange)
|
|
config.setSurfaceColors(VDSColor.badgesBackgroundBlueOnlight, VDSColor.badgesBackgroundBlueOndark, forKey: .blue)
|
|
config.setSurfaceColors(VDSColor.paletteGray44, VDSColor.paletteGray65, forKey: .grayHighContrast)
|
|
config.setSurfaceColors(VDSColor.paletteGray85, VDSColor.paletteGray20, forKey: .grayLowContrast)
|
|
config.setSurfaceColors(VDSColor.badgesBackgroundBlackOnlight, VDSColor.badgesBackgroundBlackOndark, forKey: .black)
|
|
config.setSurfaceColors(VDSColor.badgesBackgroundWhiteOnlight, VDSColor.badgesBackgroundWhiteOndark, 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()
|
|
isAccessibilityElement = true
|
|
|
|
addSubview(badgeView)
|
|
badgeView.addSubview(label)
|
|
|
|
heightConstraint = badgeView.heightGreaterThanEqualTo(constant: badgeSize)
|
|
widthConstraint = badgeView.widthGreaterThanEqualTo(constant: badgeSize)
|
|
|
|
//we are insetting the padding to compensate for the border
|
|
badgeView
|
|
.pinTop(borderWidth)
|
|
.pinLeading(borderWidth)
|
|
.pinTrailing(borderWidth, .defaultHigh)
|
|
.pinBottom(borderWidth, .defaultHigh)
|
|
|
|
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.textAlignment = .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()
|
|
}
|
|
|
|
open override func updateAccessibility() {
|
|
super.updateAccessibility()
|
|
if let accessibilityText {
|
|
accessibilityLabel = kind == .numbered ? label.text + " " + accessibilityText : accessibilityText
|
|
} else if kind == .numbered {
|
|
accessibilityLabel = label.text
|
|
} else {
|
|
accessibilityLabel = "Simple"
|
|
}
|
|
}
|
|
|
|
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 {
|
|
removeBezierPathBorder()
|
|
} else {
|
|
bezierPathBorder(borderColorConfiguration.getColor(surface), width: borderWidth)
|
|
}
|
|
|
|
//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, .grayHighContrast, .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)
|
|
}
|
|
}
|