Repository: joshbirnholz/JBCalendarDatePicker Branch: master Commit: acc2e63aebd4 Files: 22 Total size: 79.7 KB Directory structure: gitextract_jmv4cs2m/ ├── .gitignore ├── .swiftpm/ │ └── xcode/ │ ├── package.xcworkspace/ │ │ └── contents.xcworkspacedata │ └── xcshareddata/ │ └── xcschemes/ │ └── JBCalendarDatePicker.xcscheme ├── JBCalendarDatePicker.podspec ├── LICENSE ├── Package.swift ├── README.md ├── Sources/ │ ├── Info.plist │ └── JBCalendarDatePicker/ │ ├── CalendarDatePickerViewController.h │ ├── DateInputView.swift │ ├── Day.swift │ ├── JBCalendarDateCell.swift │ ├── JBCalendarDateCell.xib │ ├── JBCalendarDatePicker.h │ ├── JBCalendarViewController.swift │ ├── JBCalendarViewController.xib │ ├── JBDatePicker.swift │ ├── JBDatePickerViewController.swift │ ├── JBDatePickerViewController.xib │ └── UIColor+SystemAccent.swift └── Tests/ └── JBCalendarDatePickerTests/ ├── JBCalendarDatePickerTests.swift └── XCTestManifests.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store /.build /Packages /*.xcodeproj xcuserdata/ ================================================ FILE: .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: .swiftpm/xcode/xcshareddata/xcschemes/JBCalendarDatePicker.xcscheme ================================================ ================================================ FILE: JBCalendarDatePicker.podspec ================================================ Pod::Spec.new do |s| # 1 s.platform = :ios s.ios.deployment_target = '13.0' s.name = "JBCalendarDatePicker" s.summary = "A replacement for UIDatePicker made for Catalyst." s.requires_arc = true # 2 s.version = "0.2.3" # 3 s.license = { :type => "MIT", :file => "LICENSE" } # 4 - Replace with your name and e-mail address s.author = { "Josh Birnholz" => "josh@birnholz.com" } # 5 - Replace this URL with your own GitHub page's URL (from the address bar) s.homepage = "https://github.com/joshbirnholz/JBCalendarDatePicker" # 6 - Replace this URL with your own Git URL from "Quick Setup" s.source = { :git => "https://github.com/joshbirnholz/JBCalendarDatePicker.git", :tag => "#{s.version}" } # 7 s.framework = "UIKit" # 8 s.source_files = "JBCalendarDatePicker/**/*.{swift}" # 9 s.resources = "JBCalendarDatePicker/**/*.{xib}" # 10 s.swift_version = "5" end ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Joshua Birnholz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Package.swift ================================================ // swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "JBCalendarDatePicker", platforms: [ .iOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "JBCalendarDatePicker", targets: ["JBCalendarDatePicker"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "JBCalendarDatePicker", dependencies: [], path: "Sources", exclude: ["Info.plist"], resources: [ .process("JBCalendarDatePicker/JBDatePickerViewController.xib"), .process("JBCalendarDatePicker/JBCalendarViewController.xib"), .process("JBCalendarDatePicker/JBCalendarDateCell.xib"), ] ), .testTarget( name: "JBCalendarDatePickerTests", dependencies: ["JBCalendarDatePicker"], path: "Tests" ), ], swiftLanguageVersions: [.v5] ) ================================================ FILE: README.md ================================================ # JBCalendarDatePicker A replacement for UIDatePicker made for Catalyst. This is still a work in progress, there are bugs, and although it's written to work with different calendar systems and locales, it's not guaranteed to work correctly with everything! ![JBCalendarDatePicker](https://i.imgur.com/XusV7dx.gif) ## Installation To install as SPM, Go to: `Xcode -> File -> Swift Packages -> Add Package Dependency` Then enter this URL: `https://github.com/mohitnandwani/JBCalendarDatePicker.git` To install, add the source to the top of your podfile: `source 'https://github.com/joshbirnholz/JBPodSpecs.git'` Then add this pod to your targets: `pod 'JBCalendarDatePicker'` ## Use There are two classes you can use: `JBDatePickerViewController` and `JBCalendarViewController`. They are both similar to `UIDatePicker`, and their `date`, `minimumDate`, `maximumDate`, `calendar`, and `locale` properties can be configured in the same way. Configure them before presenting either of the view controllers. `JBDatePickerViewController` also has a `datePickerMode` property, although `UIDatePicker.Mode.countDownTimer` is not supported. ### JBDatePickerViewController ![JBDatePickerViewController](https://i.imgur.com/OtPr5V7.png) `JBDatePickerViewController` displays labels showing its represented date and allows the user to use the keyboard to enter a date. When the user clicks on the date portion, the view controller presents its own `JBCalendarViewController`. You can allow the user to select a date, time, or both, by setting the `datePickerMode` property. ```Swift import JBCalendarDatePicker class ViewController: UIViewController { var datePicker: JBDatePickerViewController! override func viewDidLoad() { super.viewDidLoad() let datePicker = JBDatePickerViewController() view.addSubview(datePicker.view) addChild(datePicker) datePicker.didMove(toParent: self) self.datePicker = datePicker // Configure the datePicker's properties } } ``` Or use it from a storyboard. Drag a Container View onto your storyboard. Change the view controller's class to `JBDatePickerViewController`. Give the embed segue an identifier, and then capture a reference to it: ```Swift override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "Embed Date Picker", let destination = segue.destination as? JBDatePickerViewController { self.datePicker = destination // Configure the datePicker's properties } } ``` ### JBCalendarViewController ![JBCalendarViewController](https://i.imgur.com/NV48jUk.png) `JBCalendarViewController` is just the calendar, without the labels. The view controller tries to present itself as a popover automatically, so be sure to set the `popoverPresentationController`'s `barButtonItem` property or the `sourceView` and `sourceRect` properties. ```Swift @IBOutlet func buttonPressed(_ sender: UIBarButtonItem) { let calendarPicker = JBCalendarViewController() calendarPicker.popoverPresentationController?.barButtonItem = sender // Configure the calendar's properties present(calendarPicker, animated: true, completion: nil) } ``` There is also a `JBCalendarViewControllerDelegate` protocol. ```Swift public protocol JBCalendarViewControllerDelegate: class { func calendarViewControllerDateChanged(_ calendarViewController: JBCalendarViewController) func calendarViewControllerWillDismiss(_ calendarViewController: JBCalendarViewController) func calendarViewControllerDidDismiss(_ calendarViewController: JBCalendarViewController) } ``` ================================================ FILE: Sources/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) ================================================ FILE: Sources/JBCalendarDatePicker/CalendarDatePickerViewController.h ================================================ // // CalendarDatePickerViewController.h // CalendarDatePickerViewController // // Created by Josh Birnholz on 28/10/2019. // Copyright © 2019 Josh Birnholz. All rights reserved. // #import //! Project version number for CalendarDatePickerViewController. FOUNDATION_EXPORT double CalendarDatePickerViewControllerVersionNumber; //! Project version string for CalendarDatePickerViewController. FOUNDATION_EXPORT const unsigned char CalendarDatePickerViewControllerVersionString[]; // In this header, you should import all the public headers of your framework using statements like #import ================================================ FILE: Sources/JBCalendarDatePicker/DateInputView.swift ================================================ // // DateInputView.swift // CalendarDatePickerViewController // // Created by Josh Birnholz on 10/29/19. // Copyright © 2019 Josh Birnholz. All rights reserved. // #if canImport(UIKit) import UIKit protocol DateInputViewDelegate: UIResponder, UIKeyInput { } class DateInputView: UIView, UIKeyInput { weak var delegate: DateInputViewDelegate? override func becomeFirstResponder() -> Bool { print(type(of: self), #function) let value = super.becomeFirstResponder() print(type(of: self), "is first responder: \(self.isFirstResponder)") return value } override func resignFirstResponder() -> Bool { print(type(of: self), #function) return delegate?.resignFirstResponder() ?? super.resignFirstResponder() } override var canBecomeFirstResponder: Bool { return true } // MARK: UIKeyInput var hasText: Bool { return delegate?.hasText ?? false } func insertText(_ text: String) { delegate?.insertText(text) } func deleteBackward() { delegate?.deleteBackward() } // MARK: UITextInputTraits // this doesn't seem to work for some reason. private var keyboardType: UIKeyboardType { return .numberPad } } #endif ================================================ FILE: Sources/JBCalendarDatePicker/Day.swift ================================================ // // Day.swift // CalendarDatePickerViewController // // Created by Josh Birnholz on 28/10/2019. // Copyright © 2019 Josh Birnholz. All rights reserved. // import Foundation struct Day: Equatable, Hashable { var calendar: Calendar var day: Int var month: Int var year: Int var date: Date { DateComponents(calendar: calendar, year: year, month: month, day: day).date! } var isToday: Bool { let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) var components = DateComponents(calendar: calendar, year: year, month: month, day: day) let date = calendar.date(from: components)! components = calendar.dateComponents([.year, .month, .day], from: date) return todayComponents.day == components.day && todayComponents.month == components.month && todayComponents.year == components.year } static func == (lhs: Day, rhs: Day) -> Bool { if lhs.day == rhs.day && lhs.month == rhs.month && lhs.year == rhs.year { return true } return lhs.date == rhs.date } } ================================================ FILE: Sources/JBCalendarDatePicker/JBCalendarDateCell.swift ================================================ // // CalendarDateCollectionViewCell.swift // CalendarDatePickerViewController // // Created by Josh Birnholz on 28/10/2019. // Copyright © 2019 Josh Birnholz. All rights reserved. // #if canImport(UIKit) import UIKit class JBCalendarDateCell: UICollectionViewCell { @IBOutlet weak var label: UILabel! } #endif ================================================ FILE: Sources/JBCalendarDatePicker/JBCalendarDateCell.xib ================================================ ================================================ FILE: Sources/JBCalendarDatePicker/JBCalendarDatePicker.h ================================================ // // JBDatePicker.h // JBDatePicker // // Created by Josh Birnholz on 10/30/19. // Copyright © 2019 Josh Birnholz. All rights reserved. // #import //! Project version number for JBDatePicker. FOUNDATION_EXPORT double JBDatePickerVersionNumber; //! Project version string for JBDatePicker. FOUNDATION_EXPORT const unsigned char JBDatePickerVersionString[]; // In this header, you should import all the public headers of your framework using statements like #import ================================================ FILE: Sources/JBCalendarDatePicker/JBCalendarViewController.swift ================================================ // // CalendarDatePickerViewController.swift // Calendar Picker // // Created by Josh Birnholz on 10/27/19. // Copyright © 2019 Josh Birnholz. All rights reserved. // #if canImport(UIKit) import UIKit @objc public protocol JBCalendarViewControllerDelegate: class { func calendarViewControllerDateChanged(_ calendarViewController: JBCalendarViewController) func calendarViewControllerWillDismiss(_ calendarViewController: JBCalendarViewController) func calendarViewControllerDidDismiss(_ calendarViewController: JBCalendarViewController) } public class JBCalendarViewController: UIViewController, JBDatePicker { @objc public weak var delegate: JBCalendarViewControllerDelegate? @IBOutlet private weak var monthLabel: UILabel! @IBOutlet private weak var collectionView: UICollectionView! @IBOutlet private var weekSymbolLabels: [UILabel]! /// This property always returns `UIDatePicker.Mode.date`. Setting this property to a new value does nothing. It is not possible to change the date picker mode of the calendar interface. @objc public var datePickerMode: UIDatePicker.Mode { get { return .date } set { } } @objc public var calendar: Calendar! = Calendar.current { didSet { if calendar == nil { calendar = .current } updateWeekLabels() } } @objc public var locale: Locale? = .current { didSet { calendar.locale = locale } } @objc public var date: Date = Date() { didSet { switch (minimumDate, maximumDate) { case(let minimumDate?, let maximumDate?) where minimumDate < maximumDate : date = min(max(date, minimumDate), maximumDate) case (let minimumDate?, nil): date = max(date, minimumDate) case (nil, let maximumDate?): date = min(date, maximumDate) default: break } if current != nil { let components = calendar.dateComponents([.month, .year], from: date) if components.month! != current.month || components.year! != current.year { (current.month, current.year) = (components.month!, components.year!) } else { collectionView?.reloadData() } } delegate?.calendarViewControllerDateChanged(self) } } @objc public var minimumDate: Date? { didSet { collectionView?.reloadData() } } @objc public var maximumDate: Date? { didSet { collectionView?.reloadData() } } private var usableMinimumDate: Date? { if let minimumDate = minimumDate { if let maximumDate = maximumDate { if minimumDate < maximumDate { return minimumDate } else { return nil } } return minimumDate } return nil } private var usableMaximumDate: Date? { if let maximumDate = maximumDate { if let minimumDate = minimumDate { if minimumDate < maximumDate { return maximumDate } else { return nil } } return maximumDate } return nil } private var selectedDay: Day { let components = calendar.dateComponents([.day, .month, .year], from: date) return Day(calendar: calendar, day: components.day!, month: components.month!, year: components.year!) } private struct Current { var month: Int { didSet { let firstOfMonth = DateComponents(calendar: calendar, year: year, month: month, day: 1).date! let range = calendar.range(of: .month, in: .year, for: firstOfMonth)! if month > range.last! { month = range.first! year += 1 } else if month < range.first! { month = range.last! year -= 1 } } } var year: Int private let calendar: Calendar init(calendar: Calendar, month: Int, year: Int) { self.calendar = calendar self.month = month self.year = year } } private var current: Current! { didSet { updateMonthLabel() updateDays() collectionView.reloadData() } } private var days: [Day] = [] public required init?(coder: NSCoder) { super.init(nibName: "JBCalendarViewController", bundle: Bundle(for: Self.self)) commonInit() } public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) commonInit() } public init() { super.init(nibName: "JBCalendarViewController", bundle: Bundle(for: Self.self)) commonInit() } private func commonInit() { modalPresentationStyle = .popover popoverPresentationController?.delegate = self preferredContentSize = CGSize(width: 200, height: 210) } override public func viewDidLoad() { super.viewDidLoad() calendar.locale = self.locale collectionView.delegate = self collectionView.dataSource = self collectionView.register(UINib(nibName: "JBCalendarDateCell", bundle: Bundle(for: Self.self)), forCellWithReuseIdentifier: "DateCell") #if targetEnvironment(macCatalyst) view.tintColor = .systemAccent #endif let selectedComponents = calendar.dateComponents([.year, .month], from: date) current = Current(calendar: calendar, month: selectedComponents.month!, year: selectedComponents.year!) let pan = UIPanGestureRecognizer(target: self, action: #selector(didPan(toSelectCells:))) collectionView.addGestureRecognizer(pan) let prevLong = UILongPressGestureRecognizer(target: self, action: #selector(previousMonthButtonTouchDown(_:))) prevLong.minimumPressDuration = 0 } private func updateWeekLabels() { var symbols = calendar.veryShortStandaloneWeekdaySymbols if (calendar.locale ?? .current).languageCode == "en" { symbols = calendar.shortStandaloneWeekdaySymbols.map { String($0.prefix(2)) } } guard isViewLoaded else { return } for (index, symbol) in symbols.enumerated() { weekSymbolLabels[index].text = symbol } } private func updateDays() { let components = DateComponents(calendar: calendar, year: current.year, month: current.month) let date = components.date! let range = calendar.range(of: .day, in: .month, for: date)! days = range.map { Day(calendar: calendar, day: $0, month: current.month, year: current.year) } let startDate = calendar.dateInterval(of: .month, for: date)!.start let weekday = calendar.component(.weekday, from: startDate) let firstDay = days.first! for i in 0 ..< weekday-1 { var day = firstDay day.day -= i+1 days.insert(day, at: 0) } let lastDay = days.last! let count = calendar.weekdaySymbols.count * 6 for i in 0 ..< (count-days.count) { var day = lastDay day.day += i+1 days.append(day) } } private func updateMonthLabel() { guard isViewLoaded else { return } let formatter = DateFormatter() formatter.locale = calendar.locale formatter.setLocalizedDateFormatFromTemplate("MMM yyyy") let components = DateComponents(calendar: calendar, year: current.year, month: current.month) monthLabel.text = formatter.string(from: components.date!) } @IBAction private func previousMonthButtonTouchUp(_ sender: Any) { timer?.invalidate() timer = nil } @IBAction private func selectedDayButtonPressed(_ sender: Any) { let components = calendar.dateComponents([.month, .year], from: date) let month = components.month! let year = components.year! current = Current(calendar: calendar, month: month, year: year) } @IBAction private func nextMonthButtonTouchUp(_ sender: Any) { timer?.invalidate() timer = nil } @IBAction private func previousMonthButtonTouchDown(_ sender: Any) { startRepeatingTimer { [weak self] in self?.current.month -= 1 } } private var timer: Timer? private func startRepeatingTimer(_ action: @escaping () -> Void) { action() timer = Timer(fire: Date().addingTimeInterval(0.5), interval: 0.25, repeats: true) { timer in action() } RunLoop.main.add(timer!, forMode: .common) DispatchQueue.main.asyncAfter(deadline: .now() + 4) { if let timer = self.timer { timer.invalidate() self.timer = Timer(timeInterval: 0.075, repeats: true, block: { timer in action() }) RunLoop.main.add(self.timer!, forMode: .common) } } } @IBAction private func nextMonthButtonTouchDown(_ sender: Any) { startRepeatingTimer { [weak self] in self?.current.month += 1 } } private var lastPanChangeDate: Date = Date() @objc private func didPan(toSelectCells panGesture: UIPanGestureRecognizer) { if panGesture.state == .began { collectionView.isUserInteractionEnabled = false } else if panGesture.state == .changed, let indexPath = collectionView.indexPathForItem(at: panGesture.location(in: collectionView)) { let day = days[indexPath.row] let date = DateComponents(calendar: calendar, year: current.year, month: current.month, day: day.day).date! let month = calendar.component(.month, from: date) if month == current.month || -lastPanChangeDate.timeIntervalSinceNow > 0.8 { self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) self.collectionView(collectionView, didSelectItemAt: indexPath) lastPanChangeDate = Date() } } else if panGesture.state == .ended { collectionView.isUserInteractionEnabled = true } } } extension JBCalendarViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return days.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "DateCell", for: indexPath) as! JBCalendarDateCell let day = days[indexPath.row] let date = DateComponents(calendar: calendar, year: current.year, month: current.month, day: day.day).date! let components = calendar.dateComponents([.day, .month], from: date) cell.label.text = String(components.day!) cell.layer.cornerRadius = 4 cell.layer.masksToBounds = true let isSelected = day == selectedDay let highlightedBackgroundColor: UIColor = day.isToday ? view.tintColor : .systemFill cell.backgroundColor = isSelected ? highlightedBackgroundColor : nil if day.isToday { if isSelected { cell.label.textColor = .lightLabel } else { cell.label.textColor = self.view.tintColor } } else if let minimumDate = usableMinimumDate, date < minimumDate { cell.label.textColor = .quaternaryLabel } else if let maximumDate = usableMaximumDate, date > maximumDate { cell.label.textColor = .quaternaryLabel } else if components.month == current.month { cell.label.textColor = .label } else { cell.label.textColor = .tertiaryLabel } return cell } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let numberOfItems = collectionView.numberOfItems(inSection: 0) let spacing = (collectionViewLayout as! UICollectionViewFlowLayout).minimumInteritemSpacing * CGFloat(numberOfItems-1) let width = (self.collectionView.frame.width - spacing) / CGFloat(calendar.weekdaySymbols.count) let height = (self.collectionView.frame.height - spacing) / CGFloat(6) return CGSize(width: width, height: height) } public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let day = days[indexPath.row] var components = calendar.dateComponents([.timeZone, .year, .month, .day, .hour, .minute, .second, .nanosecond], from: self.date) components.day = day.day components.month = day.month components.year = day.year let newDate = calendar.date(from: components)! if let minimumDate = usableMinimumDate, newDate < minimumDate { return } else if let maximumDate = usableMaximumDate, newDate > maximumDate { return } collectionView.reloadData() self.date = newDate } public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { collectionView.reloadData() } } extension JBCalendarViewController: UIPopoverPresentationControllerDelegate { public func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { return .none } public func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { delegate?.calendarViewControllerWillDismiss(self) print("final date:", date) } public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { delegate?.calendarViewControllerDidDismiss(self) } } #endif ================================================ FILE: Sources/JBCalendarDatePicker/JBCalendarViewController.xib ================================================ ================================================ FILE: Sources/JBCalendarDatePicker/JBDatePicker.swift ================================================ // // JBDatePicker.swift // CalendarDatePickerViewController // // Created by Josh Birnholz on 10/29/19. // Copyright © 2019 Josh Birnholz. All rights reserved. // #if canImport(UIKit) import UIKit public protocol JBDatePicker: UIResponder { var date: Date { get set } var calendar: Calendar! { get set } var locale: Locale? { get set } var minimumDate: Date? { get set } var maximumDate: Date? { get set } var datePickerMode: UIDatePicker.Mode { get set } } #endif ================================================ FILE: Sources/JBCalendarDatePicker/JBDatePickerViewController.swift ================================================ // // JBDatePickerViewController.swift // CalendarDatePickerViewController // // Created by Josh Birnholz on 28/10/2019. // Copyright © 2019 Josh Birnholz. All rights reserved. // #if canImport(UIKit) import UIKit public class JBDatePickerViewController: UIViewController, DateInputViewDelegate, JBDatePicker { // MARK: Public interface private var keyboardType: UIKeyboardType { return .numberPad } /// Use this property to change the type of information displayed by the date picker. It determines whether the date picker allows selection of a date, a time, or both date and time. The default mode is `UIDatePicker.Mode.dateAndTime`. See `UIDatePicker.Mode` for a list of mode constants. /// /// Setting this property to `UIDatePicker.Mode.countDownTimer` has no effect; this date picker does not support the countdown timer mode. @objc public var datePickerMode: UIDatePicker.Mode = .dateAndTime { didSet { if datePickerMode == .countDownTimer { datePickerMode = oldValue } } } private var dateInputView: DateInputView! { return (view as! DateInputView) } public var calendar: Calendar! = Calendar.current { didSet { if calendar == nil { calendar = .current } } } @objc public var locale: Locale? = .current { didSet { calendar.locale = locale } } @objc public var date: Date = Date() { didSet { switch (minimumDate, maximumDate) { case(let minimumDate?, let maximumDate?) where minimumDate < maximumDate : date = min(max(date, minimumDate), maximumDate) case (let minimumDate?, nil): date = max(date, minimumDate) case (nil, let maximumDate?): date = min(date, maximumDate) default: break } updateLabelText() setTextInputString("", updatingLabel: false) print("date set to \(date)") isPM = (12...23).contains(calendar.component(.hour, from: date)) presentedCalendar?.delegate = nil presentedCalendar?.date = date presentedCalendar?.delegate = self } } @objc public var minimumDate: Date? { didSet { updateLabelText() } } @objc public var maximumDate: Date? { didSet { updateLabelText() } } private var usableMinimumDate: Date? { if let minimumDate = minimumDate { if let maximumDate = maximumDate { if minimumDate < maximumDate { return minimumDate } else { return nil } } return minimumDate } return nil } private var usableMaximumDate: Date? { if let maximumDate = maximumDate { if let minimumDate = minimumDate { if minimumDate < maximumDate { return maximumDate } else { return nil } } return maximumDate } return nil } fileprivate var _textInputString: String = "" fileprivate var textInputString: String { return _textInputString } fileprivate func setTextInputString(_ newValue: String, updatingLabel: Bool) { _textInputString = newValue if updatingLabel, let selectedDatePart = selectedDatePart { label(for: selectedDatePart).text = _textInputString } } // MARK: Init public required init?(coder: NSCoder) { super.init(nibName: "JBDatePickerViewController", bundle: Bundle(for: Self.self)) commonInit() } public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) commonInit() } public init() { super.init(nibName: "JBDatePickerViewController", bundle: Bundle(for: Self.self)) commonInit() } private func commonInit() { } @IBOutlet private var labels: [UILabel]! @IBOutlet private var slashLabels: [UILabel]! @IBOutlet private weak var fullStackView: UIStackView! @IBOutlet private weak var datePartsStackView: UIStackView! @IBOutlet private weak var timePartsStackView: UIStackView! override public func viewDidLoad() { super.viewDidLoad() calendar.locale = locale ?? .current view.backgroundColor = .clear dateInputView.delegate = self #if targetEnvironment(macCatalyst) view.tintColor = .systemAccent #endif isPM = (12...23).contains(calendar.component(.hour, from: date)) datePartsStackView.isHidden = datePickerMode == .time timePartsStackView.isHidden = datePickerMode == .date setupTextFields() updateLabelText() } override public var canBecomeFirstResponder: Bool { return dateInputView.canBecomeFirstResponder } override public func becomeFirstResponder() -> Bool { print(type(of: self), #function) if selectedDatePart == nil { selectedDatePart = dateParts.first } return dateInputView.becomeFirstResponder() } override public func resignFirstResponder() -> Bool { print(type(of: self), #function) selectedDatePart = nil // if let presented = presentedCalendar { // dismiss(animated: true, completion: nil) // } return super.resignFirstResponder() } @objc private func tapGestureRecognized(_ sender: UITapGestureRecognizer) { guard let label = sender.view as? UILabel, let datePart = self.datePart(for: label) else { return } selectedDatePart = datePart _ = dateInputView.becomeFirstResponder() } private var isPM = false private enum DatePart: String, CaseIterable { case day = "dd" case month = "MM" case year = "yyyy" case hour12 = "h" case hour24 = "HH" case minute = "mm" case amPM = "a" func set(value: Int, of components: inout DateComponents, using calendar: Calendar, isPM: Bool) { switch self { case .day: components.setValue(value, for: .day) case .month: // TODO: Set day to last day of month when the date range for the new month doesn't include the old day. components.setValue(value, for: .month) case .year: components.setValue(value, for: .year) case .hour12: var value = value if value == 12 && !isPM { value = 0 } else if (1...11).contains(value) && isPM { value += 12 } components.setValue(value, for: .hour) case .hour24: components.setValue(value, for: .hour) case .minute: components.setValue(value, for: .minute) case .amPM: break } } func maxComponentLength(using calendar: Calendar) -> Int { if self == .amPM { return max(calendar.amSymbol.count, calendar.pmSymbol.count) } else if self == .hour12 { return 2 } return rawValue.count } } private var presentedCalendar: JBCalendarViewController? { return presentedViewController as? JBCalendarViewController } private var selectedDatePart: DatePart? { didSet { for datePart in visibleDateParts { let label = self.label(for: datePart) let isSelected = selectedDatePart == datePart label.backgroundColor = isSelected ? view.tintColor : nil label.textColor = isSelected ? .lightLabel : .label } self.setTextInputString("", updatingLabel: false) guard let selectedDatePart = selectedDatePart else { // presentedCalendar?.dismiss(animated: true, completion: nil) return } if selectedDatePart == .day || selectedDatePart == .month || selectedDatePart == .year && presentedCalendar == nil { let calendarVC = JBCalendarViewController() calendarVC.date = date calendarVC.calendar = calendar calendarVC.locale = locale calendarVC.minimumDate = minimumDate calendarVC.maximumDate = maximumDate calendarVC.popoverPresentationController?.sourceView = datePartsStackView calendarVC.popoverPresentationController?.sourceRect = datePartsStackView.frame // calendarVC.popoverPresentationController?.sourceRect = dayLabel.frame calendarVC.popoverPresentationController?.permittedArrowDirections = [.up] calendarVC.popoverPresentationController?.passthroughViews = [fullStackView] calendarVC.delegate = self self.present(calendarVC, animated: true, completion: nil) } else { // presentedCalendar?.dismiss(animated: true, completion: nil) } } } private var dateParts: [DatePart]! { didSet { amPMLabel.isHidden = !dateParts.contains(.hour12) } } private var yearLabel: UILabel { let index = dateParts.firstIndex(of: .year)! return labels[index] } private var monthLabel: UILabel { let index = dateParts.firstIndex(of: .month)! return labels[index] } private var dayLabel: UILabel { let index = dateParts.firstIndex(of: .day)! return labels[index] } @IBOutlet private weak var hourLabel: UILabel! @IBOutlet private weak var minuteLabel: UILabel! @IBOutlet private weak var amPMLabel: UILabel! private func setupTextFields() { var allLabels = labels ?? [] allLabels.append(hourLabel) allLabels.append(minuteLabel) allLabels.append(amPMLabel) for label in allLabels { label.font = UIFont.monospacedDigitSystemFont(ofSize: label.font!.pointSize, weight: .regular) label.sizeToFit() NSLayoutConstraint(item: label, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .width, multiplier: 1, constant: label.frame.size.width).isActive = true label.layer.masksToBounds = true label.layer.cornerRadius = 4 label.backgroundColor = .clear label.textColor = .label let gesture = UITapGestureRecognizer(target: self, action: #selector(tapGestureRecognized(_:))) label.addGestureRecognizer(gesture) label.isUserInteractionEnabled = true } let template = dateTemplate let dateFormat = DateFormatter.dateFormat(fromTemplate: template, options: 0, locale: locale ?? .current)! let components = dateFormat.split(maxSplits: .max, omittingEmptySubsequences: true, whereSeparator: { character -> Bool in !template.contains(character.lowercased()) }) dateParts = components.compactMap { DatePart(rawValue: String($0)) } } private let dateTemplate: String = "MMddyyyyhmma" private let formatter = DateFormatter() private var visibleDateParts: [DatePart] { let allowedDateParts: Set = { switch datePickerMode { case .time: if dateParts.contains(.hour12) { return [.hour12, .minute, .amPM] } else { return [.hour24, .minute] } case .date: return [.year, .month, .day] default: var returnValue: Set = [.year, .month, .day] if dateParts.contains(.hour12) { returnValue.insert(.hour12) returnValue.insert(.minute) returnValue.insert(.amPM) } else { returnValue.insert(.hour24) returnValue.insert(.minute) } return returnValue } }() return dateParts.filter { allowedDateParts.contains($0) } } // private var labelsAndDateParts: [(UILabel, DatePart)] { // return visibleDateParts.map { (self.label(for: $0), $0) } // } private func label(for datePart: DatePart) -> UILabel { switch datePart { case .day: return dayLabel case .month: return monthLabel case .year: return yearLabel case .hour12: return hourLabel case .hour24: return hourLabel case .minute: return minuteLabel case .amPM: return amPMLabel } } private func datePart(for label: UILabel) -> DatePart? { switch label { case yearLabel: return .year case monthLabel: return .month case dayLabel: return .day case hourLabel: if dateParts.contains(.hour12) { return .hour12 } return .hour24 case minuteLabel: return .minute case amPMLabel: return .amPM default: return nil } } private func updateLabelText() { for datePart in visibleDateParts { formatter.dateFormat = datePart.rawValue label(for: datePart).text = String(formatter.string(from: date).prefix(datePart.maxComponentLength(using: calendar))) } formatter.dateFormat = "a" amPMLabel.text = formatter.string(from: date) } private var finalizeEditTimer: Timer? { didSet { oldValue?.invalidate() } } } extension JBDatePickerViewController: UIKeyInput { public var hasText: Bool { return !textInputString.isEmpty } fileprivate func selectNextDatePart() { guard let selectedDatePart = selectedDatePart else { return } if let index = visibleDateParts.lastIndex(of: selectedDatePart), visibleDateParts.indices.contains(index+1) { self.selectedDatePart = visibleDateParts[index+1] } else { self.selectedDatePart = visibleDateParts.first } } public func insertText(_ text: String) { guard let selectedDatePart = selectedDatePart else { return } if text == "\t" { finalize(datePart: selectedDatePart) selectNextDatePart() return } if selectedDatePart == .amPM { setTextInputString(text, updatingLabel: true) finalize(datePart: selectedDatePart) return } guard let proposedValue = Int(textInputString + text) else { return } let validValues: [Int] = { switch selectedDatePart { case .day: return calendar.range(of: .day, in: .month, for: date).map(Array.init) ?? [] case .month: return calendar.range(of: .month, in: .year, for: date).map(Array.init) ?? [] case .year: return Array(1...9999) case .hour12: return Array(1...12) case .hour24: return Array(0...23) case .minute: return Array(0...59) case .amPM: return [] } }() let valueIsValid: Bool = { return validValues.contains(proposedValue) }() guard valueIsValid else { return } setTextInputString(String(proposedValue), updatingLabel: true) if textInputString.count >= selectedDatePart.maxComponentLength(using: calendar) { finalize(datePart: selectedDatePart) } else { startFinalizeTimer(datePart: selectedDatePart) } } public func deleteBackward() { guard !textInputString.isEmpty else { return } var input = textInputString input.removeLast() setTextInputString(input, updatingLabel: true) if let selectedDatePart = selectedDatePart, !textInputString.isEmpty { startFinalizeTimer(datePart: selectedDatePart) } } private func startFinalizeTimer(datePart: DatePart) { finalizeEditTimer = Timer(timeInterval: 1, repeats: false) { timer in self.finalize(datePart: datePart) } RunLoop.main.add(finalizeEditTimer!, forMode: .common) } private func finalize(datePart: DatePart) { var components = calendar.dateComponents([.timeZone, .year, .month, .day, .hour, .minute, .second, .nanosecond], from: date) if let value = Int(textInputString) { datePart.set(value: value, of: &components, using: calendar, isPM: isPM) if let date = calendar.date(from: components) { self.date = date } else { updateLabelText() } } else if datePart == .amPM && !textInputString.isEmpty { if calendar.amSymbol.lowercased().hasPrefix(textInputString.lowercased()) && isPM { isPM = false print("setting to am") components.hour! -= 12 } else if calendar.pmSymbol.lowercased().hasPrefix(textInputString.lowercased()) && !isPM { isPM = true print("setting to pm") components.hour! += 12 } if let date = calendar.date(from: components) { self.date = date } else { updateLabelText() } } setTextInputString("", updatingLabel: false) finalizeEditTimer?.invalidate() } } extension JBDatePickerViewController: JBCalendarViewControllerDelegate { public func calendarViewControllerDateChanged(_ calendarViewController: JBCalendarViewController) { self.date = calendarViewController.date } public func calendarViewControllerWillDismiss(_ calendarViewController: JBCalendarViewController) { _ = dateInputView.resignFirstResponder() } public func calendarViewControllerDidDismiss(_ calendarViewController: JBCalendarViewController) { } } #endif ================================================ FILE: Sources/JBCalendarDatePicker/JBDatePickerViewController.xib ================================================ ================================================ FILE: Sources/JBCalendarDatePicker/UIColor+SystemAccent.swift ================================================ // // UIColor+SystemAccent.swift // CalendarDatePickerViewController // // Created by Josh Birnholz on 28/10/2019. // Copyright © 2019 Josh Birnholz. All rights reserved. // #if canImport(UIKit) import UIKit extension UIColor { #if targetEnvironment(macCatalyst) static var systemAccent: UIColor { let hasAccentSet = UserDefaults.standard.object(forKey: "AppleAccentColor") != nil let systemAccentColor = UserDefaults.standard.integer(forKey: "AppleAccentColor") var returnColor: UIColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.008315349929, green: 0.3450804651, blue: 0.817365706, alpha: 1) : #colorLiteral(red: 0.01329958253, green: 0.3846624196, blue: 0.8779004216, alpha: 1) } if hasAccentSet { switch systemAccentColor { case -1: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.4039281607, green: 0.403850317, blue: 0.4124818146, alpha: 1) : #colorLiteral(red: 0.5019147992, green: 0.5019902587, blue: 0.5018982291, alpha: 1) } case 0: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.82002002, green: 0.2045214176, blue: 0.2204136252, alpha: 1) : #colorLiteral(red: 0.7370213866, green: 0.1443678439, blue: 0.1633504629, alpha: 1) } case 1: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.7512640357, green: 0.3605512679, blue: 0.01273573376, alpha: 1) : #colorLiteral(red: 0.8462041616, green: 0.4178547263, blue: 0.05405366421, alpha: 1) } case 2: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.8009095192, green: 0.5611655712, blue: 0.05494389683, alpha: 1) : #colorLiteral(red: 0.8690621257, green: 0.6199508309, blue: 0.07889743894, alpha: 1) } case 3: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.2549478412, green: 0.5663680434, blue: 0.1645001471, alpha: 1) : #colorLiteral(red: 0.3048421741, green: 0.6298194528, blue: 0.1963118315, alpha: 1) } case 5: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.500952661, green: 0.1951716244, blue: 0.5008149147, alpha: 1) : #colorLiteral(red: 0.4900261164, green: 0.1631549001, blue: 0.4976372719, alpha: 1) } case 6: returnColor = UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? #colorLiteral(red: 0.7823504806, green: 0.1956582665, blue: 0.4722630978, alpha: 1) : #colorLiteral(red: 0.8491325974, green: 0.2301979959, blue: 0.5240355134, alpha: 1) } default: break } } return returnColor } #endif static let lightLabel = UIColor { traitCollection in if traitCollection.userInterfaceStyle == .dark { return .label } else { return .systemBackground } } } #endif ================================================ FILE: Tests/JBCalendarDatePickerTests/JBCalendarDatePickerTests.swift ================================================ import XCTest @testable import JBCalendarDatePicker final class JBCalendarDatePickerTests: XCTestCase { func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. } static var allTests = [ ("testExample", testExample), ] } ================================================ FILE: Tests/JBCalendarDatePickerTests/XCTestManifests.swift ================================================ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { return [ testCase(JBCalendarDatePickerTests.allTests), ] } #endif