// // Calendar.swift // VDS // // Created by Kanamarlapudi, Vasavi on 19/04/24. // import Foundation import UIKit import VDSCoreTokens import Combine /// A calendar is a monthly view that lets customers select a single date. @objcMembers @objc(VDSCalendar) open class CalendarBase: Control, Changeable { //-------------------------------------------------- // 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 //-------------------------------------------------- open var onChangeSubscriber: AnyCancellable? /// If set to true, the calendar will not have a border. open var hideContainerBorder: Bool = false { didSet { setNeedsUpdate() } } /// If set to true, the calendar will not have current date indication. open var hideCurrentDateIndicator: Bool = false { didSet { setNeedsUpdate() } } /// Enable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. /// All other dates will be inactive. open var activeDates: [Date] = [] { didSet { setNeedsUpdate() } } /// Disable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. /// All other dates will be active. open var inactiveDates: [Date] = [] { didSet { setNeedsUpdate() } } /// If provided, the calendar will allow a selection to be made from this date forward. Defaults to today. open var minDate: Date = Date() { didSet { setNeedsUpdate() } } /// If provided, the calendar will allow a selection to be made up to this date. open var maxDate: Date = Date() { didSet { setNeedsUpdate() } } /// If provided, this is the date that will show as selected by the Calendar. /// If no value is provided, the current date will be used. If null is provided, no date will be selected. open var selectedDate: Date = Date() { didSet { setNeedsUpdate() } } /// If provided, the calendar will be rendered with transparent background. open var transparentBackground: Bool = false { didSet { setNeedsUpdate() } } /// Array of ``CalendarIndicatorModel`` you are wanting to show on legend. open var indicators: [CalendarIndicatorModel] = [] { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var containerSize: CGSize { CGSize(width: widthDefault, height: 336) } internal var calendar = Calendar.current private let cellItemSize = CGSize(width: 40, height: 40) private let headerHeight = 88.0 private let footerHeight = 40.0 private let calendarWidth = 304.0 private let screenThreeSixty = 360.0 private let widthDefault = 328.0 private let widthTight = 320.0 private var collectionViewLeadingConstraint: NSLayoutConstraint? private var collectionViewHeightConstraint: NSLayoutConstraint? private var containerWidthConstraint: NSLayoutConstraint? private var containerHeightConstraint: NSLayoutConstraint? private var selectedIndexPath : IndexPath? private var dates: [Date] = [] private var days: [String] = [] private var displayDate: Date = Date() internal var containerView = View().with { $0.clipsToBounds = true } /// Collectionview to load calendar month view private lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) collectionView.isScrollEnabled = false collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self collectionView.dataSource = self collectionView.showsHorizontalScrollIndicator = false collectionView.showsVerticalScrollIndicator = false collectionView.backgroundColor = .clear collectionView.register(CalendarDateViewCell.self, forCellWithReuseIdentifier: CalendarDateViewCell.identifier) collectionView.register(CalendarHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CalendarHeaderReusableView.identifier) collectionView.register(CalendarFooterReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: CalendarFooterReusableView.identifier) return collectionView }() //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- internal var containerBorderColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight , VDSColor.elementsPrimaryOndark) internal var backgroundColorConfiguration = SurfaceColorConfiguration(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark) //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open override func initialSetup() { super.initialSetup() } /// 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() isAccessibilityElement = false accessibilityLabel = "Calendar" addSubview(containerView) containerView .pinTop() .pinBottom() .pinLeadingGreaterThanOrEqualTo() .heightGreaterThanEqualTo(containerSize.height) containerView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() // Calendar View containerView.addSubview(collectionView) let calendarHeight = containerSize.height - (2 * VDSLayout.space4X) collectionView .pinTop(VDSLayout.space4X) .pinBottom(VDSLayout.space4X) .width(calendarWidth) .heightGreaterThanEqualTo(calendarHeight) collectionView.pinCenterX(anchor: containerView.centerXAnchor) } open override func updateView() { super.updateView() // range check between min & max dates if (minDate <= maxDate) { // Check if current date falls between min & max dates. let fallsBetween = displayDate.isBetweeen(date: minDate, andDate: maxDate) displayDate = fallsBetween ? displayDate : (displayDate.monthInt == minDate.monthInt) ? minDate : maxDate fetchDates(with: displayDate) } containerView.backgroundColor = transparentBackground ? .clear : backgroundColorConfiguration.getColor(self) containerView.layer.cornerRadius = VDSFormControls.borderRadius if hideContainerBorder { containerView.layer.borderColor = nil containerView.layer.borderWidth = 0 } else { containerView.layer.borderColor = containerBorderColorConfiguration.getColor(self).cgColor containerView.layer.borderWidth = VDSFormControls.borderWidth } } /// Resets to default settings. open override func reset() { super.reset() hideContainerBorder = false hideCurrentDateIndicator = false transparentBackground = false activeDates = [] inactiveDates = [] indicators = [] } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- func fetchDates(with aDate: Date) { days.removeAll() dates = aDate.calendarDisplayDays for date in dates { // code to be executed if date.monthInt != aDate.monthInt { days.append("") } else { days.append(date.getDay()) } } updateViewConstraints() } func updateViewConstraints() { collectionView.reloadData() // container width && collection view leading collectionViewLeadingConstraint?.isActive = false containerWidthConstraint?.isActive = false var width = containerView.frame.size.width width = ((width > 0) && (width < screenThreeSixty)) ? ((width > widthTight) && (width < screenThreeSixty)) ? widthTight : containerView.frame.size.width : widthDefault let spacing = (width - calendarWidth) / 2 collectionViewLeadingConstraint = collectionView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: spacing) containerWidthConstraint = containerView.widthAnchor.constraint(equalToConstant: calendarWidth + ( 2 * spacing)) collectionViewLeadingConstraint?.isActive = true containerWidthConstraint?.isActive = true // container height && collection view height collectionViewHeightConstraint?.isActive = false containerHeightConstraint?.isActive = false var height = collectionView.collectionViewLayout.collectionViewContentSize.height height = height > 0 ? height : containerSize.height containerHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: height + (2 * VDSLayout.space4X)) collectionViewHeightConstraint = collectionView.heightAnchor.constraint(equalToConstant: height) containerHeightConstraint?.isActive = true collectionViewHeightConstraint?.isActive = true layoutIfNeeded() } } extension CalendarBase: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { //-------------------------------------------------- // MARK: - UICollectionView Delegate & Datasource //-------------------------------------------------- public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { days.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CalendarDateViewCell.identifier, for: indexPath) as? CalendarDateViewCell else { return UICollectionViewCell() } var indicatorCount = 0 if indicators.count > 0 { for x in 0...indicators.count - 1 { if days[indexPath.row] == indicators[x].date.getDay() { indicatorCount += 1 } } } cell.update(with: surface, indicators: indicators, text: days[indexPath.row], indicatorCount: indicatorCount, selectedDate: selectedDate, displayDate: displayDate, hideDate: hideCurrentDateIndicator, minDate: minDate, maxDate: maxDate, activeDates: activeDates, inactiveDates: inactiveDates) if days[indexPath.row] == selectedDate.getDay() { selectedIndexPath = indexPath } return cell } public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { switch kind { case UICollectionView.elementKindSectionHeader: // Header guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarHeaderReusableView.identifier, for: indexPath) as? CalendarHeaderReusableView else { return UICollectionReusableView() } var nextEnabled = false var prevEnabled = false // check the interval between min date, max date.. set enable/disable flag for next / previous buttons. if displayDate.monthInt < maxDate.monthInt && displayDate.yearInt == maxDate.yearInt || displayDate.yearInt < maxDate.yearInt { nextEnabled = true } if minDate.monthInt < displayDate.monthInt && minDate.yearInt == displayDate.yearInt || minDate.yearInt < displayDate.yearInt { prevEnabled = true } header.nextClicked = { [weak self] in guard let self = self else { return } let date = calendar.date(byAdding: .month, value: 1, to:displayDate)! if date.monthInt <= maxDate.monthInt && date.yearInt == maxDate.yearInt || date.yearInt < maxDate.yearInt { displayDate = date fetchDates(with: displayDate) } } header.previousClicked = { [weak self] in guard let self = self else { return } let date = calendar.date(byAdding: .month, value: -1, to:displayDate)! if minDate.monthInt <= date.monthInt && minDate.yearInt == date.yearInt || (minDate.yearInt < date.yearInt) { displayDate = date fetchDates(with: displayDate) } } header.update(with: surface, date: displayDate, nextEnabled: nextEnabled, previousEnabled: prevEnabled) return header case UICollectionView.elementKindSectionFooter: guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarFooterReusableView.identifier, for: indexPath) as? CalendarFooterReusableView else { return UICollectionReusableView() } footer.update(with: surface, indicators: indicators) return footer default: return UICollectionReusableView() } } public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // reload selected index, if it is in enabled state. if let cell = collectionView.cellForItem(at: indexPath) as? CalendarDateViewCell { let hasDate: Bool = cell.hasText() if hasDate { let isEnabled: Bool = cell.isDateEnabled() if isEnabled { // Callback to pass selected date if it is enabled only. selectedDate = dates[indexPath.row] sendActions(for: .valueChanged) displayDate = selectedDate var reloadIndexPaths = [indexPath] // If an cell is already selected, then it needs to be deselected. // Add its index path to the array of index paths to be reloaded. if let deselectIndexPath = selectedIndexPath { reloadIndexPaths.append(deselectIndexPath) } collectionView.reloadItems(at: reloadIndexPaths) } } } } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { return CGSize(width: collectionView.frame.size.width, height: headerHeight) } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { return CGSize(width: collectionView.frame.size.width, height: indicators.count > 0 ? footerHeight : 0) } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return VDSLayout.space1X } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { return VDSLayout.space1X } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return cellItemSize } }