// // Badge.swift // VDS // // Created by Matt Bruce on 9/22/22. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine import VDSTypographyTokens /// Badges are visual labels used to convey status or highlight supplemental information. @objc(VDSBadgeIndicator) open class BadgeIndicator: View { //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- public enum FillColor: String, CaseIterable { case red, yellow, green, orange, blue, gray, grayLowContrast, black, white } public enum Kind: String, CaseIterable { case simple, numbered } public enum MaxDigits: String, CaseIterable { case one case two case three case four case five case six 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 } } } public enum Size: String, CaseIterable { case xxlarge = "2XLarge" case xlarge = "XLarge" case large = "Large" case medium = "Medium" case small = "Small" 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) } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var label = Label().with { $0.setContentCompressionResistancePriority(.required, for: .vertical) $0.adjustsFontSizeToFitWidth = false $0.lineBreakMode = .byTruncatingTail $0.textPosition = .center $0.numberOfLines = 1 } open var borderColorLight: UIColor? { didSet { if let borderColorLight { borderColorConfiguration.lightColor = borderColorLight } else { borderColorConfiguration.lightColor = VDSColor.paletteWhite } setNeedsUpdate() } } open var borderColorDark: UIColor? { didSet { if let borderColorDark { borderColorConfiguration.darkColor = borderColorDark } else { borderColorConfiguration.darkColor = VDSColor.paletteBlack } setNeedsUpdate() } } open var fillColor: FillColor = .red { didSet { setNeedsUpdate() }} open var number: Int? { didSet { setNeedsUpdate() }} open var kind: Kind = .simple { didSet { setNeedsUpdate() }} open var leadingCharacter: String? { didSet { setNeedsUpdate() }} open var size: Size = .xxlarge { didSet { setNeedsUpdate() }} open var dotSize: CGFloat? { didSet { setNeedsUpdate() }} open var verticalPadding: CGFloat? { didSet { setNeedsUpdate() }} open var horitonalPadding: CGFloat? { didSet { setNeedsUpdate() }} open var hideDot: Bool = false { didSet { setNeedsUpdate() }} open var hideBorder: Bool = false { didSet { setNeedsUpdate() }} open var maxDigits: MaxDigits = .two { didSet { setNeedsUpdate() }} open var width: CGFloat? { didSet { setNeedsUpdate() }} open var height: CGFloat? { didSet { setNeedsUpdate() }} //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- private let layoutGuide = UILayoutGuide() private var defaultBadgeSize: CGFloat { max(16.0, size.textStyle.font.lineHeight) } private var widthConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? private var labelContraints = NSLayoutConstraint.Container() //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() addLayoutGuide(layoutGuide) addSubview(label) accessibilityElements = [label] heightConstraint = layoutGuide.heightAnchor.constraint(greaterThanOrEqualToConstant: defaultBadgeSize) heightConstraint?.isActive = true widthConstraint = layoutGuide.widthAnchor.constraint(greaterThanOrEqualToConstant: defaultBadgeSize) widthConstraint?.isActive = true NSLayoutConstraint.activate([ layoutGuide.topAnchor.constraint(equalTo: topAnchor, constant: 1), layoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), layoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 1), layoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -1)]) labelContraints.topConstraint = label.pinTopGreaterThanOrEqualTo(anchor: layoutGuide.topAnchor) labelContraints.bottomConstraint = label.pinBottomGreaterThanOrEqualTo(anchor: layoutGuide.bottomAnchor) labelContraints.leadingConstraint = label.pinLeadingGreaterThanOrEqualTo(anchor: layoutGuide.leadingAnchor) labelContraints.trailingConstraint = label.pinTrailingGreaterThanOrEqualTo(anchor: layoutGuide.trailingAnchor) label.centerXAnchor.constraint(equalTo: layoutGuide.centerXAnchor).isActive = true label.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor).isActive = true labelContraints.isActive = true } open override func reset() { super.reset() shouldUpdateView = false label.reset() label.lineBreakMode = .byTruncatingTail label.textPosition = .center fillColor = .red number = nil shouldUpdateView = true setNeedsUpdate() } private let defaultInset = VDSLayout.Spacing.space1X.value private var labelEdgeInset: UIEdgeInsets { if let verticalPadding, let horitonalPadding { return .init(top: verticalPadding, left: horitonalPadding, bottom: verticalPadding, right: horitonalPadding) } else if let verticalPadding { return .init(top: verticalPadding, left: defaultInset, bottom: verticalPadding, right: defaultInset) } else if let horitonalPadding { return .init(top: defaultInset, left: horitonalPadding, bottom: defaultInset, right: horitonalPadding) } else { return .init(top: 0, left: defaultInset, bottom: 0, right: defaultInset) } } //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private var borderColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteBlack) private var backgroundColorConfiguration: AnyColorable = { let config = KeyedColorConfiguration(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() public 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) } } //-------------------------------------------------- // MARK: - State //-------------------------------------------------- open override func updateView() { updateTextColorConfig() backgroundColor = backgroundColorConfiguration.getColor(self) //height width heightConstraint?.isActive = false widthConstraint?.isActive = false if let width, let height { heightConstraint = layoutGuide.heightAnchor.constraint(equalToConstant: height) widthConstraint = layoutGuide.widthAnchor.constraint(equalToConstant: width) } else { heightConstraint = layoutGuide.heightAnchor.constraint(greaterThanOrEqualToConstant: defaultBadgeSize) widthConstraint = layoutGuide.widthAnchor.constraint(greaterThanOrEqualToConstant: defaultBadgeSize) } 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.disabled = disabled label.sizeToFit() setNeedsLayout() layoutIfNeeded() } private func getText() -> String { let badgeCount = number ?? 0 var text: String = "" if kind == .numbered && badgeCount >= 0 { let maxBadgetCount = limitDigits(number: badgeCount, maxDigits: maxDigits.value) let formatter = NumberFormatter() formatter.numberStyle = .decimal text = formatter.string(from: .init(integerLiteral: maxBadgetCount))! if maxDigits.value < "\(badgeCount)".count { let formatter = NumberFormatter() formatter.numberStyle = .decimal text = "\(text)+" } if let leadingCharacter { text = "\(leadingCharacter)\(text)" } else { text = "\(text)" } } return text } private func limitDigits(number: Int, maxDigits: Int) -> Int { let maxNumber = Int(pow(10.0, Double(maxDigits))) - 1 return min(number, maxNumber) } open override func layoutSubviews() { super.layoutSubviews() layer.cornerRadius = frame.size.height / 2 if hideBorder { layer.borderWidth = 0 } else { layer.borderColor = borderColorConfiguration.getColor(surface).cgColor layer.borderWidth = 1 } layer.remove(layerName: "dot") if kind == .simple && !hideDot { var dot: CGFloat = bounds.width * 0.1875 if let dotSize { dot = dotSize } let dotLayer = CAShapeLayer() dotLayer.name = "dot" let centerX = (bounds.width - dot) / 2.0 let centerY = (bounds.height - dot) / 2.0 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 layer.addSublayer(dotLayer) } } }