// // ButtonIcon.swift // VDS // // Created by Matt Bruce on 5/12/23. // import Foundation import UIKit import VDSColorTokens import Combine /// A button icon is an interactive element that visually communicates the action it triggers via an icon. /// It usually represents a supplementary or utilitarian action. A button icon can stand alone, but often /// exists in a group when there are several actions that can be performed. @objc(VDSButtonIcon) open class ButtonIcon: Control, Changeable, FormFieldable { //-------------------------------------------------- // 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 type of button based on the contrast. public enum Kind: String, CaseIterable { case ghost, lowContrast, highContrast } /// Enum used to describe the background inside icon button determining the surface type. public enum SurfaceType: String, CaseIterable { case colorFill, media } /// Enum used to describe the badge indicator direction of icon button determining the expand direction. public enum ExpandDirection: String, CaseIterable { case right, center, left } /// Enum used to describe the size of button icon. public enum Size: String, EnumSubset { case large case small public var defaultValue: Icon.Size { .large } public var containerSize: CGFloat { switch self { case .large: return 44.0 case .small: return 32.0 } } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var centerXConstraint: NSLayoutConstraint? private var centerYConstraint: NSLayoutConstraint? private var layoutGuideWidthConstraint: NSLayoutConstraint? private var layoutGuideHeightConstraint: NSLayoutConstraint? private var badgeIndicatorLeadingConstraint: NSLayoutConstraint? private var badgeIndicatorTrailingConstraint: NSLayoutConstraint? private var badgeIndicatorCenterXConstraint: NSLayoutConstraint? private var badgeIndicatorCenterYConstraint: NSLayoutConstraint? private var currentIconName: Icon.Name? { if let selectedIconName, selectable, isSelected { return selectedIconName } else { return iconName } } private var badgeIndicatorOffset: CGPoint { switch (size, kind) { case (.small, .ghost): return .init(x: 1, y: 0) case (.large, .ghost): return .init(x: 1, y: 1) case (.small, .lowContrast), (.small, .highContrast): return .init(x: 4, y: 4) case (.large, .lowContrast), (.large, .highContrast): return .init(x: 6, y: 6) } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- ///Badge Indicator object used to render for the ButtonIcon. open var badgeIndicator = BadgeIndicator().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.size = .small } /// If set, this is used to render the badge indicator. open var badgeIndicatorModel: BadgeIndicatorModel? { didSet { setNeedsUpdate() } } /// Icon object used to render out the Icon for this ButtonIcon. open var icon = Icon().with { $0.isUserInteractionEnabled = false } /// Determines the type of button based on the contrast. open var kind: Kind = .ghost { didSet { setNeedsUpdate() } } /// Applies background inside icon button determining the surface type. open var surfaceType: SurfaceType = .colorFill { didSet { setNeedsUpdate() } } /// Icon Name used within the Icon. open var iconName: Icon.Name? { didSet { setNeedsUpdate() } } /// Selected Icon Name used within the Icon if selectable true. open var selectedIconName: Icon.Name? { didSet { setNeedsUpdate() } } /// Sets the size of button icon and icon. open var size: Size = .large { didSet { setNeedsUpdate() } } /// Sets the size of button icon and icon. open var customSize: Int? { didSet { setNeedsUpdate() } } /// If provided, the button icon will have a box shadow. open var floating: Bool = false { didSet { setNeedsUpdate() } } /// If true, container shrinks to fit the size of the icon for kind equals .ghost. open var fitToIcon: Bool = false { didSet { setNeedsUpdate() } } /// If set to true, the button icon will not have a border. open var hideBorder: Bool = true { didSet { setNeedsUpdate() } } /// If provided, the badge indicator will present. open var showBadgeIndicator: Bool = false { didSet { setNeedsUpdate() } } /// If true, button will be rendered as selected, when it is selectable. open var selectable: Bool = false { didSet { //unselect if !selectable && isSelected { isSelected = false } setNeedsUpdate() } } /// Used to move the icon inside the button in both x and y axis. open var iconOffset: CGPoint = .init(x: 0, y: 0) { didSet { setNeedsUpdate() } } /// Applies expand direction to Badge Indicator if shows badge indicator. open var expandDirection: ExpandDirection = .right { didSet { setNeedsUpdate() } } open var onChangeSubscriber: AnyCancellable? open var inputId: String? { didSet { setNeedsUpdate() } } open var value: AnyHashable? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private var iconColorConfiguration: AnyColorable { if kind == .highContrast { return highContrastIconColorConfiguration } else if kind == .lowContrast { return (surfaceType == .colorFill) ? lowContrastIconColorConfiguration : (floating ? lowContrastIconColorConfiguration : standardIconColorConfiguration) } else { return standardIconColorConfiguration } } private var currentConfiguration: any Configuration { switch kind { case .ghost: return GhostConfiguration() case .lowContrast: if surfaceType == .colorFill { return floating ? LowContrastColorFillFloatingConfiguration() : LowContrastColorFillConfiguration() } else { return floating ? LowContrastMediaFloatingConfiguration() : LowContrastMediaConfiguration() } case .highContrast: return floating ? HighContrastFloatingConfiguration() : HighContrastConfiguration() } } private var standardIconColorConfiguration: AnyColorable = { return ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) }.eraseToAnyColorable() }() private var lowContrastIconColorConfiguration: AnyColorable = { return ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.paletteBlack.withAlphaComponent(0.70), forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.paletteBlack.withAlphaComponent(0.70), forState: [.selected, .disabled]) }.eraseToAnyColorable() }() private var highContrastIconColorConfiguration: AnyColorable = { return SurfaceColorConfiguration(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight).eraseToAnyColorable() }() private struct GhostConfiguration: Configuration { var kind: Kind = .ghost var surfaceType: SurfaceType = .colorFill var floating: Bool = false var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(.clear, .clear).eraseToAnyColorable() }() } private struct LowContrastColorFillConfiguration: Configuration { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = false var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteGray44.withAlphaComponent(0.06), VDSColor.paletteGray44.withAlphaComponent(0.26)).eraseToAnyColorable() }() } private struct LowContrastColorFillFloatingConfiguration: Configuration, Dropshadowable { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteGray20).eraseToAnyColorable() }() var shadowColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() }() var shadowOpacity: CGFloat = 0.16 var shadowOffset: CGSize = .init(width: 0, height: 2) var shadowRadius: CGFloat = 4 } private struct LowContrastMediaConfiguration: Configuration, Borderable { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .media var floating: Bool = false var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.backgroundPrimaryLight, VDSColor.backgroundPrimaryDark).eraseToAnyColorable() }() var borderWidth: CGFloat = 1.0 var borderColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark).eraseToAnyColorable() }() } private struct LowContrastMediaFloatingConfiguration: Configuration, Dropshadowable { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .media var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteGray20).eraseToAnyColorable() }() var shadowColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() }() var shadowOpacity: CGFloat = 0.16 var shadowOffset: CGSize = .init(width: 0, height: 2) var shadowRadius: CGFloat = 4 } private struct HighContrastConfiguration: Configuration { var kind: Kind = .highContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = false var backgroundColorConfiguration: AnyColorable = { return ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) }.eraseToAnyColorable() }() } private struct HighContrastFloatingConfiguration: Configuration, Dropshadowable { var kind: Kind = .highContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { return ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.paletteGray20, VDSColor.paletteWhite, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) }.eraseToAnyColorable() }() var shadowColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() }() var shadowOpacity: CGFloat = 0.16 var shadowOffset: CGSize = .init(width: 0, height: 2) var shadowRadius: CGFloat = 6 } private var badgeIndicatorDefaultSize: CGSize = .zero //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() onClick = { control in guard control.isEnabled else { return } if let selectedIconName = control.selectedIconName, control.selectable { control.toggle() } } } /// 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 accessibilityTraits = .image accessibilityElements = [badgeIndicator] //create a layoutGuide for the icon to key off of let iconLayoutGuide = UILayoutGuide() addLayoutGuide(iconLayoutGuide) //add the icon addSubview(icon) //add badgeIndicator addSubview(badgeIndicator) badgeIndicator.isHidden = !showBadgeIndicator badgeIndicatorDefaultSize = badgeIndicator.frame.size //determines the height/width of the icon layoutGuideWidthConstraint = iconLayoutGuide.width(constant: size.containerSize) layoutGuideHeightConstraint = iconLayoutGuide.height(constant: size.containerSize) badgeIndicatorLeadingConstraint = badgeIndicator.leadingAnchor.constraint(equalTo: icon.centerXAnchor) badgeIndicatorTrailingConstraint = badgeIndicator.trailingAnchor.constraint(equalTo: icon.centerXAnchor) badgeIndicatorCenterXConstraint = badgeIndicator.centerXAnchor.constraint(equalTo: icon.centerXAnchor) badgeIndicatorCenterYConstraint = icon.centerYAnchor.constraint(equalTo: badgeIndicator.centerYAnchor) badgeIndicatorCenterYConstraint?.isActive = true badgeIndicatorLeadingConstraint?.isActive = true //pin layout guide iconLayoutGuide .pinTop() .pinLeading() .pinTrailing(0, .defaultHigh) .pinBottom(0, .defaultHigh) //determines the center point of the icon centerXConstraint = icon.centerXAnchor.constraint(equalTo: iconLayoutGuide.centerXAnchor, constant: 0) centerXConstraint?.activate() centerYConstraint = icon.centerYAnchor.constraint(equalTo: iconLayoutGuide.centerYAnchor, constant: 0) centerYConstraint?.activate() } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false kind = .ghost surfaceType = .colorFill size = .large floating = false hideBorder = true iconOffset = .init(x: 0, y: 0) iconName = nil selectedIconName = nil showBadgeIndicator = false selectable = false badgeIndicatorModel = nil expandDirection = .right 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() //ensure there is an icon to set if let currentIconName { icon.name = currentIconName let color = iconColorConfiguration.getColor(self) icon.color = color icon.size = size.value icon.customSize = customSize } else { icon.reset() } updateBadgeIndicator() setNeedsLayout() } open override func layoutSubviews() { super.layoutSubviews() let currentConfig = currentConfiguration backgroundColor = currentConfig.backgroundColorConfiguration.getColor(self) // calculate center point for child view with offset let childCenter = CGPoint(x: center.x + iconOffset.x, y: center.y + iconOffset.y) centerXConstraint?.constant = childCenter.x - center.x centerYConstraint?.constant = childCenter.y - center.y //updating current container size var iconLayoutSize = size.containerSize if let customSize { iconLayoutSize = CGFloat(customSize) } // check to see if this is fitToIcon if fitToIcon && kind == .ghost { iconLayoutSize = size.value.dimensions.width layer.cornerRadius = 0 } else { layer.cornerRadius = min(frame.width, frame.height) / 2.0 } layoutGuideWidthConstraint?.constant = iconLayoutSize layoutGuideHeightConstraint?.constant = iconLayoutSize //border if let borderable = currentConfig as? Borderable, self.hideBorder { layer.borderColor = borderable.borderColorConfiguration.getColor(self).cgColor layer.borderWidth = borderable.borderWidth } else { layer.borderColor = nil layer.borderWidth = 0 } if let dropshadowable = currentConfig as? Dropshadowable { addDropShadow(config: dropshadowable) } else { removeDropShadow() } badgeIndicatorCenterXConstraint?.constant = badgeIndicatorOffset.x + badgeIndicatorDefaultSize.width/2 badgeIndicatorCenterYConstraint?.constant = badgeIndicatorOffset.y + badgeIndicatorDefaultSize.height/2 badgeIndicatorLeadingConstraint?.constant = badgeIndicatorOffset.x badgeIndicatorTrailingConstraint?.constant = badgeIndicatorOffset.x + badgeIndicatorDefaultSize.width if showBadgeIndicator { updateExpandDirectionalConstraints() } } /// This will change the state of the Selector and execute the actionBlock if provided. open func toggle() { //removed error isSelected.toggle() sendActions(for: .valueChanged) } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateBadgeIndicator() { if let badgeIndicatorModel { badgeIndicator.kind = badgeIndicatorModel.kind badgeIndicator.fillColor = badgeIndicatorModel.fillColor badgeIndicator.surface = badgeIndicatorModel.surface badgeIndicator.size = badgeIndicatorModel.size badgeIndicator.maximumDigits = badgeIndicatorModel.maximumDigits badgeIndicator.width = badgeIndicatorModel.width badgeIndicator.height = badgeIndicatorModel.height badgeIndicator.number = badgeIndicatorModel.number badgeIndicator.leadingCharacter = badgeIndicatorModel.leadingCharacter badgeIndicator.trailingText = badgeIndicatorModel.trailingText badgeIndicator.dotSize = badgeIndicatorModel.dotSize badgeIndicator.verticalPadding = badgeIndicatorModel.verticalPadding badgeIndicator.horizontalPadding = badgeIndicatorModel.horizontalPadding badgeIndicator.hideDot = badgeIndicatorModel.hideDot badgeIndicator.hideBorder = badgeIndicatorModel.hideBorder } } private func updateExpandDirectionalConstraints() { switch expandDirection { case .right: badgeIndicatorLeadingConstraint?.isActive = true badgeIndicatorTrailingConstraint?.isActive = false badgeIndicatorCenterXConstraint?.isActive = false case .center: badgeIndicatorLeadingConstraint?.isActive = false badgeIndicatorTrailingConstraint?.isActive = false badgeIndicatorCenterXConstraint?.isActive = true case .left: badgeIndicatorLeadingConstraint?.isActive = false badgeIndicatorCenterXConstraint?.isActive = false badgeIndicatorTrailingConstraint?.isActive = true } } /// Used to update any Accessibility properties. open override func updateAccessibility() { super.updateAccessibility() setAccessibilityLabel(for: [icon, badgeIndicator.label]) } } // MARK: AppleGuidelinesTouchable extension ButtonIcon: AppleGuidelinesTouchable { /// Overrides to ensure that the touch point meets a minimum of the minimumTappableArea. override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { Self.acceptablyOutsideBounds(point: point, bounds: bounds) } } extension UIView { fileprivate func addDropShadow(config: Dropshadowable) { layer.masksToBounds = false layer.shadowColor = config.shadowColorConfiguration.getColor(self).cgColor layer.shadowOpacity = Float(config.shadowOpacity) layer.shadowOffset = config.shadowOffset layer.shadowRadius = config.shadowRadius layer.shouldRasterize = true layer.rasterizationScale = UIScreen.main.scale layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath } fileprivate func removeDropShadow() { layer.shadowOpacity = 0 layer.shadowRadius = 0 layer.shadowPath = nil } } private protocol Borderable { var borderWidth: CGFloat { get set } var borderColorConfiguration: AnyColorable { get set } } private protocol Dropshadowable { var shadowColorConfiguration: AnyColorable { get set } var shadowOpacity: CGFloat { get set } var shadowOffset: CGSize { get set } var shadowRadius: CGFloat { get set } } private protocol Configuration { var kind: ButtonIcon.Kind { get set } var surfaceType: ButtonIcon.SurfaceType { get set } var floating: Bool { get set } var backgroundColorConfiguration: AnyColorable { get set } }