// // 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 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, 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 $0.isHidden = true } /// 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 $0.accessibilityTraits = .button } /// 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() } } /// Icon Name used within the Icon within the Selected State. 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() } } 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.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .selected) $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.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark, forState: .selected) $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, DropShadowableConfiguration { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteGray20).eraseToAnyColorable() }() private let dropshadow1Configuration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() $0.shadowOpacityConfiguration = SurfaceConfigurationValue(CGFloat(0.12), CGFloat(0.22)) $0.shadowOffsetConfiguration = SurfaceConfigurationValue(.init(width: 0, height: 1), .init(width: 0, height: 1)) $0.shadowRadiusConfiguration = SurfaceConfigurationValue(CGFloat(10), CGFloat(12)) } private let dropshadow2Configuration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() $0.shadowOpacityConfiguration = SurfaceConfigurationValue(CGFloat(0.05), CGFloat(0.15)) $0.shadowOffsetConfiguration = SurfaceConfigurationValue(.init(width: 0, height: 2), .init(width: 0, height: 2)) $0.shadowRadiusConfiguration = SurfaceConfigurationValue(CGFloat(4), CGFloat(6)) } var configurations: [DropShadowable] { [dropshadow1Configuration, dropshadow2Configuration] } } 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, DropShadowableConfiguration { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .media var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteGray20).eraseToAnyColorable() }() private let dropshadow1Configuration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() $0.shadowOpacityConfiguration = SurfaceConfigurationValue(CGFloat(0.12), CGFloat(0.22)) $0.shadowOffsetConfiguration = SurfaceConfigurationValue(.init(width: 0, height: 1), .init(width: 0, height: 1)) $0.shadowRadiusConfiguration = SurfaceConfigurationValue(CGFloat(10), CGFloat(12)) } private let dropshadow2Configuration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() $0.shadowOpacityConfiguration = SurfaceConfigurationValue(CGFloat(0.05), CGFloat(0.15)) $0.shadowOffsetConfiguration = SurfaceConfigurationValue(.init(width: 0, height: 2), .init(width: 0, height: 2)) $0.shadowRadiusConfiguration = SurfaceConfigurationValue(CGFloat(4), CGFloat(6)) } var configurations: [DropShadowable] { [dropshadow1Configuration, dropshadow2Configuration] } } 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.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight, forState: .selected) $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, DropShadowableConfiguration { 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.paletteGray20, VDSColor.paletteWhite, forState: .selected) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: [.selected, .highlighted]) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: [.selected, .disabled]) }.eraseToAnyColorable() }() private let dropshadow1Configuration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() $0.shadowOpacityConfiguration = SurfaceConfigurationValue(CGFloat(0.22), CGFloat(0.12)) $0.shadowOffsetConfiguration = SurfaceConfigurationValue(.init(width: 0, height: 1), .init(width: 0, height: 1)) $0.shadowRadiusConfiguration = SurfaceConfigurationValue(CGFloat(12), CGFloat(10)) } private let dropshadow2Configuration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack).eraseToAnyColorable() $0.shadowOpacityConfiguration = SurfaceConfigurationValue(CGFloat(0.15), CGFloat(0.05)) $0.shadowOffsetConfiguration = SurfaceConfigurationValue(.init(width: 0, height: 2), .init(width: 0, height: 2)) $0.shadowRadiusConfiguration = SurfaceConfigurationValue(CGFloat(6), CGFloat(4)) } var configurations: [DropShadowable] { [dropshadow1Configuration, dropshadow2Configuration] } } private var badgeIndicatorDefaultSize: CGSize = .zero //-------------------------------------------------- // 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 = false accessibilityElements = [icon, 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() } /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() onClick = { control in guard control.isEnabled else { return } if control.selectedIconName != nil && control.selectable { control.toggle() } } } /// This will change the state of the Selector and execute the actionBlock if provided. open func toggle() { //removed error isSelected.toggle() sendActions(for: .valueChanged) } /// 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 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 icon.isEnabled = isEnabled } 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, hideBorder { layer.borderColor = borderable.borderColorConfiguration.getColor(self).cgColor layer.borderWidth = borderable.borderWidth } else { layer.borderColor = nil layer.borderWidth = 0 } 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() } if let configurations = (currentConfig as? DropShadowableConfiguration)?.configurations { addDropShadows(configurations) } else { removeDropShadows() } } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func updateBadgeIndicator() { badgeIndicator.isHidden = !showBadgeIndicator guard let badgeIndicatorModel else { badgeIndicator.isHidden = true return } badgeIndicator.surface = surface badgeIndicator.kind = badgeIndicatorModel.kind badgeIndicator.fillColor = badgeIndicatorModel.fillColor 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() { guard let badgeIndicatorModel else { return } switch badgeIndicatorModel.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 } } } // 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) } } private protocol Borderable { var borderWidth: CGFloat { get set } var borderColorConfiguration: AnyColorable { 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 } }