refactored popover controlling algo

Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
This commit is contained in:
Matt Bruce 2024-06-25 11:28:40 -05:00
parent 7cd9a7d1a9
commit 8757fe6147

View File

@ -26,22 +26,13 @@ open class DatePicker: EntryFieldBase {
//-------------------------------------------------- //--------------------------------------------------
/// A callback when the selected option changes. Passes parameters (option). /// A callback when the selected option changes. Passes parameters (option).
open var onDateSelected: ((Date, DatePicker) -> Void)? open var onDateSelected: ((Date, DatePicker) -> Void)?
//-------------------------------------------------- //--------------------------------------------------
// MARK: - Private Properties // MARK: - Private Properties
//-------------------------------------------------- //--------------------------------------------------
internal override var responder: UIResponder? { hiddenView } internal override var responder: UIResponder? { hiddenView }
internal var hiddenView = UITextView().with { $0.width(0) } internal var hiddenView = UITextView().with { $0.width(0) }
internal var minWidthDefault = 186.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 = { internal var bottomStackView: UIStackView = {
return UIStackView().with { return UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false $0.translatesAutoresizingMaskIntoConstraints = false
@ -51,6 +42,24 @@ open class DatePicker: EntryFieldBase {
$0.spacing = VDSLayout.space2X $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 // MARK: - Public Properties
@ -97,12 +106,12 @@ open class DatePicker: EntryFieldBase {
} }
open var dateFormat: DateFormat = .shortNumeric { didSet{ setNeedsUpdate() } } open var dateFormat: DateFormat = .shortNumeric { didSet{ setNeedsUpdate() } }
//-------------------------------------------------- //--------------------------------------------------
// MARK: - Configuration Properties // MARK: - Configuration Properties
//-------------------------------------------------- //--------------------------------------------------
internal override var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) } internal override var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) }
//-------------------------------------------------- //--------------------------------------------------
// MARK: - Overrides // 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. /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations.
open override func setup() { open override func setup() {
super.setup() super.setup()
// setting color config // setting color config
selectedDateLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable() selectedDateLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable()
// tap gesture // tap gesture
containerView containerView
.publisher(for: UITapGestureRecognizer()) .publisher(for: UITapGestureRecognizer())
@ -131,7 +140,7 @@ open class DatePicker: EntryFieldBase {
hidePopoverView() hidePopoverView()
} }
.store(in: &subscribers) .store(in: &subscribers)
} }
open override func getFieldContainer() -> UIView { open override func getFieldContainer() -> UIView {
@ -146,7 +155,7 @@ open class DatePicker: EntryFieldBase {
controlStackView.addArrangedSubview(hiddenView) controlStackView.addArrangedSubview(hiddenView)
return controlStackView return controlStackView
} }
/// Used to make changes to the View based off a change events or from local properties. /// Used to make changes to the View based off a change events or from local properties.
open override func updateView() { open override func updateView() {
super.updateView() super.updateView()
@ -159,7 +168,7 @@ open class DatePicker: EntryFieldBase {
selectedDateLabel.isEnabled = isEnabled selectedDateLabel.isEnabled = isEnabled
calendarIcon.color = iconColorConfiguration.getColor(self) calendarIcon.color = iconColorConfiguration.getColor(self)
} }
/// Resets to default settings. /// Resets to default settings.
open override func reset() { open override func reset() {
super.reset() super.reset()
@ -172,24 +181,45 @@ open class DatePicker: EntryFieldBase {
selectedDateLabel.text = formatter.string(from: date) selectedDateLabel.text = formatter.string(from: date)
} }
} }
extension DatePicker { extension DatePicker {
private func showPopover() { private func showPopover() {
guard let viewController = UIApplication.topViewController(), let parentView = viewController.view else { return } guard let viewController = UIApplication.topViewController(), var parentView = viewController.view, !popoverVisible else {
if popoverVisible {
hidePopoverView() hidePopoverView()
} else { return
let calendar = CalendarBase() }
calendar.activeDates = calendarModel.activeDates
calendar.hideContainerBorder = calendarModel.hideContainerBorder let calendar = CalendarBase()
calendar.hideCurrentDateIndicator = calendarModel.hideCurrentDateIndicator calendar.activeDates = calendarModel.activeDates
calendar.inactiveDates = calendarModel.inactiveDates calendar.hideContainerBorder = calendarModel.hideContainerBorder
calendar.indicators = calendarModel.indicators calendar.hideCurrentDateIndicator = calendarModel.hideCurrentDateIndicator
calendar.maxDate = calendarModel.maxDate calendar.inactiveDates = calendarModel.inactiveDates
calendar.minDate = calendarModel.minDate calendar.indicators = calendarModel.indicators
calendar.surface = calendarModel.surface calendar.maxDate = calendarModel.maxDate
calendar.setNeedsLayout() calendar.minDate = calendarModel.minDate
calendar.layoutIfNeeded() 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 calendar.onChange = { [weak self] control in
guard let self else { return } guard let self else { return }
selectedDate = control.selectedDate selectedDate = control.selectedDate
@ -197,51 +227,28 @@ extension DatePicker {
UIAccessibility.post(notification: .layoutChanged, argument: containerView) UIAccessibility.post(notification: .layoutChanged, argument: containerView)
hidePopoverView() hidePopoverView()
} }
outsideTapGesture = UITapGestureRecognizer() // popoverView container
outsidePanGesture = UIPanGestureRecognizer() popoverView = UIView()
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.backgroundColor = .clear
popoverView.isHidden = true popoverView.frame = CGRect(x: popoverOrigin.x, y: popoverOrigin.y, width: calendar.frame.width, height: calendar.frame.height)
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.alpha = 0
popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
popoverView.isHidden = false
popoverVisible = true popoverVisible = true
popoverView.addSubview(calendar)
calendar.pinToSuperView()
// add views
parentView.addSubview(popoverView)
parentView.layoutIfNeeded()
// update containerview
_ = responder?.becomeFirstResponder() _ = responder?.becomeFirstResponder()
updateContainerView() updateContainerView()
UIView.animate(withDuration: 0.3,
// animate the calendar to show
UIView.animate(withDuration: 0.5,
delay: 0, delay: 0,
usingSpringWithDamping: 0.8, usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2, initialSpringVelocity: 0.2,
@ -250,48 +257,87 @@ extension DatePicker {
guard let self else { return } guard let self else { return }
popoverView.alpha = 1 popoverView.alpha = 1
popoverView.transform = CGAffineTransform.identity popoverView.transform = CGAffineTransform.identity
if popoverSize.height > adjustedHeight {
popoverView.flashScrollIndicators()
}
UIAccessibility.post(notification: .layoutChanged, argument: calendar) UIAccessibility.post(notification: .layoutChanged, argument: calendar)
parentView.layoutIfNeeded() parentView.layoutIfNeeded()
}) })
}
} } else {
let dialog = UIScrollView()
private func hidePopoverView() { dialog.translatesAutoresizingMaskIntoConstraints = false
overlayView.isHidden = true dialog.addSubview(calendar)
overlayView.removeFromSuperview() dialog.backgroundColor = .clear
outsideTapGesture = nil dialog.contentSize = .init(width: calendar.frame.width + 20, height: calendar.frame.width + 20)
outsidePanGesture = nil dialog.width(calendar.frame.width + 20)
UIView.animate(withDuration: 0.2, dialog.height(calendar.frame.height + 20)
animations: {[weak self] in calendar.pinToSuperView(.uniform(10))
guard let self else { return } calendar.onChange = { [weak self] control in
popoverView.alpha = 0 guard let self else { return }
popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) selectedDate = control.selectedDate
}) { [weak self] _ in sendActions(for: .valueChanged)
guard let self else { return } UIAccessibility.post(notification: .layoutChanged, argument: containerView)
popoverView.isHidden = true viewController.dismiss(animated: true)
popoverView.removeFromSuperview() }
popoverVisible = false
responder?.resignFirstResponder() let alert = AlertViewController().with {
setNeedsUpdate() $0.dialog = dialog
UIAccessibility.post(notification: .layoutChanged, argument: containerView) $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 sourceFrameInParent = sourceView.convert(sourceView.bounds, to: parentView)
let parentBounds = parentView.bounds let parentBounds = parentView.bounds
let safeAreaInsets = parentView.safeAreaInsets let safeAreaInsets = parentView.safeAreaInsets
let popoverWidth = size.width let popoverWidth = size.width
let popoverHeight = size.height let popoverHeight = size.height
var popoverX: CGFloat = 0 var popoverX: CGFloat = 0
var popoverY: CGFloat = 0 var popoverY: CGFloat = 0
var adjustedHeight = popoverHeight
// Calculate horizontal position // Calculate horizontal position
if sourceFrameInParent.width < popoverWidth { if sourceFrameInParent.width < popoverWidth {
if sourceFrameInParent.midX - popoverWidth / 2 < 0 { if sourceFrameInParent.midX - popoverWidth / 2 < 0 {
@ -307,35 +353,51 @@ extension DatePicker {
} else { } else {
popoverX = sourceFrameInParent.midX - popoverWidth / 2 popoverX = sourceFrameInParent.midX - popoverWidth / 2
} }
// Ensure the popover is within the parent's bounds horizontally // Ensure the popover is within the parent's bounds horizontally
popoverX = max(0, min(popoverX, parentBounds.width - popoverWidth)) 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 { var availableSpaceAbove: CGFloat = 0.0
// Show above without adjusting height var availableSpaceBelow: CGFloat = 0.0
popoverY = sourceFrameInParent.minY - popoverHeight - spacing
} else if availableSpaceBelow >= popoverHeight { /// if the scrollView is set we want to change how we calculate the containerView's position
// Show below without adjusting height if var scrollView = parentView as? UIScrollView {
popoverY = sourceFrameInParent.maxY + spacing // 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 { if availableSpaceAbove > availableSpaceBelow {
popoverY = safeAreaInsets.top // Show above
popoverY = sourceFrameInParent.minY - popoverHeight - spacing
} else { } 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 { } else {
popoverY = safeAreaInsets.top // Calculate vertical position and height
adjustedHeight = totalAvailableHeight 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)
} }
} }