├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .spi.yml
├── LICENSE
├── MultiModal.playground
├── Contents.swift
├── contents.xcplayground
├── playground.xcworkspace
│ └── contents.xcworkspacedata
└── timeline.xctimeline
├── MultiModal.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ ├── IDEWorkspaceChecks.plist
│ ├── swiftpm
│ └── Package.resolved
│ └── xcschemes
│ ├── Benchmarks.xcscheme
│ └── MultiModal.xcscheme
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
├── Benchmarks
│ └── main.swift
└── MultiModal
│ └── MultiModal.swift
└── Tests
└── MultiModalTests
└── MultiModalView.swift
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - "**"
10 | schedule:
11 | - cron: '3 3 * * 2' # 3:03 AM, every Tuesday
12 |
13 | concurrency:
14 | group: ci-${{ github.ref }}
15 | cancel-in-progress: true
16 |
17 | jobs:
18 | macOS:
19 | name: ${{ matrix.platform }} (Swift ${{ matrix.swift }})
20 | runs-on: ${{ matrix.os }}
21 | strategy:
22 | fail-fast: false
23 | matrix:
24 | platform:
25 | - iOS
26 | - macOS
27 | - tvOS
28 | - watchOS
29 | swift:
30 | - 5.5
31 | - 5.6
32 | - 5.7
33 | - 5.8
34 | include:
35 | - swift: 5.5
36 | os: macos-12
37 | - swift: 5.6
38 | os: macos-12
39 | - swift: 5.7
40 | os: macos-13
41 | - swift: 5.8
42 | os: macos-13
43 | - action: test
44 | - platform: tvOS
45 | action: build
46 | - platform: watchOS
47 | action: build
48 | steps:
49 | - uses: actions/checkout@v3
50 | - uses: mxcl/xcodebuild@v2
51 | with:
52 | action: ${{ matrix.action }}
53 | platform: ${{ matrix.platform }}
54 | swift: ~${{ matrix.swift }}
55 | scheme: MultiModal
56 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /.swiftpm
4 | /Packages
5 | /*.xcodeproj
6 | xcuserdata/
7 | DerivedData/
8 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - platform: ios
5 | scheme: MultiModal
6 | - platform: macos-xcodebuild
7 | scheme: MultiModal
8 | - platform: tvos
9 | scheme: MultiModal
10 | - platform: watchos
11 | scheme: MultiModal
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
--------------------------------------------------------------------------------
/MultiModal.playground/Contents.swift:
--------------------------------------------------------------------------------
1 | import MultiModal
2 | import PlaygroundSupport
3 | import SwiftUI
4 |
5 | struct WithoutView: View {
6 | @State var sheetAPresented = false
7 | @State var sheetBPresented = false
8 |
9 | @State var alertAPresented = false
10 | @State var alertBPresented = false
11 |
12 | var body: some View {
13 | VStack(spacing: 60) {
14 | Text("Without MultiModal")
15 | VStack(spacing: 20) {
16 | Button("Sheet A") { self.sheetAPresented = true }
17 | Button("Sheet B") { self.sheetBPresented = true }
18 | }
19 | VStack(spacing: 20) {
20 | Button("Alert A") { self.alertAPresented = true }
21 | Button("Alert B") { self.alertBPresented = true }
22 | }
23 | }
24 | .sheet(isPresented: $sheetAPresented) { Text("Sheet A") }
25 | .sheet(isPresented: $sheetBPresented) { Text("Sheet B") }
26 |
27 | .alert(isPresented: $alertAPresented) { Alert(title: Text("Alert A")) }
28 | .alert(isPresented: $alertBPresented) { Alert(title: Text("Alert B")) }
29 | }
30 | }
31 |
32 | struct WithView: View {
33 | @State var sheetAPresented = false
34 | @State var sheetBPresented = false
35 |
36 | @State var alertAPresented = false
37 | @State var alertBPresented = false
38 |
39 | var body: some View {
40 | VStack(spacing: 60) {
41 | Text("With MultiModal")
42 | VStack(spacing: 20) {
43 | Button("Sheet A") { self.sheetAPresented = true }
44 | Button("Sheet B") { self.sheetBPresented = true }
45 | }
46 | VStack(spacing: 20) {
47 | Button("Alert A") { self.alertAPresented = true }
48 | Button("Alert B") { self.alertBPresented = true }
49 | }
50 | }
51 | .multiModal {
52 | $0.sheet(isPresented: $sheetAPresented) { Text("Sheet A") }
53 | $0.sheet(isPresented: $sheetBPresented) { Text("Sheet B") }
54 |
55 | $0.alert(isPresented: $alertAPresented) { Alert(title: Text("Alert A")) }
56 | $0.alert(isPresented: $alertBPresented) { Alert(title: Text("Alert B")) }
57 | }
58 | }
59 | }
60 |
61 | let view = UIHostingController(
62 | // rootView: WithoutView()
63 | rootView: WithView()
64 | )
65 | PlaygroundPage.current.needsIndefiniteExecution = true
66 | PlaygroundPage.current.liveView = view
67 |
--------------------------------------------------------------------------------
/MultiModal.playground/contents.xcplayground:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/MultiModal.playground/playground.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/MultiModal.playground/timeline.xctimeline:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/MultiModal.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/MultiModal.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/MultiModal.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "swift-argument-parser",
6 | "repositoryURL": "https://github.com/apple/swift-argument-parser",
7 | "state": {
8 | "branch": null,
9 | "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1",
10 | "version": "1.1.4"
11 | }
12 | },
13 | {
14 | "package": "Benchmark",
15 | "repositoryURL": "https://github.com/google/swift-benchmark",
16 | "state": {
17 | "branch": null,
18 | "revision": "8163295f6fe82356b0bcf8e1ab991645de17d096",
19 | "version": "0.1.2"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/MultiModal.xcworkspace/xcshareddata/xcschemes/Benchmarks.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
44 |
46 |
52 |
53 |
54 |
55 |
61 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/MultiModal.xcworkspace/xcshareddata/xcschemes/MultiModal.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
34 |
40 |
41 |
42 |
43 |
44 |
54 |
55 |
61 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "swift-argument-parser",
6 | "repositoryURL": "https://github.com/apple/swift-argument-parser",
7 | "state": {
8 | "branch": null,
9 | "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1",
10 | "version": "1.1.4"
11 | }
12 | },
13 | {
14 | "package": "Benchmark",
15 | "repositoryURL": "https://github.com/google/swift-benchmark",
16 | "state": {
17 | "branch": null,
18 | "revision": "8163295f6fe82356b0bcf8e1ab991645de17d096",
19 | "version": "0.1.2"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "MultiModal",
8 | platforms: [
9 | .iOS(.v13),
10 | .macOS(.v10_15),
11 | .tvOS(.v13),
12 | .watchOS(.v6),
13 | ],
14 | products: [
15 | .library(name: "MultiModal", targets: ["MultiModal"]),
16 | ],
17 | targets: [
18 | .target(name: "MultiModal"),
19 | .testTarget(name: "MultiModalTests", dependencies: ["MultiModal"]),
20 |
21 | .executableTarget(name: "Benchmarks", dependencies: [
22 | .product(name: "Benchmark", package: "swift-benchmark"),
23 | .target(name: "MultiModal"),
24 | ]),
25 | ]
26 | )
27 |
28 | package.dependencies = [
29 | .package(url: "https://github.com/google/swift-benchmark", from: "0.1.2"),
30 | ]
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > # Obsoleted
2 | > This library is obsoleted in favor of properly modeling the navigation domain using enums along with [swiftui-navigation](https://github.com/pointfreeco/swiftui-navigation).
3 |
4 | # MultiModal
5 |
6 | 
7 | [](https://swiftpackageindex.com/davdroman/MultiModal)
8 | [](https://swiftpackageindex.com/davdroman/MultiModal)
9 |
10 | ## Introduction
11 |
12 | By default, SwiftUI views with multiple modal modifiers (e.g. `.sheet`, `.alert`) in the same body will only use the last one in the chain of modifiers and ignore all previous ones.
13 |
14 | ```swift
15 | struct NoMultiModalDemoView: View {
16 | @State var sheetAPresented = false
17 | @State var sheetBPresented = false
18 | @State var sheetCPresented = false
19 |
20 | var body: some View {
21 | VStack(spacing: 20) {
22 | Button("Sheet A") { sheetAPresented = true }
23 | Button("Sheet B") { sheetBPresented = true }
24 | Button("Sheet C") { sheetCPresented = true }
25 | }
26 | .sheet(isPresented: $sheetAPresented) { Text("Sheet A") } // does not work
27 | .sheet(isPresented: $sheetBPresented) { Text("Sheet B") } // does not work
28 | .sheet(isPresented: $sheetCPresented) { Text("Sheet C") } // works
29 | }
30 | }
31 | ```
32 |
33 | **MultiModal** brings a `.multiModal` modifier to declare multiple modal modifiers in the same view body.
34 |
35 | ```swift
36 | struct MultiModalDemoView: View {
37 | @State var sheetAPresented = false
38 | @State var sheetBPresented = false
39 | @State var sheetCPresented = false
40 |
41 | var body: some View {
42 | VStack(spacing: 20) {
43 | Button("Sheet A") { sheetAPresented = true }
44 | Button("Sheet B") { sheetBPresented = true }
45 | Button("Sheet C") { sheetCPresented = true }
46 | }
47 | .multiModal {
48 | $0.sheet(isPresented: $sheetAPresented) { Text("Sheet A") } // works
49 | $0.sheet(isPresented: $sheetBPresented) { Text("Sheet B") } // works
50 | $0.sheet(isPresented: $sheetCPresented) { Text("Sheet C") } // works
51 | }
52 | }
53 | }
54 | ```
55 |
56 | ## Disclaimer
57 |
58 | MultiModal does not enable "nested" modals; it just enables multiple modals appearing within a view body **one at a time**. For this reason, it's recommended that your modal presentation be dependant on a source of truth that ensures only one of them is presented at any given time.
59 |
60 | Hopefully Apple will introduce support for multiple modals in a future iteration of SwiftUI, rendering this library unnecessary.
61 |
62 | ## Benchmarks
63 |
64 | ```
65 | MacBook Pro (14-inch, 2021)
66 | Apple M1 Pro (10 cores, 8 performance and 2 efficiency)
67 | 32 GB Memory
68 |
69 | $ swift run -c release Benchmarks
70 |
71 | name time std iterations
72 | ------------------------------------------
73 | Modifier 2416.000 ns ± 15.72 % 571301
74 | ```
75 |
--------------------------------------------------------------------------------
/Sources/Benchmarks/main.swift:
--------------------------------------------------------------------------------
1 | import Benchmark
2 | import MultiModal
3 | import SwiftUI
4 |
5 | benchmark("Modifier") {
6 | _ = EmptyView().multiModal {
7 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 1") }
8 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 2") }
9 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 3") }
10 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 4") }
11 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 5") }
12 | }
13 | }
14 |
15 | Benchmark.main()
16 |
--------------------------------------------------------------------------------
/Sources/MultiModal/MultiModal.swift:
--------------------------------------------------------------------------------
1 | // Based off [https://stackoverflow.com/a/57873137].
2 | // TODO: check if this workaround is still required in iOS 17.
3 |
4 | import SwiftUI
5 |
6 | extension View {
7 | /// Presents multiple modals (e.g. sheet, alert) at the same view level.
8 | ///
9 | /// Example:
10 | ///
11 | /// ```swift
12 | /// .multiModal {
13 | /// $0.sheet(isPresented: $sheetAPresented) { Text("Sheet A") }
14 | /// $0.sheet(isPresented: $sheetBPresented) { Text("Sheet B") }
15 | /// $0.sheet(isPresented: $sheetCPresented) { Text("Sheet C") }
16 | /// }
17 | /// ```
18 | #if compiler(>=5.7)
19 | @inlinable
20 | public func multiModal(
21 | @MultiModalBuilder _ modals: (EmptyView) -> some View
22 | ) -> some View {
23 | self.background(modals(EmptyView()))
24 | }
25 | #else
26 | public func multiModal(
27 | @MultiModalBuilder _ modals: (EmptyView) -> [AnyView]
28 | ) -> some View {
29 | modals(EmptyView()).reduce(AnyView(self)) { view, modal in
30 | AnyView(view.background(modal))
31 | }
32 | }
33 | #endif
34 | }
35 |
36 | @resultBuilder
37 | public struct MultiModalBuilder {
38 | #if compiler(>=5.7)
39 | @inlinable
40 | public static func buildPartialBlock(first: some View) -> some View {
41 | first
42 | }
43 |
44 | @inlinable
45 | public static func buildPartialBlock(accumulated: some View, next: some View) -> some View {
46 | accumulated.background(next)
47 | }
48 | #else
49 | public static func buildBlock(
50 | _ v0: V0
51 | ) -> [AnyView] {
52 | [AnyView(v0)]
53 | }
54 |
55 | public static func buildBlock(
56 | _ v0: V0,
57 | _ v1: V1
58 | ) -> [AnyView] {
59 | [AnyView(v0), AnyView(v1)]
60 | }
61 |
62 | public static func buildBlock(
63 | _ v0: V0,
64 | _ v1: V1,
65 | _ v2: V2
66 | ) -> [AnyView] {
67 | [AnyView(v0), AnyView(v1), AnyView(v2)]
68 | }
69 |
70 | public static func buildBlock(
71 | _ v0: V0,
72 | _ v1: V1,
73 | _ v2: V2,
74 | _ v3: V3
75 | ) -> [AnyView] {
76 | [AnyView(v0), AnyView(v1), AnyView(v2), AnyView(v3)]
77 | }
78 |
79 | public static func buildBlock(
80 | _ v0: V0,
81 | _ v1: V1,
82 | _ v2: V2,
83 | _ v3: V3,
84 | _ v4: V4
85 | ) -> [AnyView] {
86 | [AnyView(v0), AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4)]
87 | }
88 | #endif
89 | }
90 |
--------------------------------------------------------------------------------
/Tests/MultiModalTests/MultiModalView.swift:
--------------------------------------------------------------------------------
1 | import MultiModal
2 | import SwiftUI
3 |
4 | // This declaration is used to prove the `multiModal`
5 | // modifier compiles successfully on all Swift versions.
6 | // Additionally, it ensures that unlimited modals can be
7 | // used from Swift 5.7 onwards thanks to `buildPartialBlock`.
8 |
9 | // With a bit of time and effort, it could be expanded into a UI test suite.
10 |
11 | struct MultiModalView: View {
12 | var body: some View {
13 | EmptyView().multiModal {
14 | #if compiler(>=5.7)
15 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 1") }
16 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 2") }
17 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 3") }
18 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 4") }
19 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 5") }
20 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 6") }
21 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 7") }
22 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 8") }
23 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 9") }
24 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 10") }
25 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 11") }
26 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 12") }
27 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 13") }
28 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 14") }
29 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 15") }
30 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 16") }
31 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 17") }
32 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 18") }
33 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 19") }
34 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 20") }
35 | #else
36 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 1") }
37 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 2") }
38 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 3") }
39 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 4") }
40 | $0.sheet(isPresented: .constant(false)) { Text("Sheet 5") }
41 | #endif
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------