// // TileContainer.swift // VDS // // Created by Matt Bruce on 12/16/22. // import Foundation import VDSCoreTokens import UIKit import Combine @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: Control 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 } internal var containerView = View() //-------------------------------------------------- // 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 heightGreaterThanConstraint: NSLayoutConstraint? internal var containerTopConstraint: NSLayoutConstraint? internal var containerBottomConstraint: NSLayoutConstraint? internal var containerLeadingConstraint: NSLayoutConstraint? internal var containerTrailingConstraint: 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 let layoutGuide = UILayoutGuide() addLayoutGuide(layoutGuide) layoutGuide .pinTop() .pinLeading() .pinTrailing(0, .defaultHigh) .pinBottom(0, .defaultHigh) addSubview(backgroundImageView) addSubview(containerView) containerView.addSubview(contentView) addSubview(highlightView) containerView.pinToSuperView() widthConstraint = layoutGuide.widthAnchor.constraint(equalToConstant: 0) heightGreaterThanConstraint = layoutGuide.heightAnchor.constraint(greaterThanOrEqualToConstant: 44.0) heightGreaterThanConstraint?.isActive = false heightConstraint = layoutGuide.heightAnchor.constraint(equalToConstant: 0) backgroundImageView.pinToSuperView() 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 containerTopConstraint = contentView.pinTop(anchor: layoutGuide.topAnchor, constant: padding.value) containerBottomConstraint = layoutGuide.pinBottom(anchor: contentView.bottomAnchor, constant: padding.value) containerLeadingConstraint = contentView.pinLeading(anchor: layoutGuide.leadingAnchor, constant: padding.value) containerTrailingConstraint = layoutGuide.pinTrailing(anchor: contentView.trailingAnchor, constant: padding.value) highlightView.pin(layoutGuide) highlightView.isHidden = true highlightView.backgroundColor = .clear //corner radius layer.cornerRadius = cornerRadius backgroundImageView.layer.cornerRadius = cornerRadius highlightView.layer.cornerRadius = cornerRadius clipsToBounds = true } /// 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 = .ratio1x1 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 layer.borderColor = borderColorConfiguration.getColor(self).cgColor layer.borderWidth = showBorder ? VDSFormControls.borderWidth : 0 containerTopConstraint?.constant = padding.value containerLeadingConstraint?.constant = padding.value containerBottomConstraint?.constant = padding.value containerTrailingConstraint?.constant = padding.value if let width, aspectRatio == .none && height == nil{ widthConstraint?.constant = width widthConstraint?.isActive = true heightConstraint?.isActive = false heightGreaterThanConstraint?.isActive = true } else if let height, let width { widthConstraint?.constant = width heightConstraint?.constant = height heightConstraint?.isActive = true widthConstraint?.isActive = true heightGreaterThanConstraint?.isActive = false } else if let width { let size = ratioSize(for: width) widthConstraint?.constant = size.width heightConstraint?.constant = size.height widthConstraint?.isActive = true heightConstraint?.isActive = true heightGreaterThanConstraint?.isActive = false } else { widthConstraint?.isActive = false heightConstraint?.isActive = false } applyBackgroundEffects() if showDropShadow, surface == .light { addDropShadow(dropShadowConfiguration) } else { removeDropShadows() } } open override func updateAccessibility() { super.updateAccessibility() containerView.isAccessibilityElement = onClickSubscriber != nil containerView.accessibilityHint = "Double tap to open." containerView.accessibilityLabel = nil } 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 {} } /// Used to update frames for the added CAlayers to our view open override func layoutSubviews() { super.layoutSubviews() dropShadowLayers?.forEach { $0.frame = bounds } gradientLayers?.forEach { $0.frame = bounds } } //-------------------------------------------------- // 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 removeGradientLayer() case .gradient(let firstColor, let secondColor): alphaConfiguration = 1.0 addGradientLayer(with: firstColor, secondColor: secondColor) backgroundImageView.isHidden = true backgroundImageView.alpha = 1.0 case .none: alphaConfiguration = 1.0 removeGradientLayer() } if let backgroundImage { backgroundImageView.image = backgroundImage backgroundImageView.isHidden = false backgroundImageView.alpha = alphaConfiguration backgroundColor = imageFallbackColor.withAlphaComponent(alphaConfiguration) } else { backgroundImageView.isHidden = true backgroundImageView.alpha = 1.0 backgroundColor = color.withAlphaComponent(alphaConfiguration) } } private func ratioSize(for width: CGFloat) -> CGSize { var height: CGFloat = width switch aspectRatio { case .ratio1x1: break; case .ratio3x4: height = (4 / 3) * width case .ratio4x3: height = (3 / 4) * width case .ratio2x3: height = (3 / 2) * width case .ratio3x2: height = (2 / 3) * width case .ratio9x16: height = (16 / 9) * width case .ratio16x9: height = (9 / 16) * width case .ratio1x2: height = (2 / 1) * width case .ratio2x1: height = (1 / 2) * width default: break } return CGSize(width: width, height: height) } } 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 } } } }