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 @@
-
+
-
+
-
-
+
+
@@ -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
+}