// // BaseButton.swift // VDS // // Created by Matt Bruce on 11/22/22. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine public protocol Buttonable: UIControl, Surfaceable, Disabling { var availableSizes: [ButtonSize] { get } var text: String? { get set } var intrinsicContentSize: CGSize { get } } @objc(VDSButtonBase) open class ButtonBase: UIButton, Buttonable, Handlerable, ViewProtocol, Resettable, UserInfoable, Clickable { //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- private let hitAreaHeight = 44.0 //-------------------------------------------------- // MARK: - Combine Properties //-------------------------------------------------- public var subscribers = Set() public var onClickSubscriber: AnyCancellable? { willSet { if let onClickSubscriber { onClickSubscriber.cancel() } } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var initialSetupPerformed = false //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- open var shouldUpdateView: Bool = true open var availableSizes: [ButtonSize] { [] } open var text: String? { didSet { setNeedsUpdate() } } open var attributes: [any LabelAttributeModel]? { nil } open var surface: Surface = .light { didSet { setNeedsUpdate() }} open var disabled: Bool = false { didSet { isEnabled = !disabled } } open var userInfo = [String: Primitive]() public var touchUpInsideCount: Int = 0 internal var isHighlightAnimating = false open override var isHighlighted: Bool { didSet { if isHighlightAnimating == false && touchUpInsideCount > 0 { isHighlightAnimating = true UIView.animate(withDuration: 0.1, animations: { [weak self] in self?.updateView() }) { [weak self] _ in //you update the view since this is typically a quick change UIView.animate(withDuration: 0.1, animations: { [weak self] in self?.updateView() self?.isHighlightAnimating = false }) } } } } open var textStyle: TextStyle { .defaultStyle } open var textColor: UIColor { .black } open override var isEnabled: Bool { get { !disabled } set { if disabled != !newValue { disabled = !newValue } isUserInteractionEnabled = isEnabled setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) initialSetup() } public override init(frame: CGRect) { super.init(frame: .zero) initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } //-------------------------------------------------- // MARK: - Public Functions //-------------------------------------------------- open func initialSetup() { if !initialSetupPerformed { backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false accessibilityCustomActions = [] setup() setNeedsUpdate() } } open func setup() { translatesAutoresizingMaskIntoConstraints = false titleLabel?.adjustsFontSizeToFitWidth = false titleLabel?.lineBreakMode = .byTruncatingTail } open func reset() { shouldUpdateView = false surface = .light disabled = false text = nil accessibilityCustomActions = [] shouldUpdateView = true setNeedsUpdate() } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- override open var intrinsicContentSize: CGSize { let intrinsicContentSize = super.intrinsicContentSize let adjustedWidth = intrinsicContentSize.width + titleEdgeInsets.left + titleEdgeInsets.right let adjustedHeight = intrinsicContentSize.height + titleEdgeInsets.top + titleEdgeInsets.bottom return CGSize(width: adjustedWidth, height: adjustedHeight) } open func updateView() { updateLabel() updateAccessibilityLabel() } open func updateAccessibilityLabel() { } //-------------------------------------------------- // MARK: - PRIVATE //-------------------------------------------------- private func updateLabel() { let font = textStyle.font //clear the arrays holding actions accessibilityCustomActions = [] //create the primary string let startingAttributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: textColor] let mutableText = NSMutableAttributedString(string: text ?? "No Text", attributes: startingAttributes) //set the local lineHeight/lineSpacing attributes //get the range let entireRange = NSRange(location: 0, length: mutableText.length) //set letterSpacing if textStyle.letterSpacing > 0.0 { mutableText.addAttribute(.kern, value: textStyle.letterSpacing, range: entireRange) } let paragraph = NSMutableParagraphStyle().with { $0.alignment = titleLabel?.textAlignment ?? .center $0.lineBreakMode = titleLabel?.lineBreakMode ?? .byTruncatingTail } //set lineHeight if textStyle.lineHeight > 0.0 { let lineHeight = textStyle.lineHeight let adjustment = lineHeight > font.lineHeight ? 2.0 : 1.0 let baselineOffset = (lineHeight - font.lineHeight) / 2.0 / adjustment paragraph.maximumLineHeight = lineHeight paragraph.minimumLineHeight = lineHeight mutableText.addAttribute(.baselineOffset, value: baselineOffset, range: entireRange) mutableText.addAttribute( .paragraphStyle, value: paragraph, range: entireRange) } else { mutableText.addAttribute( .paragraphStyle, value: paragraph, range: entireRange) } if let attributes = attributes { //loop through the models attributes for attribute in attributes { //add attribute on the string attribute.setAttribute(on: mutableText) } } //set the attributed text setAttributedTitle(mutableText, for: .normal) setAttributedTitle(mutableText, for: .highlighted) } open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let size = intrinsicContentSize // Create a minimumHitArea variable with a value that represents the minimum size of the hit area you want to create for the button. let minimumHitArea = CGSize(width: size.width, height: hitAreaHeight) // Create a new hitFrame variable that is the same size as the minimumHitArea variable, but is centered on the button's frame. let hitFrame = CGRect( x: self.bounds.midX - minimumHitArea.width / 2, y: self.bounds.midY - minimumHitArea.height / 2, width: minimumHitArea.width, height: minimumHitArea.height ) // If the point that was passed to the hitTest(_:with:) method is within the hitFrame, return the button itself. This will cause the button to handle the touch event. if hitFrame.contains(point) { return self } // If the point is not within the hitFrame, return nil. This will cause the touch event to be handled by another view. return nil } } // MARK: AppleGuidlinesTouchable extension ButtonBase: AppleGuidlinesTouchable { override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { Self.acceptablyOutsideBounds(point: point, bounds: bounds) } }