// // TileContainer.swift // VDS // // Created by Matt Bruce on 12/16/22. // import Foundation import VDSColorTokens import VDSFormControlsTokens import UIKit @objc(VDSTileContainer) open class TileContainer: TileContainerBase { /// Enum used to describe the padding choices used for this component. public enum Padding: DefaultValuing { case padding2X case padding4X case padding6X case padding8X case padding12X case custom(CGFloat) public static var defaultValue: Self { .padding4X } public var value: CGFloat { switch self { case .padding2X: return VDSLayout.Spacing.space2X.value case .padding4X: return VDSLayout.Spacing.space4X.value case .padding6X: return VDSLayout.Spacing.space6X.value case .padding8X: return VDSLayout.Spacing.space8X.value case .padding12X: return VDSLayout.Spacing.space12X.value 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 custom(String) 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(String, String) 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 } //-------------------------------------------------- // 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 containerView = View().with { $0.isUserInteractionEnabled = false } /// 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 = .secondary { 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 showDropShadows: 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 = VDSFormControls.borderradius * 2 private 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(3.0, 0.0) $0.shadowOpacityConfiguration = .init(0.01, 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() let layoutGuide = UILayoutGuide() addLayoutGuide(layoutGuide) layoutGuide .pinTop() .pinLeading() .pinTrailing(0, .defaultHigh) .pinBottom(0, .defaultHigh) addSubview(backgroundImageView) addSubview(containerView) addSubview(highlightView) widthConstraint = layoutGuide.widthAnchor.constraint(equalToConstant: 0) heightGreaterThanConstraint = layoutGuide.heightAnchor.constraint(greaterThanOrEqualToConstant: 44.0) heightGreaterThanConstraint?.isActive = false heightConstraint = layoutGuide.heightAnchor.constraint(equalToConstant: 0) backgroundImageView .pinTop(layoutGuide.topAnchor) .pinLeading(layoutGuide.leadingAnchor) .pinTrailing(layoutGuide.trailingAnchor) .pinBottom(layoutGuide.bottomAnchor, 0, .defaultLow) backgroundImageView.isUserInteractionEnabled = false backgroundImageView.isHidden = true containerTopConstraint = containerView.pinTop(anchor: layoutGuide.topAnchor, constant: padding.value) containerBottomConstraint = layoutGuide.pinBottom(anchor: containerView.bottomAnchor, constant: padding.value) containerLeadingConstraint = containerView.pinLeading(anchor: layoutGuide.leadingAnchor, constant: padding.value) containerTrailingConstraint = layoutGuide.pinTrailing(anchor: containerView.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 } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false color = .white aspectRatio = .ratio1x1 imageFallbackColor = .light width = nil height = nil showBorder = false showDropShadows = 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 if let backgroundImage { backgroundImageView.image = backgroundImage backgroundImageView.isHidden = false backgroundColor = imageFallbackColorConfiguration.getColor(self) } else { backgroundImageView.isHidden = true backgroundColor = backgroundColorConfiguration.getColor(self) } layer.borderColor = borderColorConfiguration.getColor(self).cgColor layer.borderWidth = showBorder ? VDSFormControls.widthBorder : 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 } if showDropShadows, surface == .light { addDropShadow(dropShadowConfiguration) } else { removeDropShadows() } applyBackgroundEffects() } /// 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) { containerView.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: UIColor(hexString: firstColor), secondColor: UIColor(hexString: 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 { switch object.color { case .primary: primaryColorConfig.getColor(object.surface) case .secondary: secondaryColorConfig.getColor(object.surface) case .white: whiteColorConfig.getColor(object.surface) case .black: blackColorConfig.getColor(object.surface) case .custom(let hexCode): UIColor(hexString: hexCode) } } } }