vds_ios/VDS/Components/DatePicker/DatePicker.swift
Matt Bruce beaa2b3a82 refactored out old code and reorganized
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-06-21 14:43:55 -05:00

330 lines
12 KiB
Swift

import Foundation
import UIKit
import VDSCoreTokens
import Combine
/// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection.
@objc(VDSDatePicker)
open class DatePicker: EntryFieldBase {
//--------------------------------------------------
// 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: - Public Properties
//--------------------------------------------------
/// A callback when the selected option changes. Passes parameters (option).
open var onDateSelected: ((Date, DatePicker) -> Void)?
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
internal var minWidthDefault = 186.0
internal var popoverView: UIView!
internal var popoverVisible = false
internal var outsideTapGesture: UITapGestureRecognizer?
internal var outsidePanGesture: UIPanGestureRecognizer?
internal var overlayView = UIView().with {
$0.backgroundColor = .clear;
$0.isHidden = true
}
internal var bottomStackView: UIStackView = {
return UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .vertical
$0.distribution = .fill
$0.alignment = .top
$0.spacing = VDSLayout.space2X
}
}()
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
open var calendarIcon = Icon().with {
$0.name = .calendar
$0.size = .medium
}
open var selectedDate: Date? { didSet { setNeedsUpdate() } }
open var calendarModel: CalendarModel = .init() { didSet { setNeedsUpdate() } }
open override var value: String? {
get { selectedDateLabel.text }
set { }
}
open var selectedDateLabel = Label().with {
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.textAlignment = .left
$0.textStyle = .bodyLarge
$0.lineBreakMode = .byCharWrapping
}
public enum DateFormat: String, CaseIterable, CustomStringConvertible {
case shortNumeric
case longAlphabetic
case mediumNumeric
case consiseNumeric
public var format: String {
switch self {
case .shortNumeric: "MM/dd/yy"
case .longAlphabetic: "MMMM d, yyyy"
case .mediumNumeric: "MM/dd/yyyy"
case .consiseNumeric: "M/d/yyyy"
}
}
public var description: String {
return format
}
}
open var dateFormat: DateFormat = .shortNumeric { didSet{ setNeedsUpdate() } }
//--------------------------------------------------
// MARK: - Configuration Properties
//--------------------------------------------------
internal override var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) }
//--------------------------------------------------
// 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()
// setting color config
selectedDateLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable()
// tap gesture
containerView
.publisher(for: UITapGestureRecognizer())
.sink { [weak self] _ in
guard let self else { return }
if isEnabled && !isReadOnly {
showPopover()
}
}
.store(in: &subscribers)
NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification).sink { [weak self] _ in
guard let self else { return }
hidePopoverView()
}
.store(in: &subscribers)
}
open override func getFieldContainer() -> UIView {
// stackview for controls in EntryFieldBase.controlContainerView
let controlStackView = UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.axis = .horizontal
$0.spacing = VDSLayout.space3X
}
controlStackView.addArrangedSubview(calendarIcon)
controlStackView.addArrangedSubview(selectedDateLabel)
return controlStackView
}
/// Used to make changes to the View based off a change events or from local properties.
open override func updateView() {
super.updateView()
if let selectedDate {
formatDate(selectedDate)
}
selectedDateLabel.surface = surface
selectedDateLabel.isEnabled = isEnabled
calendarIcon.color = iconColorConfiguration.getColor(self)
}
/// Resets to default settings.
open override func reset() {
super.reset()
selectedDateLabel.textStyle = .bodyLarge
}
internal func formatDate(_ date: Date) {
let formatter = DateFormatter()
formatter.dateFormat = dateFormat.format
selectedDateLabel.text = formatter.string(from: date)
}
internal var popoverController: ClearPopoverViewController?
func didSelect(_ date: Date) {
selectedDate = date
sendActions(for: .valueChanged)
UIAccessibility.post(notification: .layoutChanged, argument: self.containerView)
popoverController?.dismiss(animated: true){ [weak self] in
guard let self else { return }
popoverController = nil
}
}
}
extension DatePicker {
private func showPopover() {
guard let viewController = UIApplication.topViewController(), let parentView = viewController.view else { return }
if popoverVisible {
hidePopoverView()
} else {
let calendar = CalendarBase()
calendar.activeDates = calendarModel.activeDates
calendar.hideContainerBorder = calendarModel.hideContainerBorder
calendar.hideCurrentDateIndicator = calendarModel.hideCurrentDateIndicator
calendar.inactiveDates = calendarModel.inactiveDates
calendar.indicators = calendarModel.indicators
calendar.maxDate = calendarModel.maxDate
calendar.minDate = calendarModel.minDate
calendar.surface = calendarModel.surface
calendar.setNeedsLayout()
calendar.layoutIfNeeded()
calendar.onChange = { [weak self] control in
guard let self else { return }
selectedDate = control.selectedDate
sendActions(for: .valueChanged)
UIAccessibility.post(notification: .layoutChanged, argument: containerView)
hidePopoverView()
}
outsideTapGesture = UITapGestureRecognizer()
outsidePanGesture = UIPanGestureRecognizer()
overlayView.publisher(for: outsideTapGesture!).sink { [weak self] _ in
guard let self else { return }
hidePopoverView()
}.store(in: &subscribers)
parentView.publisher(for: outsidePanGesture!).sink { [weak self] _ in
guard let self else { return }
hidePopoverView()
}.store(in: &subscribers)
overlayView.frame = parentView.bounds
overlayView.isHidden = false
parentView.addSubview(overlayView)
popoverView = UIView()
popoverView.backgroundColor = .white
popoverView.layer.cornerRadius = 10
popoverView.layer.shadowColor = UIColor.black.cgColor
popoverView.layer.shadowOpacity = 0.2
popoverView.layer.shadowOffset = CGSize(width: 0, height: 5)
popoverView.layer.shadowRadius = 10
popoverView.isHidden = true
popoverView.addSubview(calendar)
calendar.pinToSuperView()
popoverView.translatesAutoresizingMaskIntoConstraints = false
parentView.addSubview(popoverView)
popoverView.width(calendar.frame.width)
popoverView.height(calendar.frame.height)
let spacing: CGFloat = 4
let (popoverX, popoverY) = calculatePopoverPosition(relativeTo: containerView, in: parentView, size: calendar.frame.size, with: spacing)
popoverView.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, constant: popoverX).isActive = true
popoverView.topAnchor.constraint(equalTo: parentView.topAnchor, constant: popoverY).isActive = true
parentView.layoutIfNeeded()
popoverView.alpha = 0
popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
popoverView.isHidden = false
popoverVisible = true
UIView.animate(withDuration: 0.3,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2,
options: .curveEaseOut,
animations: { [weak self] in
guard let self else { return }
popoverView.alpha = 1
popoverView.transform = CGAffineTransform.identity
parentView.layoutIfNeeded()
})
}
}
private func hidePopoverView() {
overlayView.isHidden = true
overlayView.removeFromSuperview()
outsideTapGesture = nil
outsidePanGesture = nil
UIView.animate(withDuration: 0.2,
animations: {[weak self] in
guard let self else { return }
popoverView.alpha = 0
popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}) { [weak self] _ in
guard let self else { return }
popoverView.isHidden = true
popoverView.removeFromSuperview()
popoverVisible = false
}
}
private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) -> (CGFloat, CGFloat) {
let sourceFrameInParent = sourceView.convert(sourceView.bounds, to: parentView)
let parentBounds = parentView.bounds
let popoverWidth: CGFloat = size.width
let popoverHeight: CGFloat = size.height
var popoverX: CGFloat = 0
var popoverY: CGFloat = 0
// Calculate horizontal position
if sourceFrameInParent.width < popoverWidth {
if sourceFrameInParent.midX - popoverWidth / 2 < 0 {
// Align to left
popoverX = sourceFrameInParent.minX
} else if sourceFrameInParent.midX + popoverWidth / 2 > parentBounds.width {
// Align to right
popoverX = sourceFrameInParent.maxX - popoverWidth
} else {
// Center on source view
popoverX = sourceFrameInParent.midX - popoverWidth / 2
}
} else {
popoverX = sourceFrameInParent.midX - popoverWidth / 2
}
// Calculate vertical position
if sourceFrameInParent.origin.y > parentBounds.height / 2 {
// Show above
popoverY = sourceFrameInParent.minY - popoverHeight - spacing
} else {
// Show below
popoverY = sourceFrameInParent.maxY + spacing
}
// Ensure the popover is within the parent's bounds
popoverX = max(0, min(popoverX, parentBounds.width - popoverWidth))
return (popoverX, popoverY)
}
}