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. @objcMembers @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)? /// Override UIControl state to add the .error state if showError is true. open override var state: UIControl.State { get { var state = super.state if isEnabled { if isCalendarShowing { state.insert(.focused) } } return state } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- class Responder: UIView { open override var canBecomeFirstResponder: Bool { true } } internal override var responder: UIResponder? { hiddenView } internal var isCalendarShowing: Bool = false { didSet { setNeedsUpdate() } } internal var hiddenView = Responder().with { $0.width(0) } 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: - Private Popover/Alert Properties //-------------------------------------------------- /// View shown inline internal var popoverOverlayView = UIView().with { $0.backgroundColor = .clear $0.translatesAutoresizingMaskIntoConstraints = false } /// use this to track touch events outside of the popover in the overlay internal var popupOverlayTapGesture: AnyCancellable? /// View shown inline internal var popoverView: UIView! /// Size used for the popover internal var popoverViewSize: CGSize = .zero /// Spacing between the popover and the ContainerView when not a AlertViewController internal var popoverSpacing: CGFloat = VDSLayout.space1X /// Whether or not the popover is visible internal var popoverVisible = false /// If the ContainerView exists somewhere in the superview hierarch in a ScrollView. internal var scrollView: UIScrollView? /// Original Found ScrollView ContentSize, this will get reset back to this size when the Popover is removed. internal var scrollViewContentSize: CGSize? /// Presenting ViewController with showing the AlertViewController Version. internal var topViewController: UIViewController? //-------------------------------------------------- // 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) popoverOverlayView.isHidden = true } 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(), var parentView = viewController.view, !popoverVisible else { hidePopoverView() return } 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 = surface calendar.setNeedsLayout() calendar.layoutIfNeeded() //size the popover popoverViewSize = .init(width: calendar.frame.width, height: calendar.frame.height) //find scrollView if scrollView == nil { scrollView = containerView.findSuperview(ofType: UIScrollView.self) scrollViewContentSize = scrollView?.contentSize } if let scrollView { parentView = scrollView } // see if you should use the popover or show an alert if let popoverOrigin = calculatePopoverPosition(relativeTo: containerView, in: parentView, size: popoverViewSize, with: popoverSpacing) { calendar.onChange = { [weak self] control in guard let self else { return } selectedDate = control.selectedDate sendActions(for: .valueChanged) UIAccessibility.post(notification: .layoutChanged, argument: containerView) hidePopoverView() } // popoverView container popoverView = UIView() popoverView.backgroundColor = .clear popoverView.frame = CGRect(x: popoverOrigin.x, y: popoverOrigin.y, width: calendar.frame.width, height: calendar.frame.height) popoverView.alpha = 0 popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) popoverVisible = true popoverView.addSubview(calendar) calendar.pinToSuperView() // add views popoverOverlayView.isHidden = false popupOverlayTapGesture = popoverOverlayView .publisher(for: UITapGestureRecognizer()) .sink(receiveValue: { [weak self] gesture in guard let self else { return } gestureEventOccured(gesture, parentView: parentView) }) parentView.addSubview(popoverOverlayView) popoverOverlayView.pinToSuperView() parentView.addSubview(popoverView) parentView.layoutIfNeeded() // update containerview _ = responder?.becomeFirstResponder() updateContainerView() // animate the calendar to show UIView.animate(withDuration: 0.5, 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 UIAccessibility.post(notification: .layoutChanged, argument: calendar) parentView.layoutIfNeeded() }) } else { let dialog = UIScrollView() dialog.translatesAutoresizingMaskIntoConstraints = false dialog.addSubview(calendar) dialog.backgroundColor = .clear dialog.contentSize = .init(width: calendar.frame.width + 20, height: calendar.frame.width + 20) dialog.width(calendar.frame.width + 20) dialog.height(calendar.frame.height + 20) calendar.pinToSuperView(.uniform(10)) calendar.onChange = { [weak self] control in guard let self else { return } selectedDate = control.selectedDate sendActions(for: .valueChanged) UIAccessibility.post(notification: .layoutChanged, argument: containerView) viewController.dismiss(animated: true) } let alert = AlertViewController().with { $0.dialog = dialog $0.modalPresentationStyle = .overCurrentContext $0.modalTransitionStyle = .crossDissolve } topViewController = viewController viewController.present(alert, animated: true){ dialog.flashScrollIndicators() } } isCalendarShowing = true } private func hidePopoverView() { if topViewController != nil { topViewController?.dismiss(animated: true) topViewController = nil } else { popoverOverlayView.isHidden = true popoverOverlayView.removeFromSuperview() popupOverlayTapGesture?.cancel() popupOverlayTapGesture = nil UIView.animate(withDuration: 0.2, animations: {[weak self] in guard let self, let popoverView else { return } popoverView.alpha = 0 popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) if let scrollView, let scrollViewContentSize { scrollView.contentSize = scrollViewContentSize } }) { [weak self] _ in guard let self, let popoverView else { return } popoverView.isHidden = true popoverView.removeFromSuperview() popoverVisible = false responder?.resignFirstResponder() setNeedsUpdate() UIAccessibility.post(notification: .layoutChanged, argument: containerView) } } isCalendarShowing = false } private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) -> CGPoint? { 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 // 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.minX //sourceFrameInParent.midX - popoverWidth / 2 } // Ensure the popover is within the parent's bounds horizontally popoverX = max(0, min(popoverX, parentBounds.width - popoverWidth)) var availableSpaceAbove: CGFloat = 0.0 var availableSpaceBelow: CGFloat = 0.0 /// if the scrollView is set we want to change how we calculate the containerView's position if let scrollView = parentView as? UIScrollView { // Calculate vertical position and height availableSpaceAbove = sourceFrameInParent.minY - scrollView.bounds.minY - spacing availableSpaceBelow = scrollView.bounds.maxY - sourceFrameInParent.maxY - spacing if availableSpaceAbove > availableSpaceBelow { // Show above popoverY = sourceFrameInParent.minY - popoverHeight - spacing } else { // Show below popoverY = sourceFrameInParent.maxY + spacing // See if we need to expand the contentSize of the ScrollView let diff = scrollView.contentSize.height - sourceFrameInParent.maxY if diff < popoverHeight { scrollView.contentSize.height += popoverHeight - diff + VDSLayout.space4X } } } else { // Calculate vertical position and height availableSpaceAbove = sourceFrameInParent.minY - safeAreaInsets.top - spacing availableSpaceBelow = parentBounds.height - sourceFrameInParent.maxY - safeAreaInsets.bottom - spacing if availableSpaceAbove >= popoverHeight { // Show above popoverY = sourceFrameInParent.minY - popoverHeight - spacing } else if availableSpaceBelow >= popoverHeight { // Show below popoverY = sourceFrameInParent.maxY + spacing } else { //return nil since there is no way we can show the popover without a scrollview return nil } } return .init(x: popoverX, y: popoverY) } private func gestureEventOccured(_ gesture: UIGestureRecognizer, parentView: UIView) { guard let popoverView, popoverVisible else { return } let location = gesture.location(in: parentView) if !popoverView.frame.contains(location) { hidePopoverView() } } } extension UIView { public func findSuperview(ofType type: T.Type) -> T? { var currentView: UIView? = self while let view = currentView { if let superview = view.superview as? T { return superview } currentView = view.superview } return nil } }