// // TileContainer.swift // VDS // // Created by Matt Bruce on 12/16/22. // import Foundation import VDSCoreTokens import UIKit import Combine @objcMembers @objc(VDSTileContainer) open class TileContainer: TileContainerBase { /// Enum used to describe the padding choices used for this component. public enum Padding: DefaultValuing { case padding3X case padding4X case padding6X case padding8X case padding12X case custom(CGFloat) public static var defaultValue: Self { .padding4X } public var value: CGFloat { switch self { case .padding3X: return VDSLayout.space3X case .padding4X: return VDSLayout.space4X case .padding6X: return VDSLayout.space6X case .padding8X: return VDSLayout.space8X case .padding12X: return VDSLayout.space12X case .custom(let padding): return padding } } } } open class TileContainerBase: View where PaddingType.ValueType == CGFloat { //-------------------------------------------------- // 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 background color choices used for this component. public enum BackgroundColor: Equatable { case primary case secondary case white case black case token(UIColor.VDSColor) case custom(UIColor) private var reflectedValue: String { String(reflecting: self) } public static func == (lhs: Self, rhs: Self) -> Bool { lhs.reflectedValue == rhs.reflectedValue } } /// Enum used to describe the background effect choices used for this component. public enum BackgroundEffect { case transparency case gradient(UIColor, UIColor) case none } /// Enum used to describe the aspect ratios used for this component. public enum AspectRatio: String, CaseIterable { case ratio1x1 = "1:1" case ratio3x4 = "3:4" case ratio4x3 = "4:3" case ratio2x3 = "2:3" case ratio3x2 = "3:2" case ratio9x16 = "9:16" case ratio16x9 = "16:9" case ratio1x2 = "1:2" case ratio2x1 = "2:1" case none } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var backgroundImageView = UIImageView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.contentMode = .scaleAspectFill $0.clipsToBounds = true } open var containerView = View().with { $0.setContentHuggingPriority(.defaultLow, for: .horizontal) $0.setContentHuggingPriority(.defaultLow, for: .vertical) $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) $0.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) } private var isHighlighted: Bool = false { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// This takes an image source url and applies it as a background image. open var backgroundImage: UIImage? { didSet { setNeedsUpdate() } } /// This is the container in which views will be pinned. open var contentView = View() /// This is the view used to show the high light color for a onClick. open var highlightView = View().with { $0.isUserInteractionEnabled = false } /// This controls the aspect ratio for the component. open var aspectRatio: AspectRatio = .ratio1x1 { didSet { setNeedsUpdate() } } /// Sets the background color for the component. open var color: BackgroundColor? { didSet { setNeedsUpdate() } } /// Sets the background effect for the component. open var backgroundEffect: BackgroundEffect = .none { didSet { setNeedsUpdate() } } /// Sets the inside padding for the component open var padding: PaddingType = PaddingType.defaultValue { didSet { setNeedsUpdate() } } /// Applies a background color if backgroundImage prop fails or has trouble loading. open var imageFallbackColor: Surface = .light { didSet { setNeedsUpdate() } } private var _width: CGFloat? /// Sets the width for the component. Accepts a pixel value. open var width: CGFloat? { get { return _width } set { if let newValue, newValue > 100 { _width = newValue } else { _width = nil } setNeedsUpdate() } } private var _height: CGFloat? /// Sets the height for the component. Accepts a pixel value. open var height: CGFloat? { get { return _height } set { if let newValue, newValue > 44 { _height = newValue } else { _height = nil } setNeedsUpdate() } } /// If true, a border is rendered around the container. open var showBorder: Bool = false { didSet { setNeedsUpdate() } } /// Determines if there is a drop shadow or not. open var showDropShadow: Bool = false { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Constraints //-------------------------------------------------- internal var widthConstraint: NSLayoutConstraint? internal var heightConstraint: NSLayoutConstraint? internal var aspectRatioConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private let cornerRadius = VDSLayout.shapeCornerradiusTiles internal var backgroundColorConfiguration = BackgroundColorConfiguration() private let dropShadowConfiguration = DropShadowConfiguration().with { $0.shadowColorConfiguration = SurfaceColorConfiguration().with { $0.lightColor = VDSColor.elementsPrimaryOnlight }.eraseToAnyColorable() $0.shadowOffsetConfiguration = .init(.init(width: 0, height: 6), .zero) $0.shadowRadiusConfiguration = .init(8.0, 0.0) $0.shadowOpacityConfiguration = .init(0.1, 0.0) } private var borderColorConfiguration = SurfaceColorConfiguration().with { $0.lightColor = VDSColor.elementsLowcontrastOnlight $0.darkColor = VDSColor.elementsLowcontrastOndark } private var imageFallbackColorConfiguration = SurfaceColorConfiguration().with { $0.lightColor = VDSColor.backgroundPrimaryLight $0.darkColor = VDSColor.backgroundPrimaryDark } private var hightLightViewColorConfiguration = SurfaceColorConfiguration().with { $0.lightColor = VDSColor.paletteWhite.withAlphaComponent(0.3) $0.darkColor = VDSColor.paletteBlack.withAlphaComponent(0.3) } //-------------------------------------------------- // 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 addSubview(containerView) containerView.pinToSuperView() containerView.addSubview(backgroundImageView) backgroundImageView.pinToSuperView() containerView.addSubview(contentView) contentView.pinToSuperView() containerView.addSubview(highlightView) highlightView.pinToSuperView() widthConstraint = widthAnchor.constraint(equalToConstant: 0).deactivate() heightConstraint = heightAnchor.constraint(equalToConstant: 0).deactivate() backgroundImageView.setContentHuggingPriority(.defaultLow, for: .horizontal) backgroundImageView.setContentHuggingPriority(.defaultLow, for: .vertical) backgroundImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) backgroundImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) backgroundImageView.isUserInteractionEnabled = false backgroundImageView.isHidden = true highlightView.isHidden = true highlightView.backgroundColor = .clear //corner radius containerView.layer.cornerRadius = cornerRadius backgroundImageView.layer.cornerRadius = cornerRadius highlightView.layer.cornerRadius = cornerRadius containerView.clipsToBounds = true containerView.bridge_isAccessibilityElementBlock = { [weak self] in self?.onClickSubscriber != nil } containerView.accessibilityHint = "Double tap to open." containerView.accessibilityLabel = nil NotificationCenter.default .publisher(for: UIDevice.orientationDidChangeNotification) .sink() { [weak self] _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) { [weak self] in guard let self else { return } setNeedsUpdate() } }.store(in: &subscribers) } /// Overriden to take the hit if there is an onClickSubscriber and the view is not a UIControl open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let view = super.hitTest(point, with: event) guard let _ = onClickSubscriber else { return view } guard view is UIControl else { return self } return view } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false color = .white aspectRatio = .none imageFallbackColor = .light width = nil height = nil showBorder = false showDropShadow = false 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() highlightView.backgroundColor = hightLightViewColorConfiguration.getColor(self) highlightView.isHidden = !isHighlighted containerView.layer.borderColor = borderColorConfiguration.getColor(self).cgColor containerView.layer.borderWidth = showBorder ? VDSFormControls.borderWidth : 0 contentView.removeConstraints() contentView.pinToSuperView(.uniform(padding.value)) updateContainerView() } open override var accessibilityElements: [Any]? { get { var items = [Any]() if containerView.isAccessibilityElement { if !accessibilityTraits.contains(.button) && !accessibilityTraits.contains(.link) { containerView.accessibilityTraits = .button } else { containerView.accessibilityTraits = accessibilityTraits } items.append(containerView) } let elements = gatherAccessibilityElements(from: contentView) let views = elements.compactMap({ $0 as? UIView }) //update accessibilityLabel containerView.setAccessibilityLabel(for: views) //append all children that are accessible items.append(contentsOf: elements) return items } set {} } open override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) if let onClickSubscriber { isHighlighted = true } } open override func touchesEnded(_ touches: Set, with event: UIEvent?) { super.touchesEnded(touches, with: event) if let onClickSubscriber { isHighlighted = false } } open override func touchesCancelled(_ touches: Set, with event: UIEvent?) { super.touchesCancelled(touches, with: event) if let onClickSubscriber { isHighlighted = false } } //-------------------------------------------------- // MARK: - Public Methods //-------------------------------------------------- /// This will place a view within the contentView of this component. public func addContentView(_ view: UIView, shouldPin: Bool = true) { view.removeFromSuperview() contentView.addSubview(view) if shouldPin { view.pinToSuperView() } } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func applyBackgroundEffects() { let color = backgroundColorConfiguration.getColor(self) var alphaConfiguration: CGFloat = 1.0 let imageFallbackColor = imageFallbackColorConfiguration.getColor(self) switch backgroundEffect { case .transparency: alphaConfiguration = 0.8 containerView.removeGradientLayer() case .gradient(let firstColor, let secondColor): alphaConfiguration = 1.0 containerView.addGradientLayer(with: firstColor, secondColor: secondColor) backgroundImageView.isHidden = true backgroundImageView.alpha = 1.0 case .none: alphaConfiguration = 1.0 containerView.removeGradientLayer() } if let backgroundImage { backgroundImageView.image = backgroundImage backgroundImageView.isHidden = false backgroundImageView.alpha = alphaConfiguration containerView.backgroundColor = imageFallbackColor.withAlphaComponent(alphaConfiguration) } else { backgroundImageView.isHidden = true backgroundImageView.alpha = 1.0 containerView.backgroundColor = color.withAlphaComponent(alphaConfiguration) } } private func updateContainerView() { applyBackgroundEffects() if showDropShadow, surface == .light { containerView.addDropShadow(dropShadowConfiguration) } else { containerView.removeDropShadows() } containerView.dropShadowLayers?.forEach { $0.frame = containerView.bounds } containerView.gradientLayers?.forEach { $0.frame = containerView.bounds } //sizing the container with constraints //Set local vars var containerViewWidth: CGFloat? = width let containerViewHeight: CGFloat? = height let multiplier = aspectRatio.multiplier //turn off the constraints aspectRatioConstraint?.deactivate() widthConstraint?.deactivate() heightConstraint?.deactivate() //------------------------------------------------------------------------- //Overriding Nil Width Rules //------------------------------------------------------------------------- //Rule 1: //In the scenario where we only have a height but the multiplie is nil, we //want to set the width with the parent's width which will more or less "fill" //the container horizontally //- height is set //- width is not set //- aspectRatio is not set if let superviewWidth, superviewWidth > 0, containerViewHeight != nil, containerViewWidth == nil, multiplier == nil { containerViewWidth = superviewWidth } //Rule 2: //In the scenario where no width and height is set, want to set the width with the //parent's width which will more or less "fill" the container horizontally //- height is not set //- width is not set else if let superviewWidth, superviewWidth > 0, containerViewWidth == nil, containerViewHeight == nil { containerViewWidth = superviewWidth } //------------------------------------------------------------------------- //------------------------------------------------------------------------- //Width + AspectRatio Constraint //------------------------------------------------------------------------- if let containerViewWidth, let multiplier, containerViewWidth > 0, containerViewHeight == nil { widthConstraint?.constant = containerViewWidth widthConstraint?.activate() aspectRatioConstraint = heightAnchor.constraint(equalTo: widthAnchor, multiplier: multiplier) aspectRatioConstraint?.activate() } //------------------------------------------------------------------------- //Height + AspectRatio Constraint //------------------------------------------------------------------------- else if let containerViewHeight, let multiplier, containerViewHeight > 0, containerViewWidth == nil { heightConstraint?.constant = containerViewHeight heightConstraint?.activate() aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: multiplier) aspectRatioConstraint?.activate() } else { //------------------------------------------------------------------------- //Width Constraint //------------------------------------------------------------------------- if let containerViewWidth, containerViewWidth > 0 { widthConstraint?.constant = containerViewWidth widthConstraint?.activate() } //------------------------------------------------------------------------- //Height Constraint //------------------------------------------------------------------------- if let containerViewHeight, containerViewHeight > 0 { heightConstraint?.constant = containerViewHeight heightConstraint?.activate() } } } /// This is the size of the superview's allowed space for this container first by constrained size which would include padding/inset values an private var superviewWidth: CGFloat? { horizontalPinnedWidth() ?? superview?.frame.size.width } } extension TileContainerBase { final class BackgroundColorConfiguration: ObjectColorable { typealias ObjectType = TileContainerBase let primaryColorConfig = SurfaceColorConfiguration(VDSColor.backgroundPrimaryLight, VDSColor.backgroundPrimaryDark) let secondaryColorConfig = SurfaceColorConfiguration(VDSColor.backgroundSecondaryLight, VDSColor.backgroundSecondaryDark) let grayColorConfig = SurfaceColorConfiguration(VDSColor.backgroundSecondaryLight, VDSColor.backgroundSecondaryDark) let whiteColorConfig = SurfaceColorConfiguration(VDSColor.paletteWhite, VDSColor.paletteWhite) let blackColorConfig = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteBlack) required init() { } func getColor(_ object: ObjectType) -> UIColor { guard let color = object.color else { let config = object.surface == .light ? blackColorConfig : whiteColorConfig return config.getColor(object.surface) } switch color { case .primary: return primaryColorConfig.getColor(object.surface) case .secondary: return secondaryColorConfig.getColor(object.surface) case .white: return whiteColorConfig.getColor(object.surface) case .black: return blackColorConfig.getColor(object.surface) case .token(let vdsColor): return vdsColor.uiColor case .custom(let color): return color } } } } extension TileContainerBase.AspectRatio { var multiplier: CGFloat? { switch self { case .ratio1x1: return 1 case .ratio3x4: return 4 / 3 case .ratio4x3: return 3 / 4 case .ratio2x3: return 3 / 2 case .ratio3x2: return 2 / 3 case .ratio9x16: return 16 / 9 case .ratio16x9: return 9 / 16 case .ratio1x2: return 2 / 1 case .ratio2x1: return 1 / 2 case .none: return nil } } }