├── .gitignore ├── LICENSE ├── ModalArchitecture.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── ModalArchitecture.xcscheme ├── ModalArchitecture.xctestplan ├── ModalArchitecture ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Contents │ ├── End │ │ ├── EndContent.swift │ │ ├── EndPresenter.swift │ │ └── EndView.swift │ ├── First │ │ ├── FirstContent.swift │ │ ├── FirstPresenter.swift │ │ └── FirstView.swift │ ├── Root │ │ ├── RootContent.swift │ │ ├── RootPresenter.swift │ │ └── RootView.swift │ └── Second │ │ ├── SecondContent.swift │ │ ├── SecondPresenter.swift │ │ └── SecondView.swift ├── Framework │ ├── AlertModifier.swift │ ├── ConfirmationDialogModifier.swift │ ├── DialogContent.swift │ ├── EmptyChildContent.swift │ ├── EmptyParentNode.swift │ ├── EmptyTarget.swift │ ├── Modal.swift │ ├── ModalDialog.swift.swift │ ├── ModalId.swift │ ├── ModalNode.swift │ ├── ModalNodeState.swift │ ├── ModalProtocols.swift │ ├── ModalReserved.swift │ ├── TransitionDialog.swift │ ├── TransitionPopover.swift │ ├── TransitionPresenter.swift │ └── TransitionView.swift ├── ModalArchitectureApp.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── Utils │ ├── DidAppearModifier.swift │ ├── Once.swift │ ├── Sleeper.swift │ ├── SwapView.swift │ └── TickWaiter.swift ├── ModalArchitectureTests └── ModalNodeTests.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yuki Yasoshima 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 | -------------------------------------------------------------------------------- /ModalArchitecture.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | B62277072A5D883D004D0ABC /* TickWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62277062A5D883D004D0ABC /* TickWaiter.swift */; }; 11 | B6343C9F2A501C73008E077F /* ModalNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6343C9E2A501C73008E077F /* ModalNodeTests.swift */; }; 12 | B6343CAB2A50D6CD008E077F /* SwapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6343CAA2A50D6CD008E077F /* SwapView.swift */; }; 13 | B69C4A452A6BFC6400D568F8 /* ModalReserved.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69C4A442A6BFC6400D568F8 /* ModalReserved.swift */; }; 14 | B69C4A472A6BFD3D00D568F8 /* ModalNodeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69C4A462A6BFD3D00D568F8 /* ModalNodeState.swift */; }; 15 | B6A73E7B2A5A4E6800E7FACD /* Sleeper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A73E7A2A5A4E6800E7FACD /* Sleeper.swift */; }; 16 | B6BE8D2E2A55A84C0036CC76 /* DidAppearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BE8D2D2A55A84C0036CC76 /* DidAppearModifier.swift */; }; 17 | B6D1AC462A56E20C008E6444 /* ModalDialog.swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC452A56E20C008E6444 /* ModalDialog.swift.swift */; }; 18 | B6D1AC482A56F0ED008E6444 /* EmptyTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC472A56F0ED008E6444 /* EmptyTarget.swift */; }; 19 | B6D1AC4E2A56FF23008E6444 /* TransitionDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC4D2A56FF23008E6444 /* TransitionDialog.swift */; }; 20 | B6D1AC502A57005A008E6444 /* AlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC4F2A57005A008E6444 /* AlertModifier.swift */; }; 21 | B6D1AC522A57036F008E6444 /* ConfirmationDialogModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC512A57036F008E6444 /* ConfirmationDialogModifier.swift */; }; 22 | B6D1AC6E2A58D348008E6444 /* Once.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC6D2A58D348008E6444 /* Once.swift */; }; 23 | B6D1AC702A590F91008E6444 /* TransitionPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D1AC6F2A590F91008E6444 /* TransitionPopover.swift */; }; 24 | B6E00C302A45E97C003A7BB7 /* ModalArchitectureApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C2F2A45E97C003A7BB7 /* ModalArchitectureApp.swift */; }; 25 | B6E00C342A45E97D003A7BB7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B6E00C332A45E97D003A7BB7 /* Assets.xcassets */; }; 26 | B6E00C372A45E97D003A7BB7 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B6E00C362A45E97D003A7BB7 /* Preview Assets.xcassets */; }; 27 | B6E00C5F2A45E9E1003A7BB7 /* TransitionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C402A45E9E1003A7BB7 /* TransitionPresenter.swift */; }; 28 | B6E00C602A45E9E1003A7BB7 /* TransitionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C412A45E9E1003A7BB7 /* TransitionView.swift */; }; 29 | B6E00C642A45E9E1003A7BB7 /* FirstView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C472A45E9E1003A7BB7 /* FirstView.swift */; }; 30 | B6E00C652A45E9E1003A7BB7 /* FirstPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C482A45E9E1003A7BB7 /* FirstPresenter.swift */; }; 31 | B6E00C662A45E9E1003A7BB7 /* FirstContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C492A45E9E1003A7BB7 /* FirstContent.swift */; }; 32 | B6E00C672A45E9E1003A7BB7 /* SecondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C4B2A45E9E1003A7BB7 /* SecondView.swift */; }; 33 | B6E00C682A45E9E1003A7BB7 /* SecondContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C4C2A45E9E1003A7BB7 /* SecondContent.swift */; }; 34 | B6E00C692A45E9E1003A7BB7 /* SecondPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C4D2A45E9E1003A7BB7 /* SecondPresenter.swift */; }; 35 | B6E00C6A2A45E9E1003A7BB7 /* RootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C4F2A45E9E1003A7BB7 /* RootContent.swift */; }; 36 | B6E00C6B2A45E9E1003A7BB7 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C502A45E9E1003A7BB7 /* RootView.swift */; }; 37 | B6E00C6C2A45E9E1003A7BB7 /* RootPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C512A45E9E1003A7BB7 /* RootPresenter.swift */; }; 38 | B6E00C6D2A45E9E1003A7BB7 /* EndView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C532A45E9E1003A7BB7 /* EndView.swift */; }; 39 | B6E00C6E2A45E9E1003A7BB7 /* EndContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C542A45E9E1003A7BB7 /* EndContent.swift */; }; 40 | B6E00C6F2A45E9E1003A7BB7 /* EndPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C552A45E9E1003A7BB7 /* EndPresenter.swift */; }; 41 | B6E00C702A45E9E1003A7BB7 /* EmptyChildContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C572A45E9E1003A7BB7 /* EmptyChildContent.swift */; }; 42 | B6E00C722A45E9E1003A7BB7 /* ModalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C592A45E9E1003A7BB7 /* ModalId.swift */; }; 43 | B6E00C732A45E9E1003A7BB7 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C5A2A45E9E1003A7BB7 /* Modal.swift */; }; 44 | B6E00C742A45E9E1003A7BB7 /* ModalNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C5B2A45E9E1003A7BB7 /* ModalNode.swift */; }; 45 | B6E00C752A45E9E1003A7BB7 /* ModalProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C5C2A45E9E1003A7BB7 /* ModalProtocols.swift */; }; 46 | B6E00C762A45E9E1003A7BB7 /* DialogContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C5D2A45E9E1003A7BB7 /* DialogContent.swift */; }; 47 | B6E00C772A45E9E1003A7BB7 /* EmptyParentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E00C5E2A45E9E1003A7BB7 /* EmptyParentNode.swift */; }; 48 | /* End PBXBuildFile section */ 49 | 50 | /* Begin PBXContainerItemProxy section */ 51 | B6343CA02A501C73008E077F /* PBXContainerItemProxy */ = { 52 | isa = PBXContainerItemProxy; 53 | containerPortal = B6E00C242A45E97C003A7BB7 /* Project object */; 54 | proxyType = 1; 55 | remoteGlobalIDString = B6E00C2B2A45E97C003A7BB7; 56 | remoteInfo = ModalArchitecture; 57 | }; 58 | /* End PBXContainerItemProxy section */ 59 | 60 | /* Begin PBXFileReference section */ 61 | B62277062A5D883D004D0ABC /* TickWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickWaiter.swift; sourceTree = ""; }; 62 | B6343C9C2A501C73008E077F /* ModalArchitectureTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ModalArchitectureTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 63 | B6343C9E2A501C73008E077F /* ModalNodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNodeTests.swift; sourceTree = ""; }; 64 | B6343CA52A5027D1008E077F /* ModalArchitecture.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = ModalArchitecture.xctestplan; sourceTree = ""; }; 65 | B6343CAA2A50D6CD008E077F /* SwapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapView.swift; sourceTree = ""; }; 66 | B69C4A442A6BFC6400D568F8 /* ModalReserved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalReserved.swift; sourceTree = ""; }; 67 | B69C4A462A6BFD3D00D568F8 /* ModalNodeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNodeState.swift; sourceTree = ""; }; 68 | B6A73E7A2A5A4E6800E7FACD /* Sleeper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sleeper.swift; sourceTree = ""; }; 69 | B6BE8D2D2A55A84C0036CC76 /* DidAppearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DidAppearModifier.swift; sourceTree = ""; }; 70 | B6D1AC452A56E20C008E6444 /* ModalDialog.swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalDialog.swift.swift; sourceTree = ""; }; 71 | B6D1AC472A56F0ED008E6444 /* EmptyTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTarget.swift; sourceTree = ""; }; 72 | B6D1AC4D2A56FF23008E6444 /* TransitionDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionDialog.swift; sourceTree = ""; }; 73 | B6D1AC4F2A57005A008E6444 /* AlertModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModifier.swift; sourceTree = ""; }; 74 | B6D1AC512A57036F008E6444 /* ConfirmationDialogModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationDialogModifier.swift; sourceTree = ""; }; 75 | B6D1AC6D2A58D348008E6444 /* Once.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Once.swift; sourceTree = ""; }; 76 | B6D1AC6F2A590F91008E6444 /* TransitionPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionPopover.swift; sourceTree = ""; }; 77 | B6E00C2C2A45E97C003A7BB7 /* ModalArchitecture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ModalArchitecture.app; sourceTree = BUILT_PRODUCTS_DIR; }; 78 | B6E00C2F2A45E97C003A7BB7 /* ModalArchitectureApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalArchitectureApp.swift; sourceTree = ""; }; 79 | B6E00C332A45E97D003A7BB7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 80 | B6E00C362A45E97D003A7BB7 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 81 | B6E00C402A45E9E1003A7BB7 /* TransitionPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionPresenter.swift; sourceTree = ""; }; 82 | B6E00C412A45E9E1003A7BB7 /* TransitionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionView.swift; sourceTree = ""; }; 83 | B6E00C472A45E9E1003A7BB7 /* FirstView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstView.swift; sourceTree = ""; }; 84 | B6E00C482A45E9E1003A7BB7 /* FirstPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstPresenter.swift; sourceTree = ""; }; 85 | B6E00C492A45E9E1003A7BB7 /* FirstContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstContent.swift; sourceTree = ""; }; 86 | B6E00C4B2A45E9E1003A7BB7 /* SecondView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondView.swift; sourceTree = ""; }; 87 | B6E00C4C2A45E9E1003A7BB7 /* SecondContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondContent.swift; sourceTree = ""; }; 88 | B6E00C4D2A45E9E1003A7BB7 /* SecondPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondPresenter.swift; sourceTree = ""; }; 89 | B6E00C4F2A45E9E1003A7BB7 /* RootContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootContent.swift; sourceTree = ""; }; 90 | B6E00C502A45E9E1003A7BB7 /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 91 | B6E00C512A45E9E1003A7BB7 /* RootPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootPresenter.swift; sourceTree = ""; }; 92 | B6E00C532A45E9E1003A7BB7 /* EndView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndView.swift; sourceTree = ""; }; 93 | B6E00C542A45E9E1003A7BB7 /* EndContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndContent.swift; sourceTree = ""; }; 94 | B6E00C552A45E9E1003A7BB7 /* EndPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndPresenter.swift; sourceTree = ""; }; 95 | B6E00C572A45E9E1003A7BB7 /* EmptyChildContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyChildContent.swift; sourceTree = ""; }; 96 | B6E00C592A45E9E1003A7BB7 /* ModalId.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalId.swift; sourceTree = ""; }; 97 | B6E00C5A2A45E9E1003A7BB7 /* Modal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Modal.swift; sourceTree = ""; }; 98 | B6E00C5B2A45E9E1003A7BB7 /* ModalNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalNode.swift; sourceTree = ""; }; 99 | B6E00C5C2A45E9E1003A7BB7 /* ModalProtocols.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalProtocols.swift; sourceTree = ""; }; 100 | B6E00C5D2A45E9E1003A7BB7 /* DialogContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DialogContent.swift; sourceTree = ""; }; 101 | B6E00C5E2A45E9E1003A7BB7 /* EmptyParentNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyParentNode.swift; sourceTree = ""; }; 102 | /* End PBXFileReference section */ 103 | 104 | /* Begin PBXFrameworksBuildPhase section */ 105 | B6343C992A501C73008E077F /* Frameworks */ = { 106 | isa = PBXFrameworksBuildPhase; 107 | buildActionMask = 2147483647; 108 | files = ( 109 | ); 110 | runOnlyForDeploymentPostprocessing = 0; 111 | }; 112 | B6E00C292A45E97C003A7BB7 /* Frameworks */ = { 113 | isa = PBXFrameworksBuildPhase; 114 | buildActionMask = 2147483647; 115 | files = ( 116 | ); 117 | runOnlyForDeploymentPostprocessing = 0; 118 | }; 119 | /* End PBXFrameworksBuildPhase section */ 120 | 121 | /* Begin PBXGroup section */ 122 | B6343C9D2A501C73008E077F /* ModalArchitectureTests */ = { 123 | isa = PBXGroup; 124 | children = ( 125 | B6343C9E2A501C73008E077F /* ModalNodeTests.swift */, 126 | ); 127 | path = ModalArchitectureTests; 128 | sourceTree = ""; 129 | }; 130 | B69C4A432A6BFB0200D568F8 /* Utils */ = { 131 | isa = PBXGroup; 132 | children = ( 133 | B6BE8D2D2A55A84C0036CC76 /* DidAppearModifier.swift */, 134 | B6D1AC6D2A58D348008E6444 /* Once.swift */, 135 | B6A73E7A2A5A4E6800E7FACD /* Sleeper.swift */, 136 | B6343CAA2A50D6CD008E077F /* SwapView.swift */, 137 | B62277062A5D883D004D0ABC /* TickWaiter.swift */, 138 | ); 139 | path = Utils; 140 | sourceTree = ""; 141 | }; 142 | B6E00C232A45E97C003A7BB7 = { 143 | isa = PBXGroup; 144 | children = ( 145 | B6343CA52A5027D1008E077F /* ModalArchitecture.xctestplan */, 146 | B6E00C2E2A45E97C003A7BB7 /* ModalArchitecture */, 147 | B6343C9D2A501C73008E077F /* ModalArchitectureTests */, 148 | B6E00C2D2A45E97C003A7BB7 /* Products */, 149 | ); 150 | sourceTree = ""; 151 | }; 152 | B6E00C2D2A45E97C003A7BB7 /* Products */ = { 153 | isa = PBXGroup; 154 | children = ( 155 | B6E00C2C2A45E97C003A7BB7 /* ModalArchitecture.app */, 156 | B6343C9C2A501C73008E077F /* ModalArchitectureTests.xctest */, 157 | ); 158 | name = Products; 159 | sourceTree = ""; 160 | }; 161 | B6E00C2E2A45E97C003A7BB7 /* ModalArchitecture */ = { 162 | isa = PBXGroup; 163 | children = ( 164 | B6E00C2F2A45E97C003A7BB7 /* ModalArchitectureApp.swift */, 165 | B6E00C562A45E9E1003A7BB7 /* Framework */, 166 | B69C4A432A6BFB0200D568F8 /* Utils */, 167 | B6E00C3E2A45E9E1003A7BB7 /* Contents */, 168 | B6E00C332A45E97D003A7BB7 /* Assets.xcassets */, 169 | B6E00C352A45E97D003A7BB7 /* Preview Content */, 170 | ); 171 | path = ModalArchitecture; 172 | sourceTree = ""; 173 | }; 174 | B6E00C352A45E97D003A7BB7 /* Preview Content */ = { 175 | isa = PBXGroup; 176 | children = ( 177 | B6E00C362A45E97D003A7BB7 /* Preview Assets.xcassets */, 178 | ); 179 | path = "Preview Content"; 180 | sourceTree = ""; 181 | }; 182 | B6E00C3E2A45E9E1003A7BB7 /* Contents */ = { 183 | isa = PBXGroup; 184 | children = ( 185 | B6E00C4E2A45E9E1003A7BB7 /* Root */, 186 | B6E00C462A45E9E1003A7BB7 /* First */, 187 | B6E00C4A2A45E9E1003A7BB7 /* Second */, 188 | B6E00C522A45E9E1003A7BB7 /* End */, 189 | ); 190 | path = Contents; 191 | sourceTree = ""; 192 | }; 193 | B6E00C462A45E9E1003A7BB7 /* First */ = { 194 | isa = PBXGroup; 195 | children = ( 196 | B6E00C492A45E9E1003A7BB7 /* FirstContent.swift */, 197 | B6E00C482A45E9E1003A7BB7 /* FirstPresenter.swift */, 198 | B6E00C472A45E9E1003A7BB7 /* FirstView.swift */, 199 | ); 200 | path = First; 201 | sourceTree = ""; 202 | }; 203 | B6E00C4A2A45E9E1003A7BB7 /* Second */ = { 204 | isa = PBXGroup; 205 | children = ( 206 | B6E00C4C2A45E9E1003A7BB7 /* SecondContent.swift */, 207 | B6E00C4D2A45E9E1003A7BB7 /* SecondPresenter.swift */, 208 | B6E00C4B2A45E9E1003A7BB7 /* SecondView.swift */, 209 | ); 210 | path = Second; 211 | sourceTree = ""; 212 | }; 213 | B6E00C4E2A45E9E1003A7BB7 /* Root */ = { 214 | isa = PBXGroup; 215 | children = ( 216 | B6E00C4F2A45E9E1003A7BB7 /* RootContent.swift */, 217 | B6E00C512A45E9E1003A7BB7 /* RootPresenter.swift */, 218 | B6E00C502A45E9E1003A7BB7 /* RootView.swift */, 219 | ); 220 | path = Root; 221 | sourceTree = ""; 222 | }; 223 | B6E00C522A45E9E1003A7BB7 /* End */ = { 224 | isa = PBXGroup; 225 | children = ( 226 | B6E00C542A45E9E1003A7BB7 /* EndContent.swift */, 227 | B6E00C552A45E9E1003A7BB7 /* EndPresenter.swift */, 228 | B6E00C532A45E9E1003A7BB7 /* EndView.swift */, 229 | ); 230 | path = End; 231 | sourceTree = ""; 232 | }; 233 | B6E00C562A45E9E1003A7BB7 /* Framework */ = { 234 | isa = PBXGroup; 235 | children = ( 236 | B6D1AC4F2A57005A008E6444 /* AlertModifier.swift */, 237 | B6D1AC512A57036F008E6444 /* ConfirmationDialogModifier.swift */, 238 | B6E00C5D2A45E9E1003A7BB7 /* DialogContent.swift */, 239 | B6E00C572A45E9E1003A7BB7 /* EmptyChildContent.swift */, 240 | B6E00C5E2A45E9E1003A7BB7 /* EmptyParentNode.swift */, 241 | B6D1AC472A56F0ED008E6444 /* EmptyTarget.swift */, 242 | B6E00C5A2A45E9E1003A7BB7 /* Modal.swift */, 243 | B6D1AC452A56E20C008E6444 /* ModalDialog.swift.swift */, 244 | B6E00C592A45E9E1003A7BB7 /* ModalId.swift */, 245 | B6E00C5B2A45E9E1003A7BB7 /* ModalNode.swift */, 246 | B69C4A462A6BFD3D00D568F8 /* ModalNodeState.swift */, 247 | B6E00C5C2A45E9E1003A7BB7 /* ModalProtocols.swift */, 248 | B69C4A442A6BFC6400D568F8 /* ModalReserved.swift */, 249 | B6D1AC4D2A56FF23008E6444 /* TransitionDialog.swift */, 250 | B6D1AC6F2A590F91008E6444 /* TransitionPopover.swift */, 251 | B6E00C402A45E9E1003A7BB7 /* TransitionPresenter.swift */, 252 | B6E00C412A45E9E1003A7BB7 /* TransitionView.swift */, 253 | ); 254 | path = Framework; 255 | sourceTree = ""; 256 | }; 257 | /* End PBXGroup section */ 258 | 259 | /* Begin PBXNativeTarget section */ 260 | B6343C9B2A501C73008E077F /* ModalArchitectureTests */ = { 261 | isa = PBXNativeTarget; 262 | buildConfigurationList = B6343CA42A501C73008E077F /* Build configuration list for PBXNativeTarget "ModalArchitectureTests" */; 263 | buildPhases = ( 264 | B6343C982A501C73008E077F /* Sources */, 265 | B6343C992A501C73008E077F /* Frameworks */, 266 | B6343C9A2A501C73008E077F /* Resources */, 267 | ); 268 | buildRules = ( 269 | ); 270 | dependencies = ( 271 | B6343CA12A501C73008E077F /* PBXTargetDependency */, 272 | ); 273 | name = ModalArchitectureTests; 274 | productName = ModalArchitectureTests; 275 | productReference = B6343C9C2A501C73008E077F /* ModalArchitectureTests.xctest */; 276 | productType = "com.apple.product-type.bundle.unit-test"; 277 | }; 278 | B6E00C2B2A45E97C003A7BB7 /* ModalArchitecture */ = { 279 | isa = PBXNativeTarget; 280 | buildConfigurationList = B6E00C3A2A45E97D003A7BB7 /* Build configuration list for PBXNativeTarget "ModalArchitecture" */; 281 | buildPhases = ( 282 | B6E00C282A45E97C003A7BB7 /* Sources */, 283 | B6E00C292A45E97C003A7BB7 /* Frameworks */, 284 | B6E00C2A2A45E97C003A7BB7 /* Resources */, 285 | B6E00C3D2A45E9AB003A7BB7 /* ShellScript */, 286 | ); 287 | buildRules = ( 288 | ); 289 | dependencies = ( 290 | ); 291 | name = ModalArchitecture; 292 | productName = ModalArchitecture; 293 | productReference = B6E00C2C2A45E97C003A7BB7 /* ModalArchitecture.app */; 294 | productType = "com.apple.product-type.application"; 295 | }; 296 | /* End PBXNativeTarget section */ 297 | 298 | /* Begin PBXProject section */ 299 | B6E00C242A45E97C003A7BB7 /* Project object */ = { 300 | isa = PBXProject; 301 | attributes = { 302 | BuildIndependentTargetsInParallel = 1; 303 | LastSwiftUpdateCheck = 1430; 304 | LastUpgradeCheck = 1430; 305 | TargetAttributes = { 306 | B6343C9B2A501C73008E077F = { 307 | CreatedOnToolsVersion = 14.3.1; 308 | TestTargetID = B6E00C2B2A45E97C003A7BB7; 309 | }; 310 | B6E00C2B2A45E97C003A7BB7 = { 311 | CreatedOnToolsVersion = 14.3.1; 312 | }; 313 | }; 314 | }; 315 | buildConfigurationList = B6E00C272A45E97C003A7BB7 /* Build configuration list for PBXProject "ModalArchitecture" */; 316 | compatibilityVersion = "Xcode 14.0"; 317 | developmentRegion = en; 318 | hasScannedForEncodings = 0; 319 | knownRegions = ( 320 | en, 321 | Base, 322 | ); 323 | mainGroup = B6E00C232A45E97C003A7BB7; 324 | productRefGroup = B6E00C2D2A45E97C003A7BB7 /* Products */; 325 | projectDirPath = ""; 326 | projectRoot = ""; 327 | targets = ( 328 | B6E00C2B2A45E97C003A7BB7 /* ModalArchitecture */, 329 | B6343C9B2A501C73008E077F /* ModalArchitectureTests */, 330 | ); 331 | }; 332 | /* End PBXProject section */ 333 | 334 | /* Begin PBXResourcesBuildPhase section */ 335 | B6343C9A2A501C73008E077F /* Resources */ = { 336 | isa = PBXResourcesBuildPhase; 337 | buildActionMask = 2147483647; 338 | files = ( 339 | ); 340 | runOnlyForDeploymentPostprocessing = 0; 341 | }; 342 | B6E00C2A2A45E97C003A7BB7 /* Resources */ = { 343 | isa = PBXResourcesBuildPhase; 344 | buildActionMask = 2147483647; 345 | files = ( 346 | B6E00C372A45E97D003A7BB7 /* Preview Assets.xcassets in Resources */, 347 | B6E00C342A45E97D003A7BB7 /* Assets.xcassets in Resources */, 348 | ); 349 | runOnlyForDeploymentPostprocessing = 0; 350 | }; 351 | /* End PBXResourcesBuildPhase section */ 352 | 353 | /* Begin PBXShellScriptBuildPhase section */ 354 | B6E00C3D2A45E9AB003A7BB7 /* ShellScript */ = { 355 | isa = PBXShellScriptBuildPhase; 356 | alwaysOutOfDate = 1; 357 | buildActionMask = 2147483647; 358 | files = ( 359 | ); 360 | inputFileListPaths = ( 361 | ); 362 | inputPaths = ( 363 | ); 364 | outputFileListPaths = ( 365 | ); 366 | outputPaths = ( 367 | ); 368 | runOnlyForDeploymentPostprocessing = 0; 369 | shellPath = /bin/sh; 370 | shellScript = "export PATH=$PATH:/opt/homebrew/bin\n\nif which swiftlint >/dev/null; then\n swiftlint --fix\n swiftlint\nelse\n echo \"SwiftLint does not exist, download from https://github.com/realm/SwiftLint\"\nfi\n"; 371 | }; 372 | /* End PBXShellScriptBuildPhase section */ 373 | 374 | /* Begin PBXSourcesBuildPhase section */ 375 | B6343C982A501C73008E077F /* Sources */ = { 376 | isa = PBXSourcesBuildPhase; 377 | buildActionMask = 2147483647; 378 | files = ( 379 | B6343C9F2A501C73008E077F /* ModalNodeTests.swift in Sources */, 380 | ); 381 | runOnlyForDeploymentPostprocessing = 0; 382 | }; 383 | B6E00C282A45E97C003A7BB7 /* Sources */ = { 384 | isa = PBXSourcesBuildPhase; 385 | buildActionMask = 2147483647; 386 | files = ( 387 | B6E00C6C2A45E9E1003A7BB7 /* RootPresenter.swift in Sources */, 388 | B62277072A5D883D004D0ABC /* TickWaiter.swift in Sources */, 389 | B6E00C752A45E9E1003A7BB7 /* ModalProtocols.swift in Sources */, 390 | B6D1AC702A590F91008E6444 /* TransitionPopover.swift in Sources */, 391 | B6E00C742A45E9E1003A7BB7 /* ModalNode.swift in Sources */, 392 | B6D1AC482A56F0ED008E6444 /* EmptyTarget.swift in Sources */, 393 | B6E00C642A45E9E1003A7BB7 /* FirstView.swift in Sources */, 394 | B6E00C662A45E9E1003A7BB7 /* FirstContent.swift in Sources */, 395 | B6E00C302A45E97C003A7BB7 /* ModalArchitectureApp.swift in Sources */, 396 | B6E00C722A45E9E1003A7BB7 /* ModalId.swift in Sources */, 397 | B6D1AC462A56E20C008E6444 /* ModalDialog.swift.swift in Sources */, 398 | B6E00C772A45E9E1003A7BB7 /* EmptyParentNode.swift in Sources */, 399 | B6D1AC522A57036F008E6444 /* ConfirmationDialogModifier.swift in Sources */, 400 | B6E00C6B2A45E9E1003A7BB7 /* RootView.swift in Sources */, 401 | B6E00C6F2A45E9E1003A7BB7 /* EndPresenter.swift in Sources */, 402 | B69C4A452A6BFC6400D568F8 /* ModalReserved.swift in Sources */, 403 | B6E00C6A2A45E9E1003A7BB7 /* RootContent.swift in Sources */, 404 | B6E00C5F2A45E9E1003A7BB7 /* TransitionPresenter.swift in Sources */, 405 | B6343CAB2A50D6CD008E077F /* SwapView.swift in Sources */, 406 | B6E00C732A45E9E1003A7BB7 /* Modal.swift in Sources */, 407 | B6E00C672A45E9E1003A7BB7 /* SecondView.swift in Sources */, 408 | B6E00C602A45E9E1003A7BB7 /* TransitionView.swift in Sources */, 409 | B6BE8D2E2A55A84C0036CC76 /* DidAppearModifier.swift in Sources */, 410 | B6D1AC502A57005A008E6444 /* AlertModifier.swift in Sources */, 411 | B6E00C702A45E9E1003A7BB7 /* EmptyChildContent.swift in Sources */, 412 | B6E00C692A45E9E1003A7BB7 /* SecondPresenter.swift in Sources */, 413 | B6E00C6D2A45E9E1003A7BB7 /* EndView.swift in Sources */, 414 | B6D1AC4E2A56FF23008E6444 /* TransitionDialog.swift in Sources */, 415 | B6A73E7B2A5A4E6800E7FACD /* Sleeper.swift in Sources */, 416 | B6E00C682A45E9E1003A7BB7 /* SecondContent.swift in Sources */, 417 | B6E00C6E2A45E9E1003A7BB7 /* EndContent.swift in Sources */, 418 | B69C4A472A6BFD3D00D568F8 /* ModalNodeState.swift in Sources */, 419 | B6E00C762A45E9E1003A7BB7 /* DialogContent.swift in Sources */, 420 | B6D1AC6E2A58D348008E6444 /* Once.swift in Sources */, 421 | B6E00C652A45E9E1003A7BB7 /* FirstPresenter.swift in Sources */, 422 | ); 423 | runOnlyForDeploymentPostprocessing = 0; 424 | }; 425 | /* End PBXSourcesBuildPhase section */ 426 | 427 | /* Begin PBXTargetDependency section */ 428 | B6343CA12A501C73008E077F /* PBXTargetDependency */ = { 429 | isa = PBXTargetDependency; 430 | target = B6E00C2B2A45E97C003A7BB7 /* ModalArchitecture */; 431 | targetProxy = B6343CA02A501C73008E077F /* PBXContainerItemProxy */; 432 | }; 433 | /* End PBXTargetDependency section */ 434 | 435 | /* Begin XCBuildConfiguration section */ 436 | B6343CA22A501C73008E077F /* Debug */ = { 437 | isa = XCBuildConfiguration; 438 | buildSettings = { 439 | BUNDLE_LOADER = "$(TEST_HOST)"; 440 | CODE_SIGN_STYLE = Automatic; 441 | CURRENT_PROJECT_VERSION = 1; 442 | DEVELOPMENT_TEAM = WVVR5YE53F; 443 | GENERATE_INFOPLIST_FILE = YES; 444 | MARKETING_VERSION = 1.0; 445 | PRODUCT_BUNDLE_IDENTIFIER = "jp.objective-audio.ModalArchitectureTests"; 446 | PRODUCT_NAME = "$(TARGET_NAME)"; 447 | SWIFT_EMIT_LOC_STRINGS = NO; 448 | SWIFT_VERSION = 5.0; 449 | TARGETED_DEVICE_FAMILY = "1,2"; 450 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ModalArchitecture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ModalArchitecture"; 451 | }; 452 | name = Debug; 453 | }; 454 | B6343CA32A501C73008E077F /* Release */ = { 455 | isa = XCBuildConfiguration; 456 | buildSettings = { 457 | BUNDLE_LOADER = "$(TEST_HOST)"; 458 | CODE_SIGN_STYLE = Automatic; 459 | CURRENT_PROJECT_VERSION = 1; 460 | DEVELOPMENT_TEAM = WVVR5YE53F; 461 | GENERATE_INFOPLIST_FILE = YES; 462 | MARKETING_VERSION = 1.0; 463 | PRODUCT_BUNDLE_IDENTIFIER = "jp.objective-audio.ModalArchitectureTests"; 464 | PRODUCT_NAME = "$(TARGET_NAME)"; 465 | SWIFT_EMIT_LOC_STRINGS = NO; 466 | SWIFT_VERSION = 5.0; 467 | TARGETED_DEVICE_FAMILY = "1,2"; 468 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ModalArchitecture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ModalArchitecture"; 469 | }; 470 | name = Release; 471 | }; 472 | B6E00C382A45E97D003A7BB7 /* Debug */ = { 473 | isa = XCBuildConfiguration; 474 | buildSettings = { 475 | ALWAYS_SEARCH_USER_PATHS = NO; 476 | CLANG_ANALYZER_NONNULL = YES; 477 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 478 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 479 | CLANG_ENABLE_MODULES = YES; 480 | CLANG_ENABLE_OBJC_ARC = YES; 481 | CLANG_ENABLE_OBJC_WEAK = YES; 482 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 483 | CLANG_WARN_BOOL_CONVERSION = YES; 484 | CLANG_WARN_COMMA = YES; 485 | CLANG_WARN_CONSTANT_CONVERSION = YES; 486 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 487 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 488 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 489 | CLANG_WARN_EMPTY_BODY = YES; 490 | CLANG_WARN_ENUM_CONVERSION = YES; 491 | CLANG_WARN_INFINITE_RECURSION = YES; 492 | CLANG_WARN_INT_CONVERSION = YES; 493 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 494 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 495 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 496 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 497 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 498 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 499 | CLANG_WARN_STRICT_PROTOTYPES = YES; 500 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 501 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 502 | CLANG_WARN_UNREACHABLE_CODE = YES; 503 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 504 | COPY_PHASE_STRIP = NO; 505 | DEBUG_INFORMATION_FORMAT = dwarf; 506 | ENABLE_STRICT_OBJC_MSGSEND = YES; 507 | ENABLE_TESTABILITY = YES; 508 | GCC_C_LANGUAGE_STANDARD = gnu11; 509 | GCC_DYNAMIC_NO_PIC = NO; 510 | GCC_NO_COMMON_BLOCKS = YES; 511 | GCC_OPTIMIZATION_LEVEL = 0; 512 | GCC_PREPROCESSOR_DEFINITIONS = ( 513 | "DEBUG=1", 514 | "$(inherited)", 515 | ); 516 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 517 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 518 | GCC_WARN_UNDECLARED_SELECTOR = YES; 519 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 520 | GCC_WARN_UNUSED_FUNCTION = YES; 521 | GCC_WARN_UNUSED_VARIABLE = YES; 522 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 523 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 524 | MTL_FAST_MATH = YES; 525 | ONLY_ACTIVE_ARCH = YES; 526 | SDKROOT = iphoneos; 527 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 528 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 529 | }; 530 | name = Debug; 531 | }; 532 | B6E00C392A45E97D003A7BB7 /* Release */ = { 533 | isa = XCBuildConfiguration; 534 | buildSettings = { 535 | ALWAYS_SEARCH_USER_PATHS = NO; 536 | CLANG_ANALYZER_NONNULL = YES; 537 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 538 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 539 | CLANG_ENABLE_MODULES = YES; 540 | CLANG_ENABLE_OBJC_ARC = YES; 541 | CLANG_ENABLE_OBJC_WEAK = YES; 542 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 543 | CLANG_WARN_BOOL_CONVERSION = YES; 544 | CLANG_WARN_COMMA = YES; 545 | CLANG_WARN_CONSTANT_CONVERSION = YES; 546 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 547 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 548 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 549 | CLANG_WARN_EMPTY_BODY = YES; 550 | CLANG_WARN_ENUM_CONVERSION = YES; 551 | CLANG_WARN_INFINITE_RECURSION = YES; 552 | CLANG_WARN_INT_CONVERSION = YES; 553 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 554 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 555 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 556 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 557 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 558 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 559 | CLANG_WARN_STRICT_PROTOTYPES = YES; 560 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 561 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 562 | CLANG_WARN_UNREACHABLE_CODE = YES; 563 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 564 | COPY_PHASE_STRIP = NO; 565 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 566 | ENABLE_NS_ASSERTIONS = NO; 567 | ENABLE_STRICT_OBJC_MSGSEND = YES; 568 | GCC_C_LANGUAGE_STANDARD = gnu11; 569 | GCC_NO_COMMON_BLOCKS = YES; 570 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 571 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 572 | GCC_WARN_UNDECLARED_SELECTOR = YES; 573 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 574 | GCC_WARN_UNUSED_FUNCTION = YES; 575 | GCC_WARN_UNUSED_VARIABLE = YES; 576 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 577 | MTL_ENABLE_DEBUG_INFO = NO; 578 | MTL_FAST_MATH = YES; 579 | SDKROOT = iphoneos; 580 | SWIFT_COMPILATION_MODE = wholemodule; 581 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 582 | VALIDATE_PRODUCT = YES; 583 | }; 584 | name = Release; 585 | }; 586 | B6E00C3B2A45E97D003A7BB7 /* Debug */ = { 587 | isa = XCBuildConfiguration; 588 | buildSettings = { 589 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 590 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 591 | CODE_SIGN_STYLE = Automatic; 592 | CURRENT_PROJECT_VERSION = 1; 593 | DEVELOPMENT_ASSET_PATHS = "\"ModalArchitecture/Preview Content\""; 594 | DEVELOPMENT_TEAM = WVVR5YE53F; 595 | ENABLE_PREVIEWS = YES; 596 | GENERATE_INFOPLIST_FILE = YES; 597 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 598 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 599 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 600 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 601 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 602 | LD_RUNPATH_SEARCH_PATHS = ( 603 | "$(inherited)", 604 | "@executable_path/Frameworks", 605 | ); 606 | MARKETING_VERSION = 1.0; 607 | PRODUCT_BUNDLE_IDENTIFIER = "jp.objective-audio.ModalArchitecture"; 608 | PRODUCT_NAME = "$(TARGET_NAME)"; 609 | SWIFT_EMIT_LOC_STRINGS = YES; 610 | SWIFT_VERSION = 5.0; 611 | TARGETED_DEVICE_FAMILY = "1,2"; 612 | }; 613 | name = Debug; 614 | }; 615 | B6E00C3C2A45E97D003A7BB7 /* Release */ = { 616 | isa = XCBuildConfiguration; 617 | buildSettings = { 618 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 619 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 620 | CODE_SIGN_STYLE = Automatic; 621 | CURRENT_PROJECT_VERSION = 1; 622 | DEVELOPMENT_ASSET_PATHS = "\"ModalArchitecture/Preview Content\""; 623 | DEVELOPMENT_TEAM = WVVR5YE53F; 624 | ENABLE_PREVIEWS = YES; 625 | GENERATE_INFOPLIST_FILE = YES; 626 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 627 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 628 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 629 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 630 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 631 | LD_RUNPATH_SEARCH_PATHS = ( 632 | "$(inherited)", 633 | "@executable_path/Frameworks", 634 | ); 635 | MARKETING_VERSION = 1.0; 636 | PRODUCT_BUNDLE_IDENTIFIER = "jp.objective-audio.ModalArchitecture"; 637 | PRODUCT_NAME = "$(TARGET_NAME)"; 638 | SWIFT_EMIT_LOC_STRINGS = YES; 639 | SWIFT_VERSION = 5.0; 640 | TARGETED_DEVICE_FAMILY = "1,2"; 641 | }; 642 | name = Release; 643 | }; 644 | /* End XCBuildConfiguration section */ 645 | 646 | /* Begin XCConfigurationList section */ 647 | B6343CA42A501C73008E077F /* Build configuration list for PBXNativeTarget "ModalArchitectureTests" */ = { 648 | isa = XCConfigurationList; 649 | buildConfigurations = ( 650 | B6343CA22A501C73008E077F /* Debug */, 651 | B6343CA32A501C73008E077F /* Release */, 652 | ); 653 | defaultConfigurationIsVisible = 0; 654 | defaultConfigurationName = Release; 655 | }; 656 | B6E00C272A45E97C003A7BB7 /* Build configuration list for PBXProject "ModalArchitecture" */ = { 657 | isa = XCConfigurationList; 658 | buildConfigurations = ( 659 | B6E00C382A45E97D003A7BB7 /* Debug */, 660 | B6E00C392A45E97D003A7BB7 /* Release */, 661 | ); 662 | defaultConfigurationIsVisible = 0; 663 | defaultConfigurationName = Release; 664 | }; 665 | B6E00C3A2A45E97D003A7BB7 /* Build configuration list for PBXNativeTarget "ModalArchitecture" */ = { 666 | isa = XCConfigurationList; 667 | buildConfigurations = ( 668 | B6E00C3B2A45E97D003A7BB7 /* Debug */, 669 | B6E00C3C2A45E97D003A7BB7 /* Release */, 670 | ); 671 | defaultConfigurationIsVisible = 0; 672 | defaultConfigurationName = Release; 673 | }; 674 | /* End XCConfigurationList section */ 675 | }; 676 | rootObject = B6E00C242A45E97C003A7BB7 /* Project object */; 677 | } 678 | -------------------------------------------------------------------------------- /ModalArchitecture.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ModalArchitecture.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ModalArchitecture.xcodeproj/xcshareddata/xcschemes/ModalArchitecture.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 40 | 46 | 47 | 48 | 49 | 50 | 60 | 62 | 68 | 69 | 70 | 71 | 77 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /ModalArchitecture.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "AC02CD44-C540-461A-A79E-8C08CBB8BEFE", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:ModalArchitecture.xcodeproj", 14 | "identifier" : "B6E00C2B2A45E97C003A7BB7", 15 | "name" : "ModalArchitecture" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : true, 21 | "target" : { 22 | "containerPath" : "container:ModalArchitecture.xcodeproj", 23 | "identifier" : "B6343C9B2A501C73008E077F", 24 | "name" : "ModalArchitectureTests" 25 | } 26 | } 27 | ], 28 | "version" : 1 29 | } 30 | -------------------------------------------------------------------------------- /ModalArchitecture/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ModalArchitecture/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ModalArchitecture/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/End/EndContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | final class EndContent: ModalContent { 5 | typealias ChildContent = EmptyChildContent 6 | typealias DialogTarget = EmptyTarget 7 | typealias ParentNode = ModalNode 8 | 9 | let title: String 10 | let node: ModalNode 11 | 12 | init(title: String, parentNode: ModalNode) { 13 | self.title = title 14 | self.node = .init(parent: parentNode) 15 | } 16 | 17 | func makeBaseView() -> some View { 18 | EndView(presenter: .init(content: self)) 19 | } 20 | } 21 | 22 | extension EndContent { 23 | func close() { 24 | node.removeFromParent() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/End/EndPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | final class EndPresenter: ObservableObject { 5 | private weak var content: EndContent? 6 | private weak var rootContent: RootContent? 7 | 8 | init(content: EndContent, 9 | rootContent: RootContent = .shared) { 10 | self.content = content 11 | self.rootContent = rootContent 12 | } 13 | 14 | var title: String { 15 | content?.title ?? "Unknown" 16 | } 17 | } 18 | 19 | extension EndPresenter { 20 | func close() { 21 | content?.close() 22 | } 23 | 24 | func closeAll() { 25 | rootContent?.closeChild() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/End/EndView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EndView: View { 4 | @ObservedObject var presenter: EndPresenter 5 | 6 | var body: some View { 7 | VStack(spacing: 8) { 8 | Text(presenter.title) 9 | .font(.largeTitle) 10 | .padding() 11 | Button { 12 | presenter.close() 13 | } label: { 14 | Text("Close") 15 | .font(.title) 16 | } 17 | Button { 18 | presenter.closeAll() 19 | } label: { 20 | Text("Close All") 21 | .font(.title) 22 | } 23 | } 24 | .padding() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/First/FirstContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | final class FirstContent: ModalContent { 5 | enum Child { 6 | case second(SecondContent) 7 | case secondEnd(EndContent) 8 | case popover(EndContent) 9 | } 10 | 11 | enum ChildTarget: ModalTarget { 12 | case popover 13 | } 14 | 15 | final class ChildContent: ModalChildContent { 16 | let child: Child 17 | 18 | init(_ child: Child) { 19 | self.child = child 20 | } 21 | 22 | var node: ModalChildNode { 23 | switch child { 24 | case .second(let content): 25 | return content.node 26 | case .secondEnd(let content), .popover(let content): 27 | return content.node 28 | } 29 | } 30 | 31 | var target: ChildTarget? { 32 | switch child { 33 | case .second, .secondEnd: 34 | return nil 35 | case .popover: 36 | return .popover 37 | } 38 | } 39 | 40 | func makeChildView() -> AnyView { 41 | switch child { 42 | case .second(let content): 43 | return AnyView(TransitionView(presenter: .init(content: content))) 44 | case .secondEnd(let content), .popover(let content): 45 | return AnyView(TransitionView(presenter: .init(content: content))) 46 | } 47 | } 48 | } 49 | 50 | typealias DialogTarget = EmptyTarget 51 | 52 | typealias ParentNode = ModalNode 53 | 54 | let node: ModalNode 55 | 56 | init(parentNode: ModalNode) { 57 | self.node = .init(parent: parentNode) 58 | } 59 | 60 | func makeBaseView() -> some View { 61 | FirstView(presenter: .init(content: self)) 62 | } 63 | } 64 | 65 | extension FirstContent { 66 | func openSecondSheet() { 67 | node.add(.sheet( 68 | .init(.second(.init(parentNode: node))) 69 | )) 70 | } 71 | 72 | func openSecondEndSheet() { 73 | node.add(.sheet( 74 | .init(.secondEnd(.init(title: "Second End", parentNode: node))) 75 | )) 76 | } 77 | 78 | func openPopover() { 79 | node.add(.popover( 80 | .init(.popover(.init(title: "Second Popover", parentNode: node))) 81 | )) 82 | } 83 | 84 | func openAlert() { 85 | let content = DialogContent( 86 | parentNode: node, 87 | value: .init( 88 | target: nil, 89 | title: "First Alert", 90 | message: "Auto close after 2 seconds", 91 | actions: [] 92 | ) 93 | ) 94 | 95 | node.add(.alert(content)) 96 | 97 | let alertId = content.id 98 | 99 | Task { [weak self] in 100 | try await Task.sleep(for: .seconds(2)) 101 | guard let modalNode = self?.node else { return } 102 | modalNode.remove(for: alertId) 103 | } 104 | } 105 | 106 | func close() { 107 | node.removeFromParent() 108 | } 109 | 110 | func closeAfter2Sec() { 111 | Task { [weak self] in 112 | try await Task.sleep(for: .seconds(2)) 113 | self?.close() 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/First/FirstPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // swiftlint:disable unused_setter_value 4 | 5 | @MainActor 6 | final class FirstPresenter: ObservableObject { 7 | @Published private var modal: Modal? 8 | 9 | private weak var content: FirstContent? 10 | private weak var rootContent: RootContent? 11 | 12 | init( 13 | content: FirstContent, 14 | rootContent: RootContent = .shared 15 | ) { 16 | self.content = content 17 | self.rootContent = rootContent 18 | 19 | content.node 20 | .modalPublisher 21 | .assign(to: &$modal) 22 | } 23 | } 24 | 25 | extension FirstPresenter { 26 | var isFullScreen: Bool { 27 | switch content?.node.parent?.modal { 28 | case .fullScreenCover: 29 | return true 30 | default: 31 | return false 32 | } 33 | } 34 | 35 | var popover: TransitionPopover? { 36 | get { .init(modal: modal, targets: [.popover]) } 37 | set {} 38 | } 39 | 40 | func openSecondSheet() { 41 | content?.openSecondSheet() 42 | } 43 | 44 | func openSecondEndSheet() { 45 | content?.openSecondEndSheet() 46 | } 47 | 48 | func openPopover() { 49 | content?.openPopover() 50 | } 51 | 52 | func openAlert() { 53 | content?.openAlert() 54 | } 55 | 56 | func reopenFirstSheet() { 57 | rootContent?.openFirstSheet() 58 | } 59 | 60 | func reopenFirstFullScreen() { 61 | rootContent?.openFirstFullScreenCover() 62 | } 63 | 64 | func close() { 65 | content?.close() 66 | } 67 | 68 | func closeAfter2Sec() { 69 | content?.closeAfter2Sec() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/First/FirstView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FirstView: View { 4 | @ObservedObject var presenter: FirstPresenter 5 | 6 | private var buttonTextColor: Color? { 7 | if presenter.isFullScreen { 8 | return Color.white 9 | } else { 10 | return nil 11 | } 12 | } 13 | 14 | var body: some View { 15 | ZStack { 16 | if presenter.isFullScreen { 17 | Color.teal 18 | .edgesIgnoringSafeArea(.all) 19 | } 20 | VStack(spacing: 8) { 21 | Spacer().layoutPriority(1) 22 | Group { 23 | Text("First") 24 | .font(.largeTitle) 25 | .foregroundColor(buttonTextColor) 26 | .padding() 27 | Button { 28 | presenter.openSecondSheet() 29 | } label: { 30 | Text("Open Sheet") 31 | .font(.title) 32 | .foregroundColor(buttonTextColor) 33 | } 34 | Button { 35 | presenter.openSecondEndSheet() 36 | } label: { 37 | Text("Open End Sheet") 38 | .font(.title) 39 | .foregroundColor(buttonTextColor) 40 | } 41 | Button { 42 | presenter.openPopover() 43 | } label: { 44 | Text("Open Popover") 45 | .font(.title) 46 | .foregroundColor(buttonTextColor) 47 | } 48 | .popover(item: $presenter.popover) { 49 | $0.childView() 50 | } 51 | Button { 52 | presenter.openAlert() 53 | } label: { 54 | Text("Open Alert and Auto Close") 55 | .font(.title) 56 | .foregroundColor(buttonTextColor) 57 | } 58 | Button { 59 | presenter.reopenFirstSheet() 60 | } label: { 61 | Text("Reopen First Sheet") 62 | .font(.title) 63 | .foregroundColor(buttonTextColor) 64 | } 65 | Button { 66 | presenter.reopenFirstFullScreen() 67 | } label: { 68 | Text("Reopen First FullScreen") 69 | .font(.title) 70 | .foregroundColor(buttonTextColor) 71 | } 72 | Button { 73 | presenter.close() 74 | } label: { 75 | Text("Close") 76 | .font(.title) 77 | .foregroundColor(buttonTextColor) 78 | } 79 | } 80 | Spacer(minLength: 100).layoutPriority(0) 81 | Group { 82 | SwapView { 83 | Menu("Menu") { 84 | Button("One") { 85 | print("Menu One Called") 86 | } 87 | Button("Two") { 88 | print("Menu Two Called") 89 | } 90 | } 91 | .font(.title) 92 | .foregroundColor(buttonTextColor) 93 | } 94 | Button { 95 | presenter.closeAfter2Sec() 96 | } label: { 97 | Text("Close After 2sec") 98 | .font(.title) 99 | .foregroundColor(buttonTextColor) 100 | } 101 | } 102 | Spacer().layoutPriority(1) 103 | } 104 | .padding() 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/Root/RootContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | final class RootContent: ModalContent { 5 | static let shared: RootContent = .init() 6 | 7 | enum Child { 8 | case first(FirstContent) 9 | case popoverA(EndContent) 10 | case popoverB(EndContent) 11 | } 12 | 13 | enum ChildTarget: ModalTarget { 14 | case popoverA 15 | case popoverB 16 | } 17 | 18 | final class ChildContent: ModalChildContent { 19 | let child: Child 20 | 21 | init(_ child: Child) { 22 | self.child = child 23 | } 24 | 25 | var node: ModalChildNode { 26 | switch child { 27 | case .first(let content): 28 | return content.node 29 | case .popoverA(let content), .popoverB(let content): 30 | return content.node 31 | } 32 | } 33 | 34 | var target: ChildTarget? { 35 | switch child { 36 | case .first: 37 | return nil 38 | case .popoverA: 39 | return .popoverA 40 | case .popoverB: 41 | return .popoverB 42 | } 43 | } 44 | 45 | func makeChildView() -> AnyView { 46 | switch child { 47 | case .first(let content): 48 | return AnyView(TransitionView(presenter: .init(content: content))) 49 | case .popoverA(let content), .popoverB(let content): 50 | return AnyView(TransitionView(presenter: .init(content: content))) 51 | } 52 | } 53 | } 54 | 55 | enum DialogTarget: ModalTarget { 56 | case dialogA 57 | case dialogB 58 | } 59 | 60 | typealias ParentNode = EmptyParentNode 61 | 62 | let node: ModalNode 63 | 64 | init() { 65 | self.node = .init(parent: EmptyParentNode.shared) 66 | } 67 | 68 | func makeBaseView() -> some View { 69 | RootView(presenter: .init(content: self)) 70 | } 71 | } 72 | 73 | extension RootContent { 74 | func openFirstSheet() { 75 | node.add(.sheet( 76 | .init(.first(.init(parentNode: node))) 77 | )) 78 | } 79 | 80 | func openFirstFullScreenCover() { 81 | node.add(.fullScreenCover( 82 | .init(.first(.init(parentNode: node))) 83 | )) 84 | } 85 | 86 | func openAlert() { 87 | node.add(.alert( 88 | .init( 89 | parentNode: node, 90 | value: .init( 91 | target: nil, 92 | title: "Root Alert", 93 | message: "Root Alert Message", 94 | actions: [ 95 | .init(role: nil, 96 | buttonTitle: "Open Sheet", 97 | handler: { [weak self] in 98 | self?.openFirstSheet() 99 | }), 100 | .init(role: .cancel, 101 | buttonTitle: "Cancel", 102 | handler: { print("Root Alert Cancel") }), 103 | .init(role: .destructive, 104 | buttonTitle: "Destructive", 105 | handler: { print("Root Alert Destructive") }) 106 | ] 107 | ) 108 | ) 109 | )) 110 | } 111 | 112 | func openDialogA() { 113 | node.add(.confirmationDialog( 114 | .init( 115 | parentNode: node, 116 | value: .init( 117 | target: .dialogA, 118 | title: "Root Dialog A", 119 | message: "Root Dialog Message A", 120 | actions: [ 121 | .init(role: nil, 122 | buttonTitle: "OK", 123 | handler: { print("Root Dialog A OK") }), 124 | .init(role: .cancel, 125 | buttonTitle: "Cancel", 126 | handler: { print("Root Dialog A Cancel") }), 127 | .init(role: .destructive, 128 | buttonTitle: "Destructive", 129 | handler: { print("Root Dialog A Destructive") }) 130 | ] 131 | ) 132 | ) 133 | )) 134 | } 135 | 136 | func openDialogB() { 137 | node.add(.confirmationDialog( 138 | .init( 139 | parentNode: node, 140 | value: .init( 141 | target: .dialogB, 142 | title: nil, 143 | message: "Root Dialog Message B", 144 | actions: [ 145 | .init(role: nil, 146 | buttonTitle: "OK", 147 | handler: { print("Root Dialog B OK") }), 148 | .init(role: .cancel, 149 | buttonTitle: "Cancel", 150 | handler: { print("Root Dialog B Cancel") }) 151 | ] 152 | ) 153 | ) 154 | )) 155 | } 156 | 157 | func openPopoverA() { 158 | node.add(.popover( 159 | .init(.popoverA(.init(title: "First Popover A", parentNode: node))) 160 | )) 161 | } 162 | 163 | func openPopoverB() { 164 | node.add(.popover( 165 | .init(.popoverB(.init(title: "First Popover B", parentNode: node))) 166 | )) 167 | } 168 | 169 | func openPopoverAfter2Sec() { 170 | Task { [weak self] in 171 | try await Task.sleep(for: .seconds(2)) 172 | self?.openPopoverA() 173 | } 174 | } 175 | 176 | func openSheetAfter2Sec() { 177 | Task { [weak self] in 178 | try await Task.sleep(for: .seconds(2)) 179 | self?.openFirstSheet() 180 | } 181 | } 182 | 183 | func openSecondSheet() { 184 | let firstContent = FirstContent(parentNode: node) 185 | let firstNode = firstContent.node 186 | 187 | firstNode.add(.sheet(.init(.second(.init(parentNode: firstNode))))) 188 | node.add(.sheet(.init(.first(firstContent)))) 189 | } 190 | 191 | func closeChild() { 192 | node.remove() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/Root/RootPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // swiftlint:disable unused_setter_value 5 | 6 | @MainActor 7 | final class RootPresenter: ObservableObject { 8 | @Published private var modal: Modal? 9 | 10 | private weak var content: RootContent? 11 | 12 | @Published var pickerSelected: Int = 0 13 | 14 | init(content: RootContent) { 15 | self.content = content 16 | 17 | content.node 18 | .modalPublisher 19 | .assign(to: &$modal) 20 | } 21 | } 22 | 23 | extension RootPresenter { 24 | var dialogA: TransitionDialog? { 25 | guard case .confirmationDialog(let content) = modal else { 26 | return nil 27 | } 28 | return .init(content: content, targets: [.dialogA]) 29 | } 30 | 31 | var isDialogAPresented: Bool { 32 | get { dialogA != nil } 33 | set {} 34 | } 35 | 36 | var dialogB: TransitionDialog? { 37 | guard case .confirmationDialog(let content) = modal else { 38 | return nil 39 | } 40 | return .init(content: content, targets: [.dialogB]) 41 | } 42 | 43 | var isDialogBPresented: Bool { 44 | get { dialogB != nil } 45 | set {} 46 | } 47 | 48 | var popoverA: TransitionPopover? { 49 | get { .init(modal: modal, targets: [.popoverA]) } 50 | set {} 51 | } 52 | 53 | var popoverB: TransitionPopover? { 54 | get { .init(modal: modal, targets: [.popoverB]) } 55 | set {} 56 | } 57 | 58 | func openFirstSheet() { 59 | content?.openFirstSheet() 60 | } 61 | 62 | func openFirstFullScreenCover() { 63 | content?.openFirstFullScreenCover() 64 | } 65 | 66 | func openAlert() { 67 | content?.openAlert() 68 | } 69 | 70 | func openDialogA() { 71 | content?.openDialogA() 72 | } 73 | 74 | func openDialogB() { 75 | content?.openDialogB() 76 | } 77 | 78 | func openFirstPopoverA() { 79 | content?.openPopoverA() 80 | } 81 | 82 | func openFirstPopoverB() { 83 | content?.openPopoverB() 84 | } 85 | 86 | func openFirstPopoverAfter2Sec() { 87 | content?.openPopoverAfter2Sec() 88 | } 89 | 90 | func openFirstSheetAfter2Sec() { 91 | content?.openSheetAfter2Sec() 92 | } 93 | 94 | func openSecondSheet() { 95 | content?.openSecondSheet() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/Root/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RootView: View { 4 | @ObservedObject var presenter: RootPresenter 5 | 6 | var body: some View { 7 | VStack(spacing: 8) { 8 | Spacer().layoutPriority(1) 9 | Group { 10 | Text("Root") 11 | .font(.largeTitle) 12 | .padding() 13 | Button { 14 | presenter.openFirstSheet() 15 | } label: { 16 | Text("Open Sheet") 17 | .font(.title) 18 | } 19 | Button { 20 | presenter.openFirstSheetAfter2Sec() 21 | } label: { 22 | Text("Open Sheet After 2sec") 23 | .font(.title) 24 | } 25 | Button { 26 | presenter.openSecondSheet() 27 | } label: { 28 | Text("Open Second Sheet") 29 | .font(.title) 30 | } 31 | Button { 32 | presenter.openFirstFullScreenCover() 33 | } label: { 34 | Text("Open Full Screen Cover") 35 | .font(.title) 36 | } 37 | Button { 38 | presenter.openFirstPopoverA() 39 | } label: { 40 | Text("Open Popover A") 41 | .font(.title) 42 | } 43 | .popover(item: $presenter.popoverA) { 44 | $0.childView() 45 | } 46 | Button { 47 | presenter.openFirstPopoverB() 48 | } label: { 49 | Text("Open Popover B") 50 | .font(.title) 51 | } 52 | .popover(item: $presenter.popoverB) { 53 | $0.childView() 54 | } 55 | Button { 56 | presenter.openFirstPopoverAfter2Sec() 57 | } label: { 58 | Text("Open Popover After 2sec") 59 | .font(.title) 60 | } 61 | } 62 | Group { 63 | Button { 64 | presenter.openAlert() 65 | } label: { 66 | Text("Open Alert") 67 | .font(.title) 68 | } 69 | Button { 70 | presenter.openDialogA() 71 | } label: { 72 | Text("Open Confirmation Dialog A") 73 | .font(.title) 74 | } 75 | .confirmationDialog( 76 | presenter.dialogA, 77 | isPresented: $presenter.isDialogAPresented 78 | ) 79 | Button { 80 | presenter.openDialogB() 81 | } label: { 82 | Text("Open Confirmation Dialog B") 83 | .font(.title) 84 | } 85 | .confirmationDialog( 86 | presenter.dialogB, 87 | isPresented: $presenter.isDialogBPresented 88 | ) 89 | } 90 | Spacer(minLength: 100).layoutPriority(0) 91 | Group { 92 | SwapView { 93 | Menu("Menu") { 94 | Button("One") { 95 | print("Menu One Called") 96 | } 97 | Button("Two") { 98 | print("Menu Two Called") 99 | } 100 | } 101 | .font(.title) 102 | } 103 | SwapView { 104 | Picker("Picker", 105 | selection: $presenter.pickerSelected) { 106 | Text("Zero").tag(0) 107 | Text("One").tag(1) 108 | Text("Two").tag(2) 109 | } 110 | } 111 | } 112 | Spacer().layoutPriority(1) 113 | } 114 | .padding() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/Second/SecondContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | final class SecondContent: ModalContent { 5 | typealias ChildContent = EmptyChildContent 6 | typealias DialogTarget = EmptyTarget 7 | typealias ParentNode = ModalNode 8 | 9 | let node: ModalNode 10 | 11 | init(parentNode: ModalNode) { 12 | self.node = .init(parent: parentNode) 13 | } 14 | 15 | func makeBaseView() -> some View { 16 | SecondView(presenter: .init(content: self)) 17 | } 18 | } 19 | 20 | extension SecondContent { 21 | func close() { 22 | node.removeFromParent() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/Second/SecondPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | final class SecondPresenter: ObservableObject { 5 | private weak var content: SecondContent? 6 | private weak var rootContent: RootContent? 7 | 8 | init( 9 | content: SecondContent, 10 | rootContent: RootContent = .shared 11 | ) { 12 | self.content = content 13 | self.rootContent = rootContent 14 | } 15 | } 16 | 17 | extension SecondPresenter { 18 | func reopenSecondSheet() { 19 | rootContent?.openSecondSheet() 20 | } 21 | 22 | func openFirstPopoverA() { 23 | rootContent?.openPopoverA() 24 | } 25 | 26 | func close() { 27 | content?.close() 28 | } 29 | 30 | func closeAll() { 31 | rootContent?.node.remove() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ModalArchitecture/Contents/Second/SecondView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SecondView: View { 4 | @ObservedObject var presenter: SecondPresenter 5 | 6 | var body: some View { 7 | VStack(spacing: 8) { 8 | Text("Second") 9 | .font(.largeTitle) 10 | .padding() 11 | Button { 12 | presenter.reopenSecondSheet() 13 | } label: { 14 | Text("Reopen Second Sheet") 15 | .font(.title) 16 | } 17 | Button { 18 | presenter.openFirstPopoverA() 19 | } label: { 20 | Text("Open First Popover") 21 | .font(.title) 22 | } 23 | Button { 24 | presenter.close() 25 | } label: { 26 | Text("Close") 27 | .font(.title) 28 | } 29 | Button { 30 | presenter.closeAll() 31 | } label: { 32 | Text("Close All") 33 | .font(.title) 34 | } 35 | } 36 | .padding() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/AlertModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | extension View { 5 | func alert( 6 | _ dialog: TransitionDialog?, 7 | isPresented: Binding 8 | ) -> some View { 9 | alert( 10 | dialog?.title ?? "", 11 | isPresented: isPresented, 12 | presenting: dialog, 13 | actions: { dialog in 14 | ForEach(dialog.actions) { action in 15 | Button(role: action.role) { 16 | dialog.onAction(action) 17 | } label: { 18 | Text(action.buttonTitle) 19 | } 20 | } 21 | }, 22 | message: { dialog in 23 | Text("\(dialog.message)") 24 | .onAppear { dialog.onAppear() } 25 | .onDisappear { dialog.onDisappear() } 26 | } 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ConfirmationDialogModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | extension View { 5 | func confirmationDialog( 6 | _ dialog: TransitionDialog?, 7 | isPresented: Binding 8 | ) -> some View { 9 | confirmationDialog( 10 | dialog?.title ?? "", 11 | isPresented: isPresented, 12 | titleVisibility: (dialog?.title != nil) ? .visible : .hidden, 13 | presenting: dialog, 14 | actions: { dialog in 15 | ForEach(dialog.actions) { action in 16 | Button(role: action.role) { 17 | dialog.onAction(action) 18 | } label: { 19 | Text(action.buttonTitle) 20 | } 21 | } 22 | }, 23 | message: { dialog in 24 | Text("\(dialog.message)") 25 | .onAppear { dialog.onAppear() } 26 | .onDisappear { dialog.onDisappear() } 27 | } 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/DialogContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// AlertとConfirmation DialogのContent 5 | final class DialogContent: ModalContent { 6 | typealias ChildContent = EmptyChildContent 7 | typealias BaseView = EmptyView 8 | typealias DialogTarget = EmptyTarget 9 | typealias ParentNode = ModalNode 10 | 11 | let node: ModalNode> 12 | let value: ModalDialog 13 | let sleeper: Sleeping 14 | 15 | init(parentNode: ParentNode, 16 | value: ModalDialog, 17 | sleeper: Sleeping = Sleeper()) { 18 | self.node = .init(parent: parentNode) 19 | self.value = value 20 | self.sleeper = sleeper 21 | } 22 | 23 | func makeBaseView() -> EmptyView { fatalError() } 24 | 25 | func onAction(_ action: ModalDialogAction) { 26 | node.didAppear() 27 | 28 | let isAppeared = node.state.isAppeared 29 | 30 | node.didDisappear() 31 | 32 | if isAppeared { 33 | action.handler?() 34 | } 35 | } 36 | 37 | func onAppear() { 38 | sleeper.sleep(for: .milliseconds(500)) { [weak self] in 39 | self?.node.didAppear() 40 | } 41 | } 42 | 43 | func onDisappear() { 44 | node.didDisappear() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/EmptyChildContent.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// モーダルを表示しない階層で定義するための空の子のContent 4 | final class EmptyChildContent: ModalChildContent { 5 | var node: ModalChildNode { fatalError() } 6 | var target: EmptyTarget? { nil } 7 | func makeChildView() -> AnyView { fatalError() } 8 | } 9 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/EmptyParentNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// ルートの階層で定義するための、空の親のノード 4 | final class EmptyParentNode: ModalParentNode { 5 | static let shared: EmptyParentNode = .init() 6 | 7 | func remove(for id: ModalId) {} 8 | func childDidAppear(id: ModalId) {} 9 | func childDidDisappear(id: ModalId) {} 10 | func isChild(for id: ModalId) -> Bool { true } 11 | } 12 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/EmptyTarget.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Popoverなどを表示しない階層のための空のTarget 4 | enum EmptyTarget: ModalTarget {} 5 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/Modal.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// モーダルで表示する種類。子で表示するContentを保持する 4 | enum Modal { 5 | case sheet(Content.ChildContent) 6 | case fullScreenCover(Content.ChildContent) 7 | case popover(Content.ChildContent) 8 | case alert(DialogContent) 9 | case confirmationDialog(DialogContent) 10 | } 11 | 12 | @MainActor 13 | extension Modal { 14 | var childNode: ModalChildNode { 15 | switch self { 16 | case .sheet(let content), .fullScreenCover(let content), .popover(let content): 17 | return content.node 18 | case .alert(let content), .confirmationDialog(let content): 19 | return content.node 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ModalDialog.swift.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// AlertやConfirmation DialogのViewで扱うための値 4 | struct ModalDialog { 5 | let target: Target? 6 | let title: String? 7 | let message: String 8 | let actions: [ModalDialogAction] 9 | } 10 | 11 | struct ModalDialogAction: Identifiable { 12 | let id: ModalId = .init() 13 | let role: ButtonRole? 14 | let buttonTitle: String 15 | let handler: (() -> Void)? 16 | 17 | static func makeOkAction() -> ModalDialogAction { 18 | .init(role: nil, buttonTitle: "OK", handler: nil) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ModalId.swift: -------------------------------------------------------------------------------- 1 | /// モーダルの管理で使うID 2 | /// アプリ起動中にユニークであることが保証できれば良いのでclassをIDとしている 3 | final class ModalId {} 4 | 5 | extension ModalId: Hashable { 6 | static func == (lhs: ModalId, rhs: ModalId) -> Bool { 7 | ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 8 | } 9 | 10 | func hash(into hasher: inout Hasher) { 11 | hasher.combine(ObjectIdentifier(self)) 12 | } 13 | } 14 | 15 | extension ModalId: Identifiable {} 16 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ModalNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | /// モーダルの階層(ノード) 5 | /// 主にここで様々なモーダルの問題を対処する 6 | final class ModalNode: ModalParentNode, ModalChildNode { 7 | typealias State = ModalNodeState 8 | 9 | let id: ModalId = .init() 10 | 11 | /// 内部的に保持する状態 12 | private let stateSubject: CurrentValueSubject = .init(.appearing(reservedModal: nil)) 13 | private(set) var state: State { 14 | get { stateSubject.value } 15 | set { stateSubject.value = newValue } 16 | } 17 | 18 | /// Viewとバインディングするためのモーダルの状態 19 | var modal: Modal? { state.modal } 20 | var modalPublisher: AnyPublisher?, Never> { 21 | stateSubject 22 | .map(\.modal) 23 | .eraseToAnyPublisher() 24 | } 25 | 26 | private(set) weak var parent: Content.ParentNode? 27 | private let swapper: Swapper 28 | private let onceForAppearing: Once = .init() 29 | private let onceForDisappearing: Once = .init() 30 | private let onceForDialog: Once = .init() 31 | private let waiter: TickWaiting 32 | 33 | init(parent: Content.ParentNode, 34 | swapper: Swapper = .shared, 35 | waiter: TickWaiting = TickWaiter()) { 36 | self.parent = parent 37 | self.swapper = swapper 38 | self.waiter = waiter 39 | } 40 | 41 | /// 上の階層にモーダルを表示するために追加する 42 | func add(_ newModal: Modal) { 43 | switch state { 44 | case .appearing: 45 | // 自身が表示遷移中なので、開くモーダルを予約する 46 | state = .appearing(reservedModal: newModal) 47 | case .appeared: 48 | // 子のモーダルが表示されておらずアクティブな状態なので、モーダルを開き始める 49 | presentModal(newModal) 50 | case .childPresented(let modal): 51 | // 子のモーダルが表示されていてアクティブな状態なので、モーダルを閉じ始める 52 | dismissModal(modal: modal, reservedModal: newModal) 53 | case .childWaiting(let waitingId, _): 54 | // 子のモーダルが表示待機中なので、予約されたモーダルを入れ替える 55 | state = .childWaiting(waitingId: waitingId, reservedModal: newModal) 56 | case .childPresenting(let modal, _): 57 | // 子のモーダルが表示遷移中なので、次に開くモーダルを予約する 58 | state = .childPresenting(modal: modal, reserved: .add(newModal)) 59 | case .childDismissing(let modal, _): 60 | // 子のモーダルが非表示遷移中なので、次に開くモーダルを予約する 61 | state = .childDismissing(modal: modal, reservedModal: newModal) 62 | case .disappearing: 63 | // すでに自身が非表示になっているので何もしない 64 | break 65 | } 66 | } 67 | 68 | /// モーダルのIDを指定して閉じる 69 | func remove(for id: ModalId) { 70 | switch state { 71 | case .appearing(let reservedModal), .childDismissing(_, let reservedModal): 72 | if reservedModal?.childNode.id == id { 73 | remove() 74 | } 75 | case .childWaiting(_, let modal), .childPresented(let modal): 76 | if modal.childNode.id == id { 77 | remove() 78 | } 79 | case .childPresenting(let modal, let reserved): 80 | // 表示中のモーダルが一致しても、次のアクションが予約されていればすでに閉じられた扱いなので無視する 81 | if modal.childNode.id == id && reserved.isNone { 82 | remove() 83 | } else if reserved.isChild(for: id) { 84 | remove() 85 | } 86 | case .appeared, .disappearing: 87 | break 88 | } 89 | } 90 | 91 | /// 表示中のモーダルを閉じる 92 | func remove() { 93 | switch state { 94 | case .appearing: 95 | // 自身が表示遷移中なので、予約されたモーダルを削除する 96 | state = .appearing(reservedModal: nil) 97 | case .childPresented(let modal): 98 | // 子のモーダルが表示されてアクティブな状態なので閉じ始める 99 | dismissModal(modal: modal, reservedModal: nil) 100 | case .childWaiting: 101 | // 子のモーダルが表示待機中なので、中断してアクティブな状態に戻す 102 | state = .appeared 103 | case .childPresenting(let modal, _): 104 | // 子のモーダルが表示遷移中なので、閉じる予約をする 105 | state = .childPresenting(modal: modal, reserved: .remove) 106 | case .childDismissing(let modal, _): 107 | // 子のモーダルが非表示遷移中なので、次に開く予約をされたモーダルを削除する 108 | state = .childDismissing(modal: modal, reservedModal: nil) 109 | case .appeared, .disappearing: 110 | // appeared -> モーダルが表示されていないので何もしない 111 | // disappearing -> すでに自身が非表示になっているので何もしない 112 | break 113 | } 114 | } 115 | 116 | /// 親から自身のモーダルを閉じる 117 | func removeFromParent() { 118 | parent?.remove(for: id) 119 | } 120 | 121 | /// 親から自身が削除されたら呼ばれ、非表示状態にする 122 | func didRemoveFromParent() { 123 | // 子孫も全て非表示状態にする 124 | modal?.childNode.didRemoveFromParent() 125 | state = .disappearing(modal: modal) 126 | } 127 | 128 | /// 自身を表示する遷移が終わったら呼ばれる 129 | func didAppear() { 130 | onceForAppearing.perform { 131 | // 親に遷移が終わったことを伝えて、親の状態を更新する 132 | parent?.childDidAppear(id: id) 133 | 134 | // まだ親にとって子であるなら、自身の状態を更新する 135 | if parent?.isChild(for: id) ?? false { 136 | switch state { 137 | case .appearing(let reservedModal): 138 | if let reservedModal { 139 | presentModal(reservedModal) 140 | } else { 141 | state = .appeared 142 | } 143 | case .disappearing: 144 | break 145 | case .appeared, .childWaiting, .childPresenting, .childPresented, .childDismissing: 146 | assertionFailure() 147 | } 148 | } 149 | } 150 | } 151 | 152 | /// 自身を非表示にする遷移が終わったら呼ばれる 153 | func didDisappear() { 154 | onceForDisappearing.perform { 155 | removeFromParent() 156 | 157 | parent?.childDidDisappear(id: id) 158 | } 159 | } 160 | 161 | /// 子のモーダルが表示する遷移が終わったら呼ばれる 162 | func childDidAppear(id childId: ModalId) { 163 | if isChild(for: childId) { 164 | switch state { 165 | case .childPresenting(let modal, let reserved): 166 | switch reserved { 167 | case .add(let reservedModal): 168 | dismissModal(modal: modal, reservedModal: reservedModal) 169 | case .remove: 170 | dismissModal(modal: modal, reservedModal: nil) 171 | case .none: 172 | state = .childPresented(modal: modal) 173 | } 174 | case .appeared, .childPresented, .childDismissing, .disappearing: 175 | break 176 | case .appearing, .childWaiting: 177 | assertionFailure() 178 | } 179 | } 180 | } 181 | 182 | /// 子のモーダルが非表示する遷移が終わったら呼ばれる 183 | func childDidDisappear(id childId: ModalId) { 184 | if case .childDismissing(let modal, let reservedModal) = state, 185 | modal.childNode.id == childId { 186 | if let reservedModal { 187 | presentModal(reservedModal) 188 | } else { 189 | state = .appeared 190 | } 191 | } 192 | } 193 | 194 | /// 表示中の子のモーダルとidが一致するか確認する 195 | func isChild(for childId: ModalId) -> Bool { 196 | state.modal?.childNode.id == childId 197 | } 198 | 199 | /// 子のモーダルを表示する遷移を開始する 200 | /// 開始して良い状態かは呼び出す前にチェックされている前提 201 | private func presentModal(_ newModal: Modal) { 202 | assert(modal == nil) 203 | 204 | swapper.swap() 205 | 206 | let id = ModalId() 207 | 208 | state = .childWaiting(waitingId: id, reservedModal: newModal) 209 | 210 | waiter.wait { [weak self] in 211 | // MenuやAlertなどを閉じてすぐ遷移できないことがあるので遅らせる 212 | if let self, 213 | case .childWaiting(let waitingId, let reservedModal) = self.state, 214 | waitingId == id { 215 | self.state = .childPresenting(modal: reservedModal, reserved: .none) 216 | } 217 | } 218 | } 219 | 220 | /// 子のモーダルを非表示にする遷移を開始する 221 | /// 開始して良い状態かは呼び出す前にチェックされている前提 222 | private func dismissModal(modal: Modal, reservedModal: Modal?) { 223 | state = .childDismissing(modal: modal, reservedModal: reservedModal) 224 | modal.childNode.didRemoveFromParent() 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ModalNodeState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// モーダルのノードで内部的に保持する状態 4 | enum ModalNodeState { 5 | /// 自身が親から開かれる遷移中の状態 6 | case appearing(reservedModal: Modal?) 7 | /// 自身が開かれる遷移が終わり、子のモーダルも表示していない状態。自身の階層で操作ができる 8 | case appeared 9 | /// 子のモーダルを開き始めて、まだViewには反映していない状態。modalが入れ替わっても良いように待ちのidを別で保持している 10 | case childWaiting(waitingId: ModalId, reservedModal: Modal) 11 | /// 子のモーダルを開く遷移中の状態 12 | case childPresenting(modal: Modal, reserved: ModalReserved) 13 | /// 子のモーダルが表示されアクティブな状態。孫が表示されている可能性はある 14 | case childPresented(modal: Modal) 15 | /// このモーダルを閉じる遷移中の状態 16 | case childDismissing(modal: Modal, reservedModal: Modal?) 17 | /// 自身が閉じられる遷移中の状態。モーダルが表示されていたらそのまま残して余計な遷移をしないようにする 18 | case disappearing(modal: Modal?) 19 | } 20 | 21 | extension ModalNodeState { 22 | /// Viewに反映するモーダルの状態 23 | var modal: Modal? { 24 | switch self { 25 | case .disappearing(let modal): 26 | return modal 27 | case .childPresented(let modal): 28 | return modal 29 | case .childWaiting: 30 | return nil 31 | case .childPresenting(let modal, _): 32 | return modal 33 | case .appeared, .appearing, .childDismissing: 34 | return nil 35 | } 36 | } 37 | 38 | var isAppeared: Bool { 39 | switch self { 40 | case .appeared: 41 | return true 42 | case .appearing, .childWaiting, .childPresenting, .childPresented, .childDismissing, .disappearing: 43 | return false 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ModalProtocols.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SwiftUI 4 | 5 | /// 親のノードに必要なインターフェース 6 | protocol ModalParentNode: AnyObject { 7 | func remove(for id: ModalId) 8 | func childDidAppear(id: ModalId) 9 | func childDidDisappear(id: ModalId) 10 | func isChild(for id: ModalId) -> Bool 11 | } 12 | 13 | /// Sheetなどのモーダルの内容に必要なインターフェース 14 | @MainActor 15 | protocol ModalContent: AnyObject { 16 | associatedtype ChildContent: ModalChildContent 17 | associatedtype BaseView: View 18 | associatedtype DialogTarget: ModalTarget 19 | associatedtype ParentNode: ModalParentNode 20 | 21 | var node: ModalNode { get } 22 | func makeBaseView() -> BaseView 23 | } 24 | 25 | extension ModalContent { 26 | var id: ModalId { node.id } 27 | } 28 | 29 | /// モーダルの子のノードに必要なインターフェース 30 | @MainActor 31 | protocol ModalChildNode { 32 | var id: ModalId { get } 33 | func didRemoveFromParent() 34 | } 35 | 36 | /// 子のモーダルの内容で必要なインターフェース 37 | @MainActor 38 | protocol ModalChildContent: AnyObject { 39 | associatedtype Target: ModalTarget 40 | 41 | var node: any ModalChildNode { get } 42 | var target: Target? { get } 43 | func makeChildView() -> AnyView 44 | } 45 | 46 | /// PopoverやDialogなどで指し示す先の対象 47 | protocol ModalTarget: Equatable { 48 | } 49 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/ModalReserved.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// モーダルの変更を一時的に予約して保持する 4 | enum ModalReserved { 5 | case add(Modal) 6 | case remove 7 | case none 8 | } 9 | 10 | @MainActor 11 | extension ModalReserved { 12 | var isNone: Bool { 13 | switch self { 14 | case .none: 15 | return true 16 | case .add, .remove: 17 | return false 18 | } 19 | } 20 | 21 | func isChild(for id: ModalId) -> Bool { 22 | switch self { 23 | case .add(let modal): 24 | return modal.childNode.id == id 25 | case .remove, .none: 26 | return false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/TransitionDialog.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// AlertやConfirmation Dialogの表示に必要なデータを提供する 4 | @MainActor 5 | final class TransitionDialog: Identifiable { 6 | let id: ModalId 7 | var title: String { content?.value.title ?? "" } 8 | var message: String { content?.value.message ?? "" } 9 | var actions: [ModalDialogAction] { 10 | if let actions = content?.value.actions, !actions.isEmpty { 11 | return actions 12 | } else { 13 | return [ModalDialogAction.makeOkAction()] 14 | } 15 | } 16 | 17 | private weak var content: DialogContent? 18 | 19 | /// 表示対象のないAlert用のイニシャライザ 20 | init(content: DialogContent) { 21 | self.id = content.node.id 22 | self.content = content 23 | } 24 | 25 | /// 表示対象のあるConfirmationDialog用のイニシャライザ 26 | init?(content: DialogContent, targets: [Content.DialogTarget]) { 27 | if let target = content.value.target, targets.contains(target) { 28 | self.id = content.node.id 29 | self.content = content 30 | } else { 31 | return nil 32 | } 33 | } 34 | 35 | func onAction(_ action: ModalDialogAction) { 36 | content?.onAction(action) 37 | content = nil 38 | } 39 | 40 | func onAppear() { 41 | content?.onAppear() 42 | } 43 | 44 | func onDisappear() { 45 | content?.onDisappear() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/TransitionPopover.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Popoverの表示に必要なデータを提供する 4 | @MainActor 5 | final class TransitionPopover: Identifiable { 6 | let id: ModalId 7 | private weak var content: Content.ChildContent? 8 | 9 | init?(modal: Modal?, targets: [Content.ChildContent.Target]) { 10 | if case .popover(let childContent) = modal, 11 | let target = childContent.target, 12 | targets.contains(target) { 13 | self.id = childContent.node.id 14 | self.content = childContent 15 | } else { 16 | return nil 17 | } 18 | } 19 | 20 | func childView() -> AnyView { 21 | guard let view = content?.makeChildView() else { 22 | print("Child Popover Missing.") 23 | return AnyView(EmptyView()) 24 | } 25 | return view 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/TransitionPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SwiftUI 4 | 5 | // swiftlint:disable unused_setter_value 6 | 7 | /// TransitionViewに必要なデータを提供する 8 | @MainActor 9 | final class TransitionPresenter: ObservableObject { 10 | @Published private var modal: Modal? 11 | 12 | private weak var content: Content? 13 | 14 | private(set) lazy var baseView: Content.BaseView? = { 15 | return content?.makeBaseView() 16 | }() 17 | 18 | init(content: Content) { 19 | self.content = content 20 | 21 | content.node 22 | .modalPublisher 23 | .assign(to: &$modal) 24 | } 25 | 26 | var sheetId: ModalId? { 27 | get { 28 | guard case .sheet(let content) = modal else { 29 | return nil 30 | } 31 | return content.node.id 32 | } 33 | set {} 34 | } 35 | 36 | var fullScreenCoverId: ModalId? { 37 | get { 38 | guard case .fullScreenCover(let content) = modal else { 39 | return nil 40 | } 41 | return content.node.id 42 | } 43 | set {} 44 | } 45 | 46 | var alert: TransitionDialog? { 47 | guard case .alert(let content) = modal else { 48 | return nil 49 | } 50 | return .init(content: content) 51 | } 52 | 53 | var alertTitle: String { 54 | return alert?.title ?? "" 55 | } 56 | 57 | var isAlertPresented: Bool { 58 | get { alert != nil } 59 | set {} 60 | } 61 | 62 | func didAppear() { 63 | content?.node.didAppear() 64 | } 65 | 66 | func didDisappear() { 67 | content?.node.didDisappear() 68 | } 69 | 70 | func sheetChildView(_ childId: ModalId) -> AnyView { 71 | guard case .sheet(let childContent) = modal, 72 | childContent.node.id == childId else { 73 | print("Child Sheet Missing. \(self.self)") 74 | return AnyView(EmptyView()) 75 | } 76 | 77 | return childContent.makeChildView() 78 | } 79 | 80 | func fullScreenCoverChildView(_ childId: ModalId) -> AnyView { 81 | guard case .fullScreenCover(let childContent) = modal, 82 | childContent.node.id == childId else { 83 | print("Child FullScreenCover Missing. \(self.self)") 84 | return AnyView(EmptyView()) 85 | } 86 | 87 | return childContent.makeChildView() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ModalArchitecture/Framework/TransitionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// モーダルの階層の中で、表示する対象が画面全体の種類のモーダル遷移を行うView 4 | struct TransitionView: View { 5 | @ObservedObject var presenter: TransitionPresenter 6 | 7 | var body: some View { 8 | presenter.baseView 9 | .didAppear { 10 | presenter.didAppear() 11 | } 12 | .onDisappear { 13 | presenter.didDisappear() 14 | } 15 | .sheet( 16 | item: $presenter.sheetId, 17 | content: { sheetId in 18 | presenter.sheetChildView(sheetId) 19 | }) 20 | .fullScreenCover( 21 | item: $presenter.fullScreenCoverId, 22 | content: { fullScreenCoverId in 23 | presenter.fullScreenCoverChildView(fullScreenCoverId) 24 | }) 25 | .alert( 26 | presenter.alert, 27 | isPresented: $presenter.isAlertPresented 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ModalArchitecture/ModalArchitectureApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | let isTesting = NSClassFromString("XCTestCase") != nil 4 | 5 | @main 6 | struct ModalArchitectureApp: App { 7 | var body: some Scene { 8 | WindowGroup { 9 | if !isTesting { 10 | TransitionView( 11 | presenter: .init(content: RootContent.shared) 12 | ) 13 | } else { 14 | EmptyView() 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ModalArchitecture/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ModalArchitecture/Utils/DidAppearModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | /// モーダル表示の遷移が終わったタイミングを取得する 5 | func didAppear(_ action: @escaping () -> Void) -> some View { 6 | ZStack { 7 | DidAppearView(action: action) 8 | self 9 | } 10 | } 11 | } 12 | 13 | /// ViewControllerをラップしてviewDidAppearのタイミングを取得できるようにしたView 14 | struct DidAppearView: UIViewControllerRepresentable { 15 | let action: () -> Void 16 | 17 | func makeUIViewController(context: Context) -> DidAppearViewController { 18 | let viewController = DidAppearViewController() 19 | viewController.action = action 20 | return viewController 21 | } 22 | 23 | func updateUIViewController(_ uiViewController: DidAppearViewController, context: Context) {} 24 | func makeCoordinator() -> Coordinator { Coordinator() } 25 | class Coordinator {} 26 | } 27 | 28 | final class DidAppearViewController: UIViewController { 29 | var action: (() -> Void)? 30 | 31 | override func viewDidAppear(_ animated: Bool) { 32 | super.viewDidAppear(animated) 33 | 34 | // 最初の1回だけ実行する 35 | action?() 36 | action = nil 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ModalArchitecture/Utils/Once.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 処理を1回だけ実行するためのクラス 4 | final class Once { 5 | private var isPerformed: Bool = false 6 | 7 | func perform(_ action: () -> Void) { 8 | if !isPerformed { 9 | isPerformed = true 10 | action() 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ModalArchitecture/Utils/Sleeper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Sleeping { 4 | func sleep(for duration: Duration, 5 | completion: @escaping () -> Void) 6 | } 7 | 8 | /// 時間を指定して処理を遅延させる 9 | struct Sleeper: Sleeping { 10 | func sleep(for duration: Duration, 11 | completion: @escaping () -> Void) { 12 | Task { 13 | try? await Task.sleep(for: duration) 14 | assert(!Thread.isMainThread) 15 | await MainActor.run { 16 | assert(Thread.isMainThread) 17 | completion() 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ModalArchitecture/Utils/SwapView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Menuなどを強制的に閉じるためのView 4 | struct SwapView: View { 5 | @ObservedObject private var swapper: Swapper = .shared 6 | let content: () -> Content 7 | 8 | init(@ViewBuilder content: @escaping () -> Content) { 9 | self.content = content 10 | } 11 | 12 | var body: some View { 13 | if swapper.flag { 14 | content() 15 | } else { 16 | content() 17 | } 18 | } 19 | } 20 | 21 | final class Swapper: ObservableObject { 22 | static let shared: Swapper = .init() 23 | 24 | @Published private(set) var flag: Bool = false 25 | 26 | func swap() { 27 | flag.toggle() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ModalArchitecture/Utils/TickWaiter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import QuartzCore 3 | 4 | protocol TickWaiting { 5 | func wait(_ completion: @escaping () -> Void) 6 | } 7 | 8 | /// UIの定期実行のタイミングを待って処理を実行する 9 | struct TickWaiter: TickWaiting { 10 | func wait(_ completion: @escaping () -> Void) { 11 | _ = TickStepper(completion) 12 | } 13 | } 14 | 15 | private final class TickStepper { 16 | private let completion: () -> Void 17 | private var ticks: Int = 0 18 | private var displayLink: CADisplayLink! 19 | 20 | fileprivate init(_ handler: @escaping () -> Void) { 21 | self.completion = handler 22 | 23 | displayLink = CADisplayLink(target: self, 24 | selector: #selector(step)) 25 | displayLink.add(to: .current, 26 | forMode: .common) 27 | } 28 | 29 | @objc private func step(displaylink: CADisplayLink) { 30 | ticks += 1 31 | if ticks >= 2 { 32 | displayLink.invalidate() 33 | completion() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ModalArchitectureTests/ModalNodeTests.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable file_length type_body_length 2 | 3 | import XCTest 4 | import SwiftUI 5 | import Combine 6 | @testable import ModalArchitecture 7 | 8 | final class TickWaiterMock: TickWaiting { 9 | private var completion: (() -> Void)? 10 | 11 | func wait(_ completion: @escaping () -> Void) { 12 | self.completion = completion 13 | } 14 | 15 | func resume() -> Bool { 16 | if let completion { 17 | completion() 18 | self.completion = nil 19 | return true 20 | } else { 21 | return false 22 | } 23 | } 24 | } 25 | 26 | final class SleeperMock: Sleeping { 27 | private var completion: (() -> Void)? 28 | 29 | func sleep(for duration: Duration, completion: @escaping () -> Void) { 30 | self.completion = completion 31 | } 32 | 33 | func resume() -> Bool { 34 | if let completion { 35 | completion() 36 | self.completion = nil 37 | return true 38 | } else { 39 | return false 40 | } 41 | } 42 | } 43 | 44 | private enum ExpectedReserved { 45 | case add(ModalId) 46 | case none 47 | case ignore 48 | } 49 | 50 | private enum ExpectedReservedWithRemove { 51 | case add(ModalId) 52 | case remove 53 | case none 54 | case ignore 55 | } 56 | 57 | private enum ExpectedState { 58 | case appearing(reserved: ExpectedReserved) 59 | case appeared 60 | case childWaiting(id: ModalId) 61 | case childPresenting(id: ModalId, reserved: ExpectedReservedWithRemove) 62 | case childPresented(id: ModalId) 63 | case childDismissing(id: ModalId, reserved: ExpectedReserved) 64 | case disappearing 65 | } 66 | 67 | @MainActor 68 | final class ModalNodeTests: XCTestCase { 69 | var waiter: TickWaiterMock! 70 | var sleeper: SleeperMock! 71 | 72 | override func setUp() { 73 | super.setUp() 74 | 75 | waiter = .init() 76 | sleeper = .init() 77 | } 78 | 79 | override func tearDown() { 80 | waiter = nil 81 | sleeper = nil 82 | 83 | super.tearDown() 84 | } 85 | 86 | func test_didAppearが呼ばれるとaddしたmodalが反映される() { 87 | let rootNode = makeRootNode() 88 | 89 | XCTAssertNil(rootNode.modal) 90 | 91 | let firstContent = FirstContent(parentNode: rootNode) 92 | rootNode.add(.sheet(.init(.first(firstContent)))) 93 | 94 | XCTAssertNil(rootNode.modal) 95 | XCTAssertTrue(isMatch(rootNode.state, .appearing(reserved: .add(firstContent.id)))) 96 | 97 | rootNode.didAppear() 98 | 99 | XCTAssertNil(rootNode.modal) 100 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContent.id))) 101 | 102 | XCTAssertTrue(waiter.resume()) 103 | 104 | XCTAssertNotNil(rootNode.modal) 105 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContent.id, reserved: .none))) 106 | } 107 | 108 | func test_addしてもdidAppearが呼ばれるまでにremoveしたらmodalはnilで待機() { 109 | let rootNode = makeRootNode() 110 | 111 | XCTAssertNil(rootNode.modal) 112 | 113 | let firstContent = FirstContent(parentNode: rootNode) 114 | rootNode.add(.sheet(.init(.first(firstContent)))) 115 | rootNode.remove() 116 | rootNode.didAppear() 117 | 118 | XCTAssertFalse(waiter.resume()) 119 | 120 | XCTAssertNil(rootNode.modal) 121 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 122 | } 123 | 124 | func test_didAppear後には即座にaddできる() { 125 | let rootNode = makeRootNode() 126 | rootNode.didAppear() 127 | 128 | XCTAssertNil(rootNode.modal) 129 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 130 | 131 | let firstContent = FirstContent(parentNode: rootNode) 132 | rootNode.add(.sheet(.init(.first(firstContent)))) 133 | 134 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContent.id))) 135 | 136 | XCTAssertTrue(waiter.resume()) 137 | 138 | XCTAssertNotNil(rootNode.modal) 139 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContent.id, reserved: .none))) 140 | } 141 | 142 | func test_didAppear後にaddしてもmodalに反映される前にremoveしたらmodalはnilで待機() { 143 | let rootNode = makeRootNode() 144 | rootNode.didAppear() 145 | 146 | XCTAssertNil(rootNode.modal) 147 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 148 | 149 | let firstContent = FirstContent(parentNode: rootNode) 150 | rootNode.add(.sheet(.init(.first(firstContent)))) 151 | 152 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContent.id))) 153 | 154 | rootNode.remove() 155 | 156 | XCTAssertTrue(waiter.resume()) 157 | 158 | XCTAssertNil(rootNode.modal) 159 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 160 | } 161 | 162 | func test_addした後のremoveは子のdidAppearが呼ばれると反映される() { 163 | let rootNode = makeRootNode() 164 | rootNode.didAppear() 165 | let firstContent = FirstContent(parentNode: rootNode) 166 | 167 | rootNode.add(.sheet(.init(.first(firstContent)))) 168 | 169 | XCTAssertNil(rootNode.modal) 170 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContent.id))) 171 | 172 | XCTAssertTrue(waiter.resume()) 173 | 174 | XCTAssertNotNil(rootNode.modal) 175 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContent.id, reserved: .none))) 176 | 177 | rootNode.remove() 178 | 179 | XCTAssertNotNil(rootNode.modal) 180 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContent.id, reserved: .remove))) 181 | 182 | firstContent.node.didAppear() 183 | 184 | XCTAssertNil(rootNode.modal) 185 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContent.id, reserved: .none))) 186 | } 187 | 188 | func test_addした後のaddは子のdidAppearが呼ばれるとdismissされてからpresentされる() { 189 | let rootNode = makeRootNode() 190 | rootNode.didAppear() 191 | 192 | let firstContentA = FirstContent(parentNode: rootNode) 193 | rootNode.add(.sheet(.init(.first(firstContentA)))) 194 | 195 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentA.id))) 196 | 197 | XCTAssertTrue(waiter.resume()) 198 | 199 | XCTAssertNotNil(rootNode.modal) 200 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .none))) 201 | 202 | let firstContentB = FirstContent(parentNode: rootNode) 203 | rootNode.add(.sheet(.init(.first(firstContentB)))) 204 | 205 | XCTAssertNotNil(rootNode.modal) 206 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .add(firstContentB.id)))) 207 | 208 | firstContentA.node.didAppear() 209 | 210 | XCTAssertNil(rootNode.modal) 211 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 212 | 213 | firstContentA.node.didDisappear() 214 | 215 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentB.id))) 216 | 217 | XCTAssertTrue(waiter.resume()) 218 | 219 | XCTAssertNotNil(rootNode.modal) 220 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentB.id, reserved: .none))) 221 | } 222 | 223 | func test_addしてmodalが反映される前にaddしたら入れ替わる() { 224 | let rootNode = makeRootNode() 225 | rootNode.didAppear() 226 | 227 | let firstContentA = FirstContent(parentNode: rootNode) 228 | rootNode.add(.sheet(.init(.first(firstContentA)))) 229 | 230 | XCTAssertNil(rootNode.modal) 231 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentA.id))) 232 | 233 | let firstContentB = FirstContent(parentNode: rootNode) 234 | rootNode.add(.sheet(.init(.first(firstContentB)))) 235 | 236 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentB.id))) 237 | 238 | XCTAssertTrue(waiter.resume()) 239 | 240 | XCTAssertNotNil(rootNode.modal) 241 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentB.id, reserved: .none))) 242 | 243 | firstContentB.node.didAppear() 244 | 245 | XCTAssertNotNil(rootNode.modal) 246 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: firstContentB.id))) 247 | } 248 | 249 | func test_addされ子のdidAppearが呼ばれたら即座にremoveできる() { 250 | let rootNode = makeRootNode() 251 | rootNode.didAppear() 252 | let firstContent = FirstContent(parentNode: rootNode) 253 | let firstNode = firstContent.node 254 | 255 | rootNode.add(.sheet(.init(.first(firstContent)))) 256 | 257 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContent.id))) 258 | 259 | XCTAssertTrue(waiter.resume()) 260 | 261 | firstNode.didAppear() 262 | 263 | XCTAssertNotNil(rootNode.modal) 264 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: firstContent.id))) 265 | 266 | rootNode.remove() 267 | 268 | XCTAssertNil(rootNode.modal) 269 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContent.id, reserved: .none))) 270 | 271 | firstNode.didDisappear() 272 | 273 | // 完全に子がdismissされたら解放される 274 | XCTAssertNil(rootNode.modal) 275 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 276 | } 277 | 278 | func test_addされ子のdidAppearが呼ばれたら即座にaddできる() { 279 | let rootNode = makeRootNode() 280 | rootNode.didAppear() 281 | 282 | let firstContentA = FirstContent(parentNode: rootNode) 283 | let firstNodeA = firstContentA.node 284 | rootNode.add(.sheet(.init(.first(firstContentA)))) 285 | 286 | XCTAssertTrue(waiter.resume()) 287 | 288 | firstNodeA.didAppear() 289 | 290 | XCTAssertNotNil(rootNode.modal) 291 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: firstContentA.id))) 292 | 293 | let firstContentB = FirstContent(parentNode: rootNode) 294 | rootNode.add(.sheet(.init(.first(firstContentB)))) 295 | 296 | XCTAssertNil(rootNode.modal) 297 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 298 | 299 | firstNodeA.didDisappear() 300 | 301 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentB.id))) 302 | 303 | XCTAssertTrue(waiter.resume()) 304 | 305 | XCTAssertNotNil(rootNode.modal) 306 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentB.id, reserved: .none))) 307 | } 308 | 309 | func test_子がremoveされた後のaddは子のdidDisappearが呼ばれると反映される() { 310 | let rootNode = makeRootNode() 311 | rootNode.didAppear() 312 | 313 | let firstContentA = FirstContent(parentNode: rootNode) 314 | let firstNodeA = firstContentA.node 315 | rootNode.add(.sheet(.init(.first(firstContentA)))) 316 | 317 | XCTAssertTrue(waiter.resume()) 318 | 319 | firstNodeA.didAppear() 320 | rootNode.remove() 321 | 322 | XCTAssertNil(rootNode.modal) 323 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .none))) 324 | 325 | let firstContentB = FirstContent(parentNode: rootNode) 326 | rootNode.add(.sheet(.init(.first(firstContentB)))) 327 | 328 | XCTAssertNil(rootNode.modal) 329 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 330 | 331 | firstNodeA.didDisappear() 332 | 333 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentB.id))) 334 | 335 | XCTAssertTrue(waiter.resume()) 336 | 337 | XCTAssertNotNil(rootNode.modal) 338 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentB.id, reserved: .none))) 339 | } 340 | 341 | func test_子のdismiss中にaddされてもremoveするとdidDisappearが呼ばれたらmodalはnilで待機() { 342 | let rootNode = makeRootNode() 343 | rootNode.didAppear() 344 | 345 | let firstContentA = FirstContent(parentNode: rootNode) 346 | let firstNodeA = firstContentA.node 347 | rootNode.add(.sheet(.init(.first(firstContentA)))) 348 | 349 | XCTAssertTrue(waiter.resume()) 350 | 351 | firstNodeA.didAppear() 352 | rootNode.remove() 353 | 354 | XCTAssertNil(rootNode.modal) 355 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .none))) 356 | 357 | let firstContentB = FirstContent(parentNode: rootNode) 358 | rootNode.add(.sheet(.init(.first(firstContentB)))) 359 | 360 | XCTAssertNil(rootNode.modal) 361 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 362 | 363 | rootNode.remove() 364 | 365 | XCTAssertNil(rootNode.modal) 366 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .none))) 367 | 368 | firstNodeA.didDisappear() 369 | 370 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 371 | 372 | XCTAssertFalse(waiter.resume()) 373 | 374 | XCTAssertNil(rootNode.modal) 375 | } 376 | 377 | func test_dialogの遷移_onAppearが呼ばれた後にonDisappearが呼ばれ閉じる() { 378 | let rootNode = makeRootNode() 379 | 380 | let dialogContent = DialogContent( 381 | parentNode: rootNode, 382 | value: .init( 383 | target: nil, 384 | title: "title", 385 | message: "message", 386 | actions: [] 387 | ), sleeper: sleeper) 388 | rootNode.add(.alert(dialogContent)) 389 | 390 | XCTAssertNil(rootNode.modal) 391 | XCTAssertTrue(isMatch(rootNode.state, .appearing(reserved: .add(dialogContent.id)))) 392 | 393 | rootNode.didAppear() 394 | 395 | XCTAssertNil(rootNode.modal) 396 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: dialogContent.id))) 397 | 398 | XCTAssertTrue(waiter.resume()) 399 | 400 | XCTAssertNotNil(rootNode.modal) 401 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: dialogContent.id, reserved: .none))) 402 | 403 | dialogContent.onAppear() 404 | 405 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: dialogContent.id, reserved: .none))) 406 | 407 | XCTAssertTrue(sleeper.resume()) 408 | 409 | XCTAssertNotNil(rootNode.modal) 410 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: dialogContent.id))) 411 | 412 | dialogContent.onDisappear() 413 | 414 | XCTAssertNil(rootNode.modal) 415 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 416 | } 417 | 418 | func test_dialogの遷移_onAppearの前にonDisappearが呼ばれても閉じられる() { 419 | let rootNode = makeRootNode() 420 | 421 | let dialogContent = DialogContent( 422 | parentNode: rootNode, 423 | value: .init( 424 | target: nil, 425 | title: "title", 426 | message: "message", 427 | actions: [] 428 | ), sleeper: sleeper) 429 | rootNode.add(.alert(dialogContent)) 430 | 431 | XCTAssertNil(rootNode.modal) 432 | XCTAssertTrue(isMatch(rootNode.state, .appearing(reserved: .add(dialogContent.id)))) 433 | 434 | rootNode.didAppear() 435 | 436 | XCTAssertNil(rootNode.modal) 437 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: dialogContent.id))) 438 | 439 | XCTAssertTrue(waiter.resume()) 440 | 441 | XCTAssertNotNil(rootNode.modal) 442 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: dialogContent.id, reserved: .none))) 443 | 444 | dialogContent.onDisappear() 445 | 446 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: dialogContent.id, reserved: .remove))) 447 | 448 | dialogContent.onAppear() 449 | 450 | XCTAssertNotNil(rootNode.modal) 451 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: dialogContent.id, reserved: .remove))) 452 | 453 | XCTAssertTrue(sleeper.resume()) 454 | 455 | XCTAssertNil(rootNode.modal) 456 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: dialogContent.id, reserved: .none))) 457 | } 458 | 459 | func test_dialogの遷移_onActionが呼ばれonAppearを待たずに閉じる() { 460 | let rootNode = makeRootNode() 461 | 462 | var isHandlerCalled: Bool = false 463 | let handler: () -> Void = { 464 | isHandlerCalled = true 465 | } 466 | 467 | let dialogContent = DialogContent( 468 | parentNode: rootNode, 469 | value: .init( 470 | target: nil, 471 | title: "title", 472 | message: "message", 473 | actions: [.init(role: .none, buttonTitle: "", handler: handler)] 474 | ), sleeper: sleeper) 475 | let dialogNode = dialogContent.node 476 | 477 | rootNode.add(.alert(dialogContent)) 478 | 479 | XCTAssertTrue(isMatch(dialogContent.node.state, .appearing(reserved: .none))) 480 | 481 | rootNode.didAppear() 482 | 483 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: dialogNode.id))) 484 | 485 | XCTAssertTrue(waiter.resume()) 486 | 487 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: dialogNode.id, reserved: .none))) 488 | 489 | dialogContent.onAction(dialogContent.value.actions[0]) 490 | 491 | XCTAssertTrue(isHandlerCalled) 492 | XCTAssertTrue(isMatch(dialogContent.node.state, .disappearing)) 493 | } 494 | 495 | func test_dialog_すでに閉じられていたらボタンのアクションは実行されない() { 496 | let rootNode = makeRootNode() 497 | 498 | var isHandlerCalled: Bool = false 499 | let handler: () -> Void = { 500 | isHandlerCalled = true 501 | } 502 | 503 | let dialogContent = DialogContent( 504 | parentNode: rootNode, 505 | value: .init( 506 | target: nil, 507 | title: "title", 508 | message: "message", 509 | actions: [.init(role: .none, buttonTitle: "", handler: handler)] 510 | ), sleeper: sleeper) 511 | 512 | rootNode.didAppear() 513 | rootNode.add(.alert(dialogContent)) 514 | 515 | XCTAssertTrue(waiter.resume()) 516 | 517 | dialogContent.onAppear() 518 | 519 | XCTAssertTrue(sleeper.resume()) 520 | 521 | dialogContent.onDisappear() 522 | 523 | XCTAssertTrue(isMatch(dialogContent.node.state, .disappearing)) 524 | 525 | dialogContent.onAction(dialogContent.value.actions[0]) 526 | 527 | XCTAssertFalse(isHandlerCalled) 528 | } 529 | 530 | func test_appearedでremoveを呼んでも何も起きない() { 531 | let rootNode = makeRootNode() 532 | 533 | rootNode.didAppear() 534 | 535 | let firstContent = FirstContent(parentNode: rootNode) 536 | let firstNode = firstContent.node 537 | rootNode.add(.sheet(.init(.first(firstContent)))) 538 | 539 | XCTAssertTrue(waiter.resume()) 540 | 541 | firstNode.didAppear() 542 | 543 | XCTAssertTrue(isMatch(firstNode.state, .appeared)) 544 | 545 | firstNode.remove() 546 | 547 | XCTAssertTrue(isMatch(firstNode.state, .appeared)) 548 | } 549 | 550 | func test_disappearingでremoveを呼んでも何も起きない() { 551 | let rootNode = makeRootNode() 552 | 553 | rootNode.didAppear() 554 | 555 | let firstContent = FirstContent(parentNode: rootNode) 556 | let firstNode = firstContent.node 557 | rootNode.add(.sheet(.init(.first(firstContent)))) 558 | 559 | XCTAssertTrue(waiter.resume()) 560 | 561 | firstNode.didAppear() 562 | firstNode.didRemoveFromParent() 563 | 564 | XCTAssertTrue(isMatch(firstNode.state, .disappearing)) 565 | 566 | firstNode.remove() 567 | 568 | XCTAssertTrue(isMatch(firstNode.state, .disappearing)) 569 | } 570 | 571 | func test_appearingでremoveForを呼んで予約が削除される() { 572 | let rootNode = makeRootNode() 573 | 574 | let firstContentA = FirstContent(parentNode: rootNode) 575 | rootNode.add(.sheet(.init(.first(firstContentA)))) 576 | 577 | XCTAssertTrue(isMatch(rootNode.state, .appearing(reserved: .add(firstContentA.id)))) 578 | 579 | rootNode.remove(for: firstContentA.id) 580 | 581 | XCTAssertTrue(isMatch(rootNode.state, .appearing(reserved: .none))) 582 | } 583 | 584 | func test_childWaitingでremoveForを呼んで開くのを中断() { 585 | let rootNode = makeRootNode() 586 | 587 | rootNode.didAppear() 588 | 589 | let firstContentA = FirstContent(parentNode: rootNode) 590 | rootNode.add(.sheet(.init(.first(firstContentA)))) 591 | 592 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentA.id))) 593 | 594 | rootNode.remove(for: firstContentA.id) 595 | 596 | XCTAssertTrue(isMatch(rootNode.state, .appeared)) 597 | } 598 | 599 | func test_childPresentingでremoveForを呼んで閉じる予約がされる() { 600 | let rootNode = makeRootNode() 601 | 602 | rootNode.didAppear() 603 | 604 | let firstContentA = FirstContent(parentNode: rootNode) 605 | rootNode.add(.sheet(.init(.first(firstContentA)))) 606 | 607 | XCTAssertTrue(waiter.resume()) 608 | 609 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .none))) 610 | 611 | rootNode.remove(for: firstContentA.id) 612 | 613 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .remove))) 614 | } 615 | 616 | func test_childPresentingでaddの予約をしremoveForを呼んで閉じる予約に変わる() { 617 | let rootNode = makeRootNode() 618 | 619 | rootNode.didAppear() 620 | 621 | let firstContentA = FirstContent(parentNode: rootNode) 622 | rootNode.add(.sheet(.init(.first(firstContentA)))) 623 | 624 | XCTAssertTrue(waiter.resume()) 625 | 626 | let firstContentB = FirstContent(parentNode: rootNode) 627 | rootNode.add(.sheet(.init(.first(firstContentB)))) 628 | 629 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .add(firstContentB.id)))) 630 | 631 | // 元々開こうとしていたidを指定しても変わらない 632 | rootNode.remove(for: firstContentA.id) 633 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .add(firstContentB.id)))) 634 | 635 | rootNode.remove(for: firstContentB.id) 636 | 637 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .remove))) 638 | } 639 | 640 | func test_childPresentedでremoveForを呼んで閉じる() { 641 | let rootNode = makeRootNode() 642 | 643 | rootNode.didAppear() 644 | 645 | let firstContent = FirstContent(parentNode: rootNode) 646 | let firstNode = firstContent.node 647 | rootNode.add(.sheet(.init(.first(firstContent)))) 648 | XCTAssertTrue(waiter.resume()) 649 | firstNode.didAppear() 650 | 651 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: firstContent.id))) 652 | 653 | rootNode.remove(for: firstContent.id) 654 | 655 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContent.id, reserved: .none))) 656 | } 657 | 658 | func test_childDismissingでremoveForを呼んで予約されたものが削除される() { 659 | let rootNode = makeRootNode() 660 | 661 | rootNode.didAppear() 662 | 663 | let firstContentA = FirstContent(parentNode: rootNode) 664 | let firstNodeA = firstContentA.node 665 | rootNode.add(.sheet(.init(.first(firstContentA)))) 666 | XCTAssertTrue(waiter.resume()) 667 | firstNodeA.didAppear() 668 | rootNode.remove() 669 | let firstContentB = FirstContent(parentNode: rootNode) 670 | rootNode.add(.sheet(.init(.first(firstContentB)))) 671 | 672 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 673 | 674 | rootNode.remove(for: firstContentA.id) 675 | 676 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 677 | 678 | rootNode.remove(for: firstContentB.id) 679 | 680 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .none))) 681 | } 682 | 683 | func test_removeForを別のIdで呼んでも無視される() { 684 | let otherId = ModalId() 685 | 686 | let rootNode = makeRootNode() 687 | 688 | rootNode.didAppear() 689 | 690 | let firstContentA = FirstContent(parentNode: rootNode) 691 | let firstNodeA = firstContentA.node 692 | rootNode.add(.sheet(.init(.first(firstContentA)))) 693 | 694 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentA.id))) 695 | XCTAssertTrue(isMatch(firstNodeA.state, .appearing(reserved: .none))) 696 | 697 | rootNode.remove(for: otherId) 698 | 699 | XCTAssertTrue(isMatch(rootNode.state, .childWaiting(id: firstContentA.id))) 700 | XCTAssertTrue(isMatch(firstNodeA.state, .appearing(reserved: .none))) 701 | 702 | XCTAssertTrue(waiter.resume()) 703 | 704 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .none))) 705 | 706 | rootNode.remove(for: otherId) 707 | 708 | XCTAssertTrue(isMatch(rootNode.state, .childPresenting(id: firstContentA.id, reserved: .none))) 709 | 710 | firstNodeA.didAppear() 711 | 712 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: firstContentA.id))) 713 | 714 | rootNode.remove(for: otherId) 715 | 716 | XCTAssertTrue(isMatch(rootNode.state, .childPresented(id: firstContentA.id))) 717 | 718 | rootNode.remove() 719 | 720 | let firstContentB = FirstContent(parentNode: rootNode) 721 | rootNode.add(.sheet(.init(.first(firstContentB)))) 722 | 723 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 724 | XCTAssertTrue(isMatch(firstNodeA.state, .disappearing)) 725 | 726 | rootNode.remove(for: firstContentA.id) 727 | rootNode.remove(for: otherId) 728 | 729 | XCTAssertTrue(isMatch(rootNode.state, .childDismissing(id: firstContentA.id, reserved: .add(firstContentB.id)))) 730 | XCTAssertTrue(isMatch(firstNodeA.state, .disappearing)) 731 | } 732 | } 733 | 734 | private extension ModalNodeTests { 735 | func makeRootNode() -> ModalNode { 736 | ModalNode( 737 | parent: EmptyParentNode.shared, 738 | waiter: waiter 739 | ) 740 | } 741 | 742 | func isMatch(_ state: ModalNodeState, _ expected: ExpectedState) -> Bool { 743 | switch (state, expected) { 744 | case (.appearing(let reservedModal), .appearing(let reserved)): 745 | return isMatch(reservedModal, reserved) 746 | case (.appeared, .appeared): 747 | return true 748 | case (.childWaiting(_, let reservedModal), .childWaiting(let id)): 749 | return reservedModal.childNode.id == id 750 | case (.childPresenting(let modal, let reserved), .childPresenting(let id, let expectedReserved)): 751 | return modal.childNode.id == id && isMatch(reserved, expectedReserved) 752 | case (.childPresented(let modal), .childPresented(let id)): 753 | return modal.childNode.id == id 754 | case (.childDismissing(let modal, let reservedModal), .childDismissing(let id, let expectedReserved)): 755 | return modal.childNode.id == id && isMatch(reservedModal, expectedReserved) 756 | case (.disappearing, .disappearing): 757 | return true 758 | default: 759 | return false 760 | } 761 | } 762 | 763 | func isMatch( 764 | _ reserved: ModalReserved, 765 | _ expected: ExpectedReservedWithRemove 766 | ) -> Bool { 767 | switch (reserved, expected) { 768 | case (.add(let modal), .add(let id)): 769 | return modal.childNode.id == id 770 | case (.remove, .remove): 771 | return true 772 | case (.none, .none): 773 | return true 774 | case (_, .ignore): 775 | return true 776 | default: 777 | return false 778 | } 779 | } 780 | 781 | func isMatch(_ modal: Modal?, _ expected: ExpectedReserved) -> Bool { 782 | switch expected { 783 | case .add(let id): 784 | guard modal?.childNode.id == id else { return false } 785 | case .none: 786 | guard modal == nil else { return false } 787 | case .ignore: 788 | break 789 | } 790 | return true 791 | } 792 | } 793 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModalArchitecture 2 | 3 | * iOSDC Japan 2023(2023年9月1日)で発表するトーク「モーダルの遷移を理解する」のサンプルコード 4 | * SwiftUIのモーダルで起きる問題点を考慮し対策を行なった例 5 | * 問題が起きる実装のリポジトリは[こちら](https://github.com/objective-audio/ModalProblem) 6 | 7 | ## モーダルの問題点 8 | 9 | * モーダルの遷移中にisPresentedなどのモーダルのデータソースを変更すると様々な問題が起きる 10 | * モーダルを開こうとして開けないとViewとデータソースの整合性が取れなくなり、2度と開けなくなる 11 | * データソースを変更するタイミングによって、クラッシュやフリーズが起きる 12 | * iOS15以前で複数階層のモーダルを同時に閉じれない 13 | * MenuにはisPresentedのような表示の状態を管理するAPIがない 14 | 15 | ## モーダルの問題を解決する 16 | 17 | * iOS16以降に限定する 18 | * 同じ階層から表示するモーダルのデータソースを1つの値にまとめて、データソース的に不整合が起きないようにする 19 | * モーダルの遷移中にViewがバインドするデータソースを変更せず、遷移中は予約しておいて、遷移が終わってから反映する 20 | * モーダルを開いたまま別のモーダルを開くことはせず、閉じてから開くようにする 21 | * Menuは親のViewから入れ替えることでモーダルを閉じれる 22 | * Menuのモーダルを閉じるのと同時に他のモーダルを開けないので、モーダルの遷移開始を遅らせる 23 | --------------------------------------------------------------------------------