vds_ios/VDS/Components/Icon/ButtonIcon/ButtonIcon.swift
Matt Bruce 5d19edbc6e Merge branch 'develop' of https://gitlab.verizon.com/BPHV_MIPS/vds_ios.git into refactor/VDSTokens
# Conflicts:
#	VDS.xcodeproj/project.pbxproj
#	VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift
#	VDS/Components/Notification/Notification.swift
#	VDS/Components/TextFields/EntryFieldBase.swift
#	VDS/Components/TileContainer/TileContainer.swift
#	VDS/Extensions/VDSLayout.swift

Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-04-02 12:09:30 -05:00

565 lines
25 KiB
Swift

//
// ButtonIcon.swift
// VDS
//
// Created by Matt Bruce on 5/12/23.
//
import Foundation
import UIKit
import VDSTokens
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 }
/// 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 = true
accessibilityTraits = .button
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()
}
/// 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
} 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
}
}
/// 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)
}
}
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 }
}