From 5e45d470341a5f453076502a55501fbcb3e4543f Mon Sep 17 00:00:00 2001 From: UriyDevyataev <91690559+UriyDevyataev@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:55:59 +0300 Subject: [PATCH] SwiftUI Support (#54) * fix #48 FastisView for presentation in SwiftUI --------- Co-authored-by: Ilya Kharlamov --- .../FastisExample.xcodeproj/project.pbxproj | 10 +- Example/Source/HostingController.swift | 26 ++ Example/Source/MainView.swift | 91 +++++++ Example/Source/ViewController.swift | 26 +- README.md | 51 +++- ...ontroller.swift => FastisController.swift} | 53 ++-- Sources/Views/FastisView.swift | 228 ++++++++++++++++++ 7 files changed, 463 insertions(+), 22 deletions(-) create mode 100644 Example/Source/HostingController.swift create mode 100644 Example/Source/MainView.swift rename Sources/Views/{Controller.swift => FastisController.swift} (97%) create mode 100644 Sources/Views/FastisView.swift diff --git a/Example/FastisExample.xcodeproj/project.pbxproj b/Example/FastisExample.xcodeproj/project.pbxproj index 92eff59..d88b34a 100644 --- a/Example/FastisExample.xcodeproj/project.pbxproj +++ b/Example/FastisExample.xcodeproj/project.pbxproj @@ -3,11 +3,13 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1A3E7A5E2A40901600434229 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1A3E7A602A40901600434229 /* Localizable.strings */; }; + 1AB955832BA31F4200235243 /* HostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB955822BA31F4200235243 /* HostingController.swift */; }; + 1AB955852BA31F5500235243 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB955842BA31F5500235243 /* MainView.swift */; }; F381AF9628B8C7190046383A /* Fastis in Frameworks */ = {isa = PBXBuildFile; productRef = F381AF9528B8C7190046383A /* Fastis */; }; F3FCEE47244780FE000F966E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FCEE46244780FE000F966E /* AppDelegate.swift */; }; F3FCEE4B244780FE000F966E /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FCEE4A244780FE000F966E /* ViewController.swift */; }; @@ -19,6 +21,8 @@ 1A3E7A5F2A40901600434229 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 1A3E7A612A40902000434229 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 1A3E7A622A409B3A00434229 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 1AB955822BA31F4200235243 /* HostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingController.swift; sourceTree = ""; }; + 1AB955842BA31F5500235243 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; CB137E4877EBDC6E87A77A4B /* Pods_FastisExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FastisExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F31CFB6B28B8C32D00364F6A /* Fastis */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Fastis; path = ..; sourceTree = ""; }; F3FCEE43244780FE000F966E /* FastisExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FastisExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -80,6 +84,8 @@ children = ( F3FCEE46244780FE000F966E /* AppDelegate.swift */, F3FCEE4A244780FE000F966E /* ViewController.swift */, + 1AB955822BA31F4200235243 /* HostingController.swift */, + 1AB955842BA31F5500235243 /* MainView.swift */, F3FCEE4F24478100000F966E /* Assets.xcassets */, F3FCEE5124478100000F966E /* LaunchScreen.storyboard */, F3FCEE5424478100000F966E /* Info.plist */, @@ -164,6 +170,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1AB955832BA31F4200235243 /* HostingController.swift in Sources */, + 1AB955852BA31F5500235243 /* MainView.swift in Sources */, F3FCEE4B244780FE000F966E /* ViewController.swift in Sources */, F3FCEE47244780FE000F966E /* AppDelegate.swift in Sources */, ); diff --git a/Example/Source/HostingController.swift b/Example/Source/HostingController.swift new file mode 100644 index 0000000..c0fb25a --- /dev/null +++ b/Example/Source/HostingController.swift @@ -0,0 +1,26 @@ +// +// HostingController.swift +// FastisExample +// +// Created by Yriy Devyataev on 13.03.2024. +// Copyright © 2024 RetailDriver LLC. All rights reserved. +// + +import SwiftUI + +/** + The view is used to display SwiftUI view in the UIKit project + */ + +class HostingController: UIHostingController { + + init() { + super.init(rootView: MainView()) + } + + @available(*, unavailable) + dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Example/Source/MainView.swift b/Example/Source/MainView.swift new file mode 100644 index 0000000..2e322aa --- /dev/null +++ b/Example/Source/MainView.swift @@ -0,0 +1,91 @@ +// +// CalendarView.swift +// FastisExample +// +// Created by Yriy Devyataev on 13.03.2024. +// Copyright © 2024 RetailDriver LLC. All rights reserved. +// + +import Fastis +import Foundation +import SwiftUI +import UIKit + +/** + View is used as a possible example of a SwiftUI project. + */ +struct MainView: View { + + private let calendar: Calendar = .current + + @State private var showSingleCalendar = false + @State private var showRangeCalendar = false + @State private var currentValueText = "Choose a date" + + @State var currentValue: FastisValue? { + didSet { + if let rangeValue = self.currentValue as? FastisRange { + self.currentValueText = self.dateFormatter.string(from: rangeValue.fromDate) + " - " + self.dateFormatter + .string(from: rangeValue.toDate) + } else if let date = self.currentValue as? Date { + self.currentValueText = self.dateFormatter.string(from: date) + } else { + self.currentValueText = "Choose a date" + } + } + } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "dd/MM/yyyy" + formatter.calendar = self.calendar + return formatter + } + + var body: some View { + VStack(spacing: 32, content: { + Text(self.currentValueText) + VStack(alignment: .center, spacing: 16, content: { + Button("Choose range of dates") { + self.showRangeCalendar.toggle() + } + Button("Choose single date") { + self.showSingleCalendar.toggle() + } + }) + }) + .navigationTitle("SwiftUI presentation") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheet(isPresented: self.$showRangeCalendar) { + FastisView(mode: .range) { action in + if case .done(let newValue) = action { + self.currentValue = newValue + } + } + .title("Choose range") + .initialValue(self.currentValue as? FastisRange) + .minimumDate(self.calendar.date(byAdding: .month, value: -2, to: Date())) + .maximumDate(self.calendar.date(byAdding: .month, value: 3, to: Date())) + .allowToChooseNilDate(true) + .allowDateRangeChanges(false) + .shortcuts([.lastWeek, .lastMonth]) + .selectMonthOnHeaderTap(true) + .ignoresSafeArea() + } + .sheet(isPresented: self.$showSingleCalendar) { + FastisView(mode: .single) { action in + if case .done(let newValue) = action { + self.currentValue = newValue + } + } + .title("Choose date") + .initialValue(self.currentValue as? Date) + .minimumDate(self.calendar.date(byAdding: .month, value: -2, to: Date())) + .maximumDate(Date()) + .allowToChooseNilDate(true) + .shortcuts([.yesterday, .today, .tomorrow]) + .closeOnSelectionImmediately(true) + .ignoresSafeArea() + } + } +} diff --git a/Example/Source/ViewController.swift b/Example/Source/ViewController.swift index 5049ad5..df52074 100644 --- a/Example/Source/ViewController.swift +++ b/Example/Source/ViewController.swift @@ -40,10 +40,17 @@ class ViewController: UIViewController { return button }() - private lazy var chooseSingleButtonWithCustomCalendar: UIButton = { + private lazy var chooseRangeButtonWithCustomCalendar: UIButton = { let button = UIButton(type: .system) - button.setTitle("Choose single date with custom calendar", for: .normal) - button.addTarget(self, action: #selector(self.chooseSingleDateWithCustomCalendar), for: .touchUpInside) + button.setTitle("Choose range of dates with custom calendar", for: .normal) + button.addTarget(self, action: #selector(self.chooseRangeWithCustomCalendar), for: .touchUpInside) + return button + }() + + private lazy var chooseWithSwiftUI: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Choose with SwiftUI", for: .normal) + button.addTarget(self, action: #selector(self.swiftUIPresentation), for: .touchUpInside) return button }() @@ -91,7 +98,8 @@ class ViewController: UIViewController { self.containerView.setCustomSpacing(32, after: self.currentDateLabel) self.containerView.addArrangedSubview(self.chooseRangeButton) self.containerView.addArrangedSubview(self.chooseSingleButton) - self.containerView.addArrangedSubview(self.chooseSingleButtonWithCustomCalendar) + self.containerView.addArrangedSubview(self.chooseRangeButtonWithCustomCalendar) + self.containerView.addArrangedSubview(self.chooseWithSwiftUI) self.view.addSubview(self.containerView) } @@ -149,7 +157,7 @@ class ViewController: UIViewController { } @objc - private func chooseSingleDateWithCustomCalendar() { + private func chooseRangeWithCustomCalendar() { var customConfig: FastisConfig = .default var calendar: Calendar = .init(identifier: .islamicUmmAlQura) calendar.locale = .autoupdatingCurrent @@ -175,4 +183,12 @@ class ViewController: UIViewController { fastisController.present(above: self) } + @objc + private func swiftUIPresentation() { + let hostingController = HostingController() + hostingController.modalPresentationStyle = .custom + let navVC = self.parent as? UINavigationController + navVC?.pushViewController(hostingController, animated: true) + } + } diff --git a/README.md b/README.md index 4ac156c..804187e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Fastis is a fully customisable UI component for picking dates and ranges created - [Configuration](#configuration) - [Shortcuts](#shortcuts) - [Customization](#customization) + - [SwiftUI](#swiftui) - [Credits](#credits) - [License](#license) @@ -161,7 +162,6 @@ var maximumDate: Date? = nil var selectMonthOnHeaderTap: Bool = true var allowDateRangeChanges: Bool = true var closeOnSelectionImmediately: Bool = false - ``` - `shortcuts`- Shortcuts array. The default value is `[]`. See [Shortcuts](#shortcuts) section @@ -226,7 +226,7 @@ To customise a special FastisController instance with custom calendar: ```swift var customConfig: FastisConfig = .default -var calendar: Calendar = .init(identifier: .islamicUmmAlQura) +var calendar = Calendar(identifier: .islamicUmmAlQura) calendar.locale = .autoupdatingCurrent customConfig.calendar = calendar let fastisController = FastisController(mode: .range, config: customConfig) @@ -246,9 +246,56 @@ config.todayCell.circleViewColor = .red If you don't want to customzie today date cell, just set `config.todayCell = nil` and today cell will use `dayCell` config. +### SwiftUI + +The library also contains a SwiftUI wrapper + +If you want to get a date range: + +```swift +FastisView(mode: .single, dismissHandler: { action in + switch action { + case .done(let resultDate): + print(resultDate) // resultDate is Date + case .cancel: + ... + } +}) +.title("Choose range") +.initialValue(self.currentValue as? FastisRange) +.minimumDate(Calendar.current.date(byAdding: .month, value: -2, to: Date())) +.maximumDate(Calendar.current.date(byAdding: .month, value: 3, to: Date())) +.allowToChooseNilDate(true) +.allowDateRangeChanges(false) +.shortcuts([.lastWeek, .lastMonth]) +.selectMonthOnHeaderTap(true) +``` + +If you want to get a single date: + +```swift +FastisView(mode: .range, dismissHandler: { action in + switch action { + case .done(let resultRange): + print(resultRange) // resultRange is FastisRange + case .cancel: + ... + } +}) +.title("Choose date") +.initialValue(self.currentValue as? Date) +.minimumDate(Calendar.current.date(byAdding: .month, value: -2, to: Date())) +.maximumDate(Date()) +.allowToChooseNilDate(true) +.allowDateRangeChanges(false) +.shortcuts([.yesterday, .today, .tomorrow]) +.closeOnSelectionImmediately(true) +``` + ## Credits - Ilya Kharlamov ([@ilia3546](https://github.com/ilia3546)) +- Uriy Devyataev ([@UriyDevyataev](https://github.com/UriyDevyataev)) ## License diff --git a/Sources/Views/Controller.swift b/Sources/Views/FastisController.swift similarity index 97% rename from Sources/Views/Controller.swift rename to Sources/Views/FastisController.swift index b2047cb..6777063 100644 --- a/Sources/Views/Controller.swift +++ b/Sources/Views/FastisController.swift @@ -170,8 +170,12 @@ open class FastisController: UIViewController, JTACMonthView private var viewConfigs: [IndexPath: DayCell.ViewConfig] = [:] private var privateMinimumDate: Date? private var privateMaximumDate: Date? + private var privateAllowDateRangeChanges = true private var privateSelectMonthOnHeaderTap = false private var dayFormatter = DateFormatter() + private var isDone = false + private var privateCloseOnSelectionImmediately = false + private var value: Value? { didSet { self.updateSelectedShortcut() @@ -180,12 +184,11 @@ open class FastisController: UIViewController, JTACMonthView } } - private var isDone = false - private var privateCloseOnSelectionImmediately = false - /** Shortcuts array + Default value — `"[]"` + You can use prepared shortcuts depending on the current mode. - For `.single` mode: `.today`, `.tomorrow`, `.yesterday` @@ -208,11 +211,15 @@ open class FastisController: UIViewController, JTACMonthView When `allowToChooseNilDate` is `true`: * "Done" button will be always enabled * You will be able to reset selection by you tapping on selected date again + + Default value — `"false"` */ public var allowToChooseNilDate = false /** - The block to execute after the dismissal finishes, return two variable .done(FastisValue?) and .cancel + The block to execute after the dismissal finishes, return two variable `.done(FastisValue?)` and `.cancel` + + Default value — `"nil"` */ public var dismissHandler: ((DismissAction) -> Void)? @@ -228,11 +235,15 @@ open class FastisController: UIViewController, JTACMonthView /** And initial value which will be selected by default + + Default value — `"nil"` */ public var initialValue: Value? /** Minimal selection date. Dates less then current will be marked as unavailable + + Default value — `"nil"` */ public var minimumDate: Date? { get { @@ -243,16 +254,10 @@ open class FastisController: UIViewController, JTACMonthView } } - /** - Allow date range changes - - Set this variable to `false` if you want to disable date range changes. - Next tap after selecting range will start new range selection. - */ - public var allowDateRangeChanges = true - /** Maximum selection date. Dates greater then current will be marked as unavailable + + Default value — `"nil"` */ public var maximumDate: Date? { get { @@ -499,7 +504,7 @@ open class FastisController: UIViewController, JTACMonthView return .from(date.startOfDay(in: self.config.calendar), to: date.endOfDay(in: self.config.calendar)) } - let dateRangeChangesDisabled = !self.allowDateRangeChanges + let dateRangeChangesDisabled = !self.privateAllowDateRangeChanges let rangeSelected = !oldValue.fromDate.isInSameDay(in: self.config.calendar, date: oldValue.toDate) if dateRangeChangesDisabled, rangeSelected { return .from(date.startOfDay(in: self.config.calendar), to: date.endOfDay(in: self.config.calendar)) @@ -702,6 +707,8 @@ public extension FastisController where Value == FastisRange { /** Set this variable to `true` if you want to allow select date ranges by tapping on months + + Default value — `"false"` */ var selectMonthOnHeaderTap: Bool { get { @@ -711,6 +718,24 @@ public extension FastisController where Value == FastisRange { self.privateSelectMonthOnHeaderTap = newValue } } + + /** + Allow date range changes + + Set this variable to `false` if you want to disable date range changes. + Next tap after selecting range will start new range selection. + + Default value — `"true"` + */ + var allowDateRangeChanges: Bool { + get { + self.privateAllowDateRangeChanges + } + set { + self.privateAllowDateRangeChanges = newValue + } + } + } public extension FastisController where Value == Date { @@ -726,7 +751,7 @@ public extension FastisController where Value == Date { /** Set this variable to `true` if you want to hide view of the selected date and close the controller right after the date is selected. - Default value — `"False"` + Default value — `"false"` */ var closeOnSelectionImmediately: Bool { get { diff --git a/Sources/Views/FastisView.swift b/Sources/Views/FastisView.swift new file mode 100644 index 0000000..b83e372 --- /dev/null +++ b/Sources/Views/FastisView.swift @@ -0,0 +1,228 @@ +// +// FastisView.swift +// FastisExample +// +// Created by Yriy Devyataev on 13.03.2024. +// Copyright © 2024 RetailDriver LLC. All rights reserved. +// + +import SwiftUI + +/** + View of Fastis framework. Use it to create and present dade picker + + Usage example: + ```swift + FastisView(mode: .range, dismissHandler: { action in + switch action { + case .done(let newValue): + ... + case .cancel: + ... + } + }) + .title("Choose range") + .allowToChooseNilDate(true) + .shortcuts([.lastWeek, .lastMonth]) + ``` + + **Single and range modes** + + If you want to get a single date you have to use `Date` type: + + ```swift + FastisView(mode: .single, dismissHandler: { action in + switch action { + case .done(let resultDate): + print(resultDate) // resultDate is Date + case .cancel: + ... + } + }) + .initialValue(Date()) + .closeOnSelectionImmediately(true) + ``` + + If you want to get a date range you have to use `FastisRange` type: + + ```swift + FastisView(mode: .range, dismissHandler: { action in + switch action { + case .done(let resultRange): + print(resultRange) // resultRange is FastisRange + case .cancel: + ... + } + }) + .initialValue(FastisRange(from: Date(), to: Date())) // or .from(Date(), to: Date()) + ``` + */ +public struct FastisView: UIViewControllerRepresentable { + + private let controller: FastisController + + /// Initiate FastisView + /// - Parameter config: Configuration parameters + /// - Parameter dismissHandler: The block to execute after the dismissal finishes, return two variable .done(FastisValue?) and .cancel + public init( + config: FastisConfig = .default, + dismissHandler: ((FastisController.DismissAction) -> Void)? = nil + ) { + self.controller = FastisController(config: config) + self.controller.dismissHandler = dismissHandler + } + + public func makeUIViewController(context: Context) -> UINavigationController { + UINavigationController(rootViewController: self.controller) + } + + public func updateUIViewController( + _ uiViewController: UINavigationController, + context: UIViewControllerRepresentableContext + ) { } + + /** + Title of view + + Default value — `"nil"` + */ + public func title(_ value: String?) -> Self { + self.controller.title = value + return self + } + + /** + And initial value which will be selected by default + + Default value — `"nil"` + */ + public func initialValue(_ value: Value?) -> Self { + self.controller.initialValue = value + return self + } + + /** + Minimal selection date. Dates less then current will be marked as unavailable + + Default value — `"nil"` + */ + public func minimumDate(_ value: Date?) -> Self { + self.controller.minimumDate = value + return self + } + + /** + Maximum selection date. Dates greater then current will be marked as unavailable + + Default value — `"nil"` + */ + public func maximumDate(_ value: Date?) -> Self { + self.controller.maximumDate = value + return self + } + + /** + Allow to choose `nil` date + + When `allowToChooseNilDate` is `true`: + * "Done" button will be always enabled + * You will be able to reset selection by you tapping on selected date again + + Default value — `"false"` + */ + public func allowToChooseNilDate(_ value: Bool) -> Self { + self.controller.allowToChooseNilDate = value + return self + } + + /** + Shortcuts array + + Default value — `"[]"` + + You can use prepared shortcuts depending on the current mode. + + - For `.single` mode: `.today`, `.tomorrow`, `.yesterday` + - For `.range` mode: `.today`, `.lastWeek`, `.lastMonth` + + Or you can create your own shortcuts: + + ``` + var customShortcut = FastisShortcut(name: "Today") { + let now = Date() + return FastisRange(from: now.startOfDay(), to: now.endOfDay()) + } + ``` + */ + public func shortcuts(_ value: [FastisShortcut]) -> Self { + self.controller.shortcuts = value + return self + } + +} + +public extension FastisView where Value == FastisRange { + + /// Initiate FastisView + /// - Parameters: + /// - mode: Choose `.range` or `.single` mode + /// - config: Custom configuration parameters. Default value is equal to `FastisConfig.default` + init( + mode: FastisModeRange, + config: FastisConfig = .default, + dismissHandler: ((FastisController.DismissAction) -> Void)? = nil + ) { + self.init(config: config, dismissHandler: dismissHandler) + } + + /** + Allow date range changes + + Set this variable to `false` if you want to disable date range changes. + Next tap after selecting range will start new range selection. + + Default value — `"true"` + */ + func allowDateRangeChanges(_ value: Bool) -> Self { + self.controller.allowDateRangeChanges = value + return self + } + + /** + Set this variable to `true` if you want to allow select date ranges by tapping on months + + Default value — `"false"` + */ + func selectMonthOnHeaderTap(_ value: Bool) -> Self { + self.controller.selectMonthOnHeaderTap = value + return self + } + +} + +public extension FastisView where Value == Date { + + /// Initiate FastisView + /// - Parameters: + /// - mode: Choose `.range` or `.single` mode + /// - config: Custom configuration parameters. Default value is equal to `FastisConfig.default` + /// - dismissHandler: The block to execute after the dismissal finishes, return two variable .done(FastisValue?) and .cancel + init( + mode: FastisModeSingle, + config: FastisConfig = .default, + dismissHandler: ((FastisController.DismissAction) -> Void)? = nil + ) { + self.init(config: config, dismissHandler: dismissHandler) + } + + /** + Set this variable to `true` if you want to hide view of the selected date and close the controller right after the date is selected. + + Default value — `"false"` + */ + func closeOnSelectionImmediately(_ value: Bool) -> Self { + self.controller.closeOnSelectionImmediately = value + return self + } + +}