├── .github └── workflows │ └── tests.yml ├── .gitignore ├── Docs └── Resources │ └── displaying-modal-view.gif ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources └── ModalView │ └── ModalView.swift └── Tests ├── LinuxMain.swift ├── ModalViewTests ├── ModalViewTests.swift ├── SnapshotTests.swift └── XCTestManifests.swift └── Resources ├── EmptyModalPresenter.png ├── LinkInsideList.png ├── LinkWithDismissClosure.png ├── ModalPresenterWithLink.png └── ModalPresenterWithText.png /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests runner 2 | 3 | on: 4 | push: 5 | branches: [ void ] 6 | pull_request: 7 | branches: [ void ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm/ -------------------------------------------------------------------------------- /Docs/Resources/displaying-modal-view.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/modal-view/3f1a97297112770677b507556f581f7ed561593a/Docs/Resources/displaying-modal-view.gif -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Denis Chashchin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 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: "ModalView", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v9), 11 | .watchOS(.v6), 12 | .tvOS(.v13) 13 | ], 14 | products: [ 15 | .library( 16 | name: "ModalView", 17 | targets: ["ModalView"]), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "ModalView", 22 | dependencies: []), 23 | .testTarget( 24 | name: "ModalViewTests", 25 | dependencies: ["ModalView"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModalView 2 | 3 | ![Swift 5.1](https://img.shields.io/badge/Swift-5.1-FA5B2C) ![Xcode 11](https://img.shields.io/badge/Xcode-11-44B3F6) ![iOS 13.0](https://img.shields.io/badge/iOS-13.0-178DF6) ![iPadOS 13.0](https://img.shields.io/badge/iPadOS-13.0-178DF6) ![MacOS 10.15](https://img.shields.io/badge/MacOS-10.15-178DF6) ![Tests](https://github.com/diniska/modal-view/workflows/Tests%20runner/badge.svg) 4 | 5 | An analogue of SwiftUI `NavigationView` that provides a convenient interface of displaying modal views. 6 | 7 | ## How to use 8 | ### Step 1 9 | Add a dependency using Swift Package Manager to your project: [https://github.com/diniska/modal-view](https://github.com/diniska/modal-view) 10 | 11 | #### Step 2 12 | Import the dependency 13 | 14 | ```swift 15 | import ModalView 16 | ``` 17 | 18 | ### Step 3 19 | Use `ModalPresenter` and `ModalLink` the same way you would use `NavigationView` and `NavigationLink`: 20 | 21 | ```swift 22 | struct ContentView: View { 23 | var body: some View { 24 | ModalPresenter { 25 | ModalLink(destination: Text("Modal View")) { 26 | Text("Main view") 27 | } 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | ### Result 34 | ![Presenting modal view with SwiftUI](./Docs/Resources/displaying-modal-view.gif) 35 | 36 | 37 | ## Additional information 38 | To add a "close" button to a modal view we can use a `dismiss` closure provided by the `ModalLink`: 39 | 40 | ```swift 41 | struct ContentView: View { 42 | var body: some View { 43 | ModalPresenter { 44 | ModalLink(destination: { dismiss in 45 | Button(action: dismiss) { 46 | Text("Dismiss") 47 | } 48 | }) { 49 | Text("Main view") 50 | } 51 | } 52 | } 53 | } 54 | ``` 55 | 56 | Moving the destination in the code above to a separate structure is a recommended way here to refactor the code here as modal views regularly contains a bit more that just a text or button. 57 | 58 | ```swift 59 | struct ContentView: View { 60 | var body: some View { 61 | ModalPresenter { 62 | ModalLink(destination: MyModalView.init(dismiss:)) { 63 | Text("Main view") 64 | } 65 | } 66 | } 67 | } 68 | 69 | struct MyModalView: View { 70 | var dismiss: () -> () 71 | var body: some View { 72 | Button(action: dismiss) { 73 | Text("Dismiss") 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | 80 | Learn more here: [Display Modal View with SwiftUI](https://medium.com/@diniska/modal-view-in-swiftui-3f9faf910249) 81 | 82 | -------------------------------------------------------------------------------- /Sources/ModalView/ModalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalView.swift 3 | // ModalView 4 | // 5 | // Created by Denis Chaschin on 22.09.2019. 6 | // Copyright © 2019 Denis Chaschin. All rights reserved. 7 | // 8 | 9 | #if canImport(SwiftUI) && canImport(Combine) && (arch(arm64) || arch(x86_64)) 10 | // arm64 and x86_64 used for compatibility with Xcode 13. More context here: https://stackoverflow.com/a/61954608 11 | 12 | import SwiftUI 13 | 14 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 15 | private final class Pipe : ObservableObject { 16 | struct Content: Identifiable { 17 | fileprivate typealias ID = String 18 | fileprivate let id = UUID().uuidString 19 | var view: AnyView 20 | } 21 | @Published var content: Content? = nil 22 | } 23 | 24 | // Container of a view that contains ModalLink in its hierarchy 25 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 26 | public struct ModalPresenter : View where Content : View { 27 | @ObservedObject private var modalView = Pipe() 28 | 29 | private var content: Content 30 | 31 | public init(@ViewBuilder content: () -> Content) { 32 | self.content = content() 33 | } 34 | 35 | public var body: some View { 36 | content 37 | .environmentObject(modalView) 38 | .sheet(item: $modalView.content, content: { $0.view }) 39 | } 40 | } 41 | 42 | // An interactable element that presentas a modal view 43 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 44 | public struct ModalLink : View where Label : View, Destination : View { 45 | public typealias DestinationBuilder = (_ dismiss: @escaping() -> ()) -> Destination 46 | @EnvironmentObject private var modalView: Pipe 47 | 48 | private enum DestinationProvider { 49 | case view(AnyView) 50 | case builder(DestinationBuilder) 51 | } 52 | 53 | private var destinationProvider: DestinationProvider 54 | private var label: Label 55 | 56 | // Default initializer 57 | public init(destination: Destination, @ViewBuilder label: () -> Label) { 58 | self.destinationProvider = .view(AnyView(destination)) 59 | self.label = label() 60 | } 61 | 62 | // Use this initializer when `dismiss` method is needed in the modal view 63 | public init(@ViewBuilder destination: @escaping DestinationBuilder, @ViewBuilder label: () -> Label) { 64 | self.destinationProvider = .builder(destination) 65 | self.label = label() 66 | } 67 | 68 | public var body: some View { 69 | Button(action: presentModalView){ label } 70 | } 71 | 72 | private func presentModalView() { 73 | modalView.content = Pipe.Content(view: { 74 | switch destinationProvider { 75 | case let .view(view): 76 | return view 77 | case let .builder(build): 78 | return AnyView(build { self.dismissModalView() }) 79 | } 80 | }()) 81 | } 82 | 83 | private func dismissModalView() { 84 | modalView.content = nil 85 | } 86 | } 87 | 88 | #if DEBUG 89 | 90 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 91 | private struct ModalLink_Preview: PreviewProvider { 92 | 93 | static var previews: some View { 94 | ModalPresenter { 95 | List { 96 | ModalLink(destination: Text("Destination 1")) { 97 | Text("Open 1") 98 | } 99 | ModalLink(destination: Text("Destination 2")) { 100 | Text("Open 2") 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | #endif 108 | 109 | #endif 110 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import ModalViewTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += ModalViewTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/ModalViewTests/ModalViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | 4 | import ModalView 5 | 6 | final class ModalViewTests: XCTestCase { 7 | 8 | var snapshotTests = SnapshotTests(recording: false) 9 | 10 | static var allTests = [ 11 | ("testEmptyModalPresenter", testEmptyModalPresenter), 12 | ] 13 | 14 | func testEmptyModalPresenter() { 15 | snapshotTests.check(size: CGSize(width: 5, height: 5)) { 16 | ModalPresenter { 17 | EmptyView() 18 | } 19 | } 20 | } 21 | 22 | func testModalPresenterWithText() { 23 | snapshotTests.check(size: CGSize(width: 50, height: 50)) { 24 | ModalPresenter { 25 | Text("hello") 26 | } 27 | } 28 | } 29 | 30 | func testModalPresenterWithLink() { 31 | snapshotTests.check(size: CGSize(width: 50, height: 50)) { 32 | ModalPresenter { 33 | ModalLink(destination: EmptyView()) { 34 | Text("hello") 35 | } 36 | } 37 | } 38 | } 39 | 40 | func testLinkInsideList() { 41 | snapshotTests.check(size: CGSize(width: 85, height: 60)) { 42 | ModalPresenter { 43 | List { 44 | ModalLink(destination: EmptyView()) { 45 | Text("first") 46 | } 47 | ModalLink(destination: EmptyView()) { 48 | Text("second") 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | func testLinkWithDismissClosure() { 56 | struct ModalView: View { 57 | var dismiss: () -> () 58 | var body: some View { 59 | Button(action: dismiss) { 60 | Text("Close") 61 | } 62 | } 63 | } 64 | snapshotTests.check(size: CGSize(width: 60, height: 25)) { 65 | ModalPresenter { 66 | ModalLink(destination: { ModalView(dismiss: $0) }) { 67 | Text("Open") 68 | } 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/ModalViewTests/SnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapshotTests.swift 3 | // 4 | // 5 | // Created by Denis Chaschin on 26.09.2019. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | import SwiftUI 11 | import XCTest 12 | 13 | struct SnapshotTests { 14 | var recording = false 15 | 16 | mutating func check(size: CGSize, name: String = #function, @ViewBuilder view: () -> V) { 17 | if recording { 18 | record(size: size, name: name, view: view) 19 | XCTFail("Test has been recorded. Don't forget to turn off the `recording`") 20 | } else { 21 | verify(size: size, name: name, view: view) 22 | } 23 | } 24 | 25 | mutating func record(size: CGSize, name: String = #function, @ViewBuilder view: () -> V) { 26 | self[named: fileName(functionName: name)] = takeSnapshot(view: view, size: size) 27 | } 28 | 29 | func verify(size: CGSize, name: String, @ViewBuilder view: () -> V) { 30 | let existing = self[named: fileName(functionName: name)] 31 | XCTAssertNotNil(existing) 32 | let representation = takeSnapshot(view: view, size: size) 33 | XCTAssertEqual(representation, existing) 34 | } 35 | 36 | private subscript(named resourceName: String) -> Data? { 37 | get { 38 | let path = URL(fileURLWithPath: resourcesPath, isDirectory: true).appendingPathComponent(resourceName, isDirectory: false) 39 | return try? Data(contentsOf: path) 40 | } 41 | set { 42 | let path = URL(fileURLWithPath: resourcesPath, isDirectory: true).appendingPathComponent(resourceName, isDirectory: false) 43 | do { 44 | try FileManager.default.removeItem(at: path) 45 | } catch {} 46 | try! newValue?.write(to: path) 47 | } 48 | } 49 | } 50 | 51 | private var resourcesPath: String { 52 | NSString.path(withComponents: URL(fileURLWithPath: #file).pathComponents.dropLast().dropLast() + ["Resources"]) 53 | } 54 | 55 | private func takeSnapshot(@ViewBuilder view: () -> V, size: CGSize) -> Data { 56 | let view = NSHostingView(rootView: view()) 57 | view.frame.size = size 58 | return NSImage(data: view.dataWithPDF(inside: view.bounds))!.tiffRepresentation! 59 | } 60 | 61 | private func fileName(functionName: String) -> String { 62 | var result = functionName.components(separatedBy: "(")[0] 63 | if result.hasPrefix("test") { 64 | result = result.components(separatedBy: "test")[1] 65 | } 66 | return [result, ".png"].joined() 67 | } 68 | -------------------------------------------------------------------------------- /Tests/ModalViewTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ModalViewTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/Resources/EmptyModalPresenter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/modal-view/3f1a97297112770677b507556f581f7ed561593a/Tests/Resources/EmptyModalPresenter.png -------------------------------------------------------------------------------- /Tests/Resources/LinkInsideList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/modal-view/3f1a97297112770677b507556f581f7ed561593a/Tests/Resources/LinkInsideList.png -------------------------------------------------------------------------------- /Tests/Resources/LinkWithDismissClosure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/modal-view/3f1a97297112770677b507556f581f7ed561593a/Tests/Resources/LinkWithDismissClosure.png -------------------------------------------------------------------------------- /Tests/Resources/ModalPresenterWithLink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/modal-view/3f1a97297112770677b507556f581f7ed561593a/Tests/Resources/ModalPresenterWithLink.png -------------------------------------------------------------------------------- /Tests/Resources/ModalPresenterWithText.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diniska/modal-view/3f1a97297112770677b507556f581f7ed561593a/Tests/Resources/ModalPresenterWithText.png --------------------------------------------------------------------------------