// // Badge.swift // VDS // // Created by Matt Bruce on 9/22/22. // import Foundation import UIKit import VDSCoreTokens import Combine /// A badge indicator is a visual label used to convey status or highlight supplemental information. @objc(VDSBadgeIndicator) open class BadgeIndicator: View, ParentViewProtocol { //-------------------------------------------------- // 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 //-------------------------------------------------- open var children: [any ViewProtocol] { [label, badgeView] } /// 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(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 } open override func setDefaults() { super.setDefaults() label.lineBreakMode = .byTruncatingTail label.textAlignment = .center fillColor = .red number = nil kind = .simple leadingCharacter = nil trailingText = nil size = .xxlarge dotSize = nil verticalPadding = nil horizontalPadding = nil hideDot = false hideBorder = false width = nil height = nil accessibilityText = nil maximumDigits = .two bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } if let accessibilityText { return kind == .numbered ? label.text + " " + accessibilityText : accessibilityText } else if kind == .numbered { return label.text } else { return "Simple" } } } /// Resets to default settings. open override func reset() { label.reset() super.reset() } /// 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 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) } }