// // ButtonIcon.swift // VDS // // Created by Matt Bruce on 5/12/23. // import Foundation import UIKit import VDSColorTokens @objc(VDSButtonIcon) open class ButtonIcon: Control { //-------------------------------------------------- // MARK: - Models //-------------------------------------------------- //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- public enum Kind: String, CaseIterable { case ghost, lowContrast, highContrast } public enum SurfaceType: String, CaseIterable { case colorFill, media } 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 currentIconName: Icon.Name? { if let selectedIconName { return selectedIconName } else { return iconName } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var icon = Icon() open var kind: Kind = .ghost { didSet { setNeedsUpdate() } } open var surfaceType: SurfaceType = .colorFill { didSet { setNeedsUpdate() } } open var iconName: Icon.Name? { didSet { setNeedsUpdate() } } open var selectedIconName: Icon.Name? { didSet { setNeedsUpdate() } } open var size: Size = .large { didSet { setNeedsUpdate() } } open var customSize: Int? { didSet { setNeedsUpdate() }} open var floating: Bool = false { didSet { setNeedsUpdate() } } open var fitToIcon: Bool = false { didSet { setNeedsUpdate() } } open var hideBorder: Bool = true { didSet { setNeedsUpdate() } } open var iconOffset: CGPoint = .init(x: 0, y: 0) { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private var iconColorConfiguration: AnyColorable { if selectedIconName != nil { return selectedIconColorConfiguration } else { if kind == .highContrast { return highContrastIconColorConfiguration } 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) }.eraseToAnyColorable() }() private var highContrastIconColorConfiguration: AnyColorable = { return SurfaceColorConfiguration(VDSColor.elementsPrimaryOndark, VDSColor.elementsPrimaryOnlight).eraseToAnyColorable() }() private var selectedIconColorConfiguration: AnyColorable = { return ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.elementsBrandhighlight, VDSColor.elementsPrimaryOndark, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) }.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 { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.backgroundPrimaryLight, .clear).eraseToAnyColorable() }() } private struct LowContrastMediaConfiguration: Configuration, Borderable { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .media var floating: Bool = false var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.backgroundPrimaryLight, .clear).eraseToAnyColorable() }() var borderWidth: CGFloat = 1.0 var borderColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, .clear).eraseToAnyColorable() }() } private struct LowContrastMediaFloatingConfiguration: Configuration, Dropshadowable { var kind: Kind = .lowContrast var surfaceType: SurfaceType = .media var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.backgroundPrimaryLight, .clear).eraseToAnyColorable() }() var shadowColorConfiguration: AnyColorable = { SurfaceColorConfiguration(VDSColor.paletteBlack, .clear).eraseToAnyColorable() }() var shadowOpacity: CGFloat = 0.16 var shadowOffset: CGSize = .init(width: 0, height: 2) var shadowRadius: CGFloat = 2 } 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) }.eraseToAnyColorable() }() } private struct HighContrastFloatingConfiguration: Configuration { var kind: Kind = .highContrast var surfaceType: SurfaceType = .colorFill var floating: Bool = true var backgroundColorConfiguration: AnyColorable = { return ControlColorConfiguration().with { $0.setSurfaceColors(VDSColor.backgroundPrimaryLight, VDSColor.backgroundPrimaryLight, forState: .normal) $0.setSurfaceColors(VDSColor.interactiveActiveOnlight, VDSColor.interactiveActiveOndark, forState: .highlighted) $0.setSurfaceColors(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark, forState: .disabled) }.eraseToAnyColorable() }() } //-------------------------------------------------- // 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: - Lifecycle //-------------------------------------------------- open override func setup() { super.setup() //create a layoutGuide for the icon to key off of let iconLayoutGuide = UILayoutGuide() addLayoutGuide(iconLayoutGuide) //add the icon addSubview(icon) //determines the height/width of the icon layoutGuideWidthConstraint = iconLayoutGuide.width(constant: size.containerSize) layoutGuideHeightConstraint = iconLayoutGuide.height(constant: size.containerSize) //pin layout guide iconLayoutGuide.pinToSuperView() //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 shouldUpdateView = true setNeedsUpdate() } /// Function 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() } setNeedsLayout() } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- 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 { 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() } } } // MARK: AppleGuidlinesTouchable extension ButtonIcon: AppleGuidlinesTouchable { override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { Self.acceptablyOutsideBounds(point: point, bounds: bounds) } open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let view = super.hitTest(point, with: event) if view == icon { return self } return view } } 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 } }