├── .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 | ![CI](https://github.com/davdroman/MultiModal/workflows/CI/badge.svg) 7 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdavdroman%2FMultiModal%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/davdroman/MultiModal) 8 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdavdroman%2FMultiModal%2Fbadge%3Ftype%3Dplatforms)](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 | --------------------------------------------------------------------------------