diff --git a/VDS/Components/DatePicker/DatePicker.swift b/VDS/Components/DatePicker/DatePicker.swift index 991167e1..e8d13b27 100644 --- a/VDS/Components/DatePicker/DatePicker.swift +++ b/VDS/Components/DatePicker/DatePicker.swift @@ -26,22 +26,13 @@ open class DatePicker: EntryFieldBase { //-------------------------------------------------- /// 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 @@ -51,6 +42,24 @@ open class DatePicker: EntryFieldBase { $0.spacing = VDSLayout.space2X } }() + + //-------------------------------------------------- + // MARK: - Private Popover/Alert Properties + //-------------------------------------------------- + /// 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 @@ -97,12 +106,12 @@ open class DatePicker: EntryFieldBase { } open var dateFormat: DateFormat = .shortNumeric { didSet{ setNeedsUpdate() } } - + //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- internal override var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) } - + //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- @@ -110,10 +119,10 @@ open class DatePicker: EntryFieldBase { /// 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()) @@ -131,7 +140,7 @@ open class DatePicker: EntryFieldBase { hidePopoverView() } .store(in: &subscribers) - + } open override func getFieldContainer() -> UIView { @@ -146,7 +155,7 @@ open class DatePicker: EntryFieldBase { 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() @@ -159,7 +168,7 @@ open class DatePicker: EntryFieldBase { selectedDateLabel.isEnabled = isEnabled calendarIcon.color = iconColorConfiguration.getColor(self) } - + /// Resets to default settings. open override func reset() { super.reset() @@ -172,24 +181,45 @@ open class DatePicker: EntryFieldBase { selectedDateLabel.text = formatter.string(from: date) } } - + extension DatePicker { + private func showPopover() { - guard let viewController = UIApplication.topViewController(), let parentView = viewController.view else { return } - if popoverVisible { + guard let viewController = UIApplication.topViewController(), var parentView = viewController.view, !popoverVisible else { 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() + 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 = calendarModel.surface + calendar.setNeedsLayout() + calendar.layoutIfNeeded() + + //size the popover + popoverViewSize = .init(width: calendar.frame.width, height: calendar.frame.height) + + //find scrollView + if scrollView == nil { + scrollView = findScrollView(from: containerView) + scrollViewContentSize = scrollView?.contentSize + } + + if let scrollView { + parentView = scrollView + } + + // see if you should use the popover or show an alert + if let popoverOrigin = try? calculatePopoverPosition(relativeTo: containerView, + in: parentView, + size: popoverViewSize, + with: popoverSpacing) { calendar.onChange = { [weak self] control in guard let self else { return } selectedDate = control.selectedDate @@ -197,51 +227,28 @@ extension DatePicker { 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 container + popoverView = UIView() 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.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) - popoverView.isHidden = false popoverVisible = true + popoverView.addSubview(calendar) + + calendar.pinToSuperView() + + // add views + parentView.addSubview(popoverView) + parentView.layoutIfNeeded() + + // update containerview _ = responder?.becomeFirstResponder() updateContainerView() - UIView.animate(withDuration: 0.3, + + // animate the calendar to show + UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.2, @@ -250,48 +257,87 @@ extension DatePicker { 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) + + } 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() + } } } - - private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) -> (CGFloat, CGFloat, CGFloat) { + + private func hidePopoverView() { + if topViewController != nil { + topViewController?.dismiss(animated: true) + topViewController = nil + } else { + 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) + } + } + } + + private func findScrollView(from view: UIView) -> UIScrollView? { + var currentView = view + while let superview = currentView.superview { + if let scrollView = superview as? UIScrollView { + return scrollView + } + currentView = superview + } + return nil + } + + private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) throws -> 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 - var adjustedHeight = popoverHeight - + // Calculate horizontal position if sourceFrameInParent.width < popoverWidth { if sourceFrameInParent.midX - popoverWidth / 2 < 0 { @@ -307,35 +353,51 @@ extension DatePicker { } 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 + 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 var scrollView = parentView as? UIScrollView { + // Calculate vertical position and height + availableSpaceAbove = sourceFrameInParent.minY - scrollView.bounds.minY - spacing + availableSpaceBelow = scrollView.bounds.maxY - sourceFrameInParent.maxY - spacing - } else if totalAvailableHeight >= popoverHeight { - // check if the total if availableSpaceAbove > availableSpaceBelow { - popoverY = safeAreaInsets.top + // Show above + popoverY = sourceFrameInParent.minY - popoverHeight - spacing } else { - popoverY = parentBounds.height - safeAreaInsets.bottom - popoverHeight + // 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 { - popoverY = safeAreaInsets.top - adjustedHeight = totalAvailableHeight + // 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 + } } - - return (popoverX, popoverY, adjustedHeight) + + return .init(x: popoverX, y: popoverY) } } -