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 override var responder: UIResponder? { hiddenView } internal var hiddenView = UITextView().with { $0.width(0) } internal var minWidthDefault = 186.0 internal var popoverView: UIScrollView! 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) controlStackView.addArrangedSubview(hiddenView) 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) } } 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 = UIScrollView() popoverView.backgroundColor = .green popoverView.clipsToBounds = true popoverView.backgroundColor = .clear popoverView.isHidden = true popoverView.addSubview(calendar) calendar.pinToSuperView() parentView.addSubview(popoverView) let spacing: CGFloat = 4 let popoverSize: CGSize = .init(width: calendar.frame.width, height: calendar.frame.height) popoverView.contentSize = CGSize(width: popoverSize.width, height: popoverSize.height) let (popoverX, popoverY, adjustedHeight) = calculatePopoverPosition(relativeTo: containerView, in: parentView, size: popoverSize, with: spacing) //let adjustedX = adjustedHeight != popoverSize.height ? popoverX - 10 : popoverX let adjustedWidth = adjustedHeight != popoverSize.height ? popoverSize.width + 10 : popoverSize.width popoverView.frame = CGRect(x: popoverX, y: popoverY, width: adjustedWidth, height: adjustedHeight) parentView.layoutIfNeeded() popoverView.alpha = 0 popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) popoverView.isHidden = false popoverVisible = true _ = responder?.becomeFirstResponder() updateContainerView() 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 if popoverSize.height > adjustedHeight { popoverView.flashScrollIndicators() } UIAccessibility.post(notification: .layoutChanged, argument: calendar) 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 responder?.resignFirstResponder() setNeedsUpdate() UIAccessibility.post(notification: .layoutChanged, argument: containerView) } } private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) -> (CGFloat, CGFloat, CGFloat) { let sourceFrameInParent = sourceView.convert(sourceView.bounds, to: parentView) let parentBounds = parentView.bounds let safeAreaInsets = parentView.safeAreaInsets let popoverWidth = size.width let popoverHeight = size.height var popoverX: CGFloat = 0 var popoverY: CGFloat = 0 var adjustedHeight = popoverHeight // 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 } // Ensure the popover is within the parent's bounds horizontally popoverX = max(0, min(popoverX, parentBounds.width - popoverWidth)) // Calculate vertical position and height let availableSpaceAbove = sourceFrameInParent.minY - safeAreaInsets.top - spacing let availableSpaceBelow = parentBounds.height - sourceFrameInParent.maxY - safeAreaInsets.bottom - spacing let totalAvailableHeight = parentBounds.height - safeAreaInsets.top - safeAreaInsets.bottom if availableSpaceAbove >= popoverHeight { // Show above without adjusting height popoverY = sourceFrameInParent.minY - popoverHeight - spacing } else if availableSpaceBelow >= popoverHeight { // Show below without adjusting height popoverY = sourceFrameInParent.maxY + spacing } else if totalAvailableHeight >= popoverHeight { // check if the total if availableSpaceAbove > availableSpaceBelow { popoverY = safeAreaInsets.top } else { popoverY = parentBounds.height - safeAreaInsets.bottom - popoverHeight } } else { popoverY = safeAreaInsets.top adjustedHeight = totalAvailableHeight } return (popoverX, popoverY, adjustedHeight) } }