From ea35a601a5c2b539ccae20ffbdecf4f9b90c5162 Mon Sep 17 00:00:00 2001 From: "Tomasz K." Date: Thu, 17 Oct 2024 01:51:14 +0200 Subject: [PATCH] Patch 3.0.0 feat: - Added possibility to declare several PopupManager instances (#124) - Added ability to customise PopupID (#123) - Enhanced customisation options for TopPopup chore: - Added Swift 6 support (#111, #144) perf: - Reduced CPU usage by 50% fix: - Improved CentrePopupStack animations - Fixed the method of calculating the distance between the keyboard and the CentrePopup - Fixed a bug that caused the position of the popup to shift (#141, #137) - Fixed initialisation of the framework with SceneDelegate (#143) refactor: - Renamed and removed some public API methods - Introduced a modified MVVM architecture - Optimised code and file structure docs: - Created proper framework documentation tests: - Added tests for View Models - Added tests for PopupManager - Added tests for PopupID --- .gitignore | 1 + LICENSE | 222 ++- MijickPopupView.podspec | 20 - MijickPopups.podspec | 20 + Package.swift | 14 +- README.md | 146 +- .../Global/GlobalConfig+Centre.swift | 19 + .../Global/GlobalConfig+Vertical.swift | 24 + .../Configurables/Global/GlobalConfig.swift | 20 + .../Local/LocalConfig+Centre.swift | 42 + .../Local/LocalConfig+Vertical.swift | 57 + .../Configurables/Local/LocalConfig.swift | 20 + .../Containers/GlobalConfigContainer.swift | 15 + .../Containers/PopupManagerContainer.swift | 31 + Sources/Internal/Extensions/Animation++.swift | 13 +- Sources/Internal/Extensions/Array++.swift | 30 +- .../Extensions/DispatchSource++.swift | 22 - .../Internal/Extensions/EdgeInsets++.swift | 19 + Sources/Internal/Extensions/Int++.swift | 16 - Sources/Internal/Extensions/View++.swift | 62 - .../Internal/Extensions/View+Background.swift | 63 + .../Internal/Extensions/View+Gestures.swift | 37 + .../Internal/Extensions/View+Keyboard.swift | 51 + .../Internal/Extensions/View+ReadHeight.swift | 21 + Sources/Internal/Extensions/View+tvOS.swift | 22 + .../Internal/Extensions/View.Gestures++.swift | 42 - .../Extensions/View.ScreenManager++.swift | 31 - .../Internal/Managers/KeyboardManager.swift | 76 - Sources/Internal/Managers/PopupManager.swift | 159 +- Sources/Internal/Managers/ScreenManager.swift | 94 -- Sources/Internal/Models/AnyPopup.swift | 103 ++ Sources/Internal/Models/ID+Popup.swift | 53 + Sources/Internal/Models/Screen.swift | 27 + Sources/Internal/Models/StackPriority.swift | 42 + Sources/Internal/Other/AnimationType.swift | 26 - Sources/Internal/Other/AnyPopup.swift | 39 - Sources/Internal/Other/GlobalConfig.swift | 25 - Sources/Internal/Other/ID.swift | 43 - Sources/Internal/Other/Shadow.swift | 22 - Sources/Internal/Protocols/Configurable.swift | 18 - Sources/Internal/Protocols/Popup.swift | 32 - Sources/Internal/Protocols/PopupStack.swift | 174 -- .../Internal/UI/PopupCentreStackView.swift | 50 + .../Internal/UI/PopupVerticalStackView.swift | 55 + Sources/Internal/UI/PopupView.swift | 123 ++ Sources/Internal/Utilities/Logger.swift | 21 + .../Utilities/PopupActionScheduler.swift | 36 + Sources/Internal/Utilities/VerticalEdge.swift | 43 + .../View Models/ViewModel+CentreStack.swift | 126 ++ .../View Models/ViewModel+VerticalStack.swift | 484 ++++++ Sources/Internal/View Models/ViewModel.swift | 100 ++ .../View Models/ViewModelObject.swift | 23 + .../View Modifiers/HeightReader.swift | 29 - .../View Modifiers/RoundedCorner.swift | 76 - .../Internal/Views/PopupBottomStackView.swift | 241 --- .../Internal/Views/PopupCentreStackView.swift | 110 -- .../Internal/Views/PopupTopStackView.swift | 206 --- Sources/Internal/Views/PopupView.swift | 137 -- .../Configurables/GlobalConfig.Bottom.swift | 77 - .../Configurables/GlobalConfig.Centre.swift | 55 - .../Configurables/GlobalConfig.Common.swift | 30 - .../Configurables/GlobalConfig.Top.swift | 69 - .../LocalConfig.BottomPopup.swift | 72 - .../LocalConfig.CentrePopup.swift | 39 - .../Configurables/LocalConfig.TopPopup.swift | 58 - .../Delegates/Public+PopupSceneDelegate.swift | 44 - .../Dismiss/Public+Dismiss+PopupManager.swift | 58 + .../Public/Dismiss/Public+Dismiss+View.swift | 58 + Sources/Public/Extensions/Public+Popup.swift | 40 - .../Extensions/Public+PopupManager.swift | 33 - Sources/Public/Extensions/Public+View.swift | 50 - .../Public/Popup/Public+Popup+Config.swift | 111 ++ Sources/Public/Popup/Public+Popup+Main.swift | 105 ++ .../Public/Popup/Public+Popup+Utilities.swift | 78 + .../Public/Present/Public+Present+Popup.swift | 58 + .../Setup/Popup+Setup+PopupManagerID.swift | 46 + .../Public/Setup/Public+Setup+Config.swift | 102 ++ .../Setup/Public+Setup+ConfigContainer.swift | 26 + .../Setup/Public+Setup+SceneDelegate.swift | 158 ++ Sources/Public/Setup/Public+Setup+View.swift | 55 + .../Public/Utilities/Public+DragDetent.swift | 18 - Tests/Extensions/Task++.swift | 18 + Tests/Tests+PopupID.swift | 129 ++ Tests/Tests+PopupManager.swift | 294 ++++ Tests/Tests+ViewModel+PopupCentreStack.swift | 275 +++ .../Tests+ViewModel+PopupVerticalStack.swift | 1502 +++++++++++++++++ 86 files changed, 5223 insertions(+), 2308 deletions(-) delete mode 100644 MijickPopupView.podspec create mode 100644 MijickPopups.podspec create mode 100644 Sources/Internal/Configurables/Global/GlobalConfig+Centre.swift create mode 100644 Sources/Internal/Configurables/Global/GlobalConfig+Vertical.swift create mode 100644 Sources/Internal/Configurables/Global/GlobalConfig.swift create mode 100644 Sources/Internal/Configurables/Local/LocalConfig+Centre.swift create mode 100644 Sources/Internal/Configurables/Local/LocalConfig+Vertical.swift create mode 100644 Sources/Internal/Configurables/Local/LocalConfig.swift create mode 100644 Sources/Internal/Containers/GlobalConfigContainer.swift create mode 100644 Sources/Internal/Containers/PopupManagerContainer.swift delete mode 100644 Sources/Internal/Extensions/DispatchSource++.swift create mode 100644 Sources/Internal/Extensions/EdgeInsets++.swift delete mode 100644 Sources/Internal/Extensions/Int++.swift delete mode 100644 Sources/Internal/Extensions/View++.swift create mode 100644 Sources/Internal/Extensions/View+Background.swift create mode 100644 Sources/Internal/Extensions/View+Gestures.swift create mode 100644 Sources/Internal/Extensions/View+Keyboard.swift create mode 100644 Sources/Internal/Extensions/View+ReadHeight.swift create mode 100644 Sources/Internal/Extensions/View+tvOS.swift delete mode 100644 Sources/Internal/Extensions/View.Gestures++.swift delete mode 100644 Sources/Internal/Extensions/View.ScreenManager++.swift delete mode 100644 Sources/Internal/Managers/KeyboardManager.swift delete mode 100644 Sources/Internal/Managers/ScreenManager.swift create mode 100644 Sources/Internal/Models/AnyPopup.swift create mode 100644 Sources/Internal/Models/ID+Popup.swift create mode 100644 Sources/Internal/Models/Screen.swift create mode 100644 Sources/Internal/Models/StackPriority.swift delete mode 100644 Sources/Internal/Other/AnimationType.swift delete mode 100644 Sources/Internal/Other/AnyPopup.swift delete mode 100644 Sources/Internal/Other/GlobalConfig.swift delete mode 100644 Sources/Internal/Other/ID.swift delete mode 100644 Sources/Internal/Other/Shadow.swift delete mode 100644 Sources/Internal/Protocols/Configurable.swift delete mode 100644 Sources/Internal/Protocols/Popup.swift delete mode 100644 Sources/Internal/Protocols/PopupStack.swift create mode 100644 Sources/Internal/UI/PopupCentreStackView.swift create mode 100644 Sources/Internal/UI/PopupVerticalStackView.swift create mode 100644 Sources/Internal/UI/PopupView.swift create mode 100644 Sources/Internal/Utilities/Logger.swift create mode 100644 Sources/Internal/Utilities/PopupActionScheduler.swift create mode 100644 Sources/Internal/Utilities/VerticalEdge.swift create mode 100644 Sources/Internal/View Models/ViewModel+CentreStack.swift create mode 100644 Sources/Internal/View Models/ViewModel+VerticalStack.swift create mode 100644 Sources/Internal/View Models/ViewModel.swift create mode 100644 Sources/Internal/View Models/ViewModelObject.swift delete mode 100644 Sources/Internal/View Modifiers/HeightReader.swift delete mode 100644 Sources/Internal/View Modifiers/RoundedCorner.swift delete mode 100644 Sources/Internal/Views/PopupBottomStackView.swift delete mode 100644 Sources/Internal/Views/PopupCentreStackView.swift delete mode 100644 Sources/Internal/Views/PopupTopStackView.swift delete mode 100644 Sources/Internal/Views/PopupView.swift delete mode 100644 Sources/Public/Configurables/GlobalConfig.Bottom.swift delete mode 100644 Sources/Public/Configurables/GlobalConfig.Centre.swift delete mode 100644 Sources/Public/Configurables/GlobalConfig.Common.swift delete mode 100644 Sources/Public/Configurables/GlobalConfig.Top.swift delete mode 100644 Sources/Public/Configurables/LocalConfig.BottomPopup.swift delete mode 100644 Sources/Public/Configurables/LocalConfig.CentrePopup.swift delete mode 100644 Sources/Public/Configurables/LocalConfig.TopPopup.swift delete mode 100644 Sources/Public/Delegates/Public+PopupSceneDelegate.swift create mode 100644 Sources/Public/Dismiss/Public+Dismiss+PopupManager.swift create mode 100644 Sources/Public/Dismiss/Public+Dismiss+View.swift delete mode 100644 Sources/Public/Extensions/Public+Popup.swift delete mode 100644 Sources/Public/Extensions/Public+PopupManager.swift delete mode 100644 Sources/Public/Extensions/Public+View.swift create mode 100644 Sources/Public/Popup/Public+Popup+Config.swift create mode 100644 Sources/Public/Popup/Public+Popup+Main.swift create mode 100644 Sources/Public/Popup/Public+Popup+Utilities.swift create mode 100644 Sources/Public/Present/Public+Present+Popup.swift create mode 100644 Sources/Public/Setup/Popup+Setup+PopupManagerID.swift create mode 100644 Sources/Public/Setup/Public+Setup+Config.swift create mode 100644 Sources/Public/Setup/Public+Setup+ConfigContainer.swift create mode 100644 Sources/Public/Setup/Public+Setup+SceneDelegate.swift create mode 100644 Sources/Public/Setup/Public+Setup+View.swift delete mode 100644 Sources/Public/Utilities/Public+DragDetent.swift create mode 100644 Tests/Extensions/Task++.swift create mode 100644 Tests/Tests+PopupID.swift create mode 100644 Tests/Tests+PopupManager.swift create mode 100644 Tests/Tests+ViewModel+PopupCentreStack.swift create mode 100644 Tests/Tests+ViewModel+PopupVerticalStack.swift diff --git a/.gitignore b/.gitignore index 3b29812086..9124986b56 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ .swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.swiftpm/xcode/xcshareddata/xcschemes/MijickPopupsTests.xcscheme \ No newline at end of file diff --git a/LICENSE b/LICENSE index b0da13c341..7aaaeb79b9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2023 Mijick - -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. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright ©2023 Mijick + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MijickPopupView.podspec b/MijickPopupView.podspec deleted file mode 100644 index 3578b04ec4..0000000000 --- a/MijickPopupView.podspec +++ /dev/null @@ -1,20 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'MijickPopupView' - s.summary = 'Popups presentation made simple' - s.description = <<-DESC - PopupView is a free and open-source library dedicated for SwiftUI that makes the process of presenting popups easier and much cleaner. - DESC - - s.version = '2.7.0' - s.ios.deployment_target = '14.0' - s.osx.deployment_target = '13.0' - s.swift_version = '5.0' - - s.source_files = 'Sources/**/*' - s.frameworks = 'SwiftUI', 'Foundation', 'Combine' - - s.homepage = 'https://github.com/Mijick/PopupView.git' - s.license = { :type => 'MIT', :file => 'LICENSE' } - s.author = { 'Tomasz Kurylik' => 'tomasz.kurylik@mijick.com' } - s.source = { :git => 'https://github.com/Mijick/PopupView.git', :tag => s.version.to_s } -end diff --git a/MijickPopups.podspec b/MijickPopups.podspec new file mode 100644 index 0000000000..b78633ff5a --- /dev/null +++ b/MijickPopups.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'MijickPopups' + s.summary = 'Popups, popovers, sheets, alerts, toasts, banners, (...) presentation made simple. Written with and for SwiftUI.' + s.description = <<-DESC + MijickPopups is a free and open-source library dedicated for SwiftUI that makes the process of presenting popups easier and much cleaner. + DESC + + s.version = '3.0.0' + s.ios.deployment_target = '14.0' + s.osx.deployment_target = '13.0' + s.swift_version = '6.0' + + s.source_files = 'Sources/**/*' + s.frameworks = 'SwiftUI', 'Foundation', 'Combine' + + s.homepage = 'https://github.com/Mijick/Popups.git' + s.license = { :type => 'Apache License 2.0', :file => 'LICENSE' } + s.author = { 'Tomasz Kurylik from Mijick' => 'tomasz.kurylik@mijick.com' } + s.source = { :git => 'https://github.com/Mijick/Popups.git', :tag => s.version.to_s } +end diff --git a/Package.swift b/Package.swift index fd8ae02f45..0020d26f39 100644 --- a/Package.swift +++ b/Package.swift @@ -1,21 +1,23 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "MijickPopupView", + name: "MijickPopups", platforms: [ .iOS(.v14), .macOS(.v12), .tvOS(.v15), - .watchOS(.v4) + .watchOS(.v4), + .visionOS(.v1) ], products: [ - .library(name: "MijickPopupView", targets: ["MijickPopupView"]) + .library(name: "MijickPopups", targets: ["MijickPopups"]) ], targets: [ - .target(name: "MijickPopupView", dependencies: [], path: "Sources") + .target(name: "MijickPopups", dependencies: [], path: "Sources"), + .testTarget(name: "MijickPopupsTests", dependencies: ["MijickPopups"], path: "Tests") ], - swiftLanguageVersions: [.v5] + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index c1bcb8c899..2a01d4c606 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@

- Try demo we prepared + Try demo we prepared | Roadmap | - Propose a new feature + Propose a new feature


@@ -30,16 +30,16 @@ SwiftUI logo Platforms: iOS, iPadOS, macOS, tvOS Current Version - License: MIT + License: Apache 2.0

Made in Kraków - + Follow us on X - - Let's work together + + Join our community Stargazers @@ -54,9 +54,9 @@
-PopupView is a free and open-source library dedicated for SwiftUI that makes the process of presenting popups easier and much cleaner. -* **Improves code quality.** Show your popup using the `showAndStack()` or `showAndReplace()` method.
- Hide the selected one with `dismiss()`. Simple as never. +MijickPopups is a free and open-source library dedicated for SwiftUI that makes the process of presenting popups easier and much cleaner. +* **Improves code quality.** Show your popup using the `present()` method.
+ Hide with `dismissLastPopup()`. Simple as never. * **Create any popup.** We know how important customisation is; that's why we give you the opportunity to design your popup in any way you like. * **Designed for SwiftUI.** While developing the library, we have used the power of SwiftUI to give you powerful tool to speed up your implementation process. @@ -67,23 +67,23 @@ PopupView is a free and open-source library dedicated for SwiftUI that makes the | **Platforms** | **Minimum Swift Version** | |:----------|:----------| -| iOS 14+ | 5.0 | -| iPadOS 14+ | 5.0 | -| macOS 12+ | 5.0 | -| tvOS 15+ | 5.0 | -| watchOS 4+ | 5.0 | -| visionOS 1+ | 5.0 | +| iOS 14+ | 6.0 | +| iPadOS 14+ | 6.0 | +| macOS 12+ | 6.0 | +| tvOS 15+ | 6.0 | +| watchOS 4+ | 6.0 | +| visionOS 1+ | 6.0 | ### ⏳ Installation #### [Swift Package Manager][spm] Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the Swift compiler. -Once you have your Swift package set up, adding PopupView as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. +Once you have your Swift package set up, adding MijickPopups as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. ```Swift dependencies: [ - .package(url: "https://github.com/Mijick/PopupView.git", branch(“main”)) + .package(url: "https://github.com/Mijick/Popups.git", branch(“main”)) ] ``` @@ -98,59 +98,71 @@ Installation steps: ``` - Add CocoaPods dependency into your `Podfile` ```Swift - pod 'MijickPopupView' + pod 'MijickPopups' ``` - Install dependency and generate `.xcworkspace` file ```Swift pod install ``` -- Use new XCode project file `.xcworkspace` +- Use new Xcode project file `.xcworkspace`
# Usage ### 1. Setup library The library can be initialised in either of two ways: -1. **DOES NOT WORK with SwiftUI sheets**
Inside your @main structure call the implementPopupView method. It takes the optional argument - config, that can be used to configure some modifiers for all popups in the application. +1. **DOES NOT WORK with SwiftUI sheets**
Inside your @main structure call the `registerPopups()` method. It takes the optional argument - `configBuilder`, that can be used to configure some modifiers for all popups in the application. ```Swift -@main struct PopupView_Main: App { - var body: some Scene { - WindowGroup(content: ContentView().implementPopupView) - } +@main struct App_Main: App { + var body: some Scene { WindowGroup { + ContentView() + .registerPopups { config in config + .vertical { $0 + .enableDragGesture(true) + .tapOutsideToDismissPopup(true) + .cornerRadius(32) + } + .centre { $0 + .tapOutsideToDismissPopup(false) + .backgroundColor(.white) + } + } + }} } ``` 2. **WORKS with SwiftUI sheets. Only for iOS**
Declare an AppDelegate class conforming to UIApplicationDelegate and add it to the @main structure. ```Swift -@main struct PopupView_Main: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate +@main struct App_Main: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + - var body: some Scene { WindowGroup(content: ContentView.init) } + var body: some Scene { WindowGroup(content: ContentView.init) } } + class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) - sceneConfig.delegateClass = CustomPopupSceneDelegate.self - return sceneConfig - } + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + sceneConfig.delegateClass = CustomPopupSceneDelegate.self + return sceneConfig + } } + class CustomPopupSceneDelegate: PopupSceneDelegate { - override init() { - super.init() - config = { $0 - .top { $0 - .cornerRadius(24) - .dragGestureEnabled(true) - } - .centre { $0 - .tapOutsideToDismiss(false) - } - .bottom { $0 - .stackLimit(5) - } - } - } + override init() { super.init() + configBuilder = { $0 + .vertical { $0 + .enableDragGesture(true) + .tapOutsideToDismissPopup(true) + .cornerRadius(32) + } + .centre { $0 + .tapOutsideToDismissPopup(false) + .backgroundColor(.white) + } + } + } } ``` @@ -168,11 +180,11 @@ struct BottomCustomPopup: BottomPopup { } ``` -### 3. Implement `createContent()` method -The function above is used instead of the body property, and declares the design of the popup view. +### 3. Declare `body` variable +The variable above declares the design of the popup. ```Swift struct BottomCustomPopup: BottomPopup { - func createContent() -> some View { + var body: some View { HStack(spacing: 0) { Text("Witaj okrutny świecie") Spacer() @@ -186,12 +198,12 @@ struct BottomCustomPopup: BottomPopup { } ``` -### 4. Implement `configurePopup(popup: Config) -> Config` method +### 4. Implement `configurePopup(config: Config) -> Config` method *Declaring this step is optional - if you wish, you can skip this step and leave the UI configuration to us.*
Each protocol has its own set of methods that can be used to create a unique appearance for every popup. ```Swift struct BottomCustomPopup: BottomPopup { - func createContent() -> some View { + var body: some View { HStack(spacing: 0) { Text("Witaj okrutny świecie") Spacer() @@ -201,8 +213,8 @@ struct BottomCustomPopup: BottomPopup { .padding(.leading, 24) .padding(.trailing, 16) } - func configurePopup(popup: BottomPopupConfig) -> BottomPopupConfig { - popup + func configurePopup(config: BottomPopupConfig) -> BottomPopupConfig { + config .horizontalPadding(20) .bottomPadding(42) .cornerRadius(16) @@ -212,15 +224,15 @@ struct BottomCustomPopup: BottomPopup { ``` ### 5. Present your popup from any place you want! -Just call `BottomCustomPopup().showAndStack()` from the selected place. Popup can be closed automatically by adding the dismissAfter modifier. +Just call `BottomCustomPopup().present()` from the selected place. Popup can be closed automatically by adding the `dismissAfter()` modifier. ```Swift struct SettingsViewModel { ... func saveSettings() { ... BottomCustomPopup() - .showAndStack() .dismissAfter(5) + .present() ... } ... @@ -229,29 +241,32 @@ struct SettingsViewModel { ### 6. Closing popups There are two methods to do so: -- By calling one of the methods `dismiss`, `dismiss(_ popup: Popup.Type)`, `dismissAll(upTo: Popup.Type)`, `dismissAll` inside the popup you created +- By calling one of the methods `dismissLastPopup`, `dismissPopup(_ type: Popup.Type)`, `dismissPopup(_ id: String)`, `dismissAllPopups` inside the popup you created ```Swift struct BottomCustomPopup: BottomPopup { ... func createButton() -> some View { - Button(action: dismiss) { Text("Tap to close") } + Button(action: dismissLastPopup) { Text("Tap to close") } } ... } ``` - By calling one of three static methods of PopupManager: - - `PopupManager.dismiss()` - - `PopupManager.dismiss(_ popup: Popup.Type)` where popup is the popup you want to close - - `PopupManager.dismissAll(upTo popup: Popup.Type)` where popup is the popup up to which you want to close the popups on the stack - - `PopupManager.dismissAll()` + - `PopupManager.dismissLastPopup()` + - `PopupManager.dismissPopup(_ type: Popup.Type)` where popup is the popup you want to close + - `PopupManager.dismissPopup(_ id: String)` where ID is the ID of the popup you want to close + - `PopupManager.dismissAllPopups()`
+### Postscript +The framework has extensive documentation built in, so in case of any problems, please use the Xcode Quick Help 😉 + # Try our demo See for yourself how does it work by cloning [project][Demo] we created # License -PopupView is released under the MIT license. See [LICENSE][License] for details. +MijickPopups is released under the Apache License 2.0. See [LICENSE][License] for details.

@@ -267,11 +282,10 @@ PopupView is released under the MIT license. See [LICENSE][License] for details. [Timer] - Modern API for Timer -[MIT]: https://en.wikipedia.org/wiki/MIT_License [SPM]: https://www.swift.org/package-manager -[Demo]: https://github.com/Mijick/PopupView-Demo -[License]: https://github.com/Mijick/PopupView/blob/main/LICENSE +[Demo]: https://github.com/Mijick/Popups-Demo +[License]: https://github.com/Mijick/Popups/blob/main/LICENSE [spm]: https://www.swift.org/package-manager/ [cocoapods]: https://cocoapods.org/ diff --git a/Sources/Internal/Configurables/Global/GlobalConfig+Centre.swift b/Sources/Internal/Configurables/Global/GlobalConfig+Centre.swift new file mode 100644 index 0000000000..5685037481 --- /dev/null +++ b/Sources/Internal/Configurables/Global/GlobalConfig+Centre.swift @@ -0,0 +1,19 @@ +// +// GlobalConfig+Centre.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +public extension GlobalConfig { class Centre: GlobalConfig { + required init() { super.init() + self.popupPadding = .init(top: 0, leading: 16, bottom: 0, trailing: 16) + self.cornerRadius = 24 + } +}} diff --git a/Sources/Internal/Configurables/Global/GlobalConfig+Vertical.swift b/Sources/Internal/Configurables/Global/GlobalConfig+Vertical.swift new file mode 100644 index 0000000000..bd2758af18 --- /dev/null +++ b/Sources/Internal/Configurables/Global/GlobalConfig+Vertical.swift @@ -0,0 +1,24 @@ +// +// GlobalConfig+Vertical.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +public extension GlobalConfig { class Vertical: GlobalConfig { + var dragThreshold: CGFloat = 1/3 + var isStackingEnabled: Bool = true + var isDragGestureEnabled: Bool = true + + + required init() { super.init() + self.popupPadding = .init() + self.cornerRadius = 40 + } +}} diff --git a/Sources/Internal/Configurables/Global/GlobalConfig.swift b/Sources/Internal/Configurables/Global/GlobalConfig.swift new file mode 100644 index 0000000000..37bbcc426d --- /dev/null +++ b/Sources/Internal/Configurables/Global/GlobalConfig.swift @@ -0,0 +1,20 @@ +// +// GlobalConfig.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public class GlobalConfig { required init() {} + var popupPadding: EdgeInsets = .init() + var cornerRadius: CGFloat = 28 + var backgroundColor: Color = .white + var overlayColor: Color = .black.opacity(0.44) + var isTapOutsideToDismissEnabled: Bool = false +} diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Centre.swift b/Sources/Internal/Configurables/Local/LocalConfig+Centre.swift new file mode 100644 index 0000000000..c464bbe335 --- /dev/null +++ b/Sources/Internal/Configurables/Local/LocalConfig+Centre.swift @@ -0,0 +1,42 @@ +// +// LocalConfig+Centre.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public extension LocalConfig { class Centre: LocalConfig { + required init() { super.init() + self.popupPadding = GlobalConfigContainer.centre.popupPadding + self.cornerRadius = GlobalConfigContainer.centre.cornerRadius + self.backgroundColor = GlobalConfigContainer.centre.backgroundColor + self.overlayColor = GlobalConfigContainer.centre.overlayColor + self.isTapOutsideToDismissEnabled = GlobalConfigContainer.centre.isTapOutsideToDismissEnabled + } +}} + +// MARK: Typealias +public typealias CentrePopupConfig = LocalConfig.Centre + + + +// MARK: - TESTS +#if DEBUG + + + +extension LocalConfig.Centre { + static func t_createNew(popupPadding: EdgeInsets, cornerRadius: CGFloat) -> LocalConfig.Centre { + let config = LocalConfig.Centre() + config.popupPadding = popupPadding + config.cornerRadius = cornerRadius + return config + } +} +#endif diff --git a/Sources/Internal/Configurables/Local/LocalConfig+Vertical.swift b/Sources/Internal/Configurables/Local/LocalConfig+Vertical.swift new file mode 100644 index 0000000000..f1e1dc2241 --- /dev/null +++ b/Sources/Internal/Configurables/Local/LocalConfig+Vertical.swift @@ -0,0 +1,57 @@ +// +// LocalConfig+Vertical.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public extension LocalConfig { class Vertical: LocalConfig { + var ignoredSafeAreaEdges: Edge.Set = [] + var heightMode: HeightMode = .auto + var dragDetents: [DragDetent] = [] + var isDragGestureEnabled: Bool = GlobalConfigContainer.vertical.isDragGestureEnabled + + + required init() { super.init() + self.popupPadding = GlobalConfigContainer.vertical.popupPadding + self.cornerRadius = GlobalConfigContainer.vertical.cornerRadius + self.backgroundColor = GlobalConfigContainer.vertical.backgroundColor + self.overlayColor = GlobalConfigContainer.vertical.overlayColor + self.isTapOutsideToDismissEnabled = GlobalConfigContainer.vertical.isTapOutsideToDismissEnabled + } +}} + +// MARK: Subclasses & Typealiases +public typealias TopPopupConfig = LocalConfig.Vertical.Top +public typealias BottomPopupConfig = LocalConfig.Vertical.Bottom +public extension LocalConfig.Vertical { + class Top: LocalConfig.Vertical {} + class Bottom: LocalConfig.Vertical {} +} + + + +// MARK: - TESTS +#if DEBUG + + + +extension LocalConfig.Vertical { + static func t_createNew(popupPadding: EdgeInsets, cornerRadius: CGFloat, ignoredSafeAreaEdges: Edge.Set, heightMode: HeightMode, dragDetents: [DragDetent], isDragGestureEnabled: Bool) -> C { + let config = C() + config.popupPadding = popupPadding + config.cornerRadius = cornerRadius + config.ignoredSafeAreaEdges = ignoredSafeAreaEdges + config.heightMode = heightMode + config.dragDetents = dragDetents + config.isDragGestureEnabled = isDragGestureEnabled + return config + } +} +#endif diff --git a/Sources/Internal/Configurables/Local/LocalConfig.swift b/Sources/Internal/Configurables/Local/LocalConfig.swift new file mode 100644 index 0000000000..eaf824c455 --- /dev/null +++ b/Sources/Internal/Configurables/Local/LocalConfig.swift @@ -0,0 +1,20 @@ +// +// LocalConfig.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public class LocalConfig { required init() {} + var popupPadding: EdgeInsets = .init() + var cornerRadius: CGFloat = 0 + var backgroundColor: Color = .clear + var overlayColor: Color = .clear + var isTapOutsideToDismissEnabled: Bool = false +} diff --git a/Sources/Internal/Containers/GlobalConfigContainer.swift b/Sources/Internal/Containers/GlobalConfigContainer.swift new file mode 100644 index 0000000000..27347b4d27 --- /dev/null +++ b/Sources/Internal/Containers/GlobalConfigContainer.swift @@ -0,0 +1,15 @@ +// +// GlobalConfigContainer.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +public class GlobalConfigContainer { + nonisolated(unsafe) static var centre: GlobalConfig.Centre = .init() + nonisolated(unsafe) static var vertical: GlobalConfig.Vertical = .init() +} diff --git a/Sources/Internal/Containers/PopupManagerContainer.swift b/Sources/Internal/Containers/PopupManagerContainer.swift new file mode 100644 index 0000000000..603c8142c5 --- /dev/null +++ b/Sources/Internal/Containers/PopupManagerContainer.swift @@ -0,0 +1,31 @@ +// +// PopupManagerContainer.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +@MainActor class PopupManagerContainer { + static private(set) var instances: [PopupManager] = [] +} + +// MARK: Register +extension PopupManagerContainer { + static func register(popupManager: PopupManager) -> PopupManager { + if let alreadyRegisteredInstance = instances.first(where: { $0.id == popupManager.id }) { return alreadyRegisteredInstance } + + instances.append(popupManager) + return popupManager + } +} + +// MARK: Clean +extension PopupManagerContainer { + static func clean() { instances = [] } +} diff --git a/Sources/Internal/Extensions/Animation++.swift b/Sources/Internal/Extensions/Animation++.swift index 624163a517..1b524229c4 100644 --- a/Sources/Internal/Extensions/Animation++.swift +++ b/Sources/Internal/Extensions/Animation++.swift @@ -1,16 +1,19 @@ // -// Animation++.swift of PopupView +// Animation++.swift of MijickPopups // -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! // - Mail: tomasz.kurylik@mijick.com // - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick // -// Copyright ©2024 Mijick. Licensed under MIT License. +// Copyright ©2024 Mijick. All rights reserved. import SwiftUI extension Animation { - static var keyboard: Animation { .interpolatingSpring(mass: 3, stiffness: 1000, damping: 500, initialVelocity: 6.4) } + static var transition: Animation { .spring(duration: Animation.duration, bounce: 0, blendDuration: 0) } +} +extension Animation { + static var duration: CGFloat { 0.27 } } diff --git a/Sources/Internal/Extensions/Array++.swift b/Sources/Internal/Extensions/Array++.swift index 2741e006ce..49e1dd99f0 100644 --- a/Sources/Internal/Extensions/Array++.swift +++ b/Sources/Internal/Extensions/Array++.swift @@ -1,34 +1,14 @@ // -// Array++.swift of PopupView +// Array++.swift of MijickPopups // -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! // - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick // -// Copyright ©2023 Mijick. Licensed under MIT License. +// Copyright ©2024 Mijick. All rights reserved. -import Foundation - -// MARK: Mutable -extension Array { - @inlinable mutating func append(_ newElement: Element, if prerequisite: Bool) { if prerequisite { append(newElement) } } - @inlinable mutating func removeAllUpToElement(where predicate: (Element) -> Bool) { if let index = lastIndex(where: predicate) { removeLast(count - index - 1) } } - @inlinable mutating func removeLast() { if !isEmpty { removeLast(1) } } - @inlinable mutating func replaceLast(_ newElement: Element, if prerequisite: Bool) { if prerequisite { - switch isEmpty { - case true: append(newElement) - case false: self[count - 1] = newElement - } - }} -} - -// MARK: Immutable extension Array { @inlinable func appending(_ newElement: Element) -> Self { self + [newElement] } } - -// MARK: Others -extension Array { - var nextToLast: Element? { count >= 2 ? self[count - 2] : nil } -} diff --git a/Sources/Internal/Extensions/DispatchSource++.swift b/Sources/Internal/Extensions/DispatchSource++.swift deleted file mode 100644 index a4cb2f6501..0000000000 --- a/Sources/Internal/Extensions/DispatchSource++.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// DispatchSource++.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// - GitHub: https://github.com/FulcrumOne -// -// Copyright ©2024 Mijick. Licensed under MIT License. - - -import Foundation - -extension DispatchSource { - static func createAction(deadline seconds: Double, event: @escaping () -> ()) -> DispatchSourceTimer { - let action = DispatchSource.makeTimerSource(queue: .main) - action.schedule(deadline: .now() + max(0.6, seconds)) - action.setEventHandler(handler: event) - action.resume() - return action - } -} diff --git a/Sources/Internal/Extensions/EdgeInsets++.swift b/Sources/Internal/Extensions/EdgeInsets++.swift new file mode 100644 index 0000000000..ee0178dd7f --- /dev/null +++ b/Sources/Internal/Extensions/EdgeInsets++.swift @@ -0,0 +1,19 @@ +// +// EdgeInsets++.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension EdgeInsets { + subscript(_ edge: VerticalEdge) -> CGFloat { switch edge { + case .top: top + case .bottom: bottom + }} +} diff --git a/Sources/Internal/Extensions/Int++.swift b/Sources/Internal/Extensions/Int++.swift deleted file mode 100644 index efa71c8e3d..0000000000 --- a/Sources/Internal/Extensions/Int++.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Int++.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import Foundation - -extension Int { - var doubleValue: Double { Double(self) } - var floatValue: CGFloat { CGFloat(self) } -} diff --git a/Sources/Internal/Extensions/View++.swift b/Sources/Internal/Extensions/View++.swift deleted file mode 100644 index 22f39e5988..0000000000 --- a/Sources/Internal/Extensions/View++.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// View++.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Alignments -extension View { - func align(to edge: Edge, _ value: CGFloat?) -> some View { - padding(.init(edge), value) - .frame(height: value != nil ? ScreenManager.shared.size.height : nil, alignment: edge.toAlignment()) - .frame(maxHeight: value != nil ? .infinity : nil, alignment: edge.toAlignment()) - } -} -fileprivate extension Edge { - func toAlignment() -> Alignment { - switch self { - case .top: return .top - case .bottom: return .bottom - case .leading: return .leading - case .trailing: return .trailing - } - } -} - -// MARK: - Actions -extension View { - func focusSectionIfAvailable() -> some View { - #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - self - #elseif os(tvOS) - focusSection() - #endif - } -} - -// MARK: - Others -extension View { - @ViewBuilder func active(if condition: Bool) -> some View { if condition { self } } -} -extension View { - func onChange(_ value: T, completion: @escaping (T) -> Void) -> some View { - #if os(visionOS) - onChange(of: value) { completion(value) } - #else - onChange(of: value) { _ in completion(value) } - #endif - } - func overlay(view: V) -> some View { - #if os(visionOS) - overlay { view } - #else - overlay(view) - #endif - } -} diff --git a/Sources/Internal/Extensions/View+Background.swift b/Sources/Internal/Extensions/View+Background.swift new file mode 100644 index 0000000000..e05a3be3b2 --- /dev/null +++ b/Sources/Internal/Extensions/View+Background.swift @@ -0,0 +1,63 @@ +// +// View+Background.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +extension View { + func background(backgroundColor: Color, overlayColor: Color, corners: [VerticalEdge: CGFloat]) -> some View { background( + backgroundColor + .overlay(overlayColor) + .mask(RoundedCorner(corners: corners)) + )} +} + +// MARK: Background Shape +fileprivate struct RoundedCorner: Shape { + var corners: [VerticalEdge: CGFloat] + + + var animatableData: CGFloat { + get { corners.values.max() ?? 0 } + set { corners.forEach { corners[$0.key] = newValue } } + } + func path(in rect: CGRect) -> Path { + let points = createPoints(rect) + let path = createPath(rect, points) + return path + } +} +private extension RoundedCorner { + func createPoints(_ rect: CGRect) -> [CGPoint] {[ + .init(x: rect.minX, y: rect.minY + corners[.top]!), + .init(x: rect.minX + corners[.top]!, y: rect.minY), + .init(x: rect.maxX - corners[.top]!, y: rect.minY), + .init(x: rect.maxX, y: rect.minY + corners[.top]!), + .init(x: rect.maxX, y: rect.maxY - corners[.bottom]!), + .init(x: rect.maxX - corners[.bottom]!, y: rect.maxY), + .init(x: rect.minX + corners[.bottom]!, y: rect.maxY), + .init(x: rect.minX, y: rect.maxY - corners[.bottom]!) + ]} + func createPath(_ rect: CGRect, _ points: [CGPoint]) -> Path { let radius = corners.values.max() ?? 0 + var path = Path() + + path.move(to: points[0]) + path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), tangent2End: points[1], radius: radius) + path.addLine(to: points[2]) + path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), tangent2End: points[3], radius: radius) + path.addLine(to: points[4]) + path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), tangent2End: points[5], radius: radius) + path.addLine(to: points[6]) + path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), tangent2End: points[7], radius: radius) + path.closeSubpath() + + return path + } +} diff --git a/Sources/Internal/Extensions/View+Gestures.swift b/Sources/Internal/Extensions/View+Gestures.swift new file mode 100644 index 0000000000..f7e8ae0604 --- /dev/null +++ b/Sources/Internal/Extensions/View+Gestures.swift @@ -0,0 +1,37 @@ +// +// View+Gestures.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: On Tap Gesture +extension View { + func onTapGesture(perform action: @escaping () -> ()) -> some View { + #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) + onTapGesture(count: 1, perform: action) + #elseif os(tvOS) + self + #endif + } +} + +// MARK: On Drag Gesture +extension View { + func onDragGesture(onChanged actionOnChanged: @escaping (CGFloat) -> (), onEnded actionOnEnded: @escaping (CGFloat) -> ()) -> some View { + #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) + highPriorityGesture(DragGesture() + .onChanged { actionOnChanged($0.translation.height) } + .onEnded { actionOnEnded($0.translation.height) } + ) + #elseif os(tvOS) + self + #endif + } +} diff --git a/Sources/Internal/Extensions/View+Keyboard.swift b/Sources/Internal/Extensions/View+Keyboard.swift new file mode 100644 index 0000000000..e57e92d6f7 --- /dev/null +++ b/Sources/Internal/Extensions/View+Keyboard.swift @@ -0,0 +1,51 @@ +// +// View+Keyboard.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI +import Combine + +// MARK: On Keyboard State Change +extension View { + func onKeyboardStateChange(perform action: @escaping (Bool) -> ()) -> some View { + #if os(iOS) + onReceive(keyboardPublisher, perform: action) + #else + self + #endif + } +} +fileprivate extension View { + #if os(iOS) + var keyboardPublisher: AnyPublisher { + Publishers.Merge( + NotificationCenter.default + .publisher(for: UIResponder.keyboardWillShowNotification) + .map { _ in true }, + NotificationCenter.default + .publisher(for: UIResponder.keyboardWillHideNotification) + .map { _ in false } + ) + .debounce(for: .seconds(0.1), scheduler: RunLoop.main) + .eraseToAnyPublisher() + } + #endif +} + +// MARK: Hide Keyboard +extension AnyView { + @MainActor static func hideKeyboard() { + #if os(iOS) + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + #elseif os(macOS) + NSApp.keyWindow?.makeFirstResponder(nil) + #endif + } +} diff --git a/Sources/Internal/Extensions/View+ReadHeight.swift b/Sources/Internal/Extensions/View+ReadHeight.swift new file mode 100644 index 0000000000..5b28069781 --- /dev/null +++ b/Sources/Internal/Extensions/View+ReadHeight.swift @@ -0,0 +1,21 @@ +// +// View+ReadHeight.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +extension View { + func onHeightChange(perform action: @escaping (CGFloat) -> ()) -> some View { background( + GeometryReader { proxy in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { action(proxy.size.height) } + return Color.clear + } + )} +} diff --git a/Sources/Internal/Extensions/View+tvOS.swift b/Sources/Internal/Extensions/View+tvOS.swift new file mode 100644 index 0000000000..cc97cc91f8 --- /dev/null +++ b/Sources/Internal/Extensions/View+tvOS.swift @@ -0,0 +1,22 @@ +// +// View+tvOS.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +extension View { + func focusSection_tvOS() -> some View { + #if os(tvOS) + focusSection() + #else + self + #endif + } +} diff --git a/Sources/Internal/Extensions/View.Gestures++.swift b/Sources/Internal/Extensions/View.Gestures++.swift deleted file mode 100644 index d0b72b2185..0000000000 --- a/Sources/Internal/Extensions/View.Gestures++.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// View.Gestures++.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - iOS + macOS Implementation -#if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) -extension View { - func onTapGesture(perform action: @escaping () -> ()) -> some View { onTapGesture(count: 1, perform: action) } - func onDragGesture(_ state: GestureState, onChanged actionOnChanged: @escaping (CGFloat) -> (), onEnded actionOnEnded: @escaping (CGFloat) -> ()) -> some View { simultaneousGesture(createDragGesture(state, actionOnChanged, actionOnEnded)).onStateChange(state, actionOnEnded) } -} -private extension View { - func createDragGesture(_ state: GestureState, _ actionOnChanged: @escaping (CGFloat) -> (), _ actionOnEnded: @escaping (CGFloat) -> ()) -> some Gesture { - DragGesture() - .updating(state) { _, state, _ in state = true } - .onChanged { actionOnChanged($0.translation.height) } - .onEnded { actionOnEnded($0.translation.height) } - } - func onStateChange(_ state: GestureState, _ actionOnEnded: @escaping (CGFloat) -> ()) -> some View { - #if os(visionOS) - onChange(of: state.wrappedValue) { state.wrappedValue ? () : actionOnEnded(.zero) } - #else - onChange(of: state.wrappedValue) { $0 ? () : actionOnEnded(.zero) } - #endif - } -} - - -// MARK: - tvOS Implementation -#elseif os(tvOS) -extension View { - func onTapGesture(perform action: () -> ()) -> some View { self } - func onDragGesture(onChanged actionOnChanged: (CGFloat) -> (), onEnded actionOnEnded: (CGFloat) -> ()) -> some View { self } -} -#endif diff --git a/Sources/Internal/Extensions/View.ScreenManager++.swift b/Sources/Internal/Extensions/View.ScreenManager++.swift deleted file mode 100644 index 6e985e9980..0000000000 --- a/Sources/Internal/Extensions/View.ScreenManager++.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// View.ScreenManager++.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// - GitHub: https://github.com/FulcrumOne -// -// Copyright ©2024 Mijick. Licensed under MIT License. - - -import SwiftUI - -extension View { - func updateScreenSize() -> some View { GeometryReader { reader in - frame(width: reader.size.width, height: reader.size.height).frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { ScreenManager.update(reader) } - .onChange(of: reader.frame(in: .global)) { _ in ScreenManager.update(reader) } - }} -} -fileprivate extension ScreenManager { - static func update(_ reader: GeometryProxy) { - shared.size.height = reader.size.height + reader.safeAreaInsets.top + reader.safeAreaInsets.bottom - shared.size.width = reader.size.width + reader.safeAreaInsets.leading + reader.safeAreaInsets.trailing - - shared.safeArea.top = reader.safeAreaInsets.top - shared.safeArea.bottom = reader.safeAreaInsets.bottom - shared.safeArea.left = reader.safeAreaInsets.leading - shared.safeArea.right = reader.safeAreaInsets.trailing - } -} diff --git a/Sources/Internal/Managers/KeyboardManager.swift b/Sources/Internal/Managers/KeyboardManager.swift deleted file mode 100644 index f076add7c1..0000000000 --- a/Sources/Internal/Managers/KeyboardManager.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// KeyboardManager.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI -import Combine - -// MARK: -iOS Implementation -#if os(iOS) || os(visionOS) -class KeyboardManager: ObservableObject { - @Published private(set) var height: CGFloat = 0 - private var subscription: [AnyCancellable] = [] - - static let shared: KeyboardManager = .init() - private init() { subscribeToKeyboardEvents() } -} -extension KeyboardManager { - static func hideKeyboard() { DispatchQueue.main.async { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - }} -} - -private extension KeyboardManager { - func subscribeToKeyboardEvents() { - Publishers.Merge(getKeyboardWillOpenPublisher(), createKeyboardWillHidePublisher()) - .receive(on: DispatchQueue.main) - .sink { self.height = $0 } - .store(in: &subscription) - } -} -private extension KeyboardManager { - func getKeyboardWillOpenPublisher() -> Publishers.CompactMap { - NotificationCenter.default - .publisher(for: UIResponder.keyboardWillShowNotification) - .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect } - .map { max(0, $0.height - 8) } - } - func createKeyboardWillHidePublisher() -> Publishers.Map { - NotificationCenter.default - .publisher(for: UIResponder.keyboardWillHideNotification) - .map { _ in .zero } - } -} - - -// MARK: - macOS Implementation -#elseif os(macOS) -class KeyboardManager: ObservableObject { - private(set) var height: CGFloat = 0 - - static let shared: KeyboardManager = .init() - private init() {} -} -extension KeyboardManager { - static func hideKeyboard() { DispatchQueue.main.async { NSApp.keyWindow?.makeFirstResponder(nil) } } -} - - -// MARK: - tvOS Implementation -#elseif os(tvOS) || os(watchOS) -class KeyboardManager: ObservableObject { - private(set) var height: CGFloat = 0 - - static let shared: KeyboardManager = .init() - private init() {} -} -extension KeyboardManager { - static func hideKeyboard() {} -} -#endif diff --git a/Sources/Internal/Managers/PopupManager.swift b/Sources/Internal/Managers/PopupManager.swift index 1e121555dc..9a023935a0 100644 --- a/Sources/Internal/Managers/PopupManager.swift +++ b/Sources/Internal/Managers/PopupManager.swift @@ -1,82 +1,123 @@ // -// PopupManager.swift of PopupView +// PopupManager.swift of MijickPopups // -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! // - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick // -// Copyright ©2023 Mijick. Licensed under MIT License. +// Copyright ©2023 Mijick. All rights reserved. import SwiftUI -public class PopupManager: ObservableObject { - @Published private(set) var views: [any Popup] = [] { willSet { onViewsChanged(newValue) }} - private(set) var presenting: Bool = true - private(set) var popupsWithoutOverlay: [ID] = [] - private(set) var popupsToBeDismissed: [ID: DispatchSourceTimer] = [:] - private(set) var popupActionsOnDismiss: [ID: () -> ()] = [:] +@MainActor public class PopupManager: ObservableObject { + let id: PopupManagerID + @Published private(set) var stack: [AnyPopup] = [] + @Published private(set) var stackPriority: StackPriority = .init() - static let shared: PopupManager = .init() - private init() {} -} -private extension PopupManager { - func onViewsChanged(_ newViews: [any Popup]) { newViews - .difference(from: views, by: { $0.id == $1.id }) - .forEach { switch $0 { - case .remove(_, let element, _): popupActionsOnDismiss[element.id]?(); popupActionsOnDismiss.removeValue(forKey: element.id) - default: return - }} - } + private init(id: PopupManagerID) { self.id = id } } -// MARK: - Operations -enum StackOperation { - case insertAndReplace(any Popup), insertAndStack(any Popup) - case removeLast, remove(ID), removeAllUpTo(ID), removeAll -} +// MARK: Update extension PopupManager { - static func performOperation(_ operation: StackOperation) { DispatchQueue.main.async { - removePopupFromStackToBeDismissed(operation) - updateOperationType(operation) - shared.views.perform(operation) + func updateStack(_ popup: AnyPopup) { if let index = stack.firstIndex(of: popup) { + stack[index] = popup }} - static func dismissPopupAfter(_ popup: any Popup, _ seconds: Double) { shared.popupsToBeDismissed[popup.id] = DispatchSource.createAction(deadline: seconds) { performOperation(.remove(popup.id)) } } - static func hideOverlay(_ popup: any Popup) { shared.popupsWithoutOverlay.append(popup.id) } - static func onPopupDismiss(_ popup: any Popup, _ action: @escaping () -> ()) { shared.popupActionsOnDismiss[popup.id] = action } +} + + + +// MARK: - STACK OPERATIONS + + + +// MARK: Available Operations +extension PopupManager { enum StackOperation { + case insertPopup(any Popup) + case removeLastPopup, removePopupInstance(AnyPopup), removeAllPopupsOfType(any Popup.Type), removeAllPopupsWithID(String), removeAllPopups +}} + +// MARK: Perform Operation +extension PopupManager { + func stack(_ operation: StackOperation) { let oldStackCount = stack.count + hideKeyboard() + perform(operation) + reshuffleStackPriority(oldStackCount) + } } private extension PopupManager { - static func removePopupFromStackToBeDismissed(_ operation: StackOperation) { switch operation { - case .removeLast: shared.popupsToBeDismissed.removeValue(forKey: shared.views.last?.id ?? .init()) - case .remove(let id): shared.popupsToBeDismissed.removeValue(forKey: id) - case .removeAllUpTo, .removeAll: shared.popupsToBeDismissed.removeAll() - default: break - }} - static func updateOperationType(_ operation: StackOperation) { switch operation { - case .insertAndReplace, .insertAndStack: shared.presenting = true - case .removeLast, .remove, .removeAllUpTo, .removeAll: shared.presenting = false + func hideKeyboard() { + AnyView.hideKeyboard() + } + func perform(_ operation: StackOperation) { switch operation { + case .insertPopup(let popup): insertPopup(popup) + case .removeLastPopup: removeLastPopup() + case .removePopupInstance(let popup): removePopupInstance(popup) + case .removeAllPopupsOfType(let popupType): removeAllPopupsOfType(popupType) + case .removeAllPopupsWithID(let id): removeAllPopupsWithID(id) + case .removeAllPopups: removeAllPopups() }} + func reshuffleStackPriority(_ oldStackCount: Int) { + let delayDuration = oldStackCount > stack.count ? Animation.duration : 0 + + DispatchQueue.main.asyncAfter(deadline: .now() + delayDuration) { [self] in + stackPriority.reshuffle(newPopups: stack) + } + } } +private extension PopupManager { + func insertPopup(_ popup: any Popup) { + let erasedPopup = AnyPopup(popup) + let canPopupBeInserted = !stack.contains(where: { $0.id.isSameType(as: erasedPopup.id) }) -fileprivate extension [any Popup] { - mutating func perform(_ operation: StackOperation) { - hideKeyboard() - performOperation(operation) + if canPopupBeInserted { stack.append(erasedPopup.startingDismissTimerIfNeeded(self)) } + } + func removeLastPopup() { if !stack.isEmpty { + stack.removeLast() + }} + func removePopupInstance(_ popup: AnyPopup) { + stack.removeAll(where: { $0.id.isSameInstance(as: popup) }) + } + func removeAllPopupsOfType(_ popupType: any Popup.Type) { + stack.removeAll(where: { $0.id.isSameType(as: popupType) }) + } + func removeAllPopupsWithID(_ id: String) { + stack.removeAll(where: { $0.id.isSameType(as: id) }) + } + func removeAllPopups() { + stack.removeAll() } } -private extension [any Popup] { - func hideKeyboard() { KeyboardManager.hideKeyboard() } - mutating func performOperation(_ operation: StackOperation) { - switch operation { - case .insertAndReplace(let popup): replaceLast(popup, if: canBeInserted(popup)) - case .insertAndStack(let popup): append(popup, if: canBeInserted(popup)) - case .removeLast: removeLast() - case .remove(let id): removeAll(where: { $0.id ~= id }) - case .removeAllUpTo(let id): removeAllUpToElement(where: { $0.id ~= id }) - case .removeAll: removeAll() - } + + + +// MARK: - INSTACE OPERATIONS + + + +// MARK: Fetch +extension PopupManager { + static func fetchInstance(id: PopupManagerID) -> PopupManager? { + let managerObject = PopupManagerContainer.instances.first(where: { $0.id == id }) + logNoInstanceErrorIfNeeded(managerObject: managerObject, popupManagerID: id) + return managerObject } } -private extension [any Popup] { - func canBeInserted(_ popup: some Popup) -> Bool { !contains(where: { $0.id ~= popup.id }) } +private extension PopupManager { + static func logNoInstanceErrorIfNeeded(managerObject: PopupManager?, popupManagerID: PopupManagerID) { if managerObject == nil { + Logger.log( + level: .fault, + message: "PopupManager instance (\(popupManagerID.rawValue)) must be registered before use. More details can be found in the documentation." + ) + }} +} + +// MARK: Register +extension PopupManager { + static func registerInstance(id: PopupManagerID) -> PopupManager { + let instanceToRegister = PopupManager(id: id) + let registeredInstance = PopupManagerContainer.register(popupManager: instanceToRegister) + return registeredInstance + } } diff --git a/Sources/Internal/Managers/ScreenManager.swift b/Sources/Internal/Managers/ScreenManager.swift deleted file mode 100644 index f88c597df5..0000000000 --- a/Sources/Internal/Managers/ScreenManager.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// ScreenManager.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI -import Combine - -// MARK: -iOS Implementation -#if os(iOS) -class ScreenManager: ObservableObject { - @Published var size: CGSize = .init() - @Published var safeArea: UIEdgeInsets = .init() - private(set) var cornerRadius: CGFloat? = UIScreen.cornerRadius - private(set) var animationsDisabled: Bool = false - - static let shared: ScreenManager = .init() - private init() {} -} -private extension UIScreen { - static var cornerRadius: CGFloat? = main.value(forKey: ["Radius", "Corner", "display", "_"].reversed().joined()) as? CGFloat -} - - -// MARK: - macOS Implementation -#elseif os(macOS) -class ScreenManager: ObservableObject { - @Published var size: CGSize = .init() - @Published var safeArea: NSEdgeInsets = .init() - private(set) var cornerRadius: CGFloat? = 0 - private(set) var animationsDisabled: Bool = false - private var subscription: [AnyCancellable] = [] - - static let shared: ScreenManager = .init() - private init() { subscribeToWindowStartResizeEvent(); subscribeToWindowEndResizeEvent() } -} - -private extension ScreenManager { - func subscribeToWindowStartResizeEvent() { - NotificationCenter.default - .publisher(for: NSWindow.willStartLiveResizeNotification) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { _ in self.animationsDisabled = true }) - .store(in: &subscription) - } - func subscribeToWindowEndResizeEvent() { - NotificationCenter.default - .publisher(for: NSWindow.didEndLiveResizeNotification) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { _ in self.animationsDisabled = false }) - .store(in: &subscription) - } -} -// MARK: - visionOS Implementation -#elseif os(visionOS) -class ScreenManager: ObservableObject { - @Published var size: CGSize = .init() - @Published var safeArea: UIEdgeInsets = .init() - private(set) var cornerRadius: CGFloat? = 0 - private(set) var animationsDisabled: Bool = false - - static let shared: ScreenManager = .init() - private init() {} -} - -// MARK: - watchOS Implementation -#elseif os(watchOS) -class ScreenManager: ObservableObject { - @Published var size: CGSize = .init() - @Published var safeArea: UIEdgeInsets = .init() - private(set) var cornerRadius: CGFloat? = 0 - private(set) var animationsDisabled: Bool = false - - static let shared: ScreenManager = .init() - private init() { } -} - -// MARK: - tvOS Implementation -#elseif os(tvOS) -class ScreenManager: ObservableObject { - @Published private(set) var size: CGSize = .init() - @Published private(set) var safeArea: UIEdgeInsets = .init() - private(set) var cornerRadius: CGFloat? = 0 - private(set) var animationsDisabled: Bool = false - - static let shared: ScreenManager = .init() - private init() {} -} -#endif diff --git a/Sources/Internal/Models/AnyPopup.swift b/Sources/Internal/Models/AnyPopup.swift new file mode 100644 index 0000000000..76c1b145da --- /dev/null +++ b/Sources/Internal/Models/AnyPopup.swift @@ -0,0 +1,103 @@ +// +// AnyPopup.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +struct AnyPopup: Popup { + private(set) var id: PopupID + private(set) var config: LocalConfig + private(set) var height: CGFloat? = nil + private(set) var dragHeight: CGFloat? = nil + + private var dismissTimer: PopupActionScheduler? = nil + private var _body: AnyView + private let _onFocus: () -> () + private let _onDismiss: () -> () +} + + + +// MARK: - INITIALISE & UPDATE + + + +// MARK: Initialise +extension AnyPopup { + init(_ popup: P) { + if let popup = popup as? AnyPopup { self = popup } + else { + self.id = .create(from: P.self) + self.config = popup.configurePopup(config: .init()) + self._body = AnyView(popup) + self._onFocus = popup.onFocus + self._onDismiss = popup.onDismiss + } + } +} + +// MARK: Update +extension AnyPopup { + func settingCustomID(_ customID: String) -> AnyPopup { updatingPopup { $0.id = .create(from: customID) }} + func settingDismissTimer(_ secondsToDismiss: Double) -> AnyPopup { updatingPopup { $0.dismissTimer = .prepare(time: secondsToDismiss) }} + func startingDismissTimerIfNeeded(_ popupManager: PopupManager) -> AnyPopup { updatingPopup { $0.dismissTimer?.schedule { popupManager.stack(.removePopupInstance(self)) }}} + func settingHeight(_ newHeight: CGFloat?) -> AnyPopup { updatingPopup { $0.height = newHeight }} + func settingDragHeight(_ newDragHeight: CGFloat?) -> AnyPopup { updatingPopup { $0.dragHeight = newDragHeight }} + func settingEnvironmentObject(_ environmentObject: some ObservableObject) -> AnyPopup { updatingPopup { $0._body = AnyView(_body.environmentObject(environmentObject)) }} +} +private extension AnyPopup { + func updatingPopup(_ customBuilder: (inout AnyPopup) -> ()) -> AnyPopup { + var popup = self + customBuilder(&popup) + return popup + } +} + + + +// MARK: - PROTOCOLS CONFORMANCE + + + +// MARK: Popup +extension AnyPopup { typealias Config = LocalConfig + var body: some View { _body } + + func onFocus() { _onFocus() } + func onDismiss() { _onDismiss() } +} + +// MARK: Hashable +extension AnyPopup: Hashable { + nonisolated static func ==(lhs: AnyPopup, rhs: AnyPopup) -> Bool { lhs.id.isSameInstance(as: rhs) } + nonisolated func hash(into hasher: inout Hasher) { hasher.combine(id.rawValue) } +} + + + +// MARK: - TESTS +#if DEBUG + + + +// MARK: New Object +extension AnyPopup { + static func t_createNew(id: String = UUID().uuidString, config: LocalConfig) -> AnyPopup { .init( + id: .create(from: id), + config: config, + height: nil, + dragHeight: nil, + dismissTimer: nil, + _body: .init(EmptyView()), + _onFocus: {}, + _onDismiss: {} + )} +} +#endif diff --git a/Sources/Internal/Models/ID+Popup.swift b/Sources/Internal/Models/ID+Popup.swift new file mode 100644 index 0000000000..e01b899ded --- /dev/null +++ b/Sources/Internal/Models/ID+Popup.swift @@ -0,0 +1,53 @@ +// +// ID+Popup.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +struct PopupID { + let rawValue: String +} + +// MARK: Create +extension PopupID { + static func create(from id: String) -> Self { + let firstComponent = id, + secondComponent = separator, + thirdComponent = String(describing: Date()) + return .init(rawValue: firstComponent + secondComponent + thirdComponent) + } + static func create(from popupType: any Popup.Type) -> Self { + create(from: .init(describing: popupType)) + } +} + +// MARK: Comparison +extension PopupID { + func isSameType(as id: String) -> Bool { getFirstComponent(of: self) == id } + func isSameType(as popupType: any Popup.Type) -> Bool { getFirstComponent(of: self) == String(describing: popupType) } + func isSameType(as popupID: PopupID) -> Bool { getFirstComponent(of: self) == getFirstComponent(of: popupID) } + func isSameInstance(as popup: AnyPopup) -> Bool { rawValue == popup.id.rawValue } +} + + + +// MARK: - HELPERS + + + +// MARK: Methods +private extension PopupID { + func getFirstComponent(of object: Self) -> String { object.rawValue.components(separatedBy: Self.separator).first ?? "" } +} + +// MARK: Variables +private extension PopupID { + static var separator: String { "/{}/" } +} diff --git a/Sources/Internal/Models/Screen.swift b/Sources/Internal/Models/Screen.swift new file mode 100644 index 0000000000..5259ed7c99 --- /dev/null +++ b/Sources/Internal/Models/Screen.swift @@ -0,0 +1,27 @@ +// +// Screen.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct Screen { + let height: CGFloat + let safeArea: EdgeInsets + + + init(height: CGFloat = .zero, safeArea: EdgeInsets = .init()) { + self.height = height + self.safeArea = safeArea + } + init(_ reader: GeometryProxy) { + self.height = reader.size.height + reader.safeAreaInsets.top + reader.safeAreaInsets.bottom + self.safeArea = reader.safeAreaInsets + } +} diff --git a/Sources/Internal/Models/StackPriority.swift b/Sources/Internal/Models/StackPriority.swift new file mode 100644 index 0000000000..b2fb3b5808 --- /dev/null +++ b/Sources/Internal/Models/StackPriority.swift @@ -0,0 +1,42 @@ +// +// StackPriority.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +struct StackPriority: Equatable { + var top: CGFloat { values[0] } + var centre: CGFloat { values[1] } + var bottom: CGFloat { values[2] } + var overlay: CGFloat { 1 } + + private var values: [CGFloat] = [0, 0, 0] +} + +// MARK: Reshuffle +extension StackPriority { + @MainActor mutating func reshuffle(newPopups: [AnyPopup]) { switch newPopups.last { + case .some(let popup) where popup.config is TopPopupConfig: reshuffle(0) + case .some(let popup) where popup.config is CentrePopupConfig: reshuffle(1) + case .some(let popup) where popup.config is BottomPopupConfig: reshuffle(2) + default: return + }} +} +private extension StackPriority { + mutating func reshuffle(_ index: Int) { + guard values[index] != maxPriority else { return } + + let newValues = values.enumerated().map { $0.offset == index ? maxPriority : $0.element - 2 } + values = newValues + } +} +private extension StackPriority { + var maxPriority: CGFloat { 2 } +} diff --git a/Sources/Internal/Other/AnimationType.swift b/Sources/Internal/Other/AnimationType.swift deleted file mode 100644 index 49f6432056..0000000000 --- a/Sources/Internal/Other/AnimationType.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// AnimationType.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -public enum AnimationType { case spring, linear, easeInOut } -extension AnimationType { - var entry: Animation { switch self { - case .spring: return .spring(duration: 0.36, bounce: 0, blendDuration: 0.1) - case .linear: return .linear(duration: 0.4) - case .easeInOut: return .easeInOut(duration: 0.4) - }} - var removal: Animation { switch self { - case .spring: return .spring(duration: 0.32, bounce: 0, blendDuration: 0.1) - case .linear: return .linear(duration: 0.3) - case .easeInOut: return .easeInOut(duration: 0.3) - }} - var dragGesture: Animation { .linear(duration: 0.05) } -} diff --git a/Sources/Internal/Other/AnyPopup.swift b/Sources/Internal/Other/AnyPopup.swift deleted file mode 100644 index 6ea1918c86..0000000000 --- a/Sources/Internal/Other/AnyPopup.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// AnyPopup.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -struct AnyPopup: Popup, Hashable { - let id: ID - private let _body: AnyView - private let _configBuilder: (Config) -> Config - - - init(_ popup: some Popup) { - self.id = popup.id - self._body = AnyView(popup) - self._configBuilder = popup.configurePopup as! (Config) -> Config - } - init(_ popup: some Popup, _ envObject: some ObservableObject) { - self.id = popup.id - self._body = AnyView(popup.environmentObject(envObject)) - self._configBuilder = popup.configurePopup as! (Config) -> Config - } -} -extension AnyPopup { - func createContent() -> some View { _body } - func configurePopup(popup: Config) -> Config { _configBuilder(popup) } -} - -// MARK: - Hashable -extension AnyPopup { - static func == (lhs: AnyPopup, rhs: AnyPopup) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/Sources/Internal/Other/GlobalConfig.swift b/Sources/Internal/Other/GlobalConfig.swift deleted file mode 100644 index 60c95c746e..0000000000 --- a/Sources/Internal/Other/GlobalConfig.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// GlobalConfig.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -public extension GlobalConfig { - func main(_ configure: (Common) -> Common) -> GlobalConfig { changing(path: \.common, to: configure(.init())) } - func top(_ configure: (Top) -> Top) -> GlobalConfig { changing(path: \.top, to: configure(.init())) } - func centre(_ configure: (Centre) -> Centre) -> GlobalConfig { changing(path: \.centre, to: configure(.init())) } - func bottom(_ configure: (Bottom) -> Bottom) -> GlobalConfig { changing(path: \.bottom, to: configure(.init())) } -} - - -// MARK: - Internal -public struct GlobalConfig: Configurable { public init() {} - var common: Common = .init() - var top: Top = .init() - var centre: Centre = .init() - var bottom: Bottom = .init() -} diff --git a/Sources/Internal/Other/ID.swift b/Sources/Internal/Other/ID.swift deleted file mode 100644 index b25f3bd3ed..0000000000 --- a/Sources/Internal/Other/ID.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// ID.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// - GitHub: https://github.com/FulcrumOne -// -// Copyright ©2024 Mijick. Licensed under MIT License. - - -import Foundation - -public struct ID { - let value: String -} - -// MARK: - Initialisers -extension ID { - init(_ object: P) { self.init(P.self) } - init(_ type: P.Type) { self.value = .init(describing: P.self) + Self.separator + .init(describing: Date()) } - init() { self.value = "" } -} - -// MARK: - Equatable -extension ID: Equatable { - public static func ==(lhs: Self, rhs: Self) -> Bool { lhs.value == rhs.value } - public static func ~=(lhs: Self, rhs: Self) -> Bool { getComponent(lhs) == getComponent(rhs) } -} - -// MARK: - Hashing -extension ID: Hashable { - public func hash(into hasher: inout Hasher) { hasher.combine(Self.getComponent(self)) } -} - - -// MARK: - Helpers -private extension ID { - static func getComponent(_ object: Self) -> String { object.value.components(separatedBy: separator).first ?? "" } -} -private extension ID { - static var separator: String { "/{}/" } -} diff --git a/Sources/Internal/Other/Shadow.swift b/Sources/Internal/Other/Shadow.swift deleted file mode 100644 index c61c34a3ab..0000000000 --- a/Sources/Internal/Other/Shadow.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Shadow.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// - GitHub: https://github.com/FulcrumOne -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -struct Shadow { - let color: Color - let radius: CGFloat - let x: CGFloat - let y: CGFloat -} -extension Shadow { - static var none: Self { .init(color: .clear, radius: 0, x: 0, y: 0) } -} diff --git a/Sources/Internal/Protocols/Configurable.swift b/Sources/Internal/Protocols/Configurable.swift deleted file mode 100644 index 07dbd2f79b..0000000000 --- a/Sources/Internal/Protocols/Configurable.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Configurable.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -public protocol Configurable { init() } -extension Configurable { - func changing(path: WritableKeyPath, to value: T) -> Self { - var clone = self - clone[keyPath: path] = value - return clone - } -} diff --git a/Sources/Internal/Protocols/Popup.swift b/Sources/Internal/Protocols/Popup.swift deleted file mode 100644 index 26c5bb857c..0000000000 --- a/Sources/Internal/Protocols/Popup.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Popup.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -public protocol Popup: View { - associatedtype Config: Configurable - associatedtype V: View - - var id: ID { get } - - func createContent() -> V - func configurePopup(popup: Config) -> Config -} -public extension Popup { - var id: ID { .init(self) } - var body: V { createContent() } - - func configurePopup(popup: Config) -> Config { popup } -} - -// MARK: - Helpers -extension Popup { - func remove() { PopupManager.performOperation(.remove(id)) } -} diff --git a/Sources/Internal/Protocols/PopupStack.swift b/Sources/Internal/Protocols/PopupStack.swift deleted file mode 100644 index 7c24065b2b..0000000000 --- a/Sources/Internal/Protocols/PopupStack.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// PopupStack.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -protocol PopupStack: View { - associatedtype Config: Configurable - - var items: [AnyPopup] { get } - var heights: [ID: CGFloat] { get } - var dragHeights: [ID: CGFloat] { get } - var globalConfig: GlobalConfig { get } - var gestureTranslation: CGFloat { get } - var isGestureActive: Bool { get } - var translationProgress: CGFloat { get } - var cornerRadius: CGFloat { get } - - var stackLimit: Int { get } - var stackScaleFactor: CGFloat { get } - var stackCornerRadiusMultiplier: CGFloat { get } - var stackOffsetValue: CGFloat { get } - - var tapOutsideClosesPopup: Bool { get } -} -extension PopupStack { - var heights: [ID: CGFloat] { [:] } - var dragHeights: [ID: CGFloat] { [:] } - var gestureTranslation: CGFloat { 0 } - var isGestureActive: Bool { false } - var translationProgress: CGFloat { 1 } - - var stackLimit: Int { 1 } - var stackScaleFactor: CGFloat { 1 } - var stackCornerRadiusMultiplier: CGFloat { 0 } - var stackOffsetValue: CGFloat { 0 } -} - - -// MARK: - Tapable Area -extension PopupStack { - @ViewBuilder func createTapArea() -> some View { if tapOutsideClosesPopup { - Color.black.opacity(0.00000000001).onTapGesture(perform: items.last?.dismiss ?? {}) - }} -} - - -// MARK: - Corner Radius -extension PopupStack { - func getCornerRadius(_ item: AnyPopup) -> CGFloat { - if isLast(item) { return cornerRadius } - if translationProgress.isZero || translationProgress.isNaN || !isNextToLast(item) { return stackedCornerRadius } - - let difference = cornerRadius - stackedCornerRadius - let differenceProgress = difference * translationProgress - return stackedCornerRadius + differenceProgress - } -} -private extension PopupStack { - var stackedCornerRadius: CGFloat { cornerRadius * stackCornerRadiusMultiplier } -} - -// MARK: - Scale -extension PopupStack { - func getScale(_ item: AnyPopup) -> CGFloat { - let scaleValue = invertedIndex(item).floatValue * stackScaleFactor - let progressDifference = isNextToLast(item) ? remainingTranslationProgress : max(0.7, remainingTranslationProgress) - let scale = 1 - scaleValue * progressDifference - return min(1, scale) - } -} - -// MARK: - Stack Overlay Colour -extension PopupStack { - func getStackOverlayColour(_ item: AnyPopup) -> Color { - let opacity = calculateStackOverlayOpacity(item) - return stackOverlayColour.opacity(opacity) - } -} -private extension PopupStack { - func calculateStackOverlayOpacity(_ item: AnyPopup) -> Double { - let overlayValue = invertedIndex(item).doubleValue * stackOverlayFactor - let remainingTranslationProgressValue = isNextToLast(item) ? remainingTranslationProgress : max(0.6, remainingTranslationProgress) - let opacity = overlayValue * remainingTranslationProgressValue - return max(0, opacity) - } -} -private extension PopupStack { - var stackOverlayColour: Color { .black } - var stackOverlayFactor: CGFloat { 1 / stackLimit.doubleValue * 0.5 } -} - -// MARK: - Stack Opacity -extension PopupStack { - func getOpacity(_ item: AnyPopup) -> Double { invertedIndex(item) <= stackLimit ? 1 : 0.000000001 } -} - -// MARK: - Stack Offset -extension PopupStack { - func getOffset(_ item: AnyPopup) -> CGFloat { switch isLast(item) { - case true: calculateOffsetForLastItem() - case false: calculateOffsetForOtherItems(item) - }} -} -private extension PopupStack { - func calculateOffsetForLastItem() -> CGFloat { switch items { - case _ as [AnyPopup]: max(gestureTranslation - getLastDragHeight(), 0) - case _ as [AnyPopup]: min(gestureTranslation + getLastDragHeight(), 0) - default: 0 - }} - func calculateOffsetForOtherItems(_ item: AnyPopup) -> CGFloat { - invertedIndex(item).floatValue * stackOffsetValue - } -} - -// MARK: - Initial Height -extension PopupStack { - func getInitialHeight() -> CGFloat { - guard let previousView = items.nextToLast else { return 30 } - - let height = heights.filter { $0.key == previousView.id }.first?.value ?? 30 - return height - } -} - -// MARK: - Last Popup Height -extension PopupStack { - func getLastPopupHeight() -> CGFloat? { heights[items.last?.id ?? .init()] } -} - -// MARK: - Drag Height Value -extension PopupStack { - func getLastDragHeight() -> CGFloat { dragHeights[items.last?.id ?? .init()] ?? 0 } -} - -// MARK: - Item ZIndex -extension PopupStack { - func getZIndex(_ item: AnyPopup) -> Double { .init(items.firstIndex(of: item) ?? 2137) } -} - - -// MARK: - Animations -extension PopupStack { - func getHeightAnimation(isAnimationDisabled: Bool) -> Animation? { !isAnimationDisabled ? transitionEntryAnimation : nil } -} -extension PopupStack { - var transitionEntryAnimation: Animation { globalConfig.common.animation.entry } - var transitionRemovalAnimation: Animation { globalConfig.common.animation.removal } - var dragGestureAnimation: Animation { globalConfig.common.animation.dragGesture } -} - -// MARK: - Configurables -extension PopupStack { - func getConfig(_ item: AnyPopup) -> Config { item.configurePopup(popup: .init()) } - var lastPopupConfig: Config { items.last?.configurePopup(popup: .init()) ?? .init() } -} - - -// MARK: - Helpers -private extension PopupStack { - func isLast(_ item: AnyPopup) -> Bool { items.last == item } - func isNextToLast(_ item: AnyPopup) -> Bool { invertedIndex(item) == 1 } - func invertedIndex(_ item: AnyPopup) -> Int { items.count - 1 - index(item) } - func index(_ item: AnyPopup) -> Int { items.firstIndex(of: item) ?? 0 } -} -private extension PopupStack { - var remainingTranslationProgress: CGFloat { 1 - translationProgress } -} diff --git a/Sources/Internal/UI/PopupCentreStackView.swift b/Sources/Internal/UI/PopupCentreStackView.swift new file mode 100644 index 0000000000..aee1e515eb --- /dev/null +++ b/Sources/Internal/UI/PopupCentreStackView.swift @@ -0,0 +1,50 @@ +// +// PopupCentreStackView.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +struct PopupCentreStackView: View { + @ObservedObject var viewModel: VM.CentreStack + + + var body: some View { + ZStack(content: createPopupStack) + .id(viewModel.popups.isEmpty) + .transition(transition) + .frame(maxWidth: .infinity, maxHeight: viewModel.screen.height) + } +} +private extension PopupCentreStackView { + func createPopupStack() -> some View { + ForEach(viewModel.popups, id: \.self, content: createPopup) + } +} +private extension PopupCentreStackView { + func createPopup(_ popup: AnyPopup) -> some View { + popup.body + .fixedSize(horizontal: false, vertical: viewModel.calculateVerticalFixedSize(for: popup)) + .onHeightChange { viewModel.recalculateAndSave(height: $0, for: popup) } + .frame(height: viewModel.activePopupHeight) + .frame(maxWidth: .infinity, maxHeight: viewModel.activePopupHeight) + .background(backgroundColor: getBackgroundColor(for: popup), overlayColor: .clear, corners: viewModel.calculateCornerRadius()) + .opacity(viewModel.calculateOpacity(for: popup)) + .focusSection_tvOS() + .padding(viewModel.calculatePopupPadding()) + .compositingGroup() + } +} + +private extension PopupCentreStackView { + func getBackgroundColor(for popup: AnyPopup) -> Color { popup.config.backgroundColor } +} +private extension PopupCentreStackView { + var transition: AnyTransition { .scale(scale: 1.1).combined(with: .opacity) } +} diff --git a/Sources/Internal/UI/PopupVerticalStackView.swift b/Sources/Internal/UI/PopupVerticalStackView.swift new file mode 100644 index 0000000000..2ea25d664a --- /dev/null +++ b/Sources/Internal/UI/PopupVerticalStackView.swift @@ -0,0 +1,55 @@ +// +// PopupVerticalStackView.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct PopupVerticalStackView: View { + @ObservedObject var viewModel: VM.VerticalStack + + + var body: some View { + ZStack(alignment: (!viewModel.alignment).toAlignment(), content: createPopupStack) + .frame(height: viewModel.screen.height, alignment: viewModel.alignment.toAlignment()) + .onDragGesture(onChanged: viewModel.onPopupDragGestureChanged, onEnded: viewModel.onPopupDragGestureEnded) + } +} +private extension PopupVerticalStackView { + func createPopupStack() -> some View { + ForEach(viewModel.popups, id: \.self, content: createPopup) + } +} +private extension PopupVerticalStackView { + func createPopup(_ popup: AnyPopup) -> some View { + popup.body + .padding(viewModel.calculateBodyPadding(for: popup)) + .fixedSize(horizontal: false, vertical: viewModel.calculateVerticalFixedSize(for: popup)) + .onHeightChange { viewModel.recalculateAndSave(height: $0, for: popup) } + .frame(height: viewModel.activePopupHeight, alignment: (!viewModel.alignment).toAlignment()) + .frame(maxWidth: .infinity, maxHeight: viewModel.activePopupHeight, alignment: (!viewModel.alignment).toAlignment()) + .background(backgroundColor: getBackgroundColor(for: popup), overlayColor: getStackOverlayColor(for: popup), corners: viewModel.calculateCornerRadius()) + .offset(y: viewModel.calculateOffsetY(for: popup)) + .scaleEffect(x: viewModel.calculateScaleX(for: popup)) + .focusSection_tvOS() + .padding(viewModel.calculatePopupPadding()) + .transition(transition) + .zIndex(viewModel.calculateZIndex()) + .compositingGroup() + } +} + +private extension PopupVerticalStackView { + func getBackgroundColor(for popup: AnyPopup) -> Color { popup.config.backgroundColor } + func getStackOverlayColor(for popup: AnyPopup) -> Color { stackOverlayColor.opacity(viewModel.calculateStackOverlayOpacity(for: popup)) } +} +private extension PopupVerticalStackView { + var stackOverlayColor: Color { .black } + var transition: AnyTransition { .move(edge: viewModel.alignment.toEdge()) } +} diff --git a/Sources/Internal/UI/PopupView.swift b/Sources/Internal/UI/PopupView.swift new file mode 100644 index 0000000000..d477887a6d --- /dev/null +++ b/Sources/Internal/UI/PopupView.swift @@ -0,0 +1,123 @@ +// +// PopupView.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +struct PopupView: View { + #if os(tvOS) + let rootView: any View + #endif + + @ObservedObject var popupManager: PopupManager + private let topStackViewModel: VM.VerticalStack = .init() + private let centreStackViewModel: VM.CentreStack = .init() + private let bottomStackViewModel: VM.VerticalStack = .init() + + + var body: some View { + #if os(tvOS) + AnyView(rootView) + .disabled(!popupManager.stack.isEmpty) + .overlay(createBody()) + #else + createBody() + #endif + } +} +private extension PopupView { + func createBody() -> some View { + GeometryReader { reader in + createPopupStackView() + .ignoresSafeArea() + .onAppear { onScreenChange(reader) } + .onChange(of: reader.size) { _ in onScreenChange(reader) } + } + .onAppear(perform: onAppear) + .onChange(of: popupManager.stack.map { [$0.height, $0.dragHeight] }, perform: onPopupsHeightChange) + .onChange(of: popupManager.stack) { [oldValue = popupManager.stack] newValue in onStackChange(oldValue, newValue) } + .onKeyboardStateChange(perform: onKeyboardStateChange) + } +} +private extension PopupView { + func createPopupStackView() -> some View { + ZStack { + createOverlayView() + createTopPopupStackView() + createCentrePopupStackView() + createBottomPopupStackView() + } + } +} +private extension PopupView { + func createOverlayView() -> some View { + getOverlayColor() + .zIndex(popupManager.stackPriority.overlay) + .animation(.linear, value: popupManager.stack) + .onTapGesture(perform: onTap) + } + func createTopPopupStackView() -> some View { + PopupVerticalStackView(viewModel: topStackViewModel).zIndex(popupManager.stackPriority.top) + } + func createCentrePopupStackView() -> some View { + PopupCentreStackView(viewModel: centreStackViewModel).zIndex(popupManager.stackPriority.centre) + } + func createBottomPopupStackView() -> some View { + PopupVerticalStackView(viewModel: bottomStackViewModel).zIndex(popupManager.stackPriority.bottom) + } +} +private extension PopupView { + func getOverlayColor() -> Color { switch popupManager.stack.last?.config.overlayColor { + case .some(let color) where color == .clear: .black.opacity(0.0000000000001) + case .some(let color): color + case nil: .clear + }} +} + +private extension PopupView { + func onAppear() { + updateViewModels { $0.setup(updatePopupAction: updatePopup, closePopupAction: closePopup) } + } + func onScreenChange(_ reader: GeometryProxy) { + updateViewModels { $0.updateScreenValue(.init(reader)) } + } + func onPopupsHeightChange(_ p: Any) { + updateViewModels { $0.updatePopupsValue(popupManager.stack) } + } + func onStackChange(_ oldStack: [AnyPopup], _ newStack: [AnyPopup]) { + newStack + .difference(from: oldStack) + .forEach { switch $0 { + case .remove(_, let element, _): element.onDismiss() + default: return + }} + newStack.last?.onFocus() + } + func onKeyboardStateChange(_ isKeyboardActive: Bool) { + updateViewModels { $0.updateKeyboardValue(isKeyboardActive) } + } + func onTap() { if tapOutsideClosesPopup { + popupManager.stack(.removeLastPopup) + }} +} +private extension PopupView { + func updatePopup(_ popup: AnyPopup) { + popupManager.updateStack(popup) + } + func closePopup(_ popup: AnyPopup) { + popupManager.stack(.removePopupInstance(popup)) + } + func updateViewModels(_ updateBuilder: (any ViewModelObject) -> ()) { + [topStackViewModel, centreStackViewModel, bottomStackViewModel].forEach(updateBuilder) + } +} +private extension PopupView { + var tapOutsideClosesPopup: Bool { popupManager.stack.last?.config.isTapOutsideToDismissEnabled ?? false } +} diff --git a/Sources/Internal/Utilities/Logger.swift b/Sources/Internal/Utilities/Logger.swift new file mode 100644 index 0000000000..4c240161f4 --- /dev/null +++ b/Sources/Internal/Utilities/Logger.swift @@ -0,0 +1,21 @@ +// +// Logger.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import os + +class Logger {} + +// MARK: Log +extension Logger { + static func log(level: OSLogType, message: String) { + os.Logger().log(level: level, "ERROR!\n\nFRAMEWORK: MijickPopups\nDESCRIPTION: \(message)") + } +} diff --git a/Sources/Internal/Utilities/PopupActionScheduler.swift b/Sources/Internal/Utilities/PopupActionScheduler.swift new file mode 100644 index 0000000000..b210a28d19 --- /dev/null +++ b/Sources/Internal/Utilities/PopupActionScheduler.swift @@ -0,0 +1,36 @@ +// +// PopupActionScheduler.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +class PopupActionScheduler { + private var time: Double = 0 + private var action: DispatchSourceTimer? +} + +// MARK: Prepare +extension PopupActionScheduler { + static func prepare(time: Double) -> PopupActionScheduler { + let scheduler = PopupActionScheduler() + scheduler.time = time + return scheduler + } +} + +// MARK: Schedule +extension PopupActionScheduler { + func schedule(action: @escaping () -> ()) { + self.action = DispatchSource.makeTimerSource(queue: .main) + self.action?.schedule(deadline: .now() + max(0.6, time)) + self.action?.setEventHandler(handler: action) + self.action?.resume() + } +} diff --git a/Sources/Internal/Utilities/VerticalEdge.swift b/Sources/Internal/Utilities/VerticalEdge.swift new file mode 100644 index 0000000000..6aaf2ea879 --- /dev/null +++ b/Sources/Internal/Utilities/VerticalEdge.swift @@ -0,0 +1,43 @@ +// +// VerticalEdge.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +enum VerticalEdge { + case top + case bottom + + init(_ config: LocalConfig.Type) { switch config.self { + case is TopPopupConfig.Type: self = .top + case is BottomPopupConfig.Type: self = .bottom + default: fatalError() + }} +} + +// MARK: Negation +extension VerticalEdge { + static prefix func !(lhs: Self) -> Self { switch lhs { + case .top: .bottom + case .bottom: .top + }} +} + +// MARK: Type Casting +extension VerticalEdge { + func toEdge() -> Edge { switch self { + case .top: .top + case .bottom: .bottom + }} + func toAlignment() -> Alignment { switch self { + case .top: .top + case .bottom: .bottom + }} +} diff --git a/Sources/Internal/View Models/ViewModel+CentreStack.swift b/Sources/Internal/View Models/ViewModel+CentreStack.swift new file mode 100644 index 0000000000..c305029f65 --- /dev/null +++ b/Sources/Internal/View Models/ViewModel+CentreStack.swift @@ -0,0 +1,126 @@ +// +// ViewModel+CentreStack.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension VM { class CentreStack: ViewModel { + // MARK: Overridden Methods + override func recalculateAndSave(height: CGFloat, for popup: AnyPopup) { _recalculateAndSave(height: height, for: popup) } + override func calculateHeightForActivePopup() -> CGFloat? { _calculateHeightForActivePopup() } + override func calculatePopupPadding() -> EdgeInsets { _calculatePopupPadding() } + override func calculateCornerRadius() -> [VerticalEdge : CGFloat] { _calculateCornerRadius() } + override func calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { _calculateVerticalFixedSize(for: popup) } +}} + + + +// MARK: - VIEW METHODS + + + +// MARK: Recalculate & Update Popup Height +private extension VM.CentreStack { + func _recalculateAndSave(height: CGFloat, for popup: AnyPopup) { + let newHeight = calculateHeight(height) + updateHeight(newHeight, popup) + } +} +private extension VM.CentreStack { + func calculateHeight(_ heightCandidate: CGFloat) -> CGFloat { + min(heightCandidate, calculateLargeScreenHeight()) + } +} +private extension VM.CentreStack { + func calculateLargeScreenHeight() -> CGFloat { + let fullscreenHeight = screen.height, + safeAreaHeight = screen.safeArea.top + screen.safeArea.bottom + return fullscreenHeight - safeAreaHeight + } +} + +// MARK: Popup Padding +private extension VM.CentreStack { + func _calculatePopupPadding() -> EdgeInsets { .init( + top: calculateVerticalPopupPadding(for: .top), + leading: calculateLeadingPopupPadding(), + bottom: calculateVerticalPopupPadding(for: .bottom), + trailing: calculateTrailingPopupPadding() + )} +} +private extension VM.CentreStack { + func calculateVerticalPopupPadding(for edge: VerticalEdge) -> CGFloat { + guard let activePopupHeight, + isKeyboardActive && edge == .bottom + else { return 0 } + + let remainingHeight = screen.height - activePopupHeight + let paddingCandidate = (remainingHeight / 2 - screen.safeArea.bottom) * 2 + return abs(min(paddingCandidate, 0)) + } + func calculateLeadingPopupPadding() -> CGFloat { + getActivePopupConfig().popupPadding.leading + } + func calculateTrailingPopupPadding() -> CGFloat { + getActivePopupConfig().popupPadding.trailing + } +} + +// MARK: Corner Radius +private extension VM.CentreStack { + func _calculateCornerRadius() -> [VerticalEdge : CGFloat] {[ + .top: getActivePopupConfig().cornerRadius, + .bottom: getActivePopupConfig().cornerRadius + ]} +} + +// MARK: Opacity +extension VM.CentreStack { + func calculateOpacity(for popup: AnyPopup) -> CGFloat { + popups.last == popup ? 1 : 0 + } +} + +// MARK: Fixed Size +private extension VM.CentreStack { + func _calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { + activePopupHeight != calculateLargeScreenHeight() + } +} + + + +// MARK: - HELPERS + + + +// MARK: Active Popup Height +private extension VM.CentreStack { + func _calculateHeightForActivePopup() -> CGFloat? { + popups.last?.height + } +} + + + +// MARK: - TESTS +#if DEBUG + + + +// MARK: Methods +extension VM.CentreStack { + func t_calculateHeight(heightCandidate: CGFloat) -> CGFloat { calculateHeight(heightCandidate) } + func t_calculatePopupPadding() -> EdgeInsets { calculatePopupPadding() } + func t_calculateCornerRadius() -> [VerticalEdge: CGFloat] { calculateCornerRadius() } + func t_calculateOpacity(for popup: AnyPopup) -> CGFloat { calculateOpacity(for: popup) } + func t_calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { calculateVerticalFixedSize(for: popup) } +} +#endif diff --git a/Sources/Internal/View Models/ViewModel+VerticalStack.swift b/Sources/Internal/View Models/ViewModel+VerticalStack.swift new file mode 100644 index 0000000000..568189cf8c --- /dev/null +++ b/Sources/Internal/View Models/ViewModel+VerticalStack.swift @@ -0,0 +1,484 @@ +// +// ViewModel+VerticalStack.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension VM { class VerticalStack: ViewModel { + // MARK: Attributes + private(set) var alignment: VerticalEdge + private(set) var gestureTranslation: CGFloat = 0 + private(set) var translationProgress: CGFloat = 0 + + // MARK: Overridden Methods + override func recalculateAndSave(height: CGFloat, for popup: AnyPopup) { _recalculateAndSave(height: height, for: popup) } + override func calculateHeightForActivePopup() -> CGFloat? { _calculateHeightForActivePopup() } + override func calculatePopupPadding() -> EdgeInsets { _calculatePopupPadding() } + override func calculateCornerRadius() -> [VerticalEdge : CGFloat] { _calculateCornerRadius() } + override func calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { _calculateVerticalFixedSize(for: popup) } + + // MARK: Initialise + override init() { self.alignment = .init(Config.self) } +}} + + + +// MARK: - SETUP & UPDATE + + + +// MARK: Update +private extension VM.VerticalStack { + func updateGestureTranslation(_ newGestureTranslation: CGFloat) { + gestureTranslation = newGestureTranslation + translationProgress = calculateTranslationProgress() + activePopupHeight = calculateHeightForActivePopup() + + withAnimation(gestureTranslation == 0 ? .transition : nil) { objectWillChange.send() } + } +} + + + +// MARK: - VIEW METHODS + + + +// MARK: Recalculate & Update Popup Height +private extension VM.VerticalStack { + func _recalculateAndSave(height: CGFloat, for popup: AnyPopup) { if gestureTranslation.isZero, height != popup.height { + let popupConfig = getConfig(popup) + let newHeight = calculateHeight(height, popupConfig) + updateHeight(newHeight, popup) + }} +} +private extension VM.VerticalStack { + func calculateHeight(_ heightCandidate: CGFloat, _ popupConfig: Config) -> CGFloat { switch popupConfig.heightMode { + case .auto: min(heightCandidate, calculateLargeScreenHeight()) + case .large: calculateLargeScreenHeight() + case .fullscreen: getFullscreenHeight() + }} +} +private extension VM.VerticalStack { + func calculateLargeScreenHeight() -> CGFloat { + let fullscreenHeight = getFullscreenHeight(), + safeAreaHeight = screen.safeArea[!alignment], + stackHeight = calculateStackHeight() + return fullscreenHeight - safeAreaHeight - stackHeight + } + func getFullscreenHeight() -> CGFloat { + screen.height + } +} +private extension VM.VerticalStack { + func calculateStackHeight() -> CGFloat { + let numberOfStackedItems = max(popups.count - 1, 0) + + let stackedItemsHeight = stackOffset * .init(numberOfStackedItems) + return stackedItemsHeight + } +} + +// MARK: Popup Padding +private extension VM.VerticalStack { + func _calculatePopupPadding() -> EdgeInsets { .init( + top: calculateVerticalPopupPadding(for: .top), + leading: calculateLeadingPopupPadding(), + bottom: calculateVerticalPopupPadding(for: .bottom), + trailing: calculateTrailingPopupPadding() + )} +} +private extension VM.VerticalStack { + func calculateVerticalPopupPadding(for edge: VerticalEdge) -> CGFloat { + guard let activePopupHeight else { return 0 } + + let largeScreenHeight = calculateLargeScreenHeight(), + priorityPopupPaddingValue = calculatePriorityPopupPaddingValue(for: edge), + remainingHeight = largeScreenHeight - activePopupHeight - priorityPopupPaddingValue + + let popupPaddingCandidate = min(remainingHeight, getActivePopupConfig().popupPadding[edge]) + return max(popupPaddingCandidate, 0) + } + func calculateLeadingPopupPadding() -> CGFloat { + getActivePopupConfig().popupPadding.leading + } + func calculateTrailingPopupPadding() -> CGFloat { + getActivePopupConfig().popupPadding.trailing + } +} +private extension VM.VerticalStack { + func calculatePriorityPopupPaddingValue(for edge: VerticalEdge) -> CGFloat { switch edge == alignment { + case true: 0 + case false: getActivePopupConfig().popupPadding[!edge] + }} +} + +// MARK: Body Padding +extension VM.VerticalStack { + func calculateBodyPadding(for popup: AnyPopup) -> EdgeInsets { let activePopupHeight = activePopupHeight ?? 0, popupConfig = getConfig(popup); return .init( + top: calculateTopBodyPadding(activePopupHeight: activePopupHeight, popupConfig: popupConfig), + leading: calculateLeadingBodyPadding(popupConfig: popupConfig), + bottom: calculateBottomBodyPadding(activePopupHeight: activePopupHeight, popupConfig: popupConfig), + trailing: calculateTrailingBodyPadding(popupConfig: popupConfig) + )} +} +private extension VM.VerticalStack { + func calculateTopBodyPadding(activePopupHeight: CGFloat, popupConfig: Config) -> CGFloat { + if popupConfig.ignoredSafeAreaEdges.contains(.top) { return 0 } + + return switch alignment { + case .top: calculateVerticalPaddingAdhereEdge(safeAreaHeight: screen.safeArea.top, popupPadding: calculatePopupPadding().top) + case .bottom: calculateVerticalPaddingCounterEdge(popupHeight: activePopupHeight, safeArea: screen.safeArea.top) + } + } + func calculateBottomBodyPadding(activePopupHeight: CGFloat, popupConfig: Config) -> CGFloat { + if popupConfig.ignoredSafeAreaEdges.contains(.bottom) && !isKeyboardActive { return 0 } + + return switch alignment { + case .top: calculateVerticalPaddingCounterEdge(popupHeight: activePopupHeight, safeArea: screen.safeArea.bottom) + case .bottom: calculateVerticalPaddingAdhereEdge(safeAreaHeight: screen.safeArea.bottom, popupPadding: calculatePopupPadding().bottom) + } + } + func calculateLeadingBodyPadding(popupConfig: Config) -> CGFloat { switch popupConfig.ignoredSafeAreaEdges.contains(.leading) { + case true: 0 + case false: screen.safeArea.leading + }} + func calculateTrailingBodyPadding(popupConfig: Config) -> CGFloat { switch popupConfig.ignoredSafeAreaEdges.contains(.trailing) { + case true: 0 + case false: screen.safeArea.trailing + }} +} +private extension VM.VerticalStack { + func calculateVerticalPaddingCounterEdge(popupHeight: CGFloat, safeArea: CGFloat) -> CGFloat { + let paddingValueCandidate = safeArea + popupHeight - screen.height + return max(paddingValueCandidate, 0) + } + func calculateVerticalPaddingAdhereEdge(safeAreaHeight: CGFloat, popupPadding: CGFloat) -> CGFloat { + let paddingValueCandidate = safeAreaHeight - popupPadding + return max(paddingValueCandidate, 0) + } +} + +// MARK: Offset Y +extension VM.VerticalStack { + func calculateOffsetY(for popup: AnyPopup) -> CGFloat { switch popup == popups.last { + case true: calculateOffsetForActivePopup() + case false: calculateOffsetForStackedPopup(popup) + }} +} +private extension VM.VerticalStack { + func calculateOffsetForActivePopup() -> CGFloat { + let lastPopupDragHeight = popups.last?.dragHeight ?? 0 + + return switch alignment { + case .top: min(gestureTranslation + lastPopupDragHeight, 0) + case .bottom: max(gestureTranslation - lastPopupDragHeight, 0) + } + } + func calculateOffsetForStackedPopup(_ popup: AnyPopup) -> CGFloat { + let invertedIndex = getInvertedIndex(of: popup) + let offsetValue = stackOffset * .init(invertedIndex) + let alignmentMultiplier = switch alignment { + case .top: 1.0 + case .bottom: -1.0 + } + + return offsetValue * alignmentMultiplier + } +} + +// MARK: Scale X +extension VM.VerticalStack { + func calculateScaleX(for popup: AnyPopup) -> CGFloat { + guard popup != popups.last else { return 1 } + + let invertedIndex = getInvertedIndex(of: popup), + remainingTranslationProgress = 1 - translationProgress + + let progressMultiplier = invertedIndex == 1 ? remainingTranslationProgress : max(minScaleProgressMultiplier, remainingTranslationProgress) + let scaleValue = .init(invertedIndex) * stackScaleFactor * progressMultiplier + return 1 - scaleValue + } +} +private extension VM.VerticalStack { + var minScaleProgressMultiplier: CGFloat { 0.7 } +} + +// MARK: Corner Radius +private extension VM.VerticalStack { + func _calculateCornerRadius() -> [VerticalEdge: CGFloat] { + let cornerRadiusValue = calculateCornerRadiusValue(getActivePopupConfig()) + return [ + .top: calculateTopCornerRadius(cornerRadiusValue), + .bottom: calculateBottomCornerRadius(cornerRadiusValue) + ] + } +} +private extension VM.VerticalStack { + func calculateCornerRadiusValue(_ activePopupConfig: Config) -> CGFloat { switch activePopupConfig.heightMode { + case .auto, .large: activePopupConfig.cornerRadius + case .fullscreen: 0 + }} + func calculateTopCornerRadius(_ cornerRadiusValue: CGFloat) -> CGFloat { switch alignment { + case .top: calculatePopupPadding().top != 0 ? cornerRadiusValue : 0 + case .bottom: cornerRadiusValue + }} + func calculateBottomCornerRadius(_ cornerRadiusValue: CGFloat) -> CGFloat { switch alignment { + case .top: cornerRadiusValue + case .bottom: calculatePopupPadding().bottom != 0 ? cornerRadiusValue : 0 + }} +} + +// MARK: Fixed Size +private extension VM.VerticalStack { + func _calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { switch getConfig(popup).heightMode { + case .fullscreen, .large: false + case .auto: activePopupHeight != calculateLargeScreenHeight() + }} +} + +// MARK: Z Index +extension VM.VerticalStack { + func calculateZIndex() -> CGFloat { + popups.last == nil ? 2137 : .init(popups.count) + } +} + +// MARK: - Stack Overlay Opacity +extension VM.VerticalStack { + func calculateStackOverlayOpacity(for popup: AnyPopup) -> Double { + guard popup != popups.last else { return 0 } + + let invertedIndex = getInvertedIndex(of: popup), + remainingTranslationProgress = 1 - translationProgress + + let progressMultiplier = invertedIndex == 1 ? remainingTranslationProgress : max(minStackOverlayProgressMultiplier, remainingTranslationProgress) + let overlayValue = min(stackOverlayFactor * .init(invertedIndex), maxStackOverlayFactor) + + let opacity = overlayValue * progressMultiplier + return max(opacity, 0) + } +} +private extension VM.VerticalStack { + var minStackOverlayProgressMultiplier: CGFloat { 0.6 } +} + + + +// MARK: - HELPERS + + + +// MARK: Active Popup Height +private extension VM.VerticalStack { + func _calculateHeightForActivePopup() -> CGFloat? { + guard let activePopupHeight = popups.last?.height else { return nil } + + let activePopupDragHeight = popups.last?.dragHeight ?? 0 + let popupHeightFromGestureTranslation = activePopupHeight + activePopupDragHeight + gestureTranslation * getDragTranslationMultiplier() + + let newHeightCandidate1 = max(activePopupHeight, popupHeightFromGestureTranslation), + newHeightCanditate2 = screen.height + return min(newHeightCandidate1, newHeightCanditate2) + } +} +private extension VM.VerticalStack { + func getDragTranslationMultiplier() -> CGFloat { switch alignment { + case .top: 1 + case .bottom: -1 + }} +} + +// MARK: Translation Progress +private extension VM.VerticalStack { + func calculateTranslationProgress() -> CGFloat { guard let activePopupHeight = popups.last?.height else { return 0 }; return switch alignment { + case .top: abs(min(gestureTranslation + (popups.last?.dragHeight ?? 0), 0)) / activePopupHeight + case .bottom: max(gestureTranslation - (popups.last?.dragHeight ?? 0), 0) / activePopupHeight + }} +} + +// MARK: Others +private extension VM.VerticalStack { + func getInvertedIndex(of popup: AnyPopup) -> Int { + let index = popups.firstIndex(of: popup) ?? 0 + let invertedIndex = popups.count - 1 - index + return invertedIndex + } +} + +// MARK: Attributes +private extension VM.VerticalStack { + var stackScaleFactor: CGFloat { 0.025 } + var stackOverlayFactor: CGFloat { 0.1 } + var maxStackOverlayFactor: CGFloat { 0.48 } + var stackOffset: CGFloat { GlobalConfigContainer.vertical.isStackingEnabled ? 8 : 0 } + var dragThreshold: CGFloat { GlobalConfigContainer.vertical.dragThreshold } + var dragGestureEnabled: Bool { getActivePopupConfig().isDragGestureEnabled } +} + + + +// MARK: - GESTURES + + + +// MARK: On Changed +extension VM.VerticalStack { + func onPopupDragGestureChanged(_ value: CGFloat) { if dragGestureEnabled { + let newGestureTranslation = calculateGestureTranslation(value) + updateGestureTranslation(newGestureTranslation) + }} +} +private extension VM.VerticalStack { + func calculateGestureTranslation(_ value: CGFloat) -> CGFloat { switch getActivePopupConfig().dragDetents.isEmpty { + case true: calculateGestureTranslationWhenNoDragDetents(value) + case false: calculateGestureTranslationWhenDragDetents(value) + }} +} +private extension VM.VerticalStack { + func calculateGestureTranslationWhenNoDragDetents(_ value: CGFloat) -> CGFloat { + calculateDragExtremeValue(value, 0) + } + func calculateGestureTranslationWhenDragDetents(_ value: CGFloat) -> CGFloat { guard value * getDragTranslationMultiplier() > 0, let activePopupHeight = popups.last?.height else { return value } + let maxHeight = calculateMaxHeightForDragGesture(activePopupHeight) + let dragTranslation = calculateDragTranslation(maxHeight, activePopupHeight) + return calculateDragExtremeValue(dragTranslation, value) + } +} +private extension VM.VerticalStack { + func calculateMaxHeightForDragGesture(_ activePopupHeight: CGFloat) -> CGFloat { + let maxHeight1 = (calculatePopupTargetHeightsFromDragDetents(activePopupHeight).max() ?? 0) + dragTranslationThreshold + let maxHeight2 = screen.height + return min(maxHeight1, maxHeight2) + } + func calculateDragTranslation(_ maxHeight: CGFloat, _ activePopupHeight: CGFloat) -> CGFloat { + let translation = maxHeight - activePopupHeight - (popups.last?.dragHeight ?? 0) + return translation * getDragTranslationMultiplier() + } + func calculateDragExtremeValue(_ value1: CGFloat, _ value2: CGFloat) -> CGFloat { switch alignment { + case .top: min(value1, value2) + case .bottom: max(value1, value2) + }} +} +private extension VM.VerticalStack { + var dragTranslationThreshold: CGFloat { 8 } +} + +// MARK: On Ended +extension VM.VerticalStack { + func onPopupDragGestureEnded(_ value: CGFloat) { if value != 0 { + dismissLastItemIfNeeded() + updateTranslationValues() + }} +} +private extension VM.VerticalStack { + func dismissLastItemIfNeeded() { if shouldDismissPopup() { if let popup = popups.last { + closePopupAction(popup) + }}} + func updateTranslationValues() { if let activePopupHeight = popups.last?.height { + let currentPopupHeight = calculateCurrentPopupHeight(activePopupHeight) + let popupTargetHeights = calculatePopupTargetHeightsFromDragDetents(activePopupHeight) + let targetHeight = calculateTargetPopupHeight(currentPopupHeight, popupTargetHeights) + let targetDragHeight = calculateTargetDragHeight(targetHeight, activePopupHeight) + + resetGestureTranslation() + updateDragHeight(targetDragHeight) + }} +} +private extension VM.VerticalStack { + func calculateCurrentPopupHeight(_ activePopupHeight: CGFloat) -> CGFloat { + let activePopupDragHeight = popups.last?.dragHeight ?? 0 + let currentDragHeight = activePopupDragHeight + gestureTranslation * getDragTranslationMultiplier() + + let currentPopupHeight = activePopupHeight + currentDragHeight + return currentPopupHeight + } + func calculatePopupTargetHeightsFromDragDetents(_ activePopupHeight: CGFloat) -> [CGFloat] { + getActivePopupConfig().dragDetents + .map { switch $0 { + case .height(let targetHeight): min(targetHeight, calculateLargeScreenHeight()) + case .fraction(let fraction): min(fraction * activePopupHeight, calculateLargeScreenHeight()) + case .large: calculateLargeScreenHeight() + case .fullscreen: screen.height + }} + .appending(activePopupHeight) + .sorted(by: <) + } + func calculateTargetPopupHeight(_ currentPopupHeight: CGFloat, _ popupTargetHeights: [CGFloat]) -> CGFloat { + guard let activePopupHeight = popups.last?.height, + currentPopupHeight < screen.height + else { return popupTargetHeights.last ?? 0 } + + let initialIndex = popupTargetHeights.firstIndex(where: { $0 >= currentPopupHeight }) ?? popupTargetHeights.count - 1, + targetIndex = gestureTranslation * getDragTranslationMultiplier() > 0 ? initialIndex : max(0, initialIndex - 1) + let previousPopupHeight = (popups.last?.dragHeight ?? 0) + activePopupHeight, + popupTargetHeight = popupTargetHeights[targetIndex], + deltaHeight = abs(previousPopupHeight - popupTargetHeight) + let progress = abs(currentPopupHeight - previousPopupHeight) / deltaHeight + + if progress < dragThreshold { + let index = gestureTranslation * getDragTranslationMultiplier() > 0 ? max(0, initialIndex - 1) : initialIndex + return popupTargetHeights[index] + } + return popupTargetHeights[targetIndex] + } + func calculateTargetDragHeight(_ targetHeight: CGFloat, _ activePopupHeight: CGFloat) -> CGFloat { + targetHeight - activePopupHeight + } + func updateDragHeight(_ targetDragHeight: CGFloat) { if let activePopup = popups.last { + updatePopupAction(activePopup.settingDragHeight(targetDragHeight)) + }} + func resetGestureTranslation() { + updateGestureTranslation(0) + } + func shouldDismissPopup() -> Bool { + translationProgress >= dragThreshold + } +} + + + +// MARK: - TESTS +#if DEBUG + + + +// MARK: Methods +extension VM.VerticalStack { + func t_calculatePopupPadding() -> EdgeInsets { calculatePopupPadding() } + func t_calculateBodyPadding(for popup: AnyPopup) -> EdgeInsets { calculateBodyPadding(for: popup) } + func t_calculateHeight(heightCandidate: CGFloat, popupConfig: Config) -> CGFloat { calculateHeight(heightCandidate, popupConfig) } + func t_calculateOffsetY(for popup: AnyPopup) -> CGFloat { calculateOffsetY(for: popup) } + func t_calculateScaleX(for popup: AnyPopup) -> CGFloat { calculateScaleX(for: popup) } + func t_calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { calculateVerticalFixedSize(for: popup) } + func t_calculateStackOverlayOpacity(for popup: AnyPopup) -> CGFloat { calculateStackOverlayOpacity(for: popup) } + func t_calculateCornerRadius() -> [VerticalEdge: CGFloat] { calculateCornerRadius() } + func t_calculateTranslationProgress() -> CGFloat { calculateTranslationProgress() } + func t_getInvertedIndex(of popup: AnyPopup) -> Int { getInvertedIndex(of: popup) } + + func t_calculateAndUpdateTranslationProgress() { translationProgress = calculateTranslationProgress() } + func t_updateGestureTranslation(_ newGestureTranslation: CGFloat) { updateGestureTranslation(newGestureTranslation) } + + func t_onPopupDragGestureChanged(_ value: CGFloat) { onPopupDragGestureChanged(value) } + func t_onPopupDragGestureEnded(_ value: CGFloat) { onPopupDragGestureEnded(value) } +} + +// MARK: Variables +extension VM.VerticalStack { + var t_stackOffset: CGFloat { stackOffset } + var t_stackScaleFactor: CGFloat { stackScaleFactor } + var t_stackOverlayFactor: CGFloat { stackOverlayFactor } + var t_minScaleProgressMultiplier: CGFloat { minScaleProgressMultiplier } + var t_minStackOverlayProgressMultiplier: CGFloat { minStackOverlayProgressMultiplier } + var t_maxStackOverlayFactor: CGFloat { maxStackOverlayFactor } + var t_dragTranslationThreshold: CGFloat { dragTranslationThreshold } + var t_gestureTranslation: CGFloat { gestureTranslation } +} +#endif diff --git a/Sources/Internal/View Models/ViewModel.swift b/Sources/Internal/View Models/ViewModel.swift new file mode 100644 index 0000000000..c8650c81b4 --- /dev/null +++ b/Sources/Internal/View Models/ViewModel.swift @@ -0,0 +1,100 @@ +// +// ViewModel.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +enum VM {} +class ViewModel: ViewModelObject { + // MARK: Attributes + private(set) var popups: [AnyPopup] = [] + private(set) var updatePopupAction: ((AnyPopup) -> ())! + private(set) var closePopupAction: ((AnyPopup) -> ())! + + // MARK: Subclass Attributes + var activePopupHeight: CGFloat? = nil + var screen: Screen = .init() + var isKeyboardActive: Bool = false + + // MARK: Methods to Override + func recalculateAndSave(height: CGFloat, for popup: AnyPopup) { fatalError() } + func calculateHeightForActivePopup() -> CGFloat? { fatalError() } + func calculatePopupPadding() -> EdgeInsets { fatalError() } + func calculateCornerRadius() -> [VerticalEdge: CGFloat] { fatalError() } + func calculateVerticalFixedSize(for popup: AnyPopup) -> Bool { fatalError() } +} + +// MARK: Setup +extension ViewModel { + func setup(updatePopupAction: @escaping (AnyPopup) -> (), closePopupAction: @escaping (AnyPopup) -> ()) { + self.updatePopupAction = updatePopupAction + self.closePopupAction = closePopupAction + } +} + +// MARK: Update +extension ViewModel { + func updatePopupsValue(_ newPopups: [AnyPopup]) { + popups = newPopups.filter { $0.config is Config } + activePopupHeight = calculateHeightForActivePopup() + + withAnimation(.transition) { objectWillChange.send() } + } + func updateScreenValue(_ newScreen: Screen) { + screen = newScreen + + withAnimation(.transition) { objectWillChange.send() } + } + func updateKeyboardValue(_ isActive: Bool) { + isKeyboardActive = isActive + + withAnimation(.transition) { objectWillChange.send() } + } +} + +// MARK: Helpers +extension ViewModel { + func updateHeight(_ newHeight: CGFloat, _ popup: AnyPopup) { if popup.height != newHeight { + updatePopupAction(popup.settingHeight(newHeight)) + }} +} +extension ViewModel { + func getConfig(_ item: AnyPopup?) -> Config { + let config = item?.config as? Config + return config ?? .init() + } + func getActivePopupConfig() -> Config { + getConfig(popups.last) + } +} + + + +// MARK: - TESTS +#if DEBUG + + + +// MARK: Methods +extension ViewModel { + func t_setup(updatePopupAction: @escaping (AnyPopup) -> (), closePopupAction: @escaping (AnyPopup) -> ()) { setup(updatePopupAction: updatePopupAction, closePopupAction: closePopupAction) } + func t_updatePopupsValue(_ newPopups: [AnyPopup]) { updatePopupsValue(newPopups) } + func t_updateScreenValue(_ newScreen: Screen) { updateScreenValue(newScreen) } + func t_updateKeyboardValue(_ isActive: Bool) { updateKeyboardValue(isActive) } + func t_updatePopup(_ popup: AnyPopup) { updatePopupAction(popup) } + func t_calculateAndUpdateActivePopupHeight() { activePopupHeight = calculateHeightForActivePopup() } +} + +// MARK: Variables +extension ViewModel { + var t_popups: [AnyPopup] { popups } + var t_activePopupHeight: CGFloat? { activePopupHeight } +} +#endif diff --git a/Sources/Internal/View Models/ViewModelObject.swift b/Sources/Internal/View Models/ViewModelObject.swift new file mode 100644 index 0000000000..d917d39a60 --- /dev/null +++ b/Sources/Internal/View Models/ViewModelObject.swift @@ -0,0 +1,23 @@ +// +// ViewModelObject.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +@MainActor protocol ViewModelObject: ObservableObject { + associatedtype Config = LocalConfig + + func setup(updatePopupAction: @escaping (AnyPopup) -> (), closePopupAction: @escaping (AnyPopup) -> ()) + func updatePopupsValue(_ newPopups: [AnyPopup]) + func updateScreenValue(_ newScreen: Screen) + func updateKeyboardValue(_ isActive: Bool) + func getConfig(_ item: AnyPopup?) -> Config + func getActivePopupConfig() -> Config +} diff --git a/Sources/Internal/View Modifiers/HeightReader.swift b/Sources/Internal/View Modifiers/HeightReader.swift deleted file mode 100644 index d57e47ca8d..0000000000 --- a/Sources/Internal/View Modifiers/HeightReader.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// HeightReader.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -extension View { - func readHeight(onChange action: @escaping (CGFloat) -> ()) -> some View { modifier(Modifier(onHeightChange: action)) } -} - -// MARK: - Implementation -fileprivate struct Modifier: ViewModifier { - let onHeightChange: (CGFloat) -> () - - func body(content: Content) -> some View { content - .background( - GeometryReader { geo -> Color in - DispatchQueue.main.async { onHeightChange(geo.size.height) } - return Color.clear - } - ) - } -} diff --git a/Sources/Internal/View Modifiers/RoundedCorner.swift b/Sources/Internal/View Modifiers/RoundedCorner.swift deleted file mode 100644 index 1ffbb4f3b8..0000000000 --- a/Sources/Internal/View Modifiers/RoundedCorner.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// RoundedCorner.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -extension View { - func background(_ backgroundColour: Color, overlayColour: Color, radius: CGFloat, corners: RectCorner, shadow: Shadow) -> some View { - overlay(createRoundedCorner(overlayColour, radius, corners)) - .background(createRoundedCorner(backgroundColour, radius, corners).createShadow(shadow)) - } -} -private extension View { - func createRoundedCorner(_ colour: Color, _ radius: CGFloat, _ corners: RectCorner) -> some View { RoundedCorner(radius: radius, corners: corners).fill(colour) } - func createShadow(_ shadowAttributes: Shadow) -> some View { shadow(color: shadowAttributes.color, radius: shadowAttributes.radius, x: shadowAttributes.x, y: shadowAttributes.y) } -} - -// MARK: - Implementation -fileprivate struct RoundedCorner: Shape { - var radius: CGFloat - var corners: RectCorner - - - var animatableData: CGFloat { - get { radius } - set { radius = newValue } - } - func path(in rect: CGRect) -> Path { - let points = createPoints(rect) - let path = createPath(rect, points) - return path - } -} -private extension RoundedCorner { - func createPoints(_ rect: CGRect) -> [CGPoint] {[ - .init(x: rect.minX, y: corners.contains(.topLeft) ? rect.minY + radius : rect.minY), - .init(x: corners.contains(.topLeft) ? rect.minX + radius : rect.minX, y: rect.minY ), - .init(x: corners.contains(.topRight) ? rect.maxX - radius : rect.maxX, y: rect.minY ), - .init(x: rect.maxX, y: corners.contains(.topRight) ? rect.minY + radius : rect.minY ), - .init(x: rect.maxX, y: corners.contains(.bottomRight) ? rect.maxY - radius : rect.maxY ), - .init(x: corners.contains(.bottomRight) ? rect.maxX - radius : rect.maxX, y: rect.maxY ), - .init(x: corners.contains(.bottomLeft) ? rect.minX + radius : rect.minX, y: rect.maxY ), - .init(x: rect.minX, y: corners.contains(.bottomLeft) ? rect.maxY - radius : rect.maxY ) - ]} - func createPath(_ rect: CGRect, _ points: [CGPoint]) -> Path { - var path = Path() - - path.move(to: points[0]) - path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), tangent2End: points[1], radius: radius) - path.addLine(to: points[2]) - path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), tangent2End: points[3], radius: radius) - path.addLine(to: points[4]) - path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), tangent2End: points[5], radius: radius) - path.addLine(to: points[6]) - path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), tangent2End: points[7], radius: radius) - path.closeSubpath() - - return path - } -} - - -struct RectCorner: OptionSet { let rawValue: Int } -extension RectCorner { - static let topLeft = RectCorner(rawValue: 1 << 0) - static let topRight = RectCorner(rawValue: 1 << 1) - static let bottomRight = RectCorner(rawValue: 1 << 2) - static let bottomLeft = RectCorner(rawValue: 1 << 3) - static let allCorners: RectCorner = [.topLeft, topRight, .bottomLeft, .bottomRight] -} diff --git a/Sources/Internal/Views/PopupBottomStackView.swift b/Sources/Internal/Views/PopupBottomStackView.swift deleted file mode 100644 index 230a2076f6..0000000000 --- a/Sources/Internal/Views/PopupBottomStackView.swift +++ /dev/null @@ -1,241 +0,0 @@ -// -// PopupBottomStackView.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -struct PopupBottomStackView: PopupStack { - let items: [AnyPopup] - let globalConfig: GlobalConfig - @State var gestureTranslation: CGFloat = 0 - @State var heights: [ID: CGFloat] = [:] - @State var dragHeights: [ID: CGFloat] = [:] - @GestureState var isGestureActive: Bool = false - @ObservedObject private var screenManager: ScreenManager = .shared - @ObservedObject private var keyboardManager: KeyboardManager = .shared - - - var body: some View { - ZStack(alignment: .top, content: createPopupStack) - .background(createTapArea()) - .animation(getHeightAnimation(isAnimationDisabled: screenManager.animationsDisabled), value: heights) - .animation(isGestureActive ? dragGestureAnimation : transitionRemovalAnimation, value: gestureTranslation) - .animation(.keyboard, value: isKeyboardVisible) - .onDragGesture($isGestureActive, onChanged: onPopupDragGestureChanged, onEnded: onPopupDragGestureEnded) - } -} - -private extension PopupBottomStackView { - func createPopupStack() -> some View { - ForEach(items, id: \.self, content: createPopup) - } -} - -private extension PopupBottomStackView { - func createPopup(_ item: AnyPopup) -> some View { - item.body - .padding(.top, getContentTopPadding()) - .padding(.bottom, getContentBottomPadding()) - .padding(.leading, screenManager.safeArea.left) - .padding(.trailing, screenManager.safeArea.right) - .fixedSize(horizontal: false, vertical: getFixedSize(item)) - .readHeight { saveHeight($0, for: item) } - .frame(height: getHeight(item), alignment: .top).frame(maxWidth: .infinity, maxHeight: height) - .background(getBackgroundColour(for: item), overlayColour: getStackOverlayColour(item), radius: getCornerRadius(item), corners: getCorners(), shadow: popupShadow) - .padding(.horizontal, popupHorizontalPadding) - .offset(y: getOffset(item)) - .scaleEffect(x: getScale(item)) - .opacity(getOpacity(item)) - .compositingGroup() - .focusSectionIfAvailable() - .align(to: .bottom, lastPopupConfig.contentFillsEntireScreen ? 0 : popupBottomPadding) - .transition(transition) - .zIndex(getZIndex(item)) - } -} - -// MARK: - Gestures - -// MARK: On Changed -private extension PopupBottomStackView { - func onPopupDragGestureChanged(_ value: CGFloat) { if canDragGestureBeUsed() { - updateGestureTranslation(value) - }} -} -private extension PopupBottomStackView { - func canDragGestureBeUsed() -> Bool { lastPopupConfig.dragGestureEnabled ?? globalConfig.bottom.dragGestureEnabled } - func updateGestureTranslation(_ value: CGFloat) { switch lastPopupConfig.dragDetents.isEmpty { - case true: gestureTranslation = calculateGestureTranslationWhenNoDragDetents(value) - case false: gestureTranslation = calculateGestureTranslationWhenDragDetents(value) - }} -} -private extension PopupBottomStackView { - func calculateGestureTranslationWhenNoDragDetents(_ value: CGFloat) -> CGFloat { max(value, 0) } - func calculateGestureTranslationWhenDragDetents(_ value: CGFloat) -> CGFloat { guard value < 0, let lastPopupHeight = getLastPopupHeight() else { return value } - let maxHeight = calculateMaxHeightForDragGesture(lastPopupHeight) - let dragTranslation = calculateDragTranslation(maxHeight, lastPopupHeight) - return max(dragTranslation, value) - } -} -private extension PopupBottomStackView { - func calculateMaxHeightForDragGesture(_ lastPopupHeight: CGFloat) -> CGFloat { - let maxHeight1 = (calculatePopupTargetHeightsFromDragDetents(lastPopupHeight).max() ?? 0) + dragTranslationThreshold - let maxHeight2 = screenManager.size.height - return min(maxHeight1, maxHeight2) - } - func calculateDragTranslation(_ maxHeight: CGFloat, _ lastPopupHeight: CGFloat) -> CGFloat { - let translation = maxHeight - lastPopupHeight - getLastDragHeight() - return -translation - } -} -private extension PopupBottomStackView { - var dragTranslationThreshold: CGFloat { 8 } -} - -// MARK: On Ended -private extension PopupBottomStackView { - func onPopupDragGestureEnded(_ value: CGFloat) { guard value != 0 else { return } - dismissLastItemIfNeeded() - updateTranslationValues() - } -} -private extension PopupBottomStackView { - func dismissLastItemIfNeeded() { if shouldDismissPopup() { - items.last?.remove() - }} - func updateTranslationValues() { if let lastPopupHeight = getLastPopupHeight() { - let currentPopupHeight = calculateCurrentPopupHeight(lastPopupHeight) - let popupTargetHeights = calculatePopupTargetHeightsFromDragDetents(lastPopupHeight) - let targetHeight = calculateTargetPopupHeight(currentPopupHeight, popupTargetHeights) - let targetDragHeight = calculateTargetDragHeight(targetHeight, lastPopupHeight) - - resetGestureTranslation() - updateDragHeight(targetDragHeight) - }} -} -private extension PopupBottomStackView { - func calculateCurrentPopupHeight(_ lastPopupHeight: CGFloat) -> CGFloat { - let lastDragHeight = getLastDragHeight() - let currentDragHeight = lastDragHeight - gestureTranslation - - let currentPopupHeight = lastPopupHeight + currentDragHeight - return currentPopupHeight - } - func calculatePopupTargetHeightsFromDragDetents(_ lastPopupHeight: CGFloat) -> [CGFloat] { lastPopupConfig.dragDetents - .map { switch $0 { - case .fixed(let targetHeight): min(targetHeight, getMaxHeight()) - case .fraction(let fraction): min(fraction * lastPopupHeight, getMaxHeight()) - case .fullscreen(let stackVisible): stackVisible ? getMaxHeight() : screenManager.size.height - }} - .appending(lastPopupHeight) - .sorted(by: <) - } - func calculateTargetPopupHeight(_ currentPopupHeight: CGFloat, _ popupTargetHeights: [CGFloat]) -> CGFloat { - guard let lastPopupHeight = getLastPopupHeight(), - currentPopupHeight < screenManager.size.height - else { return popupTargetHeights.last ?? 0 } - - let initialIndex = popupTargetHeights.firstIndex(where: { $0 >= currentPopupHeight }) ?? popupTargetHeights.count - 1, - targetIndex = gestureTranslation <= 0 ? initialIndex : max(0, initialIndex - 1) - let previousPopupHeight = getLastDragHeight() + lastPopupHeight, - popupTargetHeight = popupTargetHeights[targetIndex], - deltaHeight = abs(previousPopupHeight - popupTargetHeight) - let progress = abs(currentPopupHeight - previousPopupHeight) / deltaHeight - - if progress < gestureClosingThresholdFactor { - let index = gestureTranslation <= 0 ? max(0, initialIndex - 1) : initialIndex - return popupTargetHeights[index] - } - return popupTargetHeights[targetIndex] - } - func calculateTargetDragHeight(_ targetHeight: CGFloat, _ lastPopupHeight: CGFloat) -> CGFloat { - targetHeight - lastPopupHeight - } - func updateDragHeight(_ targetDragHeight: CGFloat) { if let id = items.last?.id { DispatchQueue.main.async { - dragHeights[id] = targetDragHeight - }}} - func resetGestureTranslation() { - let resetAfter = items.count == 1 && shouldDismissPopup() ? 0.25 : 0 - DispatchQueue.main.asyncAfter(deadline: .now() + resetAfter) { gestureTranslation = 0 } - } - func shouldDismissPopup() -> Bool { - translationProgress >= gestureClosingThresholdFactor - } -} - -// MARK: - View Modifiers -private extension PopupBottomStackView { - func getCorners() -> RectCorner { switch popupBottomPadding { - case 0: return [.topLeft, .topRight] - default: return .allCorners - }} - func saveHeight(_ height: CGFloat, for item: AnyPopup) { if !isGestureActive { - let config = item.configurePopup(popup: .init()) - - if config.contentFillsEntireScreen { return heights[item.id] = screenManager.size.height } - if config.contentFillsWholeHeight { return heights[item.id] = getMaxHeight() } - return heights[item.id] = min(height, maxHeight) - }} - func getMaxHeight() -> CGFloat { - let basicHeight = screenManager.size.height - screenManager.safeArea.top - popupBottomPadding - let stackedViewsCount = min(max(0, globalConfig.bottom.stackLimit - 1), items.count - 1) - let stackedViewsHeight = globalConfig.bottom.stackOffset * .init(stackedViewsCount) * maxHeightStackedFactor - return basicHeight - stackedViewsHeight - } - func getContentBottomPadding() -> CGFloat { - if isKeyboardVisible { return keyboardManager.height + distanceFromKeyboard } - if lastPopupConfig.contentIgnoresSafeArea { return 0 } - - return max(screenManager.safeArea.bottom - popupBottomPadding, 0) - } - func getContentTopPadding() -> CGFloat { - if lastPopupConfig.contentIgnoresSafeArea { return 0 } - - let heightWithoutTopSafeArea = screenManager.size.height - screenManager.safeArea.top - let topPadding = height - heightWithoutTopSafeArea - return max(topPadding, 0) - } - func getHeight(_ item: AnyPopup) -> CGFloat? { getConfig(item).contentFillsEntireScreen ? nil : height } - func getFixedSize(_ item: AnyPopup) -> Bool { !(getConfig(item).contentFillsEntireScreen || getConfig(item).contentFillsWholeHeight || height == maxHeight) } - func getBackgroundColour(for item: AnyPopup) -> Color { item.configurePopup(popup: .init()).backgroundColour ?? globalConfig.bottom.backgroundColour } -} - -// MARK: - Flags & Values -extension PopupBottomStackView { - var popupBottomPadding: CGFloat { lastPopupConfig.popupPadding.bottom } - var popupHorizontalPadding: CGFloat { lastPopupConfig.popupPadding.horizontal } - var popupShadow: Shadow { globalConfig.bottom.shadow } - var height: CGFloat { - let lastDragHeight = getLastDragHeight(), - lastPopupHeight = getLastPopupHeight() ?? (lastPopupConfig.contentFillsEntireScreen ? screenManager.size.height : getInitialHeight()) - let dragTranslation = lastPopupHeight + lastDragHeight - gestureTranslation - let newHeight = max(lastPopupHeight, dragTranslation) - - switch lastPopupHeight + lastDragHeight > screenManager.size.height && !lastPopupConfig.contentIgnoresSafeArea { - case true: return newHeight == screenManager.size.height ? newHeight : newHeight - screenManager.safeArea.top - case false: return newHeight - } - } - var maxHeight: CGFloat { getMaxHeight() - popupBottomPadding } - var distanceFromKeyboard: CGFloat { lastPopupConfig.distanceFromKeyboard ?? globalConfig.bottom.distanceFromKeyboard } - var cornerRadius: CGFloat { let cornerRadius = lastPopupConfig.cornerRadius ?? globalConfig.bottom.cornerRadius; return lastPopupConfig.contentFillsEntireScreen ? min(cornerRadius, screenManager.cornerRadius ?? 0) : cornerRadius } - var maxHeightStackedFactor: CGFloat { 0.85 } - var isKeyboardVisible: Bool { keyboardManager.height > 0 } - - var stackLimit: Int { globalConfig.bottom.stackLimit } - var stackScaleFactor: CGFloat { globalConfig.bottom.stackScaleFactor } - var stackOffsetValue: CGFloat { -globalConfig.bottom.stackOffset } - var stackCornerRadiusMultiplier: CGFloat { globalConfig.bottom.stackCornerRadiusMultiplier } - - var translationProgress: CGFloat { guard let popupHeight = getLastPopupHeight() else { return 0 }; return max(gestureTranslation - getLastDragHeight(), 0) / popupHeight } - var gestureClosingThresholdFactor: CGFloat { globalConfig.bottom.dragGestureProgressToClose } - var transition: AnyTransition { .move(edge: .bottom) } - - var tapOutsideClosesPopup: Bool { lastPopupConfig.tapOutsideClosesView ?? globalConfig.bottom.tapOutsideClosesView } -} diff --git a/Sources/Internal/Views/PopupCentreStackView.swift b/Sources/Internal/Views/PopupCentreStackView.swift deleted file mode 100644 index ee09cd4a3a..0000000000 --- a/Sources/Internal/Views/PopupCentreStackView.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// PopupCentreStackView.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -struct PopupCentreStackView: PopupStack { - let items: [AnyPopup] - let globalConfig: GlobalConfig - @ObservedObject private var screen: ScreenManager = .shared - @ObservedObject private var keyboardManager: KeyboardManager = .shared - @State private var activeView: AnyView? - @State private var configTemp: CentrePopupConfig? - @State private var height: CGFloat? - @State private var contentIsAnimated: Bool = false - - - var body: some View { - createPopup() - .align(to: .bottom, keyboardManager.height == 0 ? nil : keyboardManager.height) - .frame(height: screen.size.height) - .background(createTapArea()) - .animation(transitionEntryAnimation, value: lastPopupConfig.horizontalPadding) - .animation(height == nil ? transitionRemovalAnimation : transitionEntryAnimation, value: height) - .animation(transitionEntryAnimation, value: contentIsAnimated) - .animation(.keyboard, value: keyboardManager.height) - .transition(getTransition()) - .onChange(of: items, perform: onItemsChange) - } -} - -private extension PopupCentreStackView { - @ViewBuilder func createPopup() -> some View { - if #available(iOS 15, *) { createPopupForNewPlatforms() } - else { createPopupForOlderPlatforms() } - } -} - -private extension PopupCentreStackView { - func createPopupForNewPlatforms() -> some View { - activeView? - .readHeight(onChange: saveHeight) - .frame(height: height).frame(maxWidth: .infinity) - .opacity(contentOpacity) - .background(backgroundColour, overlayColour: .clear, radius: cornerRadius, corners: .allCorners, shadow: popupShadow) - .padding(.horizontal, lastPopupConfig.horizontalPadding) - .compositingGroup() - .focusSectionIfAvailable() - } - func createPopupForOlderPlatforms() -> some View { - items.last?.body - .readHeight(onChange: saveHeight) - .frame(height: height).frame(maxWidth: .infinity) - .background(backgroundColour, overlayColour: .clear, radius: cornerRadius, corners: .allCorners, shadow: popupShadow) - .padding(.horizontal, lastPopupConfig.horizontalPadding) - .compositingGroup() - .focusSectionIfAvailable() - } -} - -// MARK: - Logic Modifiers -private extension PopupCentreStackView { - func onItemsChange(_ items: [AnyPopup]) { - guard let popup = items.last else { return handleClosingPopup() } - - showNewPopup(popup) - animateContentIfNeeded() - } -} -private extension PopupCentreStackView { - func showNewPopup(_ popup: AnyPopup) { DispatchQueue.main.async { - activeView = AnyView(popup.body) - configTemp = popup.configurePopup(popup: .init()) - }} - func animateContentIfNeeded() { if height != nil { - contentIsAnimated = true - DispatchQueue.main.asyncAfter(deadline: .now() + contentOpacityAnimationTime) { contentIsAnimated = false } - }} - func handleClosingPopup() { DispatchQueue.main.async { - height = nil - activeView = nil - }} -} - -// MARK: - View Modifiers -private extension PopupCentreStackView { - func saveHeight(_ value: CGFloat) { height = items.isEmpty ? nil : value } - func getTransition() -> AnyTransition { - .scale(scale: items.isEmpty ? globalConfig.centre.transitionExitScale : globalConfig.centre.transitionEntryScale) - .combined(with: .opacity) - .animation(height == nil || items.isEmpty ? transitionRemovalAnimation : nil) - } -} - -// MARK: - Flags & Values -extension PopupCentreStackView { - var cornerRadius: CGFloat { lastPopupConfig.cornerRadius ?? globalConfig.centre.cornerRadius } - var contentOpacity: CGFloat { contentIsAnimated ? 0 : 1 } - var popupShadow: Shadow { globalConfig.centre.shadow } - var contentOpacityAnimationTime: CGFloat { globalConfig.centre.contentAnimationTime } - var backgroundColour: Color { lastPopupConfig.backgroundColour ?? globalConfig.centre.backgroundColour } - - var tapOutsideClosesPopup: Bool { lastPopupConfig.tapOutsideClosesView ?? globalConfig.bottom.tapOutsideClosesView } -} diff --git a/Sources/Internal/Views/PopupTopStackView.swift b/Sources/Internal/Views/PopupTopStackView.swift deleted file mode 100644 index 081639509f..0000000000 --- a/Sources/Internal/Views/PopupTopStackView.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// PopupTopStackView.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -struct PopupTopStackView: PopupStack { - let items: [AnyPopup] - let globalConfig: GlobalConfig - @State var gestureTranslation: CGFloat = 0 - @State var heights: [ID: CGFloat] = [:] - @State var dragHeights: [ID: CGFloat] = [:] - @GestureState var isGestureActive: Bool = false - @ObservedObject private var screenManager: ScreenManager = .shared - - - var body: some View { - ZStack(alignment: .bottom, content: createPopupStack) - .background(createTapArea()) - .animation(getHeightAnimation(isAnimationDisabled: screenManager.animationsDisabled), value: heights) - .animation(isGestureActive ? dragGestureAnimation : transitionRemovalAnimation, value: gestureTranslation) - .onDragGesture($isGestureActive, onChanged: onPopupDragGestureChanged, onEnded: onPopupDragGestureEnded) - } -} - -private extension PopupTopStackView { - func createPopupStack() -> some View { - ForEach(items, id: \.self, content: createPopup) - } -} - -private extension PopupTopStackView { - func createPopup(_ item: AnyPopup) -> some View { - item.body - .padding(.top, contentTopPadding) - .padding(.leading, screenManager.safeArea.left) - .padding(.trailing, screenManager.safeArea.right) - .readHeight { saveHeight($0, for: item) } - .frame(height: height, alignment: .bottom).frame(maxWidth: .infinity) - .background(getBackgroundColour(for: item), overlayColour: getStackOverlayColour(item), radius: getCornerRadius(item), corners: getCorners(), shadow: popupShadow) - .padding(.horizontal, lastPopupConfig.popupPadding.horizontal) - .offset(y: getOffset(item)) - .scaleEffect(x: getScale(item)) - .opacity(getOpacity(item)) - .compositingGroup() - .focusSectionIfAvailable() - .align(to: .top, popupTopPadding) - .transition(transition) - .zIndex(getZIndex(item)) - } -} - -// MARK: - Gestures - -// MARK: On Changed -private extension PopupTopStackView { - func onPopupDragGestureChanged(_ value: CGFloat) { if canDragGestureBeUsed() { - updateGestureTranslation(value) - }} -} -private extension PopupTopStackView { - func canDragGestureBeUsed() -> Bool { lastPopupConfig.dragGestureEnabled ?? globalConfig.bottom.dragGestureEnabled } - func updateGestureTranslation(_ value: CGFloat) { switch lastPopupConfig.dragDetents.isEmpty { - case true: gestureTranslation = calculateGestureTranslationWhenNoDragDetents(value) - case false: gestureTranslation = calculateGestureTranslationWhenDragDetents(value) - }} -} -private extension PopupTopStackView { - func calculateGestureTranslationWhenNoDragDetents(_ value: CGFloat) -> CGFloat { min(value, 0) } - func calculateGestureTranslationWhenDragDetents(_ value: CGFloat) -> CGFloat { guard value > 0, let lastPopupHeight = getLastPopupHeight() else { return value } - let maxHeight = calculateMaxHeightForDragGesture(lastPopupHeight) - let dragTranslation = calculateDragTranslation(maxHeight, lastPopupHeight) - return min(dragTranslation, value) - } -} -private extension PopupTopStackView { - func calculateMaxHeightForDragGesture(_ lastPopupHeight: CGFloat) -> CGFloat { - let maxHeight1 = (calculatePopupTargetHeightsFromDragDetents(lastPopupHeight).max() ?? 0) + dragTranslationThreshold - let maxHeight2 = screenManager.size.height - return min(maxHeight1, maxHeight2) - } - func calculateDragTranslation(_ maxHeight: CGFloat, _ lastPopupHeight: CGFloat) -> CGFloat { - let translation = maxHeight - lastPopupHeight - getLastDragHeight() - return translation - } -} -private extension PopupTopStackView { - var dragTranslationThreshold: CGFloat { 8 } -} - -// MARK: On Ended -private extension PopupTopStackView { - func onPopupDragGestureEnded(_ value: CGFloat) { guard value != 0 else { return } - dismissLastItemIfNeeded() - updateTranslationValues() - } -} -private extension PopupTopStackView { - func dismissLastItemIfNeeded() { if shouldDismissPopup() { - items.last?.remove() - }} - func updateTranslationValues() { if let lastPopupHeight = getLastPopupHeight() { - let currentPopupHeight = calculateCurrentPopupHeight(lastPopupHeight) - let popupTargetHeights = calculatePopupTargetHeightsFromDragDetents(lastPopupHeight) - let targetHeight = calculateTargetPopupHeight(currentPopupHeight, popupTargetHeights) - let targetDragHeight = calculateTargetDragHeight(targetHeight, lastPopupHeight) - - resetGestureTranslation() - updateDragHeight(targetDragHeight) - }} -} -private extension PopupTopStackView { - func calculateCurrentPopupHeight(_ lastPopupHeight: CGFloat) -> CGFloat { - let lastDragHeight = getLastDragHeight() - let currentDragHeight = lastDragHeight + gestureTranslation - - let currentPopupHeight = lastPopupHeight + currentDragHeight - return currentPopupHeight - } - func calculatePopupTargetHeightsFromDragDetents(_ lastPopupHeight: CGFloat) -> [CGFloat] { lastPopupConfig.dragDetents - .map { switch $0 { - case .fixed(let targetHeight): min(targetHeight, screenManager.size.height) - case .fraction(let fraction): min(fraction * lastPopupHeight, screenManager.size.height) - case .fullscreen(let stackVisible): stackVisible ? screenManager.size.height - screenManager.safeArea.bottom : screenManager.size.height - }} - .appending(lastPopupHeight) - .sorted(by: <) - } - func calculateTargetPopupHeight(_ currentPopupHeight: CGFloat, _ popupTargetHeights: [CGFloat]) -> CGFloat { - guard let lastPopupHeight = getLastPopupHeight(), - currentPopupHeight < screenManager.size.height - else { return popupTargetHeights.last ?? 0 } - - let initialIndex = popupTargetHeights.firstIndex(where: { $0 >= currentPopupHeight }) ?? popupTargetHeights.count - 1, - targetIndex = gestureTranslation > 0 ? initialIndex : max(0, initialIndex - 1) - let previousPopupHeight = getLastDragHeight() + lastPopupHeight, - popupTargetHeight = popupTargetHeights[targetIndex], - deltaHeight = abs(previousPopupHeight - popupTargetHeight) - let progress = abs(currentPopupHeight - previousPopupHeight) / deltaHeight - - if progress < gestureClosingThresholdFactor { - let index = gestureTranslation > 0 ? max(0, initialIndex - 1) : initialIndex - return popupTargetHeights[index] - } - return popupTargetHeights[targetIndex] - } - func calculateTargetDragHeight(_ targetHeight: CGFloat, _ lastPopupHeight: CGFloat) -> CGFloat { - targetHeight - lastPopupHeight - } - func updateDragHeight(_ targetDragHeight: CGFloat) { if let id = items.last?.id { DispatchQueue.main.async { - dragHeights[id] = targetDragHeight - }}} - func resetGestureTranslation() { - let resetAfter = items.count == 1 && shouldDismissPopup() ? 0.25 : 0 - DispatchQueue.main.asyncAfter(deadline: .now() + resetAfter) { gestureTranslation = 0 } - } - func shouldDismissPopup() -> Bool { translationProgress >= gestureClosingThresholdFactor } -} - -// MARK: - View Modifiers -private extension PopupTopStackView { - func getCorners() -> RectCorner { - switch popupTopPadding { - case 0: return [.bottomLeft, .bottomRight] - default: return .allCorners - } - } - func getBackgroundColour(for item: AnyPopup) -> Color { getConfig(item).backgroundColour ?? globalConfig.top.backgroundColour } - func saveHeight(_ height: CGFloat, for item: AnyPopup) { if !isGestureActive { heights[item.id] = height }} -} - -// MARK: - Flags & Values -extension PopupTopStackView { - var contentTopPadding: CGFloat { lastPopupConfig.contentIgnoresSafeArea ? 0 : max(screenManager.safeArea.top - popupTopPadding, 0) } - var popupTopPadding: CGFloat { lastPopupConfig.popupPadding.top } - var popupShadow: Shadow { globalConfig.top.shadow } - var height: CGFloat { - let lastDragHeight = getLastDragHeight(), - lastPopupHeight = getLastPopupHeight() ?? getInitialHeight() - let dragTranslation = lastPopupHeight + lastDragHeight + gestureTranslation - popupTopPadding - let newHeight = max(lastPopupHeight, dragTranslation) - - switch lastPopupHeight + lastDragHeight > screenManager.size.height && !lastPopupConfig.contentIgnoresSafeArea { - case true: return newHeight == screenManager.size.height ? newHeight : newHeight - screenManager.safeArea.top - case false: return newHeight - } - } - var cornerRadius: CGFloat { lastPopupConfig.cornerRadius ?? globalConfig.top.cornerRadius } - - var stackLimit: Int { globalConfig.top.stackLimit } - var stackScaleFactor: CGFloat { globalConfig.top.stackScaleFactor } - var stackOffsetValue: CGFloat { globalConfig.top.stackOffset } - var stackCornerRadiusMultiplier: CGFloat { globalConfig.top.stackCornerRadiusMultiplier } - - var translationProgress: CGFloat { guard let popupHeight = getLastPopupHeight() else { return 0 }; return abs(min(gestureTranslation + getLastDragHeight(), 0)) / popupHeight } - var gestureClosingThresholdFactor: CGFloat { globalConfig.top.dragGestureProgressToClose } - var transition: AnyTransition { .move(edge: .top) } - - var tapOutsideClosesPopup: Bool { lastPopupConfig.tapOutsideClosesView ?? globalConfig.top.tapOutsideClosesView } -} diff --git a/Sources/Internal/Views/PopupView.swift b/Sources/Internal/Views/PopupView.swift deleted file mode 100644 index d798031c89..0000000000 --- a/Sources/Internal/Views/PopupView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// PopupView.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - iOS / macOS Implementation -#if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) -struct PopupView: View { - let globalConfig: GlobalConfig - @State private var zIndex: ZIndex = .init() - @ObservedObject private var popupManager: PopupManager = .shared - - - var body: some View { createBody() } -} - -// MARK: - tvOS Implementation -#elseif os(tvOS) -struct PopupView: View { - let rootView: any View - let globalConfig: GlobalConfig - @State private var zIndex: ZIndex = .init() - @ObservedObject private var popupManager: PopupManager = .shared - - - var body: some View { - AnyView(rootView) - .disabled(!popupManager.views.isEmpty) - .overlay(createBody()) - } -} -#endif - - -// MARK: - Common Part -private extension PopupView { - func createBody() -> some View { - createPopupStackView() - .ignoresSafeArea() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .animation(stackAnimation, value: popupManager.views.map(\.id)) - .onChange(popupManager.views.count, completion: onViewsCountChange) - } -} - -private extension PopupView { - func createPopupStackView() -> some View { - ZStack { - createTopPopupStackView() - createCentrePopupStackView() - createBottomPopupStackView() - } - } -} - -private extension PopupView { - func createTopPopupStackView() -> some View { - PopupTopStackView(items: getViews(AnyPopup.self), globalConfig: globalConfig) - .addOverlay(overlayColour, isOverlayActive(AnyPopup.self)) - .zIndex(zIndex.top) - } - func createCentrePopupStackView() -> some View { - PopupCentreStackView(items: getViews(AnyPopup.self), globalConfig: globalConfig) - .addOverlay(overlayColour, isOverlayActive(AnyPopup.self)) - .zIndex(zIndex.centre) - } - func createBottomPopupStackView() -> some View { - PopupBottomStackView(items: getViews(AnyPopup.self), globalConfig: globalConfig) - .addOverlay(overlayColour, isOverlayActive(AnyPopup.self)) - .zIndex(zIndex.bottom) - } -} -private extension PopupView { - func getViews(_ type: T.Type) -> [T] { popupManager.views.compactMap { $0 as? T } } -} - -private extension PopupView { - func onViewsCountChange(_ count: Int) { DispatchQueue.main.asyncAfter(deadline: .now() + (!popupManager.presenting && zIndex.centre == 3 ? 0.4 : 0)) { - zIndex.reshuffle(popupManager.views.last) - }} -} - -private extension PopupView { - func isOverlayActive(_ type: P.Type) -> Bool { popupManager.views.last is P && !shouldOverlayBeHiddenForCurrentPopup } -} -private extension PopupView { - var shouldOverlayBeHiddenForCurrentPopup: Bool { popupManager.popupsWithoutOverlay.contains(popupManager.views.last?.id ?? .init()) } -} - -private extension PopupView { - var stackAnimation: Animation { popupManager.presenting ? globalConfig.common.animation.entry : globalConfig.common.animation.removal } - var overlayColour: Color { globalConfig.common.overlayColour } -} - - -// MARK: - Counting zIndexes -// Purpose: To ensure that the stacks are displayed in the correct order -// Example: There are three bottom popups on the screen, and the user wants to display the centre one - to make sure they are displayed in the right order, we need to count the indexes; otherwise centre popup would be hidden by the bottom three. -extension PopupView { struct ZIndex { - private var values: [Double] = [1, 1, 1] -}} -extension PopupView.ZIndex { - mutating func reshuffle(_ lastPopup: (any Popup)?) { if let lastPopup { - if lastPopup is AnyPopup { reshuffle(0) } - else if lastPopup is AnyPopup { reshuffle(1) } - else if lastPopup is AnyPopup { reshuffle(2) } - }} -} -private extension PopupView.ZIndex { - mutating func reshuffle(_ index: Int) { if values[index] != 3 { - values.enumerated().forEach { - values[$0.offset] = $0.offset == index ? 3 : max(1, $0.element - 1) - } - }} -} -private extension PopupView.ZIndex { - var top: Double { values[0] } - var centre: Double { values[1] } - var bottom: Double { values[2] } -} - - -// MARK: - Helpers -fileprivate extension View { - func addOverlay(_ colour: Color, _ active: Bool) -> some View { ZStack { - colour.active(if: active) - self - }} -} diff --git a/Sources/Public/Configurables/GlobalConfig.Bottom.swift b/Sources/Public/Configurables/GlobalConfig.Bottom.swift deleted file mode 100644 index a808b98c46..0000000000 --- a/Sources/Public/Configurables/GlobalConfig.Bottom.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// GlobalConfig.Bottom.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Content Customisation -public extension GlobalConfig.Bottom { - /// Distance between content and keyboard (if present) - func distanceFromKeyboard(_ value: CGFloat) -> Self { changing(path: \.distanceFromKeyboard, to: value) } -} - -// MARK: - Popup Customisation -public extension GlobalConfig.Bottom { - /// Background colour of the popup - func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) } - - /// Corner radius of the popup at the top of the stack - func cornerRadius(_ value: CGFloat) -> Self { changing(path: \.cornerRadius, to: value) } - - /// Applies shadows to the popup - func applyShadow(color: Color = .black.opacity(0.16), radius: CGFloat = 16, x: CGFloat = 0, y: CGFloat = 0) -> Self { changing(path: \.shadow, to: .init(color: color, radius: radius, x: x, y: y)) } -} - -// MARK: - Stack Customisation -public extension GlobalConfig.Bottom { - /// Corner radius multiplier for popups on the stack. - /// For example **value** = 0.5 means that the stacked popups will be have a corner radius equal to activeCornerRadius * 0.5. - func stackCornerRadiusMultiplier(_ value: CGFloat) -> Self { changing(path: \.stackCornerRadiusMultiplier, to: value) } - - /// Distance between popups on the stack - func stackOffset(_ value: CGFloat) -> Self { changing(path: \.stackOffset, to: value) } - - /// Scale factor of subsequent popups on the stack. - /// For example, for **value** = 0.1, the next popup on the stack will have a size of 0.9 of the active popup, and the one after next 0.8. - func stackScale(_ value: CGFloat) -> Self { changing(path: \.stackScaleFactor, to: value) } - - /// Maximum number of popups on the stack - func stackLimit(_ value: Int) -> Self { changing(path: \.stackLimit, to: value) } -} - -// MARK: - Gestures -public extension GlobalConfig.Bottom { - /// Dismisses the active popup when tapped outside its area if enabled - func tapOutsideToDismiss(_ value: Bool) -> Self { changing(path: \.tapOutsideClosesView, to: value) } - - /// Popup can be closed with drag gesture if enabled - func dragGestureEnabled(_ value: Bool) -> Self { changing(path: \.dragGestureEnabled, to: value) } - - /// Minimal threshold of a drag gesture to close the active popup - func minimalDragThresholdToClose(_ value: CGFloat) -> Self { changing(path: \.dragGestureProgressToClose, to: value) } -} - - -// MARK: - Internal -public extension GlobalConfig { struct Bottom: Configurable { public init() {} - private(set) var distanceFromKeyboard: CGFloat = 8 - - private(set) var backgroundColour: Color = .white - private(set) var cornerRadius: CGFloat = 32 - private(set) var shadow: Shadow = .none - - private(set) var stackCornerRadiusMultiplier: CGFloat = 0.6 - private(set) var stackOffset: CGFloat = 8 - private(set) var stackScaleFactor: CGFloat = 0.1 - private(set) var stackLimit: Int = 4 - - private(set) var tapOutsideClosesView: Bool = false - private(set) var dragGestureEnabled: Bool = true - private(set) var dragGestureProgressToClose: CGFloat = 1/3 -}} diff --git a/Sources/Public/Configurables/GlobalConfig.Centre.swift b/Sources/Public/Configurables/GlobalConfig.Centre.swift deleted file mode 100644 index 87ce8f64ba..0000000000 --- a/Sources/Public/Configurables/GlobalConfig.Centre.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// GlobalConfig.Centre.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Popup Customisation -public extension GlobalConfig.Centre { - /// Background colour of the popup - func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) } - - /// Corner radius of the popup at the top of the stack - func cornerRadius(_ value: CGFloat) -> Self { changing(path: \.cornerRadius, to: value) } - - /// Applies shadows to the popup - func applyShadow(color: Color = .black.opacity(0.16), radius: CGFloat = 16, x: CGFloat = 0, y: CGFloat = 0) -> Self { changing(path: \.shadow, to: .init(color: color, radius: radius, x: x, y: y)) } -} - -// MARK: - Gestures -public extension GlobalConfig.Centre { - /// Dismisses the active popup when tapped outside its area if enabled - func tapOutsideToDismiss(_ value: Bool) -> Self { changing(path: \.tapOutsideClosesView, to: value) } -} - -// MARK: - Animations -public extension GlobalConfig.Centre { - /// Time to animate content while presenting new popup - func contentAnimationTime(_ value: CGFloat) -> Self { changing(path: \.contentAnimationTime, to: value) } - - /// Scale of the initial state of the popup animation while opening - func transitionEntryScale(_ value: CGFloat) -> Self { changing(path: \.transitionEntryScale, to: value) } - - /// Scale of the final state of the popup animation while closing - func transitionExitScale(_ value: CGFloat) -> Self { changing(path: \.transitionExitScale, to: value) } -} - - -// MARK: - Internal -public extension GlobalConfig { struct Centre: Configurable { public init() {} - private(set) var backgroundColour: Color = .white - private(set) var cornerRadius: CGFloat = 24 - private(set) var shadow: Shadow = .none - - private(set) var tapOutsideClosesView: Bool = true - - private(set) var contentAnimationTime: CGFloat = 0.1 - private(set) var transitionEntryScale: CGFloat = 1.1 - private(set) var transitionExitScale: CGFloat = 0.86 -}} diff --git a/Sources/Public/Configurables/GlobalConfig.Common.swift b/Sources/Public/Configurables/GlobalConfig.Common.swift deleted file mode 100644 index ae60ab09c8..0000000000 --- a/Sources/Public/Configurables/GlobalConfig.Common.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// GlobalConfig.Common.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Overlay -public extension GlobalConfig.Common { - /// Colour of the overlay covering the view behind the popup - func overlayColour(_ value: Color) -> Self { changing(path: \.overlayColour, to: value) } -} - -// MARK: - Animations -public extension GlobalConfig.Common { - /// Animation for closing and opening popups - func animation(_ value: AnimationType) -> Self { changing(path: \.animation, to: value) } -} - - -// MARK: - Internal -public extension GlobalConfig { struct Common: Configurable { public init() {} - private(set) var overlayColour: Color = .black.opacity(0.44) - private(set) var animation: AnimationType = .spring -}} diff --git a/Sources/Public/Configurables/GlobalConfig.Top.swift b/Sources/Public/Configurables/GlobalConfig.Top.swift deleted file mode 100644 index 2b1d669c2c..0000000000 --- a/Sources/Public/Configurables/GlobalConfig.Top.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// GlobalConfig.Top.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Popup Customisation -public extension GlobalConfig.Top { - /// Background colour of the popup - func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) } - - /// Corner radius of the popup at the top of the stack - func cornerRadius(_ value: CGFloat) -> Self { changing(path: \.cornerRadius, to: value) } - - /// Applies shadows to the popup - func applyShadow(color: Color = .black.opacity(0.16), radius: CGFloat = 16, x: CGFloat = 0, y: CGFloat = 0) -> Self { changing(path: \.shadow, to: .init(color: color, radius: radius, x: x, y: y)) } -} - -// MARK: - Stack Customisation -public extension GlobalConfig.Top { - /// Corner radius multiplier for popups on the stack. - /// For example **value** = 0.5 means that the stacked popups will be have a corner radius equal to activeCornerRadius * 0.5. - func stackCornerRadiusMultiplier(_ value: CGFloat) -> Self { changing(path: \.stackCornerRadiusMultiplier, to: value) } - - /// Distance between popups on the stack - func stackOffset(_ value: CGFloat) -> Self { changing(path: \.stackOffset, to: value) } - - /// Scale factor of subsequent popups on the stack. - /// For example, for **value** = 0.1, the next popup on the stack will have a size of 0.9 of the active popup, and the one after next 0.8. - func stackScale(_ value: CGFloat) -> Self { changing(path: \.stackScaleFactor, to: value) } - - /// Maximum number of popups on the stack - func stackLimit(_ value: Int) -> Self { changing(path: \.stackLimit, to: value) } -} - -// MARK: - Gestures -public extension GlobalConfig.Top { - /// Dismisses the active popup when tapped outside its area if enabled - func tapOutsideToDismiss(_ value: Bool) -> Self { changing(path: \.tapOutsideClosesView, to: value) } - - /// Popup can be closed with drag gesture if enabled - func dragGestureEnabled(_ value: Bool) -> Self { changing(path: \.dragGestureEnabled, to: value) } - - /// Minimal threshold of a drag gesture to close the active popup - func minimalDragThresholdToClose(_ value: CGFloat) -> Self { changing(path: \.dragGestureProgressToClose, to: value) } -} - - -// MARK: - Internal -public extension GlobalConfig { struct Top: Configurable { public init() {} - private(set) var backgroundColour: Color = .white - private(set) var cornerRadius: CGFloat = 24 - private(set) var shadow: Shadow = .none - - private(set) var stackCornerRadiusMultiplier: CGFloat = 0.6 - private(set) var stackOffset: CGFloat = 6 - private(set) var stackScaleFactor: CGFloat = 0.06 - private(set) var stackLimit: Int = 3 - - private(set) var tapOutsideClosesView: Bool = false - private(set) var dragGestureEnabled: Bool = true - private(set) var dragGestureProgressToClose: CGFloat = 1/3 -}} diff --git a/Sources/Public/Configurables/LocalConfig.BottomPopup.swift b/Sources/Public/Configurables/LocalConfig.BottomPopup.swift deleted file mode 100644 index 08d83adbda..0000000000 --- a/Sources/Public/Configurables/LocalConfig.BottomPopup.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// LocalConfig.BottomPopup.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Content Customisation -public extension BottomPopupConfig { - /// Whether content should ignore safe area - func contentIgnoresSafeArea(_ value: Bool) -> Self { changing(path: \.contentIgnoresSafeArea, to: value) } - - /// Whether the content should take up the entire height of the screen. - /// Stacked items will be visible. - func contentFillsWholeHeigh(_ value: Bool) -> Self { changing(path: \.contentFillsWholeHeight, to: value) } - - /// Whether the content should take up the entire height of the screen. - /// Stacked items will be invisible - func contentFillsEntireScreen(_ value: Bool) -> Self { changing(path: \.contentFillsEntireScreen, to: value) } - - /// Distance between content and keyboard (if present) - func distanceFromKeyboard(_ value: CGFloat) -> Self { changing(path: \.distanceFromKeyboard, to: value) } -} - -// MARK: - Popup Customisation -public extension BottomPopupConfig { - /// Background colour of the popup - func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) } - - /// Corner radius of the popup at the top of the stack - func cornerRadius(_ value: CGFloat) -> Self { changing(path: \.cornerRadius, to: value) } - - /// Distance of the entire popup (including its background) from the bottom edge - func bottomPadding(_ value: CGFloat) -> Self { changing(path: \.popupPadding.bottom, to: value) } - - /// Distance of the entire popup (including its background) from the horizontal edges - func horizontalPadding(_ value: CGFloat) -> Self { changing(path: \.popupPadding.horizontal, to: value) } -} - -// MARK: - Gestures -public extension BottomPopupConfig { - /// Dismisses the active popup when tapped outside its area if enabled - func tapOutsideToDismiss(_ value: Bool) -> Self { changing(path: \.tapOutsideClosesView, to: value) } - - /// Popup can be closed with drag gesture if enabled - func dragGestureEnabled(_ value: Bool) -> Self { changing(path: \.dragGestureEnabled, to: value) } - - /// Sets available detents for the popupSets the available detents for the enclosing sheet - func dragDetents(_ value: [DragDetent]) -> Self { changing(path: \.dragDetents, to: value) } -} - - -// MARK: - Internal -public struct BottomPopupConfig: Configurable { public init() {} - private(set) var contentIgnoresSafeArea: Bool = false - private(set) var contentFillsWholeHeight: Bool = false - private(set) var contentFillsEntireScreen: Bool = false - private(set) var distanceFromKeyboard: CGFloat? = nil - - private(set) var backgroundColour: Color? = nil - private(set) var cornerRadius: CGFloat? = nil - private(set) var popupPadding: (bottom: CGFloat, horizontal: CGFloat) = (0, 0) - - private(set) var tapOutsideClosesView: Bool? = nil - private(set) var dragGestureEnabled: Bool? = nil - private(set) var dragDetents: [DragDetent] = [] -} diff --git a/Sources/Public/Configurables/LocalConfig.CentrePopup.swift b/Sources/Public/Configurables/LocalConfig.CentrePopup.swift deleted file mode 100644 index 5bf9954e60..0000000000 --- a/Sources/Public/Configurables/LocalConfig.CentrePopup.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// LocalConfig.CentrePopup.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Popup Customisation -public extension CentrePopupConfig { - /// Background colour of the popup - func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) } - - /// Corner radius of the popup - func cornerRadius(_ value: CGFloat) -> Self { changing(path: \.cornerRadius, to: value) } - - /// Distance of the entire popup (including its background) from the horizontal edges - func horizontalPadding(_ value: CGFloat) -> Self { changing(path: \.horizontalPadding, to: value) } -} - -// MARK: - Gestures -public extension CentrePopupConfig { - /// Dismisses the active popup when tapped outside its area if enabled - func tapOutsideToDismiss(_ value: Bool) -> Self { changing(path: \.tapOutsideClosesView, to: value) } -} - - -// MARK: - Internal -public struct CentrePopupConfig: Configurable { public init() {} - private(set) var backgroundColour: Color? = nil - private(set) var cornerRadius: CGFloat? = nil - private(set) var horizontalPadding: CGFloat = 12 - - private(set) var tapOutsideClosesView: Bool? = nil -} diff --git a/Sources/Public/Configurables/LocalConfig.TopPopup.swift b/Sources/Public/Configurables/LocalConfig.TopPopup.swift deleted file mode 100644 index 6f480f4625..0000000000 --- a/Sources/Public/Configurables/LocalConfig.TopPopup.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// LocalConfig.TopPopup.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Content Customisation -public extension TopPopupConfig { - /// Whether content should ignore safe area - func contentIgnoresSafeArea(_ value: Bool) -> Self { changing(path: \.contentIgnoresSafeArea, to: value) } -} - -// MARK: - Popup Customisation -public extension TopPopupConfig { - /// Background colour of the popup - func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) } - - /// Corner radius of the popup at the top of the stack - func cornerRadius(_ value: CGFloat) -> Self { changing(path: \.cornerRadius, to: value) } - - /// Distance of the entire popup (including its background) from the top edge - func topPadding(_ value: CGFloat) -> Self { changing(path: \.popupPadding.top, to: value) } - - /// Distance of the entire popup (including its background) from the horizontal edges - func horizontalPadding(_ value: CGFloat) -> Self { changing(path: \.popupPadding.horizontal, to: value) } -} - -// MARK: - Gestures -public extension TopPopupConfig { - /// Dismisses the active popup when tapped outside its area if enabled - func tapOutsideToDismiss(_ value: Bool) -> Self { changing(path: \.tapOutsideClosesView, to: value) } - - /// Popup can be closed with drag gesture if enabled - func dragGestureEnabled(_ value: Bool) -> Self { changing(path: \.dragGestureEnabled, to: value) } - - /// Sets available detents for the popupSets the available detents for the enclosing sheet - func dragDetents(_ value: [DragDetent]) -> Self { changing(path: \.dragDetents, to: value) } -} - - -// MARK: - Internal -public struct TopPopupConfig: Configurable { public init() {} - private(set) var contentIgnoresSafeArea: Bool = false - - private(set) var backgroundColour: Color? = nil - private(set) var cornerRadius: CGFloat? = nil - private(set) var popupPadding: (top: CGFloat, horizontal: CGFloat) = (0, 0) - - private(set) var tapOutsideClosesView: Bool? = nil - private(set) var dragGestureEnabled: Bool? = nil - private(set) var dragDetents: [DragDetent] = [] -} diff --git a/Sources/Public/Delegates/Public+PopupSceneDelegate.swift b/Sources/Public/Delegates/Public+PopupSceneDelegate.swift deleted file mode 100644 index e7892825f6..0000000000 --- a/Sources/Public/Delegates/Public+PopupSceneDelegate.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Public+PopupSceneDelegate.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// - GitHub: https://github.com/FulcrumOne -// -// Copyright ©2024 Mijick. Licensed under MIT License. - - -import SwiftUI - -#if os(iOS) -open class PopupSceneDelegate: NSObject, UIWindowSceneDelegate { - open var window: UIWindow? - open var config: (GlobalConfig) -> (GlobalConfig) = { _ in .init() } -} - -// MARK: - Creating Popup Scene -extension PopupSceneDelegate { - open func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if let windowScene = scene as? UIWindowScene { - let hostingController = UIHostingController(rootView: Rectangle() - .fill(Color.clear) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .implementPopupView(config: config) - ) - hostingController.view.backgroundColor = .clear - - window = Window(windowScene: windowScene) - window?.rootViewController = hostingController - window?.isHidden = false - }} -} - - -// MARK: - Helpers -fileprivate class Window: UIWindow { - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - guard let view = super.hitTest(point, with: event) else { return nil } - return rootViewController?.view == view ? nil : view - } -} -#endif diff --git a/Sources/Public/Dismiss/Public+Dismiss+PopupManager.swift b/Sources/Public/Dismiss/Public+Dismiss+PopupManager.swift new file mode 100644 index 0000000000..ba9b81a243 --- /dev/null +++ b/Sources/Public/Dismiss/Public+Dismiss+PopupManager.swift @@ -0,0 +1,58 @@ +// +// Public+Dismiss+PopupManager.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +public extension PopupManager { + /** + Removes the currently active popup from the stack. + Makes the next popup in the stack the new active popup. + + - Parameters: + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popup. + */ + static func dismissLastPopup(popupManagerID: PopupManagerID = .shared) { fetchInstance(id: popupManagerID)?.stack(.removeLastPopup) } + + /** + Removes all popups with the specified identifier from the stack. + + - Parameters: + - id: Identifier of the popup located on the stack. + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popup. + */ + static func dismissPopup(_ id: String, popupManagerID: PopupManagerID = .shared) { fetchInstance(id: popupManagerID)?.stack(.removeAllPopupsWithID(id)) } + + /** + Removes all popups of the provided type from the stack. + + - Parameters: + - type: Type of the popup located on the stack. + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: If a custom ID (``Popup/setCustomID(_:)``) is set for the popup, use the ``dismissPopup(_:popupManagerID:)-1atvy`` method instead. + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popup. + */ + static func dismissPopup(_ type: P.Type, popupManagerID: PopupManagerID = .shared) { fetchInstance(id: popupManagerID)?.stack(.removeAllPopupsOfType(type)) } + + /** + Removes all popups from the stack. + + - Parameters: + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popups. + */ + static func dismissAllPopups(popupManagerID: PopupManagerID = .shared) { fetchInstance(id: popupManagerID)?.stack(.removeAllPopups) } +} diff --git a/Sources/Public/Dismiss/Public+Dismiss+View.swift b/Sources/Public/Dismiss/Public+Dismiss+View.swift new file mode 100644 index 0000000000..c3b99e4687 --- /dev/null +++ b/Sources/Public/Dismiss/Public+Dismiss+View.swift @@ -0,0 +1,58 @@ +// +// Public+Dismiss+View.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public extension View { + /** + Removes the currently active popup from the stack. + Makes the next popup in the stack the new active popup. + + - Parameters: + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popup. + */ + func dismissLastPopup(popupManagerID: PopupManagerID = .shared) { PopupManager.dismissLastPopup(popupManagerID: popupManagerID) } + + /** + Removes all popups with the specified identifier from the stack. + + - Parameters: + - id: Identifier of the popup located on the stack. + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popup. + */ + func dismissPopup(_ id: String, popupManagerID: PopupManagerID = .shared) { PopupManager.dismissPopup(id, popupManagerID: popupManagerID) } + + /** + Removes all popups of the provided type from the stack. + + - Parameters: + - type: Type of the popup located on the stack. + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: If a custom ID (see ``Popup/setCustomID(_:)`` method for reference) is set for the popup, use the ``SwiftUICore/View/dismissPopup(_:popupManagerID:)-55ubm`` method instead. + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popup. + */ + func dismissPopup(_ type: P.Type, popupManagerID: PopupManagerID = .shared) { PopupManager.dismissPopup(type, popupManagerID: popupManagerID) } + + /** + Removes all popups from the stack. + + - Parameters: + - popupManagerID: The identifier for which the popup was presented. For more information, see ``Popup/present(popupManagerID:)``. + + - Important: Make sure you use the correct **popupManagerID** from which you want to remove the popups. + */ + func dismissAllPopups(popupManagerID: PopupManagerID = .shared) { PopupManager.dismissAllPopups(popupManagerID: popupManagerID) } +} diff --git a/Sources/Public/Extensions/Public+Popup.swift b/Sources/Public/Extensions/Public+Popup.swift deleted file mode 100644 index 8484c16457..0000000000 --- a/Sources/Public/Extensions/Public+Popup.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Public+Popup.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Presenting -public extension Popup { - /// Displays the popup. Stacks previous one - @discardableResult func showAndStack() -> some Popup { PopupManager.showAndStack(AnyPopup(self)); return self } - - /// Displays the popup. Closes previous one - @discardableResult func showAndReplace() -> some Popup { PopupManager.showAndReplace(AnyPopup(self)); return self } -} - -// MARK: - Modifiers -public extension Popup { - /// Closes popup after n seconds - @discardableResult func dismissAfter(_ seconds: Double) -> some Popup { PopupManager.dismissPopupAfter(self, seconds); return self } - - /// Hides the overlay for the selected popup - @discardableResult func hideOverlay() -> some Popup { PopupManager.hideOverlay(self); return self } - - /// Supplies an observable object to a view’s hierarchy - @discardableResult func environmentObject(_ object: T) -> any Popup { AnyPopup(self, object) } - - /// Action to be executed after popups is dismissed - @discardableResult func onDismiss(_ action: @escaping () -> ()) -> any Popup { PopupManager.onPopupDismiss(self, action); return self } -} - -// MARK: - Available Popups -public protocol TopPopup: Popup { associatedtype Config = TopPopupConfig } -public protocol CentrePopup: Popup { associatedtype Config = CentrePopupConfig } -public protocol BottomPopup: Popup { associatedtype Config = BottomPopupConfig } diff --git a/Sources/Public/Extensions/Public+PopupManager.swift b/Sources/Public/Extensions/Public+PopupManager.swift deleted file mode 100644 index 9d332d1b60..0000000000 --- a/Sources/Public/Extensions/Public+PopupManager.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Public+PopupManager.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Presenting and Dismissing -public extension PopupManager { - /// Displays the popup. Stacks previous one - static func showAndStack(_ popup: some Popup) { performOperation(.insertAndStack(popup)) } - - /// Displays the popup. Closes previous one - static func showAndReplace(_ popup: some Popup) { performOperation(.insertAndReplace(popup)) } -} -public extension PopupManager { - /// Dismisses last popup on the stack - static func dismiss() { performOperation(.removeLast) } - - /// Dismisses all popups of provided type on the stack. - static func dismiss(_ popup: P.Type) { performOperation(.remove(ID(popup))) } - - /// Dismisses all popups on the stack up to the popup with the selected type - static func dismissAll(upTo popup: P.Type) { performOperation(.removeAllUpTo(ID(popup))) } - - /// Dismisses all the popups on the stack. - static func dismissAll() { performOperation(.removeAll) } -} diff --git a/Sources/Public/Extensions/Public+View.swift b/Sources/Public/Extensions/Public+View.swift deleted file mode 100644 index d0b66b29c6..0000000000 --- a/Sources/Public/Extensions/Public+View.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Public+View.swift of -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// -// Copyright ©2023 Mijick. Licensed under MIT License. - - -import SwiftUI - -// MARK: - Initialising -public extension View { - /// Initialises the library. Use directly with the view in your @main structure - func implementPopupView(config: (GlobalConfig) -> GlobalConfig = { $0 }) -> some View { - #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - updateScreenSize() - .frame(maxWidth: .infinity) - .overlay(view: PopupView(globalConfig: config(.init()))) - #elseif os(tvOS) - PopupView(rootView: updateScreenSize(), globalConfig: config(.init())) - #endif - } -} - -// MARK: - Dismissing Popups -public extension View { - /// Dismisses last popup on the stack - func dismiss() { PopupManager.dismiss() } - - /// Dismisses all popups of provided type on the stack. - func dismiss(_ popup: P.Type) { PopupManager.dismiss(popup) } - - /// Dismisses all popups on the stack up to the popup with the selected type - func dismissAll(upTo popup: P.Type) { PopupManager.dismissAll(upTo: popup) } - - /// Dismisses all the popups on the stack. - func dismissAll() { PopupManager.dismissAll() } -} - -// MARK: - Actions -public extension View { - /// Triggers every time the popup is at the top of the stack - func onFocus(_ popup: some Popup, perform action: @escaping () -> ()) -> some View { - onReceive(PopupManager.shared.$views) { views in - if views.last?.id == popup.id { action() } - } - } -} diff --git a/Sources/Public/Popup/Public+Popup+Config.swift b/Sources/Public/Popup/Public+Popup+Config.swift new file mode 100644 index 0000000000..17e01bed88 --- /dev/null +++ b/Sources/Public/Popup/Public+Popup+Config.swift @@ -0,0 +1,111 @@ +// +// Public+Popup+Config.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Vertical & Centre +public extension LocalConfig { + /** + Distance of the entire popup (including its background) from the horizontal edges of the screen. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/horizontal-padding.png?raw=true) + */ + func popupHorizontalPadding(_ value: CGFloat) -> Self { self.popupPadding = .init(top: popupPadding.top, leading: value, bottom: popupPadding.bottom, trailing: value); return self } + + /** + Corner radius of the background of the active popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/corner-radius.png?raw=true) + */ + func cornerRadius(_ value: CGFloat) -> Self { self.cornerRadius = value; return self } + + /** + Background color of the popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/background-color.png?raw=true) + */ + func backgroundColor(_ color: Color) -> Self { self.backgroundColor = color; return self } + + /** + The color of the overlay covering the view behind the popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/overlay-color.png?raw=true) + + - tip: Use .clear to hide the overlay. + */ + func overlayColor(_ color: Color) -> Self { self.overlayColor = color; return self } + + /** + If enabled, dismisses the active popup when touched outside its area. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/tap-to-close.png?raw=true) + */ + func tapOutsideToDismissPopup(_ value: Bool) -> Self { self.isTapOutsideToDismissEnabled = value; return self } +} + +// MARK: Only Vertical +public extension LocalConfig.Vertical { + /** + Distance of the entire popup (including its background) from the top edge of the screen. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/top-padding.png?raw=true) + */ + func popupTopPadding(_ value: CGFloat) -> Self { self.popupPadding = .init(top: value, leading: popupPadding.leading, bottom: popupPadding.bottom, trailing: popupPadding.trailing); return self } + + /** + Distance of the entire popup (including its background) from the bottom edge of the screen. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/bottom-padding.png?raw=true) + */ + func popupBottomPadding(_ value: CGFloat) -> Self { self.popupPadding = .init(top: popupPadding.top, leading: popupPadding.leading, bottom: value, trailing: popupPadding.trailing); return self } + + /** + Expands the safe area of a popup. + + - Parameters: + - edges: The regions to expand the popup’s safe area into. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/ignore-safe-area.png?raw=true) + */ + func ignoreSafeArea(edges: Edge.Set) -> Self { self.ignoredSafeAreaEdges = edges; return self } + + /** + Sets the height for the popup. By default, the height of the popup is calculated based on its content. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/height-mode.png?raw=true) + */ + func heightMode(_ value: HeightMode) -> Self { self.heightMode = value; return self } + + /** + Sets the available detents for the popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/drag-detent.png?raw=true) + */ + func dragDetents(_ value: [DragDetent]) -> Self { self.dragDetents = value; return self } + + /** + Determines whether it's possible to interact with popups using a drag gesture. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/enable-drag-gesture.png?raw=true) + */ + func enableDragGesture(_ value: Bool) -> Self { self.isDragGestureEnabled = value; return self } +} diff --git a/Sources/Public/Popup/Public+Popup+Main.swift b/Sources/Public/Popup/Public+Popup+Main.swift new file mode 100644 index 0000000000..b5f84301ab --- /dev/null +++ b/Sources/Public/Popup/Public+Popup+Main.swift @@ -0,0 +1,105 @@ +// +// Public+Popup+Main.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +/** + The view to be displayed as a popup. It may appear in one of three positions (see **Usage Examples** section). + # Optional Methods + - ``configurePopup(config:)-3ze4`` + - ``onFocus()-6krqs`` + - ``onDismiss()-254h8`` + + # Usage Examples + + ## TopPopup + ```swift + struct TopPopupExample: TopPopup { + func onFocus() { print("Popup is now active") } + func onDismiss() { print("Popup was dismissed") } + func configurePopup(config: TopPopupConfig) -> TopPopupConfig { config + .heightMode(.auto) + .cornerRadius(44) + .dragDetents([.fraction(1.2), .fraction(1.4), .large]) + } + var body: some View { + Text("Hello Kitty") + } + } + ``` + ![TopPopup](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/top-popup.png?raw=true) + + ## CentrePopup + ```swift + struct CentrePopupExample: CentrePopup { + func onFocus() { print("Popup is now active") } + func onDismiss() { print("Popup was dismissed") } + func configurePopup(config: CentrePopupConfig) -> CentrePopupConfig { config + .cornerRadius(44) + .tapOutsideToDismissPopup(true) + } + var body: some View { + Text("Hello Kitty") + } + } + ``` + ![CentrePopup](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/centre-popup.png?raw=true) + + ## BottomPopup + ```swift + struct BottomPopupExample: BottomPopup { + func onFocus() { print("Popup is now active") } + func onDismiss() { print("Popup was dismissed") } + func configurePopup(config: BottomPopupConfig) -> BottomPopupConfig { config + .heightMode(.auto) + .cornerRadius(44) + .dragDetents([.fraction(1.2), .fraction(1.4), .large]) + } + var body: some View { + Text("Hello Kitty") + } + } + ``` + ![BottomPopup](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/bottom-popup.png?raw=true) + */ +public protocol Popup: View { + associatedtype Config: LocalConfig + + /** + Configures the popup. + See the list of available methods in ``LocalConfig`` and ``LocalConfig/Vertical``. + + - important: If a certain method is not called here, the popup inherits the configuration from ``GlobalConfigContainer``. + */ + func configurePopup(config: Config) -> Config + + /** + Method triggered **every time** a popup is at the top of the stack. + */ + func onFocus() + + /** + Method triggered when a popup is dismissed. + */ + func onDismiss() +} + +// MARK: Default Methods Implementation +public extension Popup { + func configurePopup(config: Config) -> Config { config } + func onFocus() {} + func onDismiss() {} +} + +// MARK: Available Types +public protocol TopPopup: Popup { associatedtype Config = TopPopupConfig } +public protocol CentrePopup: Popup { associatedtype Config = CentrePopupConfig } +public protocol BottomPopup: Popup { associatedtype Config = BottomPopupConfig } diff --git a/Sources/Public/Popup/Public+Popup+Utilities.swift b/Sources/Public/Popup/Public+Popup+Utilities.swift new file mode 100644 index 0000000000..9209854162 --- /dev/null +++ b/Sources/Public/Popup/Public+Popup+Utilities.swift @@ -0,0 +1,78 @@ +// +// Public+Popup+Utilities.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +// MARK: Height Mode +public enum HeightMode { + /** + Popup height is calculated based on its content. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/height-mode-auto.png?raw=true) + + - note: If the calculated height is greater than the screen height, the height mode will automatically be switched to ``large``. + */ + case auto + + /** + The popup has a fixed height, which is equal to the height of the screen minus the safe area and the height of the popups stack (if ``GlobalConfig/Vertical/enableStacking(_:)`` is enabled). + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/height-mode-large.png?raw=true) + */ + case large + + /** + Fills the entire height of the screen, regardless of the height of the popup content. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/height-mode-fullscreen.png?raw=true) + */ + case fullscreen +} + +// MARK: Drag Detent +public enum DragDetent { + /** + A detent with the specified height. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/drag-detent-height.png?raw=true) + */ + case height(CGFloat) + + /** + A detent with the specified fractional height. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/drag-detent-fraction.png?raw=true) + */ + case fraction(CGFloat) + + /** + A detent for a popup at large height. + See ``HeightMode/large`` for more details. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/drag-detent-large.png?raw=true) + */ + case large + + /** + A detent for a popup at fullscreen height. + See ``HeightMode/fullscreen`` for more details. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/drag-detent-fullscreen.png?raw=true) + */ + case fullscreen +} diff --git a/Sources/Public/Present/Public+Present+Popup.swift b/Sources/Public/Present/Public+Present+Popup.swift new file mode 100644 index 0000000000..b00dfadf4b --- /dev/null +++ b/Sources/Public/Present/Public+Present+Popup.swift @@ -0,0 +1,58 @@ +// +// Public+Present+Popup.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2023 Mijick. All rights reserved. + + +import SwiftUI + +public extension Popup { + /** + Presents the popup. + + - Parameters: + - popupManagerID: The identifier registered in one of the application windows in which the popup is to be displayed. + + - Important: The **popupManagerID** must be registered prior to use. For more information see ``SwiftUICore/View/registerPopups(id:configBuilder:)``. + - Important: The methods + ``PopupManager/dismissLastPopup(popupManagerID:)``, + ``PopupManager/dismissPopup(_:popupManagerID:)-1atvy``, + ``PopupManager/dismissPopup(_:popupManagerID:)-6l2c2``, + ``PopupManager/dismissAllPopups(popupManagerID:)``, + ``SwiftUICore/View/dismissLastPopup(popupManagerID:)``, + ``SwiftUICore/View/dismissPopup(_:popupManagerID:)-55ubm``, + ``SwiftUICore/View/dismissPopup(_:popupManagerID:)-9mkd5``, + ``SwiftUICore/View/dismissAllPopups(popupManagerID:)`` + should be called with the same **popupManagerID** as the one used here. + */ + func present(popupManagerID: PopupManagerID = .shared) { PopupManager.fetchInstance(id: popupManagerID)?.stack(.insertPopup(self)) } +} + +// MARK: Configure Popup +public extension Popup { + /** + Sets the custom ID for the selected popup. + + - important: To dismiss a popup with a custom ID set, use methods ``PopupManager/dismissPopup(_:popupManagerID:)-1atvy`` or ``SwiftUICore/View/dismissPopup(_:popupManagerID:)-55ubm`` + - tip: Useful if you want to display several different popups of the same type. + */ + func setCustomID(_ id: String) -> some Popup { AnyPopup(self).settingCustomID(id) } + + /** + Supplies an observable object to a popup's hierarchy. + */ + func setEnvironmentObject(_ object: T) -> some Popup { AnyPopup(self).settingEnvironmentObject(object) } + + /** + Dismisses the popup after a specified period of time. + + - Parameters: + - seconds: Time in seconds after which the popup will be closed. + */ + func dismissAfter(_ seconds: Double) -> some Popup { AnyPopup(self).settingDismissTimer(seconds) } +} diff --git a/Sources/Public/Setup/Popup+Setup+PopupManagerID.swift b/Sources/Public/Setup/Popup+Setup+PopupManagerID.swift new file mode 100644 index 0000000000..e6ec71f860 --- /dev/null +++ b/Sources/Public/Setup/Popup+Setup+PopupManagerID.swift @@ -0,0 +1,46 @@ +// +// Popup+Setup+PopupManagerID.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +/** + A set of identifiers to be registered. + + # Usage Example + ```swift + @main struct App_Main: App { + var body: some Scene { + Window("Window1", id: "Window1") { + ContentView().registerPopups(id: .custom1) + } + Window("Window2", id: "Window2") { + ContentView().registerPopups(id: .custom2) + } + } + } + + extension PopupManagerID { + static let custom1: Self = .init(rawValue: "custom1") + static let custom2: Self = .init(rawValue: "custom2") + } + ``` + + - important: Use methods like ``SwiftUICore/View/dismissLastPopup(popupManagerID:)`` or ``Popup/present(popupManagerID:)`` only with a registered PopupManagerID. + - tip: The main use case where you might need to register a different PopupManagerID is when your application has multiple windows - for example, on macOS, iPad or visionOS. + */ +public struct PopupManagerID: Equatable, Sendable { + let rawValue: String + + public init(rawValue: String) { self.rawValue = rawValue } +} + +// MARK: Default Instance +public extension PopupManagerID { + static let shared: Self = .init(rawValue: "shared") +} diff --git a/Sources/Public/Setup/Public+Setup+Config.swift b/Sources/Public/Setup/Public+Setup+Config.swift new file mode 100644 index 0000000000..4c1485233d --- /dev/null +++ b/Sources/Public/Setup/Public+Setup+Config.swift @@ -0,0 +1,102 @@ +// +// Public+Setup+Config.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Vertical & Centre +public extension GlobalConfig { + /** + Distance of the entire popup (including its background) from the horizontal edges of the screen. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/horizontal-padding.png?raw=true) + */ + func popupHorizontalPadding(_ value: CGFloat) -> Self { self.popupPadding = .init(top: popupPadding.top, leading: value, bottom: popupPadding.bottom, trailing: value); return self } + + /** + Corner radius of the background of the active popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/corner-radius.png?raw=true) + */ + func cornerRadius(_ value: CGFloat) -> Self { self.cornerRadius = value; return self } + + /** + Background color of the popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/background-color.png?raw=true) + */ + func backgroundColor(_ color: Color) -> Self { self.backgroundColor = color; return self } + + /** + The color of the overlay covering the view behind the popup. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/overlay-color.png?raw=true) + + - tip: Use .clear to hide the overlay. + */ + func overlayColor(_ color: Color) -> Self { self.overlayColor = color; return self } + + /** + If enabled, dismisses the active popup when touched outside its area. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/tap-to-close.png?raw=true) + */ + func tapOutsideToDismissPopup(_ value: Bool) -> Self { self.isTapOutsideToDismissEnabled = value; return self } +} + +// MARK: Only Vertical +public extension GlobalConfig.Vertical { + /** + Distance of the entire popup (including its background) from the top edge of the screen. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/top-padding.png?raw=true) + */ + func popupTopPadding(_ value: CGFloat) -> Self { self.popupPadding = .init(top: value, leading: popupPadding.leading, bottom: popupPadding.bottom, trailing: popupPadding.trailing); return self } + + /** + Distance of the entire popup (including its background) from the bottom edge of the screen. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/bottom-padding.png?raw=true) + */ + func popupBottomPadding(_ value: CGFloat) -> Self { self.popupPadding = .init(top: popupPadding.top, leading: popupPadding.leading, bottom: value, trailing: popupPadding.trailing); return self } + + /** + The drag progress value above which the popup will either be dismissed or move to the next drag detent value. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/drag-threshold.png?raw=true) + + - important: Drag progress is calculated as **dragTranslation** / **popupHeight**, therefore drag threshold value is expected to be between 0 and 1. + */ + func dragThreshold(_ value: CGFloat) -> Self { self.dragThreshold = value; return self } + + /** + Indicates whether stacked popups should be visible in the view. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/enable-stacking.png?raw=true) + */ + func enableStacking(_ value: Bool) -> Self { self.isStackingEnabled = value; return self } + + /** + Determines whether it's possible to interact with popups using a drag gesture. + + ## Visualisation + ![image](https://github.com/Mijick/Assets/blob/main/Framework%20Docs/Popups/enable-drag-gesture.png?raw=true) + */ + func enableDragGesture(_ value: Bool) -> Self { self.isDragGestureEnabled = value; return self } +} diff --git a/Sources/Public/Setup/Public+Setup+ConfigContainer.swift b/Sources/Public/Setup/Public+Setup+ConfigContainer.swift new file mode 100644 index 0000000000..cbf5d77940 --- /dev/null +++ b/Sources/Public/Setup/Public+Setup+ConfigContainer.swift @@ -0,0 +1,26 @@ +// +// Public+Setup+ConfigContainer.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +public extension GlobalConfigContainer { + /** + Default configuration for all centre popups. + Use the ``Popup/configurePopup(config:)-98ha0`` method to change the configuration for a specific popup. + See the list of available methods in ``GlobalConfig``. + */ + func centre(_ builder: (GlobalConfig.Centre) -> GlobalConfig.Centre) -> Self { Self.centre = builder(.init()); return self } + + /** + Default configuration for all top and bottom popups. + Use the ``Popup/configurePopup(config:)-98ha0`` method to change the configuration for a specific popup. + See the list of available methods in ``GlobalConfig`` and ``GlobalConfig/Vertical``. + */ + func vertical(_ builder: (GlobalConfig.Vertical) -> GlobalConfig.Vertical) -> Self { Self.vertical = builder(.init()); return self } +} diff --git a/Sources/Public/Setup/Public+Setup+SceneDelegate.swift b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift new file mode 100644 index 0000000000..8ab5a7db80 --- /dev/null +++ b/Sources/Public/Setup/Public+Setup+SceneDelegate.swift @@ -0,0 +1,158 @@ +// +// Public+Setup+SceneDelegate.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +#if os(iOS) +/** + Registers the framework to work in your application. Works on iOS only. + + - tip: Recommended initialisation way when using the framework with standard Apple sheets. + + ## Usage Example + ```swift + @main struct App_Main: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { WindowGroup(content: ContentView.init) } + } + + class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + sceneConfig.delegateClass = CustomPopupSceneDelegate.self + return sceneConfig + } + } + + class CustomPopupSceneDelegate: PopupSceneDelegate { + override init() { super.init() + configBuilder = { $0 + .vertical { $0 + .enableDragGesture(true) + .tapOutsideToDismissPopup(true) + .cornerRadius(32) + } + .centre { $0 + .tapOutsideToDismissPopup(false) + .backgroundColor(.white) + } + } + } + } + ``` + + - seealso: It's also possible to register the framework with ``SwiftUICore/View/registerPopups(id:configBuilder:)``. + */ +open class PopupSceneDelegate: NSObject, UIWindowSceneDelegate { + open var window: UIWindow? + open var configBuilder: (GlobalConfigContainer) -> (GlobalConfigContainer) = { _ in .init() } +} + +// MARK: Create Popup Scene +extension PopupSceneDelegate { + open func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { if let windowScene = scene as? UIWindowScene { + let hostingController = UIHostingController(rootView: Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .registerPopups(configBuilder: configBuilder) + ) + hostingController.view.backgroundColor = .clear + + window = Window(windowScene: windowScene) + window?.rootViewController = hostingController + window?.isHidden = false + }} +} + + + +// MARK: - WINDOW + + + +// MARK: Implementation +fileprivate class Window: UIWindow { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if #available(iOS 18, *) { point_iOS18(inside: point, with: event) } + else { point_iOS17(inside: point, with: event) } + } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if #available(iOS 18, *) { hitTest_iOS18(point, with: event) } + else { hitTest_iOS17(point, with: event) } + } +} + +// MARK: Point +private extension Window { + @available(iOS 18, *) + func point_iOS18(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard let view = rootViewController?.view else { return false } + + let hit = hitTestHelper(point, with: event, view: subviews.count > 1 ? self : view) + return hit != nil + } + func point_iOS17(inside point: CGPoint, with event: UIEvent?) -> Bool { + super.point(inside: point, with: event) + } +} + +// MARK: Hit Test +private extension Window { + @available(iOS 18, *) + func hitTest_iOS18(_ point: CGPoint, with event: UIEvent?) -> UIView? { + super.hitTest(point, with: event) + } + func hitTest_iOS17(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let hit = super.hitTest(point, with: event) else { return nil } + return rootViewController?.view == hit ? nil : hit + } +} + + +// MARK: Hit Test Helper +// Based on philip_trauner solution: https://forums.developer.apple.com/forums/thread/762292?answerId=803885022#803885022 +@available(iOS 18, *) +private extension Window { + func hitTestHelper(_ point: CGPoint, with event: UIEvent?, view: UIView, depth: Int = 0) -> HitTestResult? { + view.subviews.reversed().reduce(nil) { deepest, subview in let convertedPoint = view.convert(point, to: subview) + guard shouldCheckSubview(subview, convertedPoint: convertedPoint, event: event) else { return deepest } + + let result = calculateHitTestSubviewResult(convertedPoint, with: event, subview: subview, depth: depth) + return getDeepestHitTestResult(candidate: result, current: deepest) + } + } +} +@available(iOS 18, *) +private extension Window { + func shouldCheckSubview(_ subview: UIView, convertedPoint: CGPoint, event: UIEvent?) -> Bool { + subview.isUserInteractionEnabled && + subview.isHidden == false && + subview.alpha > 0 && + subview.point(inside: convertedPoint, with: event) + } + func calculateHitTestSubviewResult(_ point: CGPoint, with event: UIEvent?, subview: UIView, depth: Int) -> HitTestResult { + switch hitTestHelper(point, with: event, view: subview, depth: depth + 1) { + case .some(let result): result + case nil: (subview, depth) + } + } + func getDeepestHitTestResult(candidate: HitTestResult, current: HitTestResult?) -> HitTestResult { + switch current { + case .some(let current) where current.depth > candidate.depth: current + default: candidate + } + } +} +@available(iOS 18, *) +private extension Window { + typealias HitTestResult = (view: UIView, depth: Int) +} +#endif diff --git a/Sources/Public/Setup/Public+Setup+View.swift b/Sources/Public/Setup/Public+Setup+View.swift new file mode 100644 index 0000000000..b4e01dfe8a --- /dev/null +++ b/Sources/Public/Setup/Public+Setup+View.swift @@ -0,0 +1,55 @@ +// +// Public+Setup+View.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public extension View { + /** + Registers the framework to work in your application. + + - Parameters: + - id: It is possible to register multiple managers (for different windows); especially useful in a macOS or iPad implementation. Read more in ``PopupManagerID``. + - configBuilder: Default configuration for all popups. Use the ``Popup/configurePopup(config:)-98ha0`` method to change the configuration for a specific popup. See the list of available methods in ``GlobalConfig``. + + + ## Usage Example + ```swift + @main struct App_Main: App { + var body: some Scene { WindowGroup { + ContentView() + .registerPopups { config in config + .vertical { $0 + .enableDragGesture(true) + .tapOutsideToDismissPopup(true) + .cornerRadius(32) + } + .centre { $0 + .tapOutsideToDismissPopup(false) + .backgroundColor(.white) + } + } + }} + } + ``` + + - seealso: It's also possible to register the framework with ``PopupSceneDelegate``; useful if you want to use the library with Apple's default sheets. + */ + func registerPopups(id: PopupManagerID = .shared, configBuilder: @escaping (GlobalConfigContainer) -> GlobalConfigContainer = { $0 }) -> some View { + #if os(tvOS) + PopupView(rootView: self, popupManager: .registerInstance(id: id)).onAppear { _ = configBuilder(.init()) } + #else + self + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(PopupView(popupManager: .registerInstance(id: id)), alignment: .top) + .onAppear { _ = configBuilder(.init()) } + #endif + } +} diff --git a/Sources/Public/Utilities/Public+DragDetent.swift b/Sources/Public/Utilities/Public+DragDetent.swift deleted file mode 100644 index 10ce389860..0000000000 --- a/Sources/Public/Utilities/Public+DragDetent.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Public+DragDetent.swift of PopupView -// -// Created by Tomasz Kurylik -// - Twitter: https://twitter.com/tkurylik -// - Mail: tomasz.kurylik@mijick.com -// - GitHub: https://github.com/FulcrumOne -// -// Copyright ©2024 Mijick. Licensed under MIT License. - - -import Foundation - -public enum DragDetent { - case fixed(CGFloat) - case fraction(CGFloat) - case fullscreen(stackVisible: Bool) -} diff --git a/Tests/Extensions/Task++.swift b/Tests/Extensions/Task++.swift new file mode 100644 index 0000000000..62f5d9e2ed --- /dev/null +++ b/Tests/Extensions/Task++.swift @@ -0,0 +1,18 @@ +// +// Task++.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension Task where Success == Never, Failure == Never { + static func sleep(seconds: Double) async { + try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } +} diff --git a/Tests/Tests+PopupID.swift b/Tests/Tests+PopupID.swift new file mode 100644 index 0000000000..585ec52b99 --- /dev/null +++ b/Tests/Tests+PopupID.swift @@ -0,0 +1,129 @@ +// +// Tests+PopupID.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import XCTest +import SwiftUI +@testable import MijickPopups + +@MainActor final class PopupIDTests: XCTestCase {} + + + +// MARK: - TEST CASES + + + +// MARK: Create ID +extension PopupIDTests { + func test_createPopupID_1() { + let dateString = String(describing: Date()) + + let popupID = PopupID.create(from: TestTopPopup.self) + let idComponents = popupID.rawValue.components(separatedBy: "/{}/") + + XCTAssertEqual(idComponents.count, 2) + XCTAssertEqual(idComponents[0], "TestTopPopup") + XCTAssertEqual(idComponents[1], dateString) + } + func test_createPopupID_2() { + let dateString = String(describing: Date()) + + let popupID = PopupID.create(from: TestCentrePopup.self) + let idComponents = popupID.rawValue.components(separatedBy: "/{}/") + + XCTAssertEqual(idComponents.count, 2) + XCTAssertEqual(idComponents[0], "TestCentrePopup") + XCTAssertEqual(idComponents[1], dateString) + } +} + +// MARK: Is Same Type +extension PopupIDTests { + func test_isSameType_1() { + let popupID1 = PopupID.create(from: TestTopPopup.self), + popupID2 = PopupID.create(from: TestBottomPopup.self) + + let result = popupID1.isSameType(as: popupID2) + XCTAssertEqual(result, false) + } + func test_isSameType_2() { + let popupID1 = PopupID.create(from: TestTopPopup.self), + popupID2 = "TestTopPopup" + + let result = popupID1.isSameType(as: popupID2) + XCTAssertEqual(result, true) + } + func test_isSameType_3() { + let popupID1 = PopupID.create(from: "2137"), + popupID2 = "2137" + + let result = popupID1.isSameType(as: popupID2) + XCTAssertEqual(result, true) + } + func test_isSameType_4() { + let popupID1 = AnyPopup(TestTopPopup().setCustomID("2137")).id, + popupID2 = AnyPopup(TestTopPopup()).id + + let result = popupID1.isSameType(as: popupID2) + XCTAssertEqual(result, false) + } + func test_isSameType_5() async { + let popupID1 = PopupID.create(from: TestTopPopup.self) + await Task.sleep(seconds: 1) + let popupID2 = PopupID.create(from: TestTopPopup.self) + + let result = popupID1.isSameType(as: popupID2) + XCTAssertEqual(result, true) + } +} + +// MARK: Is Same Instance +extension PopupIDTests { + func test_isSameInstance_1() { + let popupID = PopupID.create(from: TestTopPopup.self), + popup = AnyPopup(TestCentrePopup()) + + let result = popupID.isSameInstance(as: popup) + XCTAssertEqual(result, false) + } + func test_isSameInstance_2() { + let popupID = PopupID.create(from: TestTopPopup.self), + popup = AnyPopup(TestTopPopup()) + + let result = popupID.isSameInstance(as: popup) + XCTAssertEqual(result, true) + } + func test_isSameInstance_3() async { + let popupID = PopupID.create(from: TestTopPopup.self) + await Task.sleep(seconds: 1) + let popup = AnyPopup(TestTopPopup()) + + let result = popupID.isSameInstance(as: popup) + XCTAssertEqual(result, false) + } +} + + + +// MARK: - HELPERS + + + +// MARK: Test Popups +private struct TestTopPopup: TopPopup { + var body: some View { EmptyView() } +} +private struct TestCentrePopup: CentrePopup { + var body: some View { EmptyView() } +} +private struct TestBottomPopup: BottomPopup { + var body: some View { EmptyView() } +} diff --git a/Tests/Tests+PopupManager.swift b/Tests/Tests+PopupManager.swift new file mode 100644 index 0000000000..f172a83983 --- /dev/null +++ b/Tests/Tests+PopupManager.swift @@ -0,0 +1,294 @@ +// +// Tests+PopupManager.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import XCTest +import SwiftUI +@testable import MijickPopups + +@MainActor final class PopupManagerTests: XCTestCase { + override func setUp() async throws { + PopupManagerContainer.clean() + } +} + + + +// MARK: - TEST CASES + + + +// MARK: Register New Instance +extension PopupManagerTests { + func test_registerNewInstance_withNoInstancesToRegister() { + let popupManagerIds: [PopupManagerID] = [] + + registerNewInstances(popupManagerIds: popupManagerIds) + XCTAssertEqual(popupManagerIds, getRegisteredInstances()) + } + func test_registerNewInstance_withUniqueInstancesToRegister() { + let popupManagerIds: [PopupManagerID] = [ + .staremiasto, + .grzegorzki, + .krowodrza, + .bronowice + ] + + registerNewInstances(popupManagerIds: popupManagerIds) + XCTAssertEqual(popupManagerIds, getRegisteredInstances()) + } + func test_registerNewInstance_withRepeatingInstancesToRegister() { + let popupManagerIds: [PopupManagerID] = [ + .staremiasto, + .grzegorzki, + .krowodrza, + .bronowice, + .bronowice, + .pradnikbialy, + .pradnikczerwony, + .krowodrza + ] + + registerNewInstances(popupManagerIds: popupManagerIds) + XCTAssertNotEqual(popupManagerIds, getRegisteredInstances()) + XCTAssertEqual(getRegisteredInstances().count, 6) + } +} +private extension PopupManagerTests { + func registerNewInstances(popupManagerIds: [PopupManagerID]) { + popupManagerIds.forEach { _ = PopupManager.registerInstance(id: $0) } + } + func getRegisteredInstances() -> [PopupManagerID] { + PopupManagerContainer.instances.map(\.id) + } +} + +// MARK: Get Instance +extension PopupManagerTests { + func test_getInstance_whenNoInstancesAreRegistered() { + let managerInstance = PopupManager.fetchInstance(id: .bronowice) + XCTAssertNil(managerInstance) + } + func test_getInstance_whenInstanceIsNotRegistered() { + registerNewInstances(popupManagerIds: [ + .krowodrza, + .staremiasto, + .pradnikczerwony, + .pradnikbialy, + .grzegorzki + ]) + + let managerInstance = PopupManager.fetchInstance(id: .bronowice) + XCTAssertNil(managerInstance) + } + func test_getInstance_whenInstanceIsRegistered() { + registerNewInstances(popupManagerIds: [ + .krowodrza, + .staremiasto, + .grzegorzki + ]) + + let managerInstance = PopupManager.fetchInstance(id: .grzegorzki) + XCTAssertNotNil(managerInstance) + } +} + +// MARK: Present Popup +extension PopupManagerTests { + func test_presentPopup_withThreePopupsToBePresented() { + registerNewInstanceAndPresentPopups(popups: [ + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()) + ]) + + let popupsOnStack = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack.count, 3) + } + func test_presentPopup_withPopupsWithSameID() { + registerNewInstanceAndPresentPopups(popups: [ + AnyPopup.t_createNew(id: "2137", config: .init()), + AnyPopup.t_createNew(id: "2137", config: .init()), + AnyPopup.t_createNew(id: "2331", config: .init()) + ]) + + let popupsOnStack = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack.count, 2) + } + func test_presentPopup_withCustomID() { + registerNewInstanceAndPresentPopups(popups: [ + AnyPopup.t_createNew(id: "2137", config: .init()).setCustomID("1"), + AnyPopup.t_createNew(id: "2137", config: .init()), + AnyPopup.t_createNew(id: "2137", config: .init()).setCustomID("3") + ]) + + let popupsOnStack = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack.count, 3) + } + func test_presentPopup_withDismissAfter() async { + registerNewInstanceAndPresentPopups(popups: [ + AnyPopup.t_createNew(config: .init()).dismissAfter(0.7), + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()).dismissAfter(1.5) + ]) + + let popupsOnStack1 = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack1.count, 3) + + await Task.sleep(seconds: 1) + + let popupsOnStack2 = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack2.count, 2) + + await Task.sleep(seconds: 1) + + let popupsOnStack3 = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack3.count, 1) + } +} + +// MARK: Dismiss Popup +extension PopupManagerTests { + func test_dismissLastPopup_withNoPopupsOnStack() { + registerNewInstanceAndPresentPopups(popups: []) + PopupManager.dismissLastPopup(popupManagerID: defaultPopupManagerID) + + let popupsOnStack = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack.count, 0) + } + func test_dismissLastPopup_withThreePopupsOnStack() { + registerNewInstanceAndPresentPopups(popups: [ + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()) + ]) + PopupManager.dismissLastPopup(popupManagerID: defaultPopupManagerID) + + let popupsOnStack = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack.count, 2) + } + func test_dismissPopupWithType_whenPopupOnStack() { + let popups: [AnyPopup] = [ + .init(TestTopPopup()), + .init(TestCentrePopup()), + .init(TestBottomPopup()) + ] + registerNewInstanceAndPresentPopups(popups: popups) + + let popupsOnStackBefore = getPopupsForActiveInstance() + XCTAssertEqual(popups, popupsOnStackBefore) + + PopupManager.dismissPopup(TestBottomPopup.self, popupManagerID: defaultPopupManagerID) + + let popupsOnStackAfter = getPopupsForActiveInstance() + XCTAssertEqual([popups[0], popups[1]], popupsOnStackAfter) + } + func test_dismissPopupWithType_whenPopupNotOnStack() { + let popups: [AnyPopup] = [ + .init(TestTopPopup()), + .init(TestBottomPopup()) + ] + registerNewInstanceAndPresentPopups(popups: popups) + + let popupsOnStackBefore = getPopupsForActiveInstance() + XCTAssertEqual(popups, popupsOnStackBefore) + + PopupManager.dismissPopup(TestCentrePopup.self, popupManagerID: defaultPopupManagerID) + + let popupsOnStackAfter = getPopupsForActiveInstance() + XCTAssertEqual(popups, popupsOnStackAfter) + } + func test_dismissPopupWithType_whenPopupHasCustomID() { + let popups: [AnyPopup] = [ + .init(TestTopPopup().setCustomID("2137")), + .init(TestBottomPopup()) + ] + registerNewInstanceAndPresentPopups(popups: popups) + + let popupsOnStackBefore = getPopupsForActiveInstance() + XCTAssertEqual(popups, popupsOnStackBefore) + + PopupManager.dismissPopup(TestTopPopup.self, popupManagerID: defaultPopupManagerID) + + let popupsOnStackAfter = getPopupsForActiveInstance() + XCTAssertEqual(popups, popupsOnStackAfter) + } + func test_dismissPopupWithID_whenPopupHasCustomID() { + let popups: [AnyPopup] = [ + .init(TestTopPopup().setCustomID("2137")), + .init(TestBottomPopup()) + ] + registerNewInstanceAndPresentPopups(popups: popups) + + let popupsOnStackBefore = getPopupsForActiveInstance() + XCTAssertEqual(popups, popupsOnStackBefore) + + PopupManager.dismissPopup("2137", popupManagerID: defaultPopupManagerID) + + let popupsOnStackAfter = getPopupsForActiveInstance() + XCTAssertEqual([popups[1]], popupsOnStackAfter) + } + func test_dismissAllPopups() { + registerNewInstanceAndPresentPopups(popups: [ + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()), + AnyPopup.t_createNew(config: .init()) + ]) + PopupManager.dismissAllPopups(popupManagerID: defaultPopupManagerID) + + let popupsOnStack = getPopupsForActiveInstance() + XCTAssertEqual(popupsOnStack.count, 0) + } +} + + + +// MARK: - HELPERS + + + +// MARK: Methods +private extension PopupManagerTests { + func registerNewInstanceAndPresentPopups(popups: [any Popup]) { + registerNewInstances(popupManagerIds: [defaultPopupManagerID]) + popups.forEach { $0.present(popupManagerID: defaultPopupManagerID) } + } + func getPopupsForActiveInstance() -> [AnyPopup] { + PopupManager + .fetchInstance(id: defaultPopupManagerID)? + .stack ?? [] + } +} + +// MARK: Variables +private extension PopupManagerTests { + var defaultPopupManagerID: PopupManagerID { .staremiasto } +} + +// MARK: Popup Manager Identifiers +private extension PopupManagerID { + static let staremiasto: Self = .init(rawValue: "staremiasto") + static let grzegorzki: Self = .init(rawValue: "grzegorzki") + static let pradnikczerwony: Self = .init(rawValue: "pradnikczerwony") + static let pradnikbialy: Self = .init(rawValue: "pradnikbialy") + static let krowodrza: Self = .init(rawValue: "krowodrza") + static let bronowice: Self = .init(rawValue: "bronowice") +} + +// MARK: Test Popups +private struct TestTopPopup: TopPopup { + var body: some View { EmptyView() } +} +private struct TestCentrePopup: CentrePopup { + var body: some View { EmptyView() } +} +private struct TestBottomPopup: BottomPopup { + var body: some View { EmptyView() } +} diff --git a/Tests/Tests+ViewModel+PopupCentreStack.swift b/Tests/Tests+ViewModel+PopupCentreStack.swift new file mode 100644 index 0000000000..4ddbc39b32 --- /dev/null +++ b/Tests/Tests+ViewModel+PopupCentreStack.swift @@ -0,0 +1,275 @@ +// +// Tests+ViewModel+PopupCentreStack.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import XCTest +import SwiftUI +@testable import MijickPopups + +@MainActor final class PopupCentreStackViewModelTests: XCTestCase { + @ObservedObject private var viewModel: ViewModel = .init() + + override func setUp() async throws { + viewModel.t_updateScreenValue(screen) + viewModel.t_setup(updatePopupAction: { [self] in updatePopupAction(viewModel, $0) }, closePopupAction: { [self] in closePopupAction(viewModel, $0) }) + } +} +private extension PopupCentreStackViewModelTests { + func updatePopupAction(_ viewModel: ViewModel, _ popup: AnyPopup) { if let index = viewModel.t_popups.firstIndex(of: popup) { + var popups = viewModel.t_popups + popups[index] = popup + + viewModel.t_updatePopupsValue(popups) + viewModel.t_calculateAndUpdateActivePopupHeight() + }} + func closePopupAction(_ viewModel: ViewModel, _ popup: AnyPopup) { if let index = viewModel.t_popups.firstIndex(of: popup) { + var popups = viewModel.t_popups + popups.remove(at: index) + + viewModel.t_updatePopupsValue(popups) + }} +} + + + +// MARK: - TEST CASES + + + +// MARK: Popup Padding +extension PopupCentreStackViewModelTests { + func test_calculatePopupPadding_withKeyboardHidden_whenCustomPaddingNotSet() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72), + createPopupInstanceForPopupHeightTests(popupHeight: 400) + ] + + appendPopupsAndCheckPopupPadding( + popups: popups, + isKeyboardActive: false, + expectedValue: .init() + ) + } + func test_calculatePopupPadding_withKeyboardHidden_whenCustomPaddingSet() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72, popupPadding: .init(top: 0, leading: 11, bottom: 0, trailing: 11)), + createPopupInstanceForPopupHeightTests(popupHeight: 400, popupPadding: .init(top: 0, leading: 16, bottom: 0, trailing: 16)) + ] + + appendPopupsAndCheckPopupPadding( + popups: popups, + isKeyboardActive: false, + expectedValue: .init(top: 0, leading: 16, bottom: 0, trailing: 16) + ) + } + func test_calculatePopupPadding_withKeyboardShown_whenKeyboardNotOverlapingPopup() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72, popupPadding: .init(top: 0, leading: 11, bottom: 0, trailing: 11)), + createPopupInstanceForPopupHeightTests(popupHeight: 400, popupPadding: .init(top: 0, leading: 16, bottom: 0, trailing: 16)) + ] + + appendPopupsAndCheckPopupPadding( + popups: popups, + isKeyboardActive: true, + expectedValue: .init(top: 0, leading: 16, bottom: 0, trailing: 16) + ) + } + func test_calculatePopupPadding_withKeyboardShown_whenKeyboardOverlapingPopup() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72, popupPadding: .init(top: 0, leading: 11, bottom: 0, trailing: 11)), + createPopupInstanceForPopupHeightTests(popupHeight: 1000, popupPadding: .init(top: 0, leading: 16, bottom: 0, trailing: 16)) + ] + + appendPopupsAndCheckPopupPadding( + popups: popups, + isKeyboardActive: true, + expectedValue: .init(top: 0, leading: 16, bottom: 250, trailing: 16) + ) + } +} +private extension PopupCentreStackViewModelTests { + func appendPopupsAndCheckPopupPadding(popups: [AnyPopup], isKeyboardActive: Bool, expectedValue: EdgeInsets) { + appendPopupsAndPerformChecks( + popups: popups, + isKeyboardActive: isKeyboardActive, + calculatedValue: { $0.t_calculatePopupPadding() }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Corner Radius +extension PopupCentreStackViewModelTests { + func test_calculateCornerRadius_withCornerRadiusZero() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 234, cornerRadius: 20), + createPopupInstanceForPopupHeightTests(popupHeight: 234, cornerRadius: 0), + ] + + appendPopupsAndCheckCornerRadius( + popups: popups, + expectedValue: [.top: 0, .bottom: 0] + ) + } + func test_calculateCornerRadius_withCornerRadiusNonZero() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 234, cornerRadius: 20), + createPopupInstanceForPopupHeightTests(popupHeight: 234, cornerRadius: 24), + ] + + appendPopupsAndCheckCornerRadius( + popups: popups, + expectedValue: [.top: 24, .bottom: 24] + ) + } +} +private extension PopupCentreStackViewModelTests { + func appendPopupsAndCheckCornerRadius(popups: [AnyPopup], expectedValue: [MijickPopups.VerticalEdge: CGFloat]) { + appendPopupsAndPerformChecks( + popups: popups, + isKeyboardActive: false, + calculatedValue: { $0.t_calculateCornerRadius() }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Opacity +extension PopupCentreStackViewModelTests { + func test_calculatePopupOpacity_1() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72), + createPopupInstanceForPopupHeightTests(popupHeight: 400) + ] + + appendPopupsAndCheckOpacity( + popups: popups, + calculateForIndex: 1, + expectedValue: 0 + ) + } + func test_calculatePopupOpacity_2() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72), + createPopupInstanceForPopupHeightTests(popupHeight: 400) + ] + + appendPopupsAndCheckOpacity( + popups: popups, + calculateForIndex: 2, + expectedValue: 1 + ) + } +} +private extension PopupCentreStackViewModelTests { + func appendPopupsAndCheckOpacity(popups: [AnyPopup], calculateForIndex index: Int, expectedValue: CGFloat) { + appendPopupsAndPerformChecks( + popups: popups, + isKeyboardActive: false, + calculatedValue: { [self] in $0.t_calculateOpacity(for: viewModel.t_popups[index]) }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Vertical Fixed Size +extension PopupCentreStackViewModelTests { + func test_calculateVerticalFixedSize_withHeightSmallerThanScreen() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 913), + createPopupInstanceForPopupHeightTests(popupHeight: 400) + ] + + appendPopupsAndCheckVerticalFixedSize( + popups: popups, + calculateForIndex: 2, + expectedValue: true + ) + } + func test_calculateVerticalFixedSize_withHeightLargerThanScreen() { + let popups = [ + createPopupInstanceForPopupHeightTests(popupHeight: 350), + createPopupInstanceForPopupHeightTests(popupHeight: 72), + createPopupInstanceForPopupHeightTests(popupHeight: 913) + ] + + appendPopupsAndCheckVerticalFixedSize( + popups: popups, + calculateForIndex: 2, + expectedValue: false + ) + } +} +private extension PopupCentreStackViewModelTests { + func appendPopupsAndCheckVerticalFixedSize(popups: [AnyPopup], calculateForIndex index: Int, expectedValue: Bool) { + appendPopupsAndPerformChecks( + popups: popups, + isKeyboardActive: false, + calculatedValue: { $0.t_calculateVerticalFixedSize(for: $0.t_popups[index]) }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + + + +// MARK: - HELPERS + + + +// MARK: Methods +private extension PopupCentreStackViewModelTests { + func createPopupInstanceForPopupHeightTests(popupHeight: CGFloat, popupPadding: EdgeInsets = .init(), cornerRadius: CGFloat = 0) -> AnyPopup { + let config = getConfigForPopupHeightTests(cornerRadius: cornerRadius, popupPadding: popupPadding) + return AnyPopup.t_createNew(config: config).settingHeight(popupHeight) + } + func appendPopupsAndPerformChecks(popups: [AnyPopup], isKeyboardActive: Bool, calculatedValue: @escaping (ViewModel) -> (Value), expectedValueBuilder: @escaping (ViewModel) -> Value) { + viewModel.t_updatePopupsValue(popups) + viewModel.t_updatePopupsValue(recalculatePopupHeights(viewModel)) + viewModel.t_updateKeyboardValue(isKeyboardActive) + viewModel.t_updateScreenValue(isKeyboardActive ? screenWithKeyboard : screen) + + XCTAssertEqual(calculatedValue(viewModel), expectedValueBuilder(viewModel)) + } +} +private extension PopupCentreStackViewModelTests { + func getConfigForPopupHeightTests(cornerRadius: CGFloat, popupPadding: EdgeInsets) -> Config { .t_createNew( + popupPadding: popupPadding, + cornerRadius: cornerRadius + )} + func recalculatePopupHeights(_ viewModel: ViewModel) -> [AnyPopup] { viewModel.t_popups.map { + $0.settingHeight(viewModel.t_calculateHeight(heightCandidate: $0.height!)) + }} +} + +// MARK: Screen +private extension PopupCentreStackViewModelTests { + var screen: Screen { .init( + height: 1000, + safeArea: .init(top: 100, leading: 20, bottom: 50, trailing: 30) + )} + var screenWithKeyboard: Screen { .init( + height: 1000, + safeArea: .init(top: 100, leading: 20, bottom: 200, trailing: 30) + )} +} + +// MARK: Typealiases +private extension PopupCentreStackViewModelTests { + typealias Config = LocalConfig.Centre + typealias ViewModel = VM.CentreStack +} diff --git a/Tests/Tests+ViewModel+PopupVerticalStack.swift b/Tests/Tests+ViewModel+PopupVerticalStack.swift new file mode 100644 index 0000000000..3961fc31b0 --- /dev/null +++ b/Tests/Tests+ViewModel+PopupVerticalStack.swift @@ -0,0 +1,1502 @@ +// +// Tests+ViewModel+PopupVerticalStack.swift of MijickPopups +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import XCTest +import SwiftUI +@testable import MijickPopups + +@MainActor final class PopupVerticalStackViewModelTests: XCTestCase { + @ObservedObject private var topViewModel: ViewModel = .init() + @ObservedObject private var bottomViewModel: ViewModel = .init() + + override func setUp() async throws { + setup(topViewModel) + setup(bottomViewModel) + } +} +private extension PopupVerticalStackViewModelTests { + func setup(_ viewModel: ViewModel) { + viewModel.t_updateScreenValue(screen) + viewModel.t_setup(updatePopupAction: { self.updatePopupAction(viewModel, $0) }, closePopupAction: { self.closePopupAction(viewModel, $0) }) + } +} +private extension PopupVerticalStackViewModelTests { + func updatePopupAction(_ viewModel: ViewModel, _ popup: AnyPopup) { if let index = viewModel.t_popups.firstIndex(of: popup) { + var popups = viewModel.t_popups + popups[index] = popup + + viewModel.t_updatePopupsValue(popups) + viewModel.t_calculateAndUpdateActivePopupHeight() + }} + func closePopupAction(_ viewModel: ViewModel, _ popup: AnyPopup) { if let index = viewModel.t_popups.firstIndex(of: popup) { + var popups = viewModel.t_popups + popups.remove(at: index) + + viewModel.t_updatePopupsValue(popups) + }} +} + + + +// MARK: - TEST CASES + + + +// MARK: Inverted Index +extension PopupVerticalStackViewModelTests { + func test_getInvertedIndex_1() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150) + ]) + + XCTAssertEqual( + bottomViewModel.t_getInvertedIndex(of: bottomViewModel.t_popups[0]), + 0 + ) + } + func test_getInvertedIndex_2() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150) + ]) + + XCTAssertEqual( + bottomViewModel.t_getInvertedIndex(of: bottomViewModel.t_popups[3]), + 1 + ) + } +} + +// MARK: Update Popup +extension PopupVerticalStackViewModelTests { + func test_updatePopup_1() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 0) + ] + let updatedPopup = popups[0] + .settingHeight(100) + .settingDragHeight(100) + + appendPopupsAndCheckPopups( + viewModel: bottomViewModel, + popups: popups, + updatedPopup: updatedPopup, + expectedValue: (height: 100, dragHeight: 100) + ) + } + func test_updatePopup_2() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 50), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 25), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 15), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 2137) + ] + let updatedPopup = popups[2].settingHeight(1371) + + appendPopupsAndCheckPopups( + viewModel: bottomViewModel, + popups: popups, + updatedPopup: updatedPopup, + expectedValue: (height: 1371, dragHeight: nil) + ) + } + func test_updatePopup_3() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 50), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 25), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 15), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 2137), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 77) + ] + let updatedPopup = popups[4].settingHeight(nil) + + appendPopupsAndCheckPopups( + viewModel: bottomViewModel, + popups: popups, + updatedPopup: updatedPopup, + expectedValue: (height: nil, dragHeight: nil) + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckPopups(viewModel: ViewModel, popups: [AnyPopup], updatedPopup: AnyPopup, expectedValue: (height: CGFloat?, dragHeight: CGFloat?)) { + viewModel.t_updatePopupsValue(popups) + viewModel.t_updatePopup(updatedPopup) + + if let index = viewModel.t_popups.firstIndex(of: updatedPopup) { + XCTAssertEqual(viewModel.t_popups[index].height, expectedValue.height) + XCTAssertEqual(viewModel.t_popups[index].dragHeight, expectedValue.dragHeight) + } + } +} + +// MARK: Popup Height +extension PopupVerticalStackViewModelTests { + func test_calculatePopupHeight_withAutoHeightMode_whenLessThanScreen_onePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + 150.0 + ) + } + func test_calculatePopupHeight_withAutoHeightMode_whenLessThanScreen_fourPopupsStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 200), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 100) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + 100.0 + ) + } + func test_calculatePopupHeight_withAutoHeightMode_whenBiggerThanScreen_onePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 2000) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height - screen.safeArea.top + ) + } + func test_calculatePopupHeight_withAutoHeightMode_whenBiggerThanScreen_fivePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 150), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 200), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 2000) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height - screen.safeArea.top - bottomViewModel.t_stackOffset * 4 + ) + } + func test_calculatePopupHeight_withLargeHeightMode_whenOnePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 100) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height - screen.safeArea.top + ) + } + func test_calculatePopupHeight_withLargeHeightMode_whenThreePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 700), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 1000) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height - screen.safeArea.top - bottomViewModel.t_stackOffset * 2 + ) + } + func test_calculatePopupHeight_withFullscreenHeightMode_whenOnePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 100) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height + ) + } + func test_calculatePopupHeight_withFullscreenHeightMode_whenThreePopupsStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 2000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 3000) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height + ) + } + func test_calculatePopupHeight_withLargeHeightMode_whenThreePopupsStacked_popupPadding() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 2000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 3000, popupPadding: .init(top: 33, leading: 15, bottom: 21, trailing: 15)) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height - screen.safeArea.top - 2 * bottomViewModel.t_stackOffset + ) + } + func test_calculatePopupHeight_withFullscreenHeightMode_whenThreePopupsStacked_popupPadding() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 2000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 3000, popupPadding: .init(top: 33, leading: 15, bottom: 21, trailing: 15)) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(bottomViewModel), + screen.height + ) + } + func test_calculatePopupHeight_withLargeHeightMode_whenPopupsHaveTopAlignment() { + topViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .large, popupHeight: 100) + ]) + + XCTAssertEqual( + calculateLastPopupHeight(topViewModel), + screen.height - screen.safeArea.bottom + ) + } +} +private extension PopupVerticalStackViewModelTests { + func calculateLastPopupHeight(_ viewModel: ViewModel) -> CGFloat { + viewModel.t_calculateHeight(heightCandidate: viewModel.t_popups.last!.height!, popupConfig: viewModel.t_popups.last!.config as! C) + } +} + +// MARK: Active Popup Height +extension PopupVerticalStackViewModelTests { + func test_calculateActivePopupHeight_withAutoHeightMode_whenLessThanScreen_onePopupStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 100) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: 100 + ) + } + func test_calculateActivePopupHeight_withAutoHeightMode_whenBiggerThanScreen_threePopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 3000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 2000) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: screen.height - screen.safeArea.top - 2 * bottomViewModel.t_stackOffset + ) + } + func test_calculateActivePopupHeight_withLargeHeightMode_whenThreePopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 2000) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: screen.height - screen.safeArea.top - 2 * bottomViewModel.t_stackOffset + ) + } + func test_calculateActivePopupHeight_withAutoHeightMode_whenGestureIsNegative_twoPopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 2000) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -51, + expectedValue: screen.height - screen.safeArea.top - bottomViewModel.t_stackOffset * 1 + 51 + ) + } + func test_calculateActivePopupHeight_withLargeHeightMode_whenGestureIsNegative_onePopupStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 350) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -99, + expectedValue: screen.height - screen.safeArea.top + 99 + ) + } + func test_calculateActivePopupHeight_withFullscreenHeightMode_whenGestureIsNegative_twoPopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 100), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 250) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -21, + expectedValue: screen.height + ) + } + func test_calculateActivePopupHeight_withAutoHeightMode_whenGestureIsPositive_threePopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 350), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1000), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 850) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 100, + expectedValue: 850 + ) + } + func test_calculateActivePopupHeight_withFullscreenHeightMode_whenGestureIsPositive_onePopupStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 350) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 31, + expectedValue: screen.height + ) + } + func test_calculateActivePopupHeight_withAutoHeightMode_whenGestureIsNegative_hasDragHeightStored_twoPopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 350), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 500, popupDragHeight: 100) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -93, + expectedValue: 500 + 100 + 93 + ) + } + func test_calculateActivePopupHeight_withAutoHeightMode_whenGestureIsPositive_hasDragHeightStored_onePopupStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1300, popupDragHeight: 100) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 350, + expectedValue: screen.height - screen.safeArea.top + ) + } + func test_calculateActivePopupHeight_withPopupsHaveTopAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .large, popupHeight: 1300) + ] + + appendPopupsAndCheckActivePopupHeight( + viewModel: topViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: screen.height - screen.safeArea.bottom + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckActivePopupHeight(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, expectedValue: CGFloat) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_activePopupHeight }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Offset +extension PopupVerticalStackViewModelTests { + func test_calculateOffsetY_withZeroGestureTranslation_fivePopupsStacked_thirdElement() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 120), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 240), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 670), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 310) + ]) + + XCTAssertEqual( + bottomViewModel.t_calculateOffsetY(for: bottomViewModel.t_popups[2]), + -bottomViewModel.t_stackOffset * 2 + ) + } + func test_calculateOffsetY_withZeroGestureTranslation_fivePopupsStacked_lastElement() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 120), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 240), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 670), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 310) + ]) + + XCTAssertEqual( + bottomViewModel.t_calculateOffsetY(for: bottomViewModel.t_popups[4]), + 0 + ) + } + func test_calculateOffsetY_withNegativeGestureTranslation_dragHeight_onePopupStacked() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350, popupDragHeight: 100) + ]) + bottomViewModel.t_updateGestureTranslation(-100) + + XCTAssertEqual( + bottomViewModel.t_calculateOffsetY(for: bottomViewModel.t_popups[0]), + 0 + ) + } + func test_calculateOffsetY_withPositiveGestureTranslation_dragHeight_twoPopupsStacked_firstElement() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350, popupDragHeight: 249), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 133, popupDragHeight: 21) + ]) + bottomViewModel.t_updateGestureTranslation(100) + + XCTAssertEqual( + bottomViewModel.t_calculateOffsetY(for: bottomViewModel.t_popups[0]), + -bottomViewModel.t_stackOffset + ) + } + func test_calculateOffsetY_withPositiveGestureTranslation_dragHeight_twoPopupsStacked_lastElement() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350, popupDragHeight: 249), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 133, popupDragHeight: 21) + ]) + bottomViewModel.t_updateGestureTranslation(100) + + XCTAssertEqual( + bottomViewModel.t_calculateOffsetY(for: bottomViewModel.t_popups[1]), + 100 - 21 + ) + } + func test_calculateOffsetY_withStackingDisabled() { + bottomViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 350, popupDragHeight: 249), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 133, popupDragHeight: 21) + ]) + GlobalConfigContainer.vertical.isStackingEnabled = false + + XCTAssertEqual( + bottomViewModel.t_calculateOffsetY(for: bottomViewModel.t_popups[0]), + 0 + ) + } + func test_calculateOffsetY_withPopupsHaveTopAlignment_1() { + topViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 350, popupDragHeight: 249), + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 133, popupDragHeight: 21) + ]) + + XCTAssertEqual( + topViewModel.t_calculateOffsetY(for: topViewModel.t_popups[0]), + topViewModel.t_stackOffset + ) + } + func test_calculateOffsetY_withPopupsHaveTopAlignment_2() { + topViewModel.t_updatePopupsValue([ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 350, popupDragHeight: 249), + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 133, popupDragHeight: 21) + ]) + topViewModel.t_updateGestureTranslation(-100) + + XCTAssertEqual( + topViewModel.t_calculateOffsetY(for: topViewModel.t_popups[1]), + 21 - 100 + ) + } +} + +// MARK: Popup Padding +extension PopupVerticalStackViewModelTests { + func test_calculatePopupPadding_withAutoHeightMode_whenLessThanScreen() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 12, leading: 17, bottom: 33, trailing: 17) + ) + } + func test_calculatePopupPadding_withAutoHeightMode_almostLikeScreen_onlyOnePaddingShouldBeNonZero() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 877, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: 17, bottom: 23, trailing: 17) + ) + } + func test_calculatePopupPadding_withAutoHeightMode_almostLikeScreen_bothPaddingsShouldBeNonZero() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 861, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 6, leading: 17, bottom: 33, trailing: 17) + ) + } + func test_calculatePopupPadding_withAutoHeightMode_almostLikeScreen_topPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 911, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: topViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 12, leading: 17, bottom: 27, trailing: 17) + ) + } + func test_calculatePopupPadding_withAutoHeightMode_whenBiggerThanScreen() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1100, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: 17, bottom: 0, trailing: 17) + ) + } + func test_calculatePopupPadding_withLargeHeightMode() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 344, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: 17, bottom: 0, trailing: 17) + ) + } + func test_calculatePopupPadding_withFullscreenHeightMode() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 344, popupPadding: .init(top: 12, leading: 17, bottom: 33, trailing: 17)) + ] + + appendPopupsAndCheckPopupPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: 17, bottom: 0, trailing: 17) + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckPopupPadding(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, expectedValue: EdgeInsets) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculatePopupPadding() }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Body Padding +extension PopupVerticalStackViewModelTests { + func test_calculateBodyPadding_withDefaultSettings() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 350) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: screen.safeArea.top, leading: screen.safeArea.leading, bottom: screen.safeArea.bottom, trailing: screen.safeArea.trailing) + ) + } + func test_calculateBodyPadding_withIgnoringSafeArea_bottom() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 200, ignoredSafeAreaEdges: .bottom) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: screen.safeArea.leading, bottom: 0, trailing: screen.safeArea.trailing) + ) + } + func test_calculateBodyPadding_withIgnoringSafeArea_all() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1200, ignoredSafeAreaEdges: .all) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: 0, bottom: 0, trailing: 0) + ) + } + func test_calculateBodyPadding_withPopupPadding() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1200, popupPadding: .init(top: 21, leading: 12, bottom: 37, trailing: 12)) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: screen.safeArea.leading, bottom: screen.safeArea.bottom, trailing: screen.safeArea.trailing) + ) + } + func test_calculateBodyPadding_withFullscreenHeightMode_ignoringSafeArea_top() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 100, ignoredSafeAreaEdges: .top) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: .init(top: 0, leading: screen.safeArea.leading, bottom: screen.safeArea.bottom, trailing: screen.safeArea.trailing) + ) + } + func test_calculateBodyPadding_withGestureTranslation() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 800) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -300, + expectedValue: .init(top: screen.safeArea.top, leading: screen.safeArea.leading, bottom: screen.safeArea.bottom, trailing: screen.safeArea.trailing) + ) + } + func test_calculateBodyPadding_withGestureTranslation_dragHeight() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, popupDragHeight: 700) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 21, + expectedValue: .init(top: screen.safeArea.top - 21, leading: screen.safeArea.leading, bottom: screen.safeArea.bottom, trailing: screen.safeArea.trailing) + ) + } + func test_calculateBodyPadding_withGestureTranslation_dragHeight_topPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 300, popupDragHeight: 700) + ] + + appendPopupsAndCheckBodyPadding( + viewModel: topViewModel, + popups: popups, + gestureTranslation: -21, + expectedValue: .init(top: screen.safeArea.top, leading: screen.safeArea.leading, bottom: screen.safeArea.bottom - 21, trailing: screen.safeArea.trailing) + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckBodyPadding(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, expectedValue: EdgeInsets) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculateBodyPadding(for: popups.last!) }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Translation Progress +extension PopupVerticalStackViewModelTests { + func test_calculateTranslationProgress_withNoGestureTranslation() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300) + ] + + appendPopupsAndCheckTranslationProgress( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: 0 + ) + } + func test_calculateTranslationProgress_withPositiveGestureTranslation() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300) + ] + + appendPopupsAndCheckTranslationProgress( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 250, + expectedValue: 250 / 300 + ) + } + func test_calculateTranslationProgress_withPositiveGestureTranslation_dragHeight() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, popupDragHeight: 120) + ] + + appendPopupsAndCheckTranslationProgress( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 250, + expectedValue: (250 - 120) / 300 + ) + } + func test_calculateTranslationProgress_withNegativeGestureTranslation() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300) + ] + + appendPopupsAndCheckTranslationProgress( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -175, + expectedValue: 0 + ) + } + func test_calculateTranslationProgress_withNegativeGestureTranslation_whenTopPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 300) + ] + + appendPopupsAndCheckTranslationProgress( + viewModel: topViewModel, + popups: popups, + gestureTranslation: -175, + expectedValue: 175 / 300 + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckTranslationProgress(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, expectedValue: CGFloat) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculateTranslationProgress() }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Corner Radius +extension PopupVerticalStackViewModelTests { + func test_calculateCornerRadius_withTwoPopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, cornerRadius: 1), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, cornerRadius: 12) + ] + + appendPopupsAndCheckCornerRadius( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: [.top: 12, .bottom: 0] + ) + } + func test_calculateCornerRadius_withPopupPadding_bottom_first() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, popupPadding: .init(top: 0, leading: 0, bottom: 12, trailing: 0), cornerRadius: 1), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, cornerRadius: 12) + ] + + appendPopupsAndCheckCornerRadius( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: [.top: 12, .bottom: 0] + ) + } + func test_calculateCornerRadius_withPopupPadding_bottom_last() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, cornerRadius: 1), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, popupPadding: .init(top: 0, leading: 0, bottom: 12, trailing: 0), cornerRadius: 12) + ] + + appendPopupsAndCheckCornerRadius( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: [.top: 12, .bottom: 12] + ) + } + func test_calculateCornerRadius_withPopupPadding_all() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, cornerRadius: 1), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300, popupPadding: .init(top: 12, leading: 24, bottom: 12, trailing: 24), cornerRadius: 12) + ] + + appendPopupsAndCheckCornerRadius( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: [.top: 12, .bottom: 12] + ) + } + func test_calculateCornerRadius_fullscreen() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 300, cornerRadius: 13) + ] + + appendPopupsAndCheckCornerRadius( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: [.top: 0, .bottom: 0] + ) + } + func test_calculateCornerRadius_whenPopupsHaveTopAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 300, cornerRadius: 12) + ] + + appendPopupsAndCheckCornerRadius( + viewModel: topViewModel, + popups: popups, + gestureTranslation: 0, + expectedValue: [.top: 0, .bottom: 12] + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckCornerRadius(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, expectedValue: [MijickPopups.VerticalEdge: CGFloat]) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculateCornerRadius() }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Scale X +extension PopupVerticalStackViewModelTests { + func test_calculateScaleX_withNoGestureTranslation_threePopupsStacked_last() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 120), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 360) + ] + + appendPopupsAndCheckScaleX( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 2, + expectedValueBuilder: {_ in 1 } + ) + } + func test_calculateScaleX_withNoGestureTranslation_fourPopupsStacked_second() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 120), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1360) + ] + + appendPopupsAndCheckScaleX( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 1, + expectedValueBuilder: { 1 - $0.t_stackScaleFactor * 2 } + ) + } + func test_calculateScaleX_withNegativeGestureTranslation_fourPopupsStacked_third() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 120), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1360) + ] + + appendPopupsAndCheckScaleX( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -100, + calculateForIndex: 2, + expectedValueBuilder: { 1 - $0.t_stackScaleFactor * 1 } + ) + } + func test_calculateScaleX_withPositiveGestureTranslation_fivePopupsStacked_second() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 300), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 120), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 123) + ] + + appendPopupsAndCheckScaleX( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 100, + calculateForIndex: 1, + expectedValueBuilder: { 1 - $0.t_stackScaleFactor * 3 * max(1 - $0.t_calculateTranslationProgress(), $0.t_minScaleProgressMultiplier) } + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckScaleX(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, calculateForIndex index: Int, expectedValueBuilder: @escaping (ViewModel) -> CGFloat) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculateScaleX(for: $0.t_popups[index]) }, + expectedValueBuilder: expectedValueBuilder + ) + } +} + +// MARK: Fixed Size +extension PopupVerticalStackViewModelTests { + func test_calculateFixedSize_withAutoHeightMode_whenLessThanScreen_twoPopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 123) + ] + + appendPopupsAndCheckVerticalFixedSize( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 1, + expectedValue: true + ) + } + func test_calculateFixedSize_withAutoHeightMode_whenBiggerThanScreen_twoPopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1223) + ] + + appendPopupsAndCheckVerticalFixedSize( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 1, + expectedValue: false + ) + } + func test_calculateFixedSize_withLargeHeightMode_threePopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1223), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 1223) + ] + + appendPopupsAndCheckVerticalFixedSize( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 2, + expectedValue: false + ) + } + func test_calculateFixedSize_withFullscreenHeightMode_fivePopupsStacked() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1223), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .large, popupHeight: 1223), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1223), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1223) + ] + + appendPopupsAndCheckVerticalFixedSize( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 4, + expectedValue: false + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckVerticalFixedSize(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, calculateForIndex index: Int, expectedValue: Bool) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculateVerticalFixedSize(for: $0.t_popups[index]) }, + expectedValueBuilder: { _ in expectedValue } + ) + } +} + +// MARK: Stack Overlay Opacity +extension PopupVerticalStackViewModelTests { + func test_calculateStackOverlayOpacity_withThreePopupsStacked_whenNoGestureTranslation_last() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 233), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 512) + ] + + appendPopupsAndCheckStackOverlayOpacity( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 2, + expectedValueBuilder: { _ in 0 } + ) + } + func test_calculateStackOverlayOpacity_withFourPopupsStacked_whenNoGestureTranslation_second() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 233), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 512), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 812) + ] + + appendPopupsAndCheckStackOverlayOpacity( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 0, + calculateForIndex: 1, + expectedValueBuilder: { $0.t_stackOverlayFactor * 2 } + ) + } + func test_calculateStackOverlayOpacity_withFourPopupsStacked_whenGestureTranslationIsNegative_last() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 233), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 512), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 812) + ] + + appendPopupsAndCheckStackOverlayOpacity( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -123, + calculateForIndex: 3, + expectedValueBuilder: { _ in 0 } + ) + } + func test_calculateStackOverlayOpacity_withTenPopupsStacked_whenGestureTranslationIsNegative_first() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 55), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 233), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 512), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 812), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 34), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 664), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 754), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 357), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 1234), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 356) + ] + + appendPopupsAndCheckStackOverlayOpacity( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: -123, + calculateForIndex: 0, + expectedValueBuilder: { min($0.t_stackOverlayFactor * 9, $0.t_maxStackOverlayFactor) } + ) + } + func test_calculateStackOverlayOpacity_withThreePopupsStacked_whenGestureTranslationIsPositive_last() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 233), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 512) + ] + + appendPopupsAndCheckStackOverlayOpacity( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 494, + calculateForIndex: 2, + expectedValueBuilder: { _ in 0 } + ) + } + func test_calculateStackOverlayOpacity_withFourPopupsStacked_whenGestureTranslationIsPositive_nextToLast() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .fullscreen, popupHeight: 1360), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 233), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 512), + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 343) + ] + + appendPopupsAndCheckStackOverlayOpacity( + viewModel: bottomViewModel, + popups: popups, + gestureTranslation: 241, + calculateForIndex: 2, + expectedValueBuilder: { (1 - $0.t_calculateTranslationProgress()) * $0.t_stackOverlayFactor } + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckStackOverlayOpacity(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, calculateForIndex index: Int, expectedValueBuilder: @escaping (ViewModel) -> CGFloat) { + appendPopupsAndPerformChecks( + viewModel: viewModel, + popups: popups, + gestureTranslation: gestureTranslation, + calculatedValue: { $0.t_calculateStackOverlayOpacity(for: $0.t_popups[index]) }, + expectedValueBuilder: expectedValueBuilder + ) + } +} + +// MARK: On Drag Gesture Changed +extension PopupVerticalStackViewModelTests { + func test_calculateValuesOnDragGestureChanged_withPositiveDragValue_whenDragGestureDisabled() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragGestureEnabled: false) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: bottomViewModel, + popups: popups, + gestureValue: 11, + expectedValues: (popupHeight: 344, gestureTranslation: 0) + ) + } + func test_calculateValuesOnDragGestureChanged_withPositiveDragValue_whenDragGestureEnabled_bottomPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: bottomViewModel, + popups: popups, + gestureValue: 11, + expectedValues: (popupHeight: 344, gestureTranslation: 11) + ) + } + func test_calculateValuesOnDragGestureChanged_withPositiveDragValue_whenDragGestureEnabled_topPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 344) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: topViewModel, + popups: popups, + gestureValue: 11, + expectedValues: (popupHeight: 344, gestureTranslation: 0) + ) + } + func test_calculateValuesOnDragGestureChanged_withNegativeDragValue_whenNoDragDetents() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: []) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -133, + expectedValues: (popupHeight: 344, gestureTranslation: 0) + ) + } + func test_calculateValuesOnDragGestureChanged_withNegativeDragValue_whenDragDetents() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(450)]) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -40, + expectedValues: (popupHeight: 384, gestureTranslation: -40) + ) + } + func test_calculateValuesOnDragGestureChanged_withNegativeDragValue_whenDragDetentsLessThanDragValue_bottomPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(370)]) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -133, + expectedValues: (popupHeight: 370 + bottomViewModel.t_dragTranslationThreshold, gestureTranslation: 344 - 370 - bottomViewModel.t_dragTranslationThreshold) + ) + } + func test_calculateValuesOnDragGestureChanged_withNegativeDragValue_whenDragDetentsLessThanDragValue_topPopupsAlignment() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(370)]) + ] + + appendPopupsAndCheckGestureTranslationOnChange( + viewModel: topViewModel, + popups: popups, + gestureValue: -133, + expectedValues: (popupHeight: 344, gestureTranslation: -133) + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckGestureTranslationOnChange(viewModel: ViewModel, popups: [AnyPopup], gestureValue: CGFloat, expectedValues: (popupHeight: CGFloat, gestureTranslation: CGFloat)) { + viewModel.t_updatePopupsValue(popups) + viewModel.t_updatePopupsValue(recalculatePopupHeights(viewModel)) + viewModel.t_onPopupDragGestureChanged(gestureValue) + + XCTAssertEqual(viewModel.t_activePopupHeight, expectedValues.popupHeight) + XCTAssertEqual(viewModel.t_gestureTranslation, expectedValues.gestureTranslation) + } +} + +// MARK: On Drag Gesture Ended +extension PopupVerticalStackViewModelTests { + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenNoDragDetents() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -200, + expectedValues: (popupHeight: 344, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_bottomPopupsAlignment_1() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440)]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -200, + expectedValues: (popupHeight: 440, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_bottomPopupsAlignment_2() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440), .height(520)]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -120, + expectedValues: (popupHeight: 520, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_bottomPopupsAlignment_3() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440), .height(520)]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -42, + expectedValues: (popupHeight: 440, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_bottomPopupsAlignment_4() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440), .height(520), .large, .fullscreen]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -300, + expectedValues: (popupHeight: screen.height - screen.safeArea.top, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_bottomPopupsAlignment_5() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440), .height(520), .large, .fullscreen]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: -600, + expectedValues: (popupHeight: screen.height, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_topPopupsAlignment_1() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440), .height(520), .large, .fullscreen]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: topViewModel, + popups: popups, + gestureValue: -300, + expectedValues: (popupHeight: nil, shouldPopupBeDismissed: true) + ) + } + func test_calculateValuesOnDragGestureEnded_withNegativeDragValue_whenDragDetentsSet_topPopupsAlignment_2() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 344, dragDetents: [.height(440), .height(520), .large, .fullscreen]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: topViewModel, + popups: popups, + gestureValue: -15, + expectedValues: (popupHeight: 344, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withPositiveDragValue_bottomPopupsAlignment_1() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 400) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: 50, + expectedValues: (popupHeight: 400, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withPositiveDragValue_bottomPopupsAlignment_2() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: BottomPopupConfig.self, heightMode: .auto, popupHeight: 400) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: bottomViewModel, + popups: popups, + gestureValue: 300, + expectedValues: (popupHeight: nil, shouldPopupBeDismissed: true) + ) + } + func test_calculateValuesOnDragGestureEnded_withPositiveDragValue_topPopupsAlignment_1() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 400) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: topViewModel, + popups: popups, + gestureValue: 400, + expectedValues: (popupHeight: 400, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withPositiveDragValue_topPopupsAlignment_2() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 400, dragDetents: [.large]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: topViewModel, + popups: popups, + gestureValue: 100, + expectedValues: (popupHeight: 400, shouldPopupBeDismissed: false) + ) + } + func test_calculateValuesOnDragGestureEnded_withPositiveDragValue_topPopupsAlignment_3() { + let popups = [ + createPopupInstanceForPopupHeightTests(type: TopPopupConfig.self, heightMode: .auto, popupHeight: 400, dragDetents: [.large]) + ] + + appendPopupsAndCheckGestureTranslationOnEnd( + viewModel: topViewModel, + popups: popups, + gestureValue: 400, + expectedValues: (popupHeight: screen.height - screen.safeArea.bottom, shouldPopupBeDismissed: false) + ) + } +} +private extension PopupVerticalStackViewModelTests { + func appendPopupsAndCheckGestureTranslationOnEnd(viewModel: ViewModel, popups: [AnyPopup], gestureValue: CGFloat, expectedValues: (popupHeight: CGFloat?, shouldPopupBeDismissed: Bool)) { + viewModel.t_updatePopupsValue(popups) + viewModel.t_updatePopupsValue(recalculatePopupHeights(viewModel)) + viewModel.t_updateGestureTranslation(gestureValue) + viewModel.t_calculateAndUpdateTranslationProgress() + viewModel.t_onPopupDragGestureEnded(gestureValue) + + XCTAssertEqual(viewModel.t_popups.count, expectedValues.shouldPopupBeDismissed ? 0 : 1) + XCTAssertEqual(viewModel.t_activePopupHeight, expectedValues.popupHeight) + } +} + + + +// MARK: - HELPERS + + + +// MARK: Methods +private extension PopupVerticalStackViewModelTests { + func createPopupInstanceForPopupHeightTests(type: C.Type, heightMode: HeightMode, popupHeight: CGFloat, popupDragHeight: CGFloat? = nil, ignoredSafeAreaEdges: Edge.Set = [], popupPadding: EdgeInsets = .init(), cornerRadius: CGFloat = 0, dragGestureEnabled: Bool = true, dragDetents: [DragDetent] = []) -> AnyPopup { + let config = getConfigForPopupHeightTests(type: type, heightMode: heightMode, ignoredSafeAreaEdges: ignoredSafeAreaEdges, popupPadding: popupPadding, cornerRadius: cornerRadius, dragGestureEnabled: dragGestureEnabled, dragDetents: dragDetents) + + return AnyPopup.t_createNew(config: config) + .settingHeight(popupHeight) + .settingDragHeight(popupDragHeight) + } + func appendPopupsAndPerformChecks(viewModel: ViewModel, popups: [AnyPopup], gestureTranslation: CGFloat, calculatedValue: @escaping (ViewModel) -> (Value), expectedValueBuilder: @escaping (ViewModel) -> Value) { + viewModel.t_updatePopupsValue(popups) + viewModel.t_updatePopupsValue(recalculatePopupHeights(viewModel)) + viewModel.t_updateGestureTranslation(gestureTranslation) + + XCTAssertEqual(calculatedValue(viewModel), expectedValueBuilder(viewModel)) + } +} +private extension PopupVerticalStackViewModelTests { + func getConfigForPopupHeightTests(type: C.Type, heightMode: HeightMode, ignoredSafeAreaEdges: Edge.Set, popupPadding: EdgeInsets, cornerRadius: CGFloat, dragGestureEnabled: Bool, dragDetents: [DragDetent]) -> C { .t_createNew( + popupPadding: popupPadding, + cornerRadius: cornerRadius, + ignoredSafeAreaEdges: ignoredSafeAreaEdges, + heightMode: heightMode, + dragDetents: dragDetents, + isDragGestureEnabled: dragGestureEnabled + )} + func recalculatePopupHeights(_ viewModel: ViewModel) -> [AnyPopup] { viewModel.t_popups.map { + $0.settingHeight(viewModel.t_calculateHeight(heightCandidate: $0.height!, popupConfig: $0.config as! C)) + }} +} + +// MARK: Screen +private extension PopupVerticalStackViewModelTests { + var screen: Screen { .init( + height: 1000, + safeArea: .init(top: 100, leading: 20, bottom: 50, trailing: 30) + )} +} + +// MARK: Typealiases +private extension PopupVerticalStackViewModelTests { + typealias Config = LocalConfig.Vertical + typealias ViewModel = VM.VerticalStack +}