556 lines
21 KiB
Swift
556 lines
21 KiB
Swift
//
|
|
// 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<TileContainer.Padding> {
|
|
|
|
/// 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<PaddingType: DefaultValuing>: 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
//--------------------------------------------------
|
|
// 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 {}
|
|
}
|
|
|
|
//--------------------------------------------------
|
|
// 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 - Will exit out if set
|
|
//-------------------------------------------------------------------------
|
|
if let containerViewWidth,
|
|
let multiplier,
|
|
containerViewWidth > 0,
|
|
containerViewHeight == nil {
|
|
widthConstraint?.constant = containerViewWidth
|
|
widthConstraint?.activate()
|
|
aspectRatioConstraint = heightAnchor.constraint(equalTo: widthAnchor, multiplier: multiplier)
|
|
aspectRatioConstraint?.activate()
|
|
return
|
|
}
|
|
//-------------------------------------------------------------------------
|
|
//Height + AspectRatio Constraint - Will exit out if set
|
|
//-------------------------------------------------------------------------
|
|
else if let containerViewHeight,
|
|
let multiplier,
|
|
containerViewHeight > 0,
|
|
containerViewWidth == nil {
|
|
heightConstraint?.constant = containerViewHeight
|
|
heightConstraint?.activate()
|
|
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: multiplier)
|
|
aspectRatioConstraint?.activate()
|
|
return
|
|
}
|
|
|
|
//-------------------------------------------------------------------------
|
|
//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
|
|
}
|
|
}
|
|
}
|