vds_ios/VDS/Components/TileContainer/TileContainer.swift

479 lines
17 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 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
}
private 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 = .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 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 = 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()
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."
}
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)
}
items.append(contentsOf: contentView.subviews.filter({ $0.isAccessibilityElement == true }))
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: 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)
}
}
}
}