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 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 self.isEnabled && !self.isReadOnly { self.togglePicker() } } .store(in: &subscribers) NotificationCenter.default .publisher(for: UIDevice.orientationDidChangeNotification).sink { [weak self] _ in guard let self else { return } popoverController?.dismiss(animated: true){ [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 } } private var overlayView = UIView().with { $0.backgroundColor = .clear; $0.isHidden = true } private var popoverView: UIView! private var popoverVisible = false private var outsideTapGesture: UITapGestureRecognizer? private var outsidePanGesture: UIPanGestureRecognizer? // internal func togglePicker() { // 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 } // didSelect(control.selectedDate) // } // // popoverController = ClearPopoverViewController(contentView: calendar, // arrow: .any, // sourceView: containerView, // sourceRect: .init(x: 0, y: 0, width: 320, height: 45), // spacing: VDSLayout.space1X) // popoverController?.maxWidth = 320 // if let viewController = UIApplication.topViewController(), let popoverController { // viewController.present(popoverController, // animated: true, // completion: nil) // } // } } extension DatePicker { private func togglePicker() { 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: { self.popoverView.alpha = 0 self.popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) }) { _ in self.popoverView.isHidden = true self.popoverView.removeFromSuperview() self.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) } }