vds_ios/VDS/Components/DatePicker/DatePicker.swift
Matt Bruce 32a44a51b5 updated date picker
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-06-24 15:34:02 -05:00

342 lines
13 KiB
Swift

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)
}
}