From d2240d0e76c1a758dbadbf737ceefc888b2e807c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 3 May 2020 16:19:55 -0700 Subject: [PATCH] The Composable Architecture Co-authored-by: Stephen Celis --- .github/workflows/ci.yml | 20 + .github/workflows/format.yml | 25 + .gitignore | 5 + .../xcschemes/ComposableArchitecture.xcscheme | 77 + CODE_OF_CONDUCT.md | 84 + .../contents.xcworkspacedata | 28 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 16 + .../contents.xcworkspacedata | 7 + .../CaseStudies.xcodeproj/project.pbxproj | 1021 +++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcschemes/CaseStudies (SwiftUI).xcscheme | 88 + .../xcschemes/CaseStudies (UIKit).xcscheme | 88 + Examples/CaseStudies/README.md | 3 + .../SwiftUICaseStudies/00-RootView.swift | 316 ++ .../01-GettingStarted-Animations.swift | 75 + .../01-GettingStarted-Bindings-Basics.swift | 136 + ...ttingStarted-Composition-TwoCounters.swift | 79 + .../01-GettingStarted-Counter.swift | 76 + .../01-GettingStarted-OptionalState.swift | 102 + .../02-Effects-Basics.swift | 165 + .../02-Effects-Cancellation.swift | 161 + .../02-Effects-LongLiving.swift | 123 + .../02-Effects-Timers.swift | 136 + .../02-GettingStarted-SharedState.swift | 281 ++ .../03-Effects-SystemEnvironment.swift | 237 + ...03-Navigation-Lists-LoadThenNavigate.swift | 133 + .../03-Navigation-Lists-NavigateAndLoad.swift | 122 + .../03-Navigation-LoadThenNavigate.swift | 108 + .../03-Navigation-NavigateAndLoad.swift | 104 + .../03-Navigation-Sheet-LoadThenPresent.swift | 108 + .../03-Navigation-Sheet-PresentAndLoad.swift | 100 + ...erOrderReducers-ElmLikeSubscriptions.swift | 159 + .../04-HigherOrderReducers-Recursion.swift | 164 + .../DownloadClient.swift | 65 + .../DownloadComponent.swift | 233 + .../ReusableComponents-Download.swift | 302 ++ ...gherOrderReducers-ReusableFavoriting.swift | 242 + ...4-HigherOrderReducers-StrictReducers.swift | 98 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../Assets.xcassets/Contents.json | 6 + .../CaseStudies/SwiftUICaseStudies/Info.plist | 65 + .../Internal/ActivityIndicator.swift | 14 + .../Internal/CircularProgressView.swift | 24 + .../Internal/IfLetSubscript.swift | 11 + .../Internal/TemplateText.swift | 59 + .../Internal/UIViewRepresented.swift | 14 + .../SwiftUICaseStudies/SceneDelegate.swift | 26 + .../02-Effects-BasicsTests.swift | 58 + .../02-Effects-CancellationTests.swift | 116 + .../02-Effects-LongLivingTests.swift | 37 + .../02-Effects-TimersTests.swift | 48 + ...ducers-ResuableOfflineDownloadsTests.swift | 238 + ...rderReducers-ReusableFavoritingTests.swift | 78 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../CounterViewController.swift | 96 + .../CaseStudies/UIKitCaseStudies/Info.plist | 60 + .../ActivityIndicatorViewController.swift | 21 + .../Internal/IfLetStoreController.swift | 52 + .../Internal/UIViewRepresented.swift | 14 + .../UIKitCaseStudies/ListsOfState.swift | 101 + .../UIKitCaseStudies/LoadThenNavigate.swift | 137 + .../UIKitCaseStudies/NavigateAndLoad.swift | 129 + .../Preview Assets.xcassets/Contents.json | 6 + .../UIKitCaseStudies/RootViewController.swift | 101 + .../UIKitCaseStudies/SceneDelegate.swift | 27 + .../UIKitCaseStudiesTests/Info.plist | 22 + .../UIKitCaseStudiesTests.swift | 8 + .../MotionManager.xcodeproj/project.pbxproj | 550 +++ .../xcschemes/MotionManager.xcscheme | 88 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../MotionManager/MotionManager/Info.plist | 60 + .../MotionManager/MotionClient/Client.swift | 30 + .../MotionManager/MotionClient/Live.swift | 61 + .../MotionManager/MotionClient/Mock.swift | 23 + .../MotionManager/MotionClient/Models.swift | 22 + .../MotionManager/MotionManagerView.swift | 132 + .../Preview Assets.xcassets/Contents.json | 6 + .../MotionManager/SceneDelegate.swift | 35 + .../MotionManagerTests/Info.plist | 22 + .../MotionManagerTests.swift | 47 + Examples/MotionManager/README.md | 5 + Examples/Package.swift | 9 + Examples/README.md | 24 + Examples/Search/README.md | 13 + .../Search/Search.xcodeproj/project.pbxproj | 525 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Search.xcscheme | 88 + .../Search/Search/ActivityIndicator.swift | 16 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../Search/Assets.xcassets/Contents.json | 6 + Examples/Search/Search/Info.plist | 60 + Examples/Search/Search/SceneDelegate.swift | 39 + Examples/Search/Search/SearchView.swift | 208 + Examples/Search/Search/WeatherClient.swift | 148 + Examples/Search/SearchTests/SearchTests.swift | 190 + Examples/SpeechRecognition/README.md | 5 + .../project.pbxproj | 539 +++ .../xcschemes/SpeechRecognition.xcscheme | 88 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../Assets.xcassets/Contents.json | 6 + .../SpeechRecognition/Info.plist | 64 + .../SpeechRecognition/SceneDelegate.swift | 38 + .../SpeechClient/Client.swift | 21 + .../SpeechRecognition/SpeechClient/Live.swift | 127 + .../SpeechRecognition/SpeechClient/Mock.swift | 29 + .../SpeechClient/Models.swift | 85 + .../SpeechRecognition/SpeechRecognition.swift | 172 + .../SpeechRecognitionTests.swift | 178 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../TicTacToe/Assets.xcassets/Contents.json | 6 + Examples/TicTacToe/Info.plist | 60 + Examples/TicTacToe/README.md | 21 + .../Sources/Common/ActivityIndicator.swift | 13 + .../TicTacToe/Sources/Common/AlertData.swift | 9 + Examples/TicTacToe/Sources/Core/AppCore.swift | 68 + .../Sources/Core/AuthenticationClient.swift | 90 + .../TicTacToe/Sources/Core/GameCore.swift | 110 + .../Core/LiveAuthenticationClient.swift | 24 + .../TicTacToe/Sources/Core/LoginCore.swift | 99 + .../TicTacToe/Sources/Core/NewGameCore.swift | 67 + .../Sources/Core/TwoFactorCore.swift | 69 + Examples/TicTacToe/Sources/RootView.swift | 66 + .../TicTacToe/Sources/SceneDelegate.swift | 30 + .../Sources/Views-SwiftUI/AppSwiftView.swift | 30 + .../Sources/Views-SwiftUI/GameSwiftView.swift | 110 + .../Views-SwiftUI/LoginSwiftView.swift | 146 + .../Views-SwiftUI/NewGameSwiftView.swift | 115 + .../Views-SwiftUI/TwoFactorSwiftView.swift | 114 + .../Views-UIKit/AppViewController.swift | 52 + .../Views-UIKit/GameViewController.swift | 175 + .../Views-UIKit/LoginViewController.swift | 218 + .../Views-UIKit/NewGameViewController.swift | 186 + .../Views-UIKit/TwoFactorViewController.swift | 139 + Examples/TicTacToe/Tests/AppCoreTests.swift | 7 + Examples/TicTacToe/Tests/GameCoreTests.swift | 83 + .../TicTacToe/Tests/GameSwiftUITests.swift | 92 + Examples/TicTacToe/Tests/LoginCoreTests.swift | 64 + .../TicTacToe/Tests/LoginSwiftUITests.swift | 136 + .../TicTacToe/Tests/NewGameCoreTests.swift | 40 + .../TicTacToe/Tests/NewGameSwiftUITests.swift | 34 + Examples/TicTacToe/Tests/TicTacToeTests.swift | 8 + .../TicTacToe/Tests/TwoFactorCoreTests.swift | 10 + .../Tests/TwoFactorSwiftUITests.swift | 103 + .../TicTacToe.xcodeproj/project.pbxproj | 4059 +++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/AppCore.xcscheme | 77 + .../xcschemes/AppSwiftUI.xcscheme | 67 + .../xcschemes/AuthenticationClient.xcscheme | 67 + .../xcshareddata/xcschemes/GameCore.xcscheme | 77 + .../xcschemes/GameSwiftUI.xcscheme | 77 + .../LiveAuthenticationClient.xcscheme | 67 + .../xcshareddata/xcschemes/LoginCore.xcscheme | 77 + .../xcschemes/LoginSwiftUI.xcscheme | 88 + .../xcschemes/NewGameCore.xcscheme | 77 + .../xcschemes/NewGameSwiftUI.xcscheme | 77 + .../xcshareddata/xcschemes/TicTacToe.xcscheme | 178 + .../xcschemes/TicTacToeCommon.xcscheme | 67 + .../xcschemes/TwoFactorCore.xcscheme | 77 + .../xcschemes/TwoFactorSwiftUI.xcscheme | 77 + Examples/Todos/README.md | 1 + .../Todos/Todos.xcodeproj/project.pbxproj | 517 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Todos.xcscheme | 88 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../Todos/Todos/Assets.xcassets/Contents.json | 6 + Examples/Todos/Todos/Info.plist | 60 + Examples/Todos/Todos/SceneDelegate.swift | 39 + Examples/Todos/Todos/Todos.swift | 243 + Examples/Todos/TodosTests/TodosTests.swift | 298 ++ .../VoiceMemos.xcodeproj/project.pbxproj | 537 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 8152 bytes .../AppIcon.appiconset/Contents.json | 99 + .../VoiceMemos/Assets.xcassets/Contents.json | 6 + .../AudioPlayerClient/AudioPlayerClient.swift | 16 + .../LiveAudioPlayerClient.swift | 63 + .../MockAudioPlayerClient.swift | 16 + .../AudioRecorderClient.swift | 20 + .../LiveAudioRecorderClient.swift | 101 + .../MockAudioRecorderClient.swift | 22 + Examples/VoiceMemos/VoiceMemos/Info.plist | 62 + .../VoiceMemos/VoiceMemos/SceneDelegate.swift | 45 + .../VoiceMemos/VoiceMemos/VoiceMemos.swift | 460 ++ .../VoiceMemosTests/VoiceMemosTests.swift | 310 ++ LICENSE | 21 + Makefile | 54 + Package.swift | 45 + README.md | 330 ++ .../CasePaths/CasePath.swift | 50 + .../CasePaths/CasePaths.swift | 55 + .../CasePaths/EnumReflection.swift | 94 + .../CasePaths/Operators.swift | 118 + .../Debugging/ReducerDebugging.swift | 119 + Sources/ComposableArchitecture/Effect.swift | 318 ++ .../Effects/Cancellation.swift | 87 + .../Effects/Debouncing.swift | 38 + .../Effects/Timer.swift | 59 + .../Internal/Create.swift | 191 + .../Internal/Debug.swift | 288 ++ .../Internal/Diff.swift | 82 + .../Internal/Locking.swift | 19 + .../Internal/Throttling.swift | 51 + Sources/ComposableArchitecture/Reducer.swift | 291 ++ .../Scheduling/AnyScheduler.swift | 99 + .../Scheduling/TestScheduler.swift | 131 + Sources/ComposableArchitecture/Store.swift | 215 + .../SwiftUI/ForEachStore.swift | 120 + .../SwiftUI/Identified.swift | 56 + .../SwiftUI/IdentifiedArray.swift | 206 + .../SwiftUI/IfLetStore.swift | 88 + .../SwiftUI/ViewStore.swift | 215 + .../SwiftUI/WithViewStore.swift | 85 + .../UIKit/IfLetUIKit.swift | 70 + .../Reducer.swift | 24 + .../TestStore.swift | 394 ++ .../ComposableArchitectureTests.swift | 160 + .../DebugTests.swift | 820 ++++ .../EffectCancellationTests.swift | 240 + .../EffectDebounceTests.swift | 87 + .../EffectTests.swift | 162 + .../Internal/EffectThrottleTests.swift | 138 + .../MemoryManagementTests.swift | 40 + .../ReducerTests.swift | 120 + .../SchedulerTests.swift | 118 + .../StoreTests.swift | 220 + .../TimerTests.swift | 133 + 243 files changed, 28515 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/format.yml create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme create mode 100644 CODE_OF_CONDUCT.md create mode 100644 ComposableArchitecture.xcworkspace/contents.xcworkspacedata create mode 100644 ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj create mode 100644 Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme create mode 100644 Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme create mode 100644 Examples/CaseStudies/README.md create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/02-GettingStarted-SharedState.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Effects-SystemEnvironment.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-StrictReducers.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Info.plist create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Internal/ActivityIndicator.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Internal/IfLetSubscript.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/Internal/UIViewRepresented.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/SceneDelegate.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Assets.xcassets/Contents.json create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Base.lproj/LaunchScreen.storyboard create mode 100644 Examples/CaseStudies/UIKitCaseStudies/CounterViewController.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Info.plist create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Internal/ActivityIndicatorViewController.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Internal/IfLetStoreController.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Internal/UIViewRepresented.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift create mode 100644 Examples/CaseStudies/UIKitCaseStudiesTests/Info.plist create mode 100644 Examples/CaseStudies/UIKitCaseStudiesTests/UIKitCaseStudiesTests.swift create mode 100644 Examples/MotionManager/MotionManager.xcodeproj/project.pbxproj create mode 100644 Examples/MotionManager/MotionManager.xcodeproj/xcshareddata/xcschemes/MotionManager.xcscheme create mode 100644 Examples/MotionManager/MotionManager/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/MotionManager/MotionManager/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/MotionManager/MotionManager/Assets.xcassets/Contents.json create mode 100644 Examples/MotionManager/MotionManager/Base.lproj/LaunchScreen.storyboard create mode 100644 Examples/MotionManager/MotionManager/Info.plist create mode 100644 Examples/MotionManager/MotionManager/MotionClient/Client.swift create mode 100644 Examples/MotionManager/MotionManager/MotionClient/Live.swift create mode 100644 Examples/MotionManager/MotionManager/MotionClient/Mock.swift create mode 100644 Examples/MotionManager/MotionManager/MotionClient/Models.swift create mode 100644 Examples/MotionManager/MotionManager/MotionManagerView.swift create mode 100644 Examples/MotionManager/MotionManager/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Examples/MotionManager/MotionManager/SceneDelegate.swift create mode 100644 Examples/MotionManager/MotionManagerTests/Info.plist create mode 100644 Examples/MotionManager/MotionManagerTests/MotionManagerTests.swift create mode 100644 Examples/MotionManager/README.md create mode 100644 Examples/Package.swift create mode 100644 Examples/README.md create mode 100644 Examples/Search/README.md create mode 100644 Examples/Search/Search.xcodeproj/project.pbxproj create mode 100644 Examples/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/Search/Search.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme create mode 100644 Examples/Search/Search/ActivityIndicator.swift create mode 100644 Examples/Search/Search/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/Search/Search/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/Search/Search/Assets.xcassets/Contents.json create mode 100644 Examples/Search/Search/Info.plist create mode 100644 Examples/Search/Search/SceneDelegate.swift create mode 100644 Examples/Search/Search/SearchView.swift create mode 100644 Examples/Search/Search/WeatherClient.swift create mode 100644 Examples/Search/SearchTests/SearchTests.swift create mode 100644 Examples/SpeechRecognition/README.md create mode 100644 Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.pbxproj create mode 100644 Examples/SpeechRecognition/SpeechRecognition.xcodeproj/xcshareddata/xcschemes/SpeechRecognition.xcscheme create mode 100644 Examples/SpeechRecognition/SpeechRecognition/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/SpeechRecognition/SpeechRecognition/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/SpeechRecognition/SpeechRecognition/Assets.xcassets/Contents.json create mode 100644 Examples/SpeechRecognition/SpeechRecognition/Info.plist create mode 100644 Examples/SpeechRecognition/SpeechRecognition/SceneDelegate.swift create mode 100644 Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift create mode 100644 Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift create mode 100644 Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Mock.swift create mode 100644 Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift create mode 100644 Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift create mode 100644 Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift create mode 100644 Examples/TicTacToe/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/TicTacToe/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/TicTacToe/Assets.xcassets/Contents.json create mode 100644 Examples/TicTacToe/Info.plist create mode 100644 Examples/TicTacToe/README.md create mode 100644 Examples/TicTacToe/Sources/Common/ActivityIndicator.swift create mode 100644 Examples/TicTacToe/Sources/Common/AlertData.swift create mode 100644 Examples/TicTacToe/Sources/Core/AppCore.swift create mode 100644 Examples/TicTacToe/Sources/Core/AuthenticationClient.swift create mode 100644 Examples/TicTacToe/Sources/Core/GameCore.swift create mode 100644 Examples/TicTacToe/Sources/Core/LiveAuthenticationClient.swift create mode 100644 Examples/TicTacToe/Sources/Core/LoginCore.swift create mode 100644 Examples/TicTacToe/Sources/Core/NewGameCore.swift create mode 100644 Examples/TicTacToe/Sources/Core/TwoFactorCore.swift create mode 100644 Examples/TicTacToe/Sources/RootView.swift create mode 100644 Examples/TicTacToe/Sources/SceneDelegate.swift create mode 100644 Examples/TicTacToe/Sources/Views-SwiftUI/AppSwiftView.swift create mode 100644 Examples/TicTacToe/Sources/Views-SwiftUI/GameSwiftView.swift create mode 100644 Examples/TicTacToe/Sources/Views-SwiftUI/LoginSwiftView.swift create mode 100644 Examples/TicTacToe/Sources/Views-SwiftUI/NewGameSwiftView.swift create mode 100644 Examples/TicTacToe/Sources/Views-SwiftUI/TwoFactorSwiftView.swift create mode 100644 Examples/TicTacToe/Sources/Views-UIKit/AppViewController.swift create mode 100644 Examples/TicTacToe/Sources/Views-UIKit/GameViewController.swift create mode 100644 Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift create mode 100644 Examples/TicTacToe/Sources/Views-UIKit/NewGameViewController.swift create mode 100644 Examples/TicTacToe/Sources/Views-UIKit/TwoFactorViewController.swift create mode 100644 Examples/TicTacToe/Tests/AppCoreTests.swift create mode 100644 Examples/TicTacToe/Tests/GameCoreTests.swift create mode 100644 Examples/TicTacToe/Tests/GameSwiftUITests.swift create mode 100644 Examples/TicTacToe/Tests/LoginCoreTests.swift create mode 100644 Examples/TicTacToe/Tests/LoginSwiftUITests.swift create mode 100644 Examples/TicTacToe/Tests/NewGameCoreTests.swift create mode 100644 Examples/TicTacToe/Tests/NewGameSwiftUITests.swift create mode 100644 Examples/TicTacToe/Tests/TicTacToeTests.swift create mode 100644 Examples/TicTacToe/Tests/TwoFactorCoreTests.swift create mode 100644 Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AppCore.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AppSwiftUI.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AuthenticationClient.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/GameCore.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/GameSwiftUI.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/LiveAuthenticationClient.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/LoginCore.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/LoginSwiftUI.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/NewGameCore.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/NewGameSwiftUI.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToe.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToeCommon.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TwoFactorCore.xcscheme create mode 100644 Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TwoFactorSwiftUI.xcscheme create mode 100644 Examples/Todos/README.md create mode 100644 Examples/Todos/Todos.xcodeproj/project.pbxproj create mode 100644 Examples/Todos/Todos.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/Todos/Todos.xcodeproj/xcshareddata/xcschemes/Todos.xcscheme create mode 100644 Examples/Todos/Todos/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/Todos/Todos/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/Todos/Todos/Assets.xcassets/Contents.json create mode 100644 Examples/Todos/Todos/Info.plist create mode 100644 Examples/Todos/Todos/SceneDelegate.swift create mode 100644 Examples/Todos/Todos/Todos.swift create mode 100644 Examples/Todos/TodosTests/TodosTests.swift create mode 100644 Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj create mode 100644 Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/VoiceMemos/VoiceMemos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Examples/VoiceMemos/VoiceMemos/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/VoiceMemos/VoiceMemos/Assets.xcassets/Contents.json create mode 100644 Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/MockAudioPlayerClient.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/MockAudioRecorderClient.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/Info.plist create mode 100644 Examples/VoiceMemos/VoiceMemos/SceneDelegate.swift create mode 100644 Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift create mode 100644 Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/ComposableArchitecture/CasePaths/CasePath.swift create mode 100644 Sources/ComposableArchitecture/CasePaths/CasePaths.swift create mode 100644 Sources/ComposableArchitecture/CasePaths/EnumReflection.swift create mode 100644 Sources/ComposableArchitecture/CasePaths/Operators.swift create mode 100644 Sources/ComposableArchitecture/Debugging/ReducerDebugging.swift create mode 100644 Sources/ComposableArchitecture/Effect.swift create mode 100644 Sources/ComposableArchitecture/Effects/Cancellation.swift create mode 100644 Sources/ComposableArchitecture/Effects/Debouncing.swift create mode 100644 Sources/ComposableArchitecture/Effects/Timer.swift create mode 100644 Sources/ComposableArchitecture/Internal/Create.swift create mode 100644 Sources/ComposableArchitecture/Internal/Debug.swift create mode 100644 Sources/ComposableArchitecture/Internal/Diff.swift create mode 100644 Sources/ComposableArchitecture/Internal/Locking.swift create mode 100644 Sources/ComposableArchitecture/Internal/Throttling.swift create mode 100644 Sources/ComposableArchitecture/Reducer.swift create mode 100644 Sources/ComposableArchitecture/Scheduling/AnyScheduler.swift create mode 100644 Sources/ComposableArchitecture/Scheduling/TestScheduler.swift create mode 100644 Sources/ComposableArchitecture/Store.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/Identified.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/IdentifiedArray.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/ViewStore.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift create mode 100644 Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift create mode 100644 Sources/ComposableArchitectureTestSupport/Reducer.swift create mode 100644 Sources/ComposableArchitectureTestSupport/TestStore.swift create mode 100644 Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift create mode 100644 Tests/ComposableArchitectureTests/DebugTests.swift create mode 100644 Tests/ComposableArchitectureTests/EffectCancellationTests.swift create mode 100644 Tests/ComposableArchitectureTests/EffectDebounceTests.swift create mode 100644 Tests/ComposableArchitectureTests/EffectTests.swift create mode 100644 Tests/ComposableArchitectureTests/Internal/EffectThrottleTests.swift create mode 100644 Tests/ComposableArchitectureTests/MemoryManagementTests.swift create mode 100644 Tests/ComposableArchitectureTests/ReducerTests.swift create mode 100644 Tests/ComposableArchitectureTests/SchedulerTests.swift create mode 100644 Tests/ComposableArchitectureTests/StoreTests.swift create mode 100644 Tests/ComposableArchitectureTests/TimerTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..48a47560a5ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - '*' + +jobs: + test: + name: Test + runs-on: macOS-latest + steps: + - uses: actions/checkout@v2 + - name: Select Xcode 11.4 + run: sudo xcode-select -s /Applications/Xcode_11.4.app + - name: Run tests + run: make test-all diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 000000000000..ca24adb9cc54 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,25 @@ +name: Format + +on: + pull_request: + paths: + - '**.swift' + +jobs: + swift_format: + name: swift-format + runs-on: macOS-latest + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + - name: Install + run: brew install swift-format + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4.1.6 + with: + commit_message: Run swift-format + branch: ${{ github.head_ref }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..95c4320919ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme new file mode 100644 index 000000000000..66b18f63a331 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..703a4725a4f4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@pointfree.co. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/ComposableArchitecture.xcworkspace/contents.xcworkspacedata b/ComposableArchitecture.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..e69aa80100e3 --- /dev/null +++ b/ComposableArchitecture.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000000..5ef234d90624 --- /dev/null +++ b/ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "swift-composable-architecture", + "repositoryURL": "http://github.com/stephencelis/swift-composable-architecture", + "state": { + "branch": "master", + "revision": "644a8f5d7c40522389ecd4631d53153aee62d717", + "version": null + } + } + ] + }, + "version": 1 +} diff --git a/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..45e4d56b9d78 --- /dev/null +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -0,0 +1,1021 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift */; }; + CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */; }; + CA27C0B7245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA27C0B6245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift */; }; + CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */; }; + CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */; }; + CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */; }; + CA6AC2672451135C00C71CB3 /* DownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2632451135C00C71CB3 /* DownloadClient.swift */; }; + CA7BC8EE245CCFE4001FB69F /* 02-GettingStarted-SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BC8ED245CCFE4001FB69F /* 02-GettingStarted-SharedState.swift */; }; + CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */; }; + CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */; }; + CAA9ADC624465C810003A984 /* 02-Effects-Cancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */; }; + CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */; }; + CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */; }; + CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */; }; + CAF7FD82245C83B40081E4C3 /* IfLetSubscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF7FD81245C83B40081E4C3 /* IfLetSubscript.swift */; }; + DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */; }; + DC072322244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */; }; + DC25DC5F2450F13200082E81 /* IfLetStoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */; }; + DC25DC612450F2B000082E81 /* LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */; }; + DC25DC642450F2DF00082E81 /* ActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */; }; + DC2E370D24573ACB00B94699 /* 04-HigherOrderReducers-StrictReducers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2E370C24573ACB00B94699 /* 04-HigherOrderReducers-StrictReducers.swift */; }; + DC4C6EAC2450DD380066A05D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */; }; + DC4C6EAE2450DD380066A05D /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EAD2450DD380066A05D /* RootViewController.swift */; }; + DC4C6EB02450DD380066A05D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EAF2450DD380066A05D /* Assets.xcassets */; }; + DC4C6EB32450DD380066A05D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EB22450DD380066A05D /* Preview Assets.xcassets */; }; + DC4C6EB62450DD380066A05D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */; }; + DC4C6EC12450DD390066A05D /* UIKitCaseStudiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */; }; + DC4C6ECC2450E0B30066A05D /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC4C6ECB2450E0B30066A05D /* ComposableArchitecture */; }; + DC4C6ECE2450E0B30066A05D /* ComposableArchitecture in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DC4C6ECB2450E0B30066A05D /* ComposableArchitecture */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DC4C6ED22450E0BA0066A05D /* ComposableArchitectureTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = DC4C6ED12450E0BA0066A05D /* ComposableArchitectureTestSupport */; }; + DC4C6ED42450E0BA0066A05D /* ComposableArchitectureTestSupport in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DC4C6ED12450E0BA0066A05D /* ComposableArchitectureTestSupport */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DC4C6ED62450E1050066A05D /* CounterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED52450E1050066A05D /* CounterViewController.swift */; }; + DC4C6ED82450E4570066A05D /* UIViewRepresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */; }; + DC4C6EDA2450E6050066A05D /* NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */; }; + DC630FDA2451016B00BAECBA /* ListsOfState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC630FD92451016B00BAECBA /* ListsOfState.swift */; }; + DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */; }; + DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */; }; + DC89C41924460F95006900B9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C41824460F95006900B9 /* SceneDelegate.swift */; }; + DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C41A24460F95006900B9 /* 00-RootView.swift */; }; + DC89C41D24460F96006900B9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC89C41C24460F96006900B9 /* Assets.xcassets */; }; + DC89C43C2446106D006900B9 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC89C43B2446106D006900B9 /* ComposableArchitecture */; }; + DC89C43E2446106D006900B9 /* ComposableArchitecture in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DC89C43B2446106D006900B9 /* ComposableArchitecture */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DC89C44024461077006900B9 /* ComposableArchitectureTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = DC89C43F24461077006900B9 /* ComposableArchitectureTestSupport */; }; + DC89C44224461077006900B9 /* ComposableArchitectureTestSupport in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DC89C43F24461077006900B9 /* ComposableArchitectureTestSupport */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DC89C4442446111B006900B9 /* 01-GettingStarted-Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */; }; + DC89C44724461431006900B9 /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C44624461431006900B9 /* ActivityIndicator.swift */; }; + DC89C449244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C448244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift */; }; + DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */; }; + DC89C45124462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45024462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift */; }; + DC89C45324465452006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */; }; + DC89C45524465C44006900B9 /* 02-Effects-Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */; }; + DC9EB4172450CBD2005F413B /* UIViewRepresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */; }; + DCAC2A4F2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */; }; + DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */; }; + DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */; }; + DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EDE2447BC810037F998 /* TemplateText.swift */; }; + DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */; }; + DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */; }; + DCE63B71245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + DC4C6EBD2450DD390066A05D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC89C40B24460F95006900B9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DC4C6EA62450DD380066A05D; + remoteInfo = UIKitCaseStudies; + }; + DC89C42A24460F96006900B9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC89C40B24460F95006900B9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DC89C41224460F95006900B9; + remoteInfo = CaseStudies; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + DC4C6ECD2450E0B30066A05D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DC4C6ECE2450E0B30066A05D /* ComposableArchitecture in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6ED32450E0BA0066A05D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DC4C6ED42450E0BA0066A05D /* ComposableArchitectureTestSupport in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C43D2446106D006900B9 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DC89C43E2446106D006900B9 /* ComposableArchitecture in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C44124461077006900B9 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DC89C44224461077006900B9 /* ComposableArchitectureTestSupport in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift"; sourceTree = ""; }; + CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Bindings-Basics.swift"; sourceTree = ""; }; + CA27C0B6245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-SystemEnvironment.swift"; sourceTree = ""; }; + CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReusableComponents-Download.swift"; sourceTree = ""; }; + CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; + CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadComponent.swift; sourceTree = ""; }; + CA6AC2632451135C00C71CB3 /* DownloadClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadClient.swift; sourceTree = ""; }; + CA7BC8ED245CCFE4001FB69F /* 02-GettingStarted-SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-GettingStarted-SharedState.swift"; sourceTree = ""; }; + CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Basics.swift"; sourceTree = ""; }; + CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-BasicsTests.swift"; sourceTree = ""; }; + CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Cancellation.swift"; sourceTree = ""; }; + CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-CancellationTests.swift"; sourceTree = ""; }; + CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLiving.swift"; sourceTree = ""; }; + CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLivingTests.swift"; sourceTree = ""; }; + CAF7FD81245C83B40081E4C3 /* IfLetSubscript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfLetSubscript.swift; sourceTree = ""; }; + DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-TimersTests.swift"; sourceTree = ""; }; + DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Sheet-LoadThenPresent.swift"; sourceTree = ""; }; + DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfLetStoreController.swift; sourceTree = ""; }; + DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadThenNavigate.swift; sourceTree = ""; }; + DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorViewController.swift; sourceTree = ""; }; + DC2E370C24573ACB00B94699 /* 04-HigherOrderReducers-StrictReducers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-StrictReducers.swift"; sourceTree = ""; }; + DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIKitCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + DC4C6EAD2450DD380066A05D /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; + DC4C6EAF2450DD380066A05D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DC4C6EB22450DD380066A05D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + DC4C6EB52450DD380066A05D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + DC4C6EB72450DD380066A05D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC4C6EBC2450DD390066A05D /* UIKitCaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UIKitCaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitCaseStudiesTests.swift; sourceTree = ""; }; + DC4C6EC22450DD390066A05D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC4C6ED52450E1050066A05D /* CounterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterViewController.swift; sourceTree = ""; }; + DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewRepresented.swift; sourceTree = ""; }; + DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigateAndLoad.swift; sourceTree = ""; }; + DC630FD92451016B00BAECBA /* ListsOfState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsOfState.swift; sourceTree = ""; }; + DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableFavoritingTests.swift"; sourceTree = ""; }; + DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Animations.swift"; sourceTree = ""; }; + DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUICaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DC89C41824460F95006900B9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + DC89C41A24460F95006900B9 /* 00-RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "00-RootView.swift"; sourceTree = ""; }; + DC89C41C24460F96006900B9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DC89C42424460F96006900B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC89C42924460F96006900B9 /* SwiftUICaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftUICaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DC89C43824460FC7006900B9 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; + DC89C43924460FFF006900B9 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Counter.swift"; sourceTree = ""; }; + DC89C44624461431006900B9 /* ActivityIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; + DC89C448244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-LoadThenNavigate.swift"; sourceTree = ""; }; + DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-NavigateAndLoad.swift"; sourceTree = ""; }; + DC89C45024462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Lists-LoadThenNavigate.swift"; sourceTree = ""; }; + DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Lists-NavigateAndLoad.swift"; sourceTree = ""; }; + DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Timers.swift"; sourceTree = ""; }; + DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewRepresented.swift; sourceTree = ""; }; + DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ElmLikeSubscriptions.swift"; sourceTree = ""; }; + DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Sheet-PresentAndLoad.swift"; sourceTree = ""; }; + DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-OptionalState.swift"; sourceTree = ""; }; + DCC68EDE2447BC810037F998 /* TemplateText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateText.swift; sourceTree = ""; }; + DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Composition-TwoCounters.swift"; sourceTree = ""; }; + DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableFavoriting.swift"; sourceTree = ""; }; + DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-Recursion.swift"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DC4C6EA42450DD380066A05D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6ECC2450E0B30066A05D /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EB92450DD390066A05D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6ED22450E0BA0066A05D /* ComposableArchitectureTestSupport in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C41024460F95006900B9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89C43C2446106D006900B9 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C42624460F96006900B9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89C44024461077006900B9 /* ComposableArchitectureTestSupport in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CA6AC25F2451131C00C71CB3 /* 04-HigherOrderReducers-ResuableOfflineDownloads */ = { + isa = PBXGroup; + children = ( + CA6AC2632451135C00C71CB3 /* DownloadClient.swift */, + CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */, + CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */, + ); + path = "04-HigherOrderReducers-ResuableOfflineDownloads"; + sourceTree = ""; + }; + DC25DC622450F2D100082E81 /* Internal */ = { + isa = PBXGroup; + children = ( + DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */, + DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */, + DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */, + ); + path = Internal; + sourceTree = ""; + }; + DC4C6EA82450DD380066A05D /* UIKitCaseStudies */ = { + isa = PBXGroup; + children = ( + DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */, + DC4C6EAD2450DD380066A05D /* RootViewController.swift */, + DC4C6ED52450E1050066A05D /* CounterViewController.swift */, + DC630FD92451016B00BAECBA /* ListsOfState.swift */, + DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */, + DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */, + DC25DC622450F2D100082E81 /* Internal */, + DC4C6EAF2450DD380066A05D /* Assets.xcassets */, + DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */, + DC4C6EB72450DD380066A05D /* Info.plist */, + DC4C6EB12450DD380066A05D /* Preview Content */, + ); + path = UIKitCaseStudies; + sourceTree = ""; + }; + DC4C6EB12450DD380066A05D /* Preview Content */ = { + isa = PBXGroup; + children = ( + DC4C6EB22450DD380066A05D /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + DC4C6EBF2450DD390066A05D /* UIKitCaseStudiesTests */ = { + isa = PBXGroup; + children = ( + DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */, + DC4C6EC22450DD390066A05D /* Info.plist */, + ); + path = UIKitCaseStudiesTests; + sourceTree = ""; + }; + DC89C40A24460F95006900B9 = { + isa = PBXGroup; + children = ( + DC89C43824460FC7006900B9 /* swift-composable-architecture */, + DC89C43924460FFF006900B9 /* README.md */, + DC89C41524460F95006900B9 /* SwiftUICaseStudies */, + DC89C42C24460F96006900B9 /* SwiftUICaseStudiesTests */, + DC4C6EA82450DD380066A05D /* UIKitCaseStudies */, + DC4C6EBF2450DD390066A05D /* UIKitCaseStudiesTests */, + DC89C41424460F95006900B9 /* Products */, + DC89C43A2446106D006900B9 /* Frameworks */, + ); + sourceTree = ""; + }; + DC89C41424460F95006900B9 /* Products */ = { + isa = PBXGroup; + children = ( + DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */, + DC89C42924460F96006900B9 /* SwiftUICaseStudiesTests.xctest */, + DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */, + DC4C6EBC2450DD390066A05D /* UIKitCaseStudiesTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + DC89C41524460F95006900B9 /* SwiftUICaseStudies */ = { + isa = PBXGroup; + children = ( + DC89C42424460F96006900B9 /* Info.plist */, + DC89C41A24460F95006900B9 /* 00-RootView.swift */, + DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */, + CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */, + DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */, + DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */, + DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */, + CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */, + CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */, + CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */, + DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */, + CA7BC8ED245CCFE4001FB69F /* 02-GettingStarted-SharedState.swift */, + CA27C0B6245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift */, + DC89C45024462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift */, + DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */, + DC89C448244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift */, + DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */, + DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */, + DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */, + DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */, + DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */, + DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */, + DC2E370C24573ACB00B94699 /* 04-HigherOrderReducers-StrictReducers.swift */, + DC89C41824460F95006900B9 /* SceneDelegate.swift */, + DC89C41C24460F96006900B9 /* Assets.xcassets */, + CA6AC25F2451131C00C71CB3 /* 04-HigherOrderReducers-ResuableOfflineDownloads */, + DC89C44524461416006900B9 /* Internal */, + ); + path = SwiftUICaseStudies; + sourceTree = ""; + }; + DC89C42C24460F96006900B9 /* SwiftUICaseStudiesTests */ = { + isa = PBXGroup; + children = ( + CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */, + CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */, + CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */, + DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */, + DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */, + CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift */, + ); + path = SwiftUICaseStudiesTests; + sourceTree = ""; + }; + DC89C43A2446106D006900B9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + DC89C44524461416006900B9 /* Internal */ = { + isa = PBXGroup; + children = ( + DC89C44624461431006900B9 /* ActivityIndicator.swift */, + CAF7FD81245C83B40081E4C3 /* IfLetSubscript.swift */, + CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */, + DCC68EDE2447BC810037F998 /* TemplateText.swift */, + DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */, + ); + path = Internal; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DC4C6EA62450DD380066A05D /* UIKitCaseStudies */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC4C6EC32450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudies" */; + buildPhases = ( + DC4C6EA32450DD380066A05D /* Sources */, + DC4C6EA42450DD380066A05D /* Frameworks */, + DC4C6EA52450DD380066A05D /* Resources */, + DC4C6ECD2450E0B30066A05D /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + DC4C6ECA2450E0AF0066A05D /* PBXTargetDependency */, + ); + name = UIKitCaseStudies; + packageProductDependencies = ( + DC4C6ECB2450E0B30066A05D /* ComposableArchitecture */, + ); + productName = UIKitCaseStudies; + productReference = DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */; + productType = "com.apple.product-type.application"; + }; + DC4C6EBB2450DD390066A05D /* UIKitCaseStudiesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC4C6EC62450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudiesTests" */; + buildPhases = ( + DC4C6EB82450DD390066A05D /* Sources */, + DC4C6EB92450DD390066A05D /* Frameworks */, + DC4C6EBA2450DD390066A05D /* Resources */, + DC4C6ED32450E0BA0066A05D /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + DC4C6ED02450E0B70066A05D /* PBXTargetDependency */, + DC4C6EBE2450DD390066A05D /* PBXTargetDependency */, + ); + name = UIKitCaseStudiesTests; + packageProductDependencies = ( + DC4C6ED12450E0BA0066A05D /* ComposableArchitectureTestSupport */, + ); + productName = UIKitCaseStudiesTests; + productReference = DC4C6EBC2450DD390066A05D /* UIKitCaseStudiesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DC89C41224460F95006900B9 /* SwiftUICaseStudies */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC89C43224460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudies" */; + buildPhases = ( + DC89C40F24460F95006900B9 /* Sources */, + DC89C41024460F95006900B9 /* Frameworks */, + DC89C41124460F95006900B9 /* Resources */, + DC89C43D2446106D006900B9 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUICaseStudies; + packageProductDependencies = ( + DC89C43B2446106D006900B9 /* ComposableArchitecture */, + ); + productName = CaseStudies; + productReference = DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */; + productType = "com.apple.product-type.application"; + }; + DC89C42824460F96006900B9 /* SwiftUICaseStudiesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC89C43524460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudiesTests" */; + buildPhases = ( + DC89C42524460F96006900B9 /* Sources */, + DC89C42624460F96006900B9 /* Frameworks */, + DC89C42724460F96006900B9 /* Resources */, + DC89C44124461077006900B9 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + DC89C42B24460F96006900B9 /* PBXTargetDependency */, + DCC68EE52448A2650037F998 /* PBXTargetDependency */, + ); + name = SwiftUICaseStudiesTests; + packageProductDependencies = ( + DC89C43F24461077006900B9 /* ComposableArchitectureTestSupport */, + ); + productName = CaseStudiesTests; + productReference = DC89C42924460F96006900B9 /* SwiftUICaseStudiesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DC89C40B24460F95006900B9 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1140; + LastUpgradeCheck = 1140; + ORGANIZATIONNAME = "Point-Free"; + TargetAttributes = { + DC4C6EA62450DD380066A05D = { + CreatedOnToolsVersion = 11.4.1; + }; + DC4C6EBB2450DD390066A05D = { + CreatedOnToolsVersion = 11.4.1; + TestTargetID = DC4C6EA62450DD380066A05D; + }; + DC89C41224460F95006900B9 = { + CreatedOnToolsVersion = 11.4; + }; + DC89C42824460F96006900B9 = { + CreatedOnToolsVersion = 11.4; + TestTargetID = DC89C41224460F95006900B9; + }; + }; + }; + buildConfigurationList = DC89C40E24460F95006900B9 /* Build configuration list for PBXProject "CaseStudies" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DC89C40A24460F95006900B9; + productRefGroup = DC89C41424460F95006900B9 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DC89C41224460F95006900B9 /* SwiftUICaseStudies */, + DC89C42824460F96006900B9 /* SwiftUICaseStudiesTests */, + DC4C6EA62450DD380066A05D /* UIKitCaseStudies */, + DC4C6EBB2450DD390066A05D /* UIKitCaseStudiesTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DC4C6EA52450DD380066A05D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6EB62450DD380066A05D /* LaunchScreen.storyboard in Resources */, + DC4C6EB32450DD380066A05D /* Preview Assets.xcassets in Resources */, + DC4C6EB02450DD380066A05D /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EBA2450DD390066A05D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C41124460F95006900B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89C41D24460F96006900B9 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C42724460F96006900B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DC4C6EA32450DD380066A05D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6ED82450E4570066A05D /* UIViewRepresented.swift in Sources */, + DC25DC642450F2DF00082E81 /* ActivityIndicatorViewController.swift in Sources */, + DC4C6ED62450E1050066A05D /* CounterViewController.swift in Sources */, + DC4C6EDA2450E6050066A05D /* NavigateAndLoad.swift in Sources */, + DC4C6EAC2450DD380066A05D /* SceneDelegate.swift in Sources */, + DC25DC612450F2B000082E81 /* LoadThenNavigate.swift in Sources */, + DC25DC5F2450F13200082E81 /* IfLetStoreController.swift in Sources */, + DC4C6EAE2450DD380066A05D /* RootViewController.swift in Sources */, + DC630FDA2451016B00BAECBA /* ListsOfState.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EB82450DD390066A05D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6EC12450DD390066A05D /* UIKitCaseStudiesTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C40F24460F95006900B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89C449244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift in Sources */, + DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */, + DC072322244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift in Sources */, + CAF7FD82245C83B40081E4C3 /* IfLetSubscript.swift in Sources */, + DC89C45324465452006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift in Sources */, + DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */, + DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */, + DCAC2A4F2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift in Sources */, + CA6AC2672451135C00C71CB3 /* DownloadClient.swift in Sources */, + DC89C44724461431006900B9 /* ActivityIndicator.swift in Sources */, + CAA9ADC624465C810003A984 /* 02-Effects-Cancellation.swift in Sources */, + DC2E370D24573ACB00B94699 /* 04-HigherOrderReducers-StrictReducers.swift in Sources */, + DC9EB4172450CBD2005F413B /* UIViewRepresented.swift in Sources */, + DC89C41924460F95006900B9 /* SceneDelegate.swift in Sources */, + CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */, + CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */, + CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */, + CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */, + DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */, + DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */, + DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */, + CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */, + DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */, + DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */, + DC89C4442446111B006900B9 /* 01-GettingStarted-Counter.swift in Sources */, + DCE63B71245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift in Sources */, + CA27C0B7245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift in Sources */, + CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */, + DC89C45124462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift in Sources */, + DC89C45524465C44006900B9 /* 02-Effects-Timers.swift in Sources */, + CA7BC8EE245CCFE4001FB69F /* 02-GettingStarted-SharedState.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C42524460F96006900B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift in Sources */, + DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */, + CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */, + DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */, + CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */, + CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + DC4C6EBE2450DD390066A05D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DC4C6EA62450DD380066A05D /* UIKitCaseStudies */; + targetProxy = DC4C6EBD2450DD390066A05D /* PBXContainerItemProxy */; + }; + DC4C6ECA2450E0AF0066A05D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = DC4C6EC92450E0AF0066A05D /* ComposableArchitecture */; + }; + DC4C6ED02450E0B70066A05D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = DC4C6ECF2450E0B70066A05D /* ComposableArchitectureTestSupport */; + }; + DC89C42B24460F96006900B9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DC89C41224460F95006900B9 /* SwiftUICaseStudies */; + targetProxy = DC89C42A24460F96006900B9 /* PBXContainerItemProxy */; + }; + DCC68EE52448A2650037F998 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = DCC68EE42448A2650037F998 /* ComposableArchitectureTestSupport */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + DC4C6EB52450DD380066A05D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + DC4C6EC42450DD390066A05D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"UIKitCaseStudies/Preview Content\""; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = UIKitCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC4C6EC52450DD390066A05D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"UIKitCaseStudies/Preview Content\""; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = UIKitCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + DC4C6EC72450DD390066A05D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = UIKitCaseStudiesTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/UIKitCaseStudies.app/UIKitCaseStudies"; + }; + name = Debug; + }; + DC4C6EC82450DD390066A05D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = UIKitCaseStudiesTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/UIKitCaseStudies.app/UIKitCaseStudies"; + }; + name = Release; + }; + DC89C43024460F96006900B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DC89C43124460F96006900B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DC89C43324460F96006900B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = SwiftUICaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC89C43424460F96006900B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = SwiftUICaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + DC89C43624460F96006900B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUICaseStudies.app/SwiftUICaseStudies"; + }; + name = Debug; + }; + DC89C43724460F96006900B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUICaseStudies.app/SwiftUICaseStudies"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DC4C6EC32450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC4C6EC42450DD390066A05D /* Debug */, + DC4C6EC52450DD390066A05D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC4C6EC62450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudiesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC4C6EC72450DD390066A05D /* Debug */, + DC4C6EC82450DD390066A05D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC89C40E24460F95006900B9 /* Build configuration list for PBXProject "CaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC89C43024460F96006900B9 /* Debug */, + DC89C43124460F96006900B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC89C43224460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC89C43324460F96006900B9 /* Debug */, + DC89C43424460F96006900B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC89C43524460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudiesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC89C43624460F96006900B9 /* Debug */, + DC89C43724460F96006900B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + DC4C6EC92450E0AF0066A05D /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; + DC4C6ECB2450E0B30066A05D /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; + DC4C6ECF2450E0B70066A05D /* ComposableArchitectureTestSupport */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitectureTestSupport; + }; + DC4C6ED12450E0BA0066A05D /* ComposableArchitectureTestSupport */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitectureTestSupport; + }; + DC89C43B2446106D006900B9 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; + DC89C43F24461077006900B9 /* ComposableArchitectureTestSupport */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitectureTestSupport; + }; + DCC68EE42448A2650037F998 /* ComposableArchitectureTestSupport */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitectureTestSupport; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DC89C40B24460F95006900B9 /* Project object */; +} diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme b/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme new file mode 100644 index 000000000000..428ff0ae4fac --- /dev/null +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme b/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme new file mode 100644 index 000000000000..99afc4c8d52c --- /dev/null +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/CaseStudies/README.md b/Examples/CaseStudies/README.md new file mode 100644 index 000000000000..0468b9f7b537 --- /dev/null +++ b/Examples/CaseStudies/README.md @@ -0,0 +1,3 @@ +# Composable Architecture Case Studies + +This project includes a number of digestible examples of how to solve common problems using the Composable Architecture. diff --git a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift new file mode 100644 index 000000000000..d3a4c4df3d5d --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -0,0 +1,316 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +struct RootView: View { + var body: some View { + NavigationView { + Form { + Section(header: Text("Getting started")) { + NavigationLink( + "Basics", + destination: CounterDemoView( + store: Store( + initialState: CounterState(), + reducer: counterReducer, + environment: CounterEnvironment() + ) + ) + ) + + NavigationLink( + "Pullback and combine", + destination: TwoCountersView( + store: Store( + initialState: TwoCountersState(), + reducer: twoCountersReducer, + environment: TwoCountersEnvironment() + ) + ) + ) + + NavigationLink( + "Bindings", + destination: BindingBasicsView( + store: Store( + initialState: BindingBasicsState(), + reducer: bindingBasicsReducer, + environment: BindingBasicsEnvironment() + ) + ) + ) + + NavigationLink( + "Optional state", + destination: OptionalBasicsView( + store: Store( + initialState: OptionalBasicsState(), + reducer: optionalBasicsReducer, + environment: OptionalBasicsEnvironment() + ) + ) + ) + + NavigationLink( + "Shared state", + destination: SharedStateView( + store: Store( + initialState: SharedState(), + reducer: sharedStateReducer, + environment: () + ) + ) + ) + + NavigationLink( + "Animations", + destination: AnimationsView( + store: Store( + initialState: AnimationsState(circleCenter: CGPoint(x: 50, y: 50)), + reducer: animationsReducer, + environment: AnimationsEnvironment() + ) + ) + ) + } + + Section(header: Text("Effects")) { + NavigationLink( + "Basics", + destination: EffectsBasicsView( + store: Store( + initialState: EffectsBasicsState(), + reducer: effectsBasicsReducer, + environment: .live + ) + ) + ) + + NavigationLink( + "Cancellation", + destination: EffectsCancellationView( + store: Store( + initialState: .init(), + reducer: effectsCancellationReducer, + environment: .live) + ) + ) + + NavigationLink( + "Long-living effects", + destination: LongLivingEffectsView( + store: Store( + initialState: LongLivingEffectsState(), + reducer: longLivingEffectsReducer, + environment: .live + ) + ) + ) + + NavigationLink( + "Timers", + destination: TimersView( + store: Store( + initialState: TimersState(), + reducer: timersReducer, + environment: TimersEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "System environment", + destination: MultipleDependenciesView( + store: Store( + initialState: MultipleDependenciesState(), + reducer: multipleDependenciesReducer, + environment: .live( + environment: MultipleDependenciesEnvironment( + fetchNumber: { + Effect(value: Int.random(in: 1...1_000)) + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToEffect() + } + ) + ) + ) + ) + ) + } + + Section(header: Text("Navigation")) { + NavigationLink( + "Navigate and load data", + destination: EagerNavigationView( + store: Store( + initialState: EagerNavigationState(), + reducer: eagerNavigationReducer, + environment: EagerNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Load data then navigate", + destination: LazyNavigationView( + store: Store( + initialState: LazyNavigationState(), + reducer: lazyNavigationReducer, + environment: LazyNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Lists: Navigate and load data", + destination: EagerListNavigationView( + store: Store( + initialState: EagerListNavigationState( + rows: [ + .init(count: 1, id: UUID()), + .init(count: 42, id: UUID()), + .init(count: 100, id: UUID()), + ] + ), + reducer: eagerListNavigationReducer, + environment: EagerListNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Lists: Load data then navigate", + destination: LazyListNavigationView( + store: Store( + initialState: LazyListNavigationState( + rows: [ + .init(count: 1, id: UUID()), + .init(count: 42, id: UUID()), + .init(count: 100, id: UUID()), + ] + ), + reducer: lazyListNavigationReducer, + environment: LazyListNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Sheets: Present and load data", + destination: EagerSheetView( + store: Store( + initialState: EagerSheetState(), + reducer: eagerSheetReducer, + environment: EagerSheetEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Sheets: Load data then present", + destination: LazySheetView( + store: Store( + initialState: LazySheetState(), + reducer: lazySheetReducer, + environment: LazySheetEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + } + + Section(header: Text("Higher-order reducers")) { + NavigationLink( + "Reusable favoriting component", + destination: EpisodesView( + store: Store( + initialState: EpisodesState( + episodes: .mocks + ), + reducer: episodesReducer, + environment: EpisodesEnvironment( + favorite: favorite(id:isFavorite:), + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Reusable offline download component", + destination: CitiesView( + store: Store( + initialState: .init(cityMaps: .mocks), + reducer: mapAppReducer, + environment: .init( + downloadClient: .live, + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Strict reducers", + destination: DieRollView( + store: Store( + initialState: DieRollState(), + reducer: dieRollReducer, + environment: DieRollEnvironment( + rollDie: { .random(in: 1...6) } + ) + ) + ) + ) + + NavigationLink( + "Elm-like subscriptions", + destination: ClockView( + store: Store( + initialState: ClockState(), + reducer: clockReducer, + environment: ClockEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + ) + + NavigationLink( + "Recursive state and actions", + destination: NestedView( + store: Store( + initialState: .mock, + reducer: nestedReducer, + environment: NestedEnvironment( + uuid: UUID.init + ) + ) + ) + ) + } + } + .navigationBarTitle("Case Studies") + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} + +struct RootView_Previews: PreviewProvider { + static var previews: some View { + RootView() + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift new file mode 100644 index 000000000000..9096fbb2b4c4 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -0,0 +1,75 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how changes to application state can drive animations. If you wrap your \ + `viewStore.send` in a `withAnimations` block, then any changes made to state after sending that \ + action will be animated. + + Try it out by tapping anywhere on the screen to move the dot. You can also drag it around the screen. + """ + +struct AnimationsState: Equatable { + var circleCenter = CGPoint.zero +} + +enum AnimationsAction: Equatable { + case tapped(CGPoint) +} + +struct AnimationsEnvironment {} + +let animationsReducer = Reducer { + state, action, environment in + + switch action { + case let .tapped(point): + state.circleCenter = point + return .none + } +} + +struct AnimationsView: View { + let store: Store + + var body: some View { + GeometryReader { proxy in + WithViewStore(self.store) { viewStore in + ZStack(alignment: .center) { + Text(template: readMe, .body) + .padding() + + Circle() + .fill(Color.white) + .blendMode(.difference) + .frame(width: 50, height: 50) + .offset( + x: viewStore.circleCenter.x - proxy.size.width / 2, + y: viewStore.circleCenter.y - proxy.size.height / 2 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .gesture( + DragGesture(minimumDistance: 0).onChanged { gesture in + withAnimation(.interactiveSpring(response: 0.25, dampingFraction: 0.1)) { + viewStore.send(.tapped(gesture.location)) + } + } + ) + } + } + } +} + +struct AnimationsView_Previews: PreviewProvider { + static var previews: some View { + AnimationsView( + store: Store( + initialState: AnimationsState(circleCenter: CGPoint(x: 50, y: 50)), + reducer: animationsReducer, + environment: AnimationsEnvironment() + ) + ) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift new file mode 100644 index 000000000000..1dbb5f9e1950 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift @@ -0,0 +1,136 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This file demonstrates how to handle two-way bindings in the Composable Architecture. + + Two-way bindings in SwiftUI are powerful, but also go against the grain of the "unidirectional \ + data flow" of the Composable Architecture. This is because anything can mutate the value \ + whenever it wants. + + On the other hand, the Composable Architecture demands that mutations can only happen by sending \ + actions to the store, and this means there is only ever one place to see how the state of our \ + feature evolves, which is the reducer. + + Any SwiftUI component that requires a Binding to do its job can be used in the Composable \ + Architecture. You can derive a Binding from your ViewStore by using the `binding` method. This \ + will allow you to specify what state renders the component, and what action to send when the \ + component changes, which means you can keep using a unidirectional style for your feature. + """ + +// The state for this screen holds a bunch of values that will drive +struct BindingBasicsState: Equatable { + var sliderValue = 5.0 + var stepCount = 10 + var text = "" + var toggleIsOn = false +} + +enum BindingBasicsAction { + case sliderValueChanged(Double) + case stepCountChanged(Int) + case textChange(String) + case toggleChange(isOn: Bool) +} + +struct BindingBasicsEnvironment {} + +let bindingBasicsReducer = Reducer< + BindingBasicsState, BindingBasicsAction, BindingBasicsEnvironment +> { + state, action, _ in + switch action { + case let .sliderValueChanged(value): + state.sliderValue = value + return .none + + case let .stepCountChanged(count): + state.sliderValue = .minimum(state.sliderValue, Double(count)) + state.stepCount = count + return .none + + case let .textChange(text): + state.text = text + return .none + + case let .toggleChange(isOn): + state.toggleIsOn = isOn + return .none + } +} + +struct BindingBasicsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(template: readMe, .caption)) { + HStack { + TextField( + "Type here", + text: viewStore.binding(get: \.text, send: BindingBasicsAction.textChange) + ) + .disableAutocorrection(true) + .foregroundColor(viewStore.toggleIsOn ? .gray : .primary) + Text(alternate(viewStore.text)) + } + .disabled(viewStore.toggleIsOn) + + Toggle(isOn: viewStore.binding(get: \.toggleIsOn, send: BindingBasicsAction.toggleChange)) + { + Text("Disable other controls") + } + + Stepper( + value: viewStore.binding(get: \.stepCount, send: BindingBasicsAction.stepCountChanged), + in: 0...100 + ) { + Text("Max slider value: \(viewStore.stepCount)") + .font(Font.body.monospacedDigit()) + } + .disabled(viewStore.toggleIsOn) + + HStack { + Text("Slider value: \(Int(viewStore.sliderValue))") + .font(Font.body.monospacedDigit()) + Slider( + value: viewStore.binding( + get: \.sliderValue, + send: BindingBasicsAction.sliderValueChanged + ), + in: 0...Double(viewStore.stepCount) + ) + } + .disabled(viewStore.toggleIsOn) + } + } + } + .navigationBarTitle("Bindings basics") + } +} + +private func alternate(_ string: String) -> String { + string + .enumerated() + .map { idx, char in + idx.isMultiple(of: 2) + ? char.uppercased() + : char.lowercased() + } + .joined() +} + +struct BindingBasicsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BindingBasicsView( + store: Store( + initialState: BindingBasicsState(), + reducer: bindingBasicsReducer, + environment: BindingBasicsEnvironment() + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift new file mode 100644 index 000000000000..f387e2ce4789 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift @@ -0,0 +1,79 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to take small features and compose them into bigger ones using the \ + `pullback` and `combine` operators on reducers, and the `scope` operator on stores. + + It reuses the the domain of the counter screen and embeds it, twice, in a larger domain. + """ + +struct TwoCountersState { + var counter1 = CounterState() + var counter2 = CounterState() +} + +enum TwoCountersAction { + case counter1(CounterAction) + case counter2(CounterAction) +} + +struct TwoCountersEnvironment {} + +let twoCountersReducer = Reducer + .combine( + counterReducer.pullback( + state: \TwoCountersState.counter1, + action: /TwoCountersAction.counter1, + environment: { _ in CounterEnvironment() } + ), + counterReducer.pullback( + state: \TwoCountersState.counter2, + action: /TwoCountersAction.counter2, + environment: { _ in CounterEnvironment() } + ) + ) + +struct TwoCountersView: View { + let store: Store + + var body: some View { + Form { + Section(header: Text(template: readMe, .caption)) { + HStack { + Text("Counter 1") + + CounterView( + store: self.store.scope(state: \.counter1, action: TwoCountersAction.counter1) + ) + .buttonStyle(BorderlessButtonStyle()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + HStack { + Text("Counter 2") + + CounterView( + store: self.store.scope(state: \.counter2, action: TwoCountersAction.counter2) + ) + .buttonStyle(BorderlessButtonStyle()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + } + .navigationBarTitle("Two counter demo") + } +} + +struct TwoCountersView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TwoCountersView( + store: Store( + initialState: TwoCountersState(), + reducer: twoCountersReducer, + environment: TwoCountersEnvironment() + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift new file mode 100644 index 000000000000..fc28eb80106a --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift @@ -0,0 +1,76 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates the basics of the Composable Architecture in an archetypal counter \ + application. + + The domain of the application is modeled using simple data types that correspond to the mutable \ + state of the application and any actions that can affect that state or the outside world. + """ + +struct CounterState: Equatable { + var count = 0 +} + +enum CounterAction: Equatable { + case decrementButtonTapped + case incrementButtonTapped +} + +struct CounterEnvironment {} + +let counterReducer = Reducer { state, action, _ in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + } +} + +struct CounterView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + HStack { + Button("−") { viewStore.send(.decrementButtonTapped) } + Text("\(viewStore.count)") + .font(Font.body.monospacedDigit()) + Button("+") { viewStore.send(.incrementButtonTapped) } + } + } + } +} + +struct CounterDemoView: View { + let store: Store + + var body: some View { + Form { + Section(header: Text(readMe)) { + CounterView(store: self.store) + .buttonStyle(BorderlessButtonStyle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationBarTitle("Counter demo") + } +} + +struct CounterView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + CounterDemoView( + store: Store( + initialState: CounterState(), + reducer: counterReducer, + environment: CounterEnvironment() + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift new file mode 100644 index 000000000000..d65e9e37f3fd --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift @@ -0,0 +1,102 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to show and hide views based on the presence of some optional child \ + state. + + The parent state holds a `CounterState?` value. When it is `nil` we will default to a plain text \ + view. But when it is non-`nil` we will show a view fragment for a counter that operates on the \ + non-optional counter state. + + Tapping "Toggle counter state" will flip between the `nil` and non-`nil` counter states. + """ + +struct OptionalBasicsState: Equatable { + var optionalCounter: CounterState? +} + +enum OptionalBasicsAction: Equatable { + case optionalCounter(CounterAction) + case toggleCounterButtonTapped +} + +struct OptionalBasicsEnvironment {} + +let optionalBasicsReducer = Reducer< + OptionalBasicsState, OptionalBasicsAction, OptionalBasicsEnvironment +>.combine( + Reducer { state, action, environment in + switch action { + case .toggleCounterButtonTapped: + state.optionalCounter = + state.optionalCounter == nil + ? CounterState() + : nil + return .none + case .optionalCounter: + return .none + } + }, + counterReducer.optional.pullback( + state: \.optionalCounter, + action: /OptionalBasicsAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) +) + +struct OptionalBasicsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(template: readMe, .caption)) { + Button("Toggle counter state") { + viewStore.send(.toggleCounterButtonTapped) + } + + IfLetStore( + self.store.scope( + state: \.optionalCounter, action: OptionalBasicsAction.optionalCounter), + then: { store in + VStack(alignment: .leading, spacing: 16) { + Text(template: "`CounterState` is non-`nil`", .body) + CounterView(store: store) + .buttonStyle(BorderlessButtonStyle()) + } + }, + else: Text(template: "`CounterState` is `nil`", .body) + ) + } + } + } + .navigationBarTitle("Optional state") + } +} + +struct OptionalBasicsView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + OptionalBasicsView( + store: Store( + initialState: OptionalBasicsState(), + reducer: optionalBasicsReducer, + environment: OptionalBasicsEnvironment() + ) + ) + } + + NavigationView { + OptionalBasicsView( + store: Store( + initialState: OptionalBasicsState(optionalCounter: CounterState(count: 42)), + reducer: optionalBasicsReducer, + environment: OptionalBasicsEnvironment() + ) + ) + } + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift new file mode 100644 index 000000000000..4eaf03ffbccd --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift @@ -0,0 +1,165 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to introduce side effects into a feature built with the \ + Composable Architecture. + + A side effect is a unit of work that needs to be performed in the outside world. For example, an \ + API request needs to reach an external service over HTTP, which brings with it lots of \ + uncertainty and complexity. + + Many things we do in our applications involve side effects, such as timers, database requests, \ + file access, socket connections, and anytime a scheduler is involved (such as debouncing, \ + throttling and delaying), and they are typically difficult to test. + + This application has two simple side effects: + + • Each time you count down the number will be incremented back up after a delay of 1 second. + • Tapping "Number fact" will trigger an API request to load a piece of trivia about that number. + + Both effects are handled by the reducer, and a full test suite is written to confirm that the \ + effects behave in the way we expect. + """ + +// MARK: - Feature domain + +struct EffectsBasicsState: Equatable { + var count = 0 + var isNumberFactRequestInFlight = false + var numberFact: String? +} + +enum EffectsBasicsAction: Equatable { + case decrementButtonTapped + case incrementButtonTapped + case numberFactButtonTapped + case numberFactResponse(Result) +} + +struct NumbersApiError: Error, Equatable {} + +struct EffectsBasicsEnvironment { + var mainQueue: AnySchedulerOf + var numberFact: (Int) -> Effect + + static let live = EffectsBasicsEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler(), + numberFact: liveNumberFact(for:) + ) +} + +// MARK: - Feature business logic + +let effectsBasicsReducer = Reducer< + EffectsBasicsState, EffectsBasicsAction, EffectsBasicsEnvironment +> { state, action, environment in + switch action { + case .decrementButtonTapped: + state.count -= 1 + state.numberFact = nil + // Return an effect that re-increments the count after 1 second. + return Effect(value: EffectsBasicsAction.incrementButtonTapped) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + + case .incrementButtonTapped: + state.count += 1 + state.numberFact = nil + return .none + + case .numberFactButtonTapped: + state.isNumberFactRequestInFlight = true + state.numberFact = nil + // Return an effect that fetches a number fact from the API and returns the + // value back to the reducer's `numberFactResponse` action. + return environment.numberFact(state.count) + .receive(on: environment.mainQueue) + .catchToEffect() + .map(EffectsBasicsAction.numberFactResponse) + + case let .numberFactResponse(.success(response)): + state.isNumberFactRequestInFlight = false + state.numberFact = response + return .none + + case .numberFactResponse(.failure): + state.isNumberFactRequestInFlight = false + return .none + } +} + +// MARK: - Feature view + +struct EffectsBasicsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + EmptyView() + } + + Section( + footer: Button("Number facts provided by numbersapi.com") { + UIApplication.shared.open(URL(string: "http://numbersapi.com")!) + } + ) { + HStack { + Spacer() + Button("−") { viewStore.send(.decrementButtonTapped) } + Text("\(viewStore.count)") + .font(Font.body.monospacedDigit()) + Button("+") { viewStore.send(.incrementButtonTapped) } + Spacer() + } + .buttonStyle(BorderlessButtonStyle()) + + Button("Number fact") { viewStore.send(.numberFactButtonTapped) } + if viewStore.isNumberFactRequestInFlight { + ActivityIndicator() + } + + viewStore.numberFact.map(Text.init) + } + } + } + .navigationBarTitle("Effects") + } +} + +// MARK: - Feature SwiftUI previews + +struct EffectsBasicsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EffectsBasicsView( + store: Store( + initialState: EffectsBasicsState(), + reducer: effectsBasicsReducer, + environment: EffectsBasicsEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler(), + numberFact: liveNumberFact(for:)) + ) + ) + } + } +} + +// This is the "live" trivia dependency that reaches into the outside world to fetch trivia. +// Typically this live implementation of the dependency would live in its own module so that the +// main feature doesn't need to compile it. +private func liveNumberFact(for n: Int) -> Effect { + return URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(n)/trivia")!) + .map { data, _ in String(decoding: data, as: UTF8.self) } + .catch { _ in + // Sometimes numbersapi.com can be flakey, so if it ever fails we will just + // default to a mock response. + Just("\(n) is a good number Brent") + .delay(for: 1, scheduler: DispatchQueue.main) + } + .mapError { _ in NumbersApiError() } + .eraseToEffect() +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift new file mode 100644 index 000000000000..a5b0c0fac720 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift @@ -0,0 +1,161 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can cancel in-flight effects in the Composable Architecture. + + Use the stepper to count to a number, and then tap the "Number fact" button to fetch \ + a random fact about that number using an API. + + While the API request is in-flight, you can tap "Cancel" to cancel the effect and prevent \ + it from feeding data back into the application. Interacting with the stepper while a \ + request is in-flight will also cancel it. + """ + +// MARK: - Demo app domain + +struct EffectsCancellationState: Equatable { + var count = 0 + var currentTrivia: String? + var isTriviaRequestInFlight = false +} + +enum EffectsCancellationAction: Equatable { + case cancelButtonTapped + case stepperChanged(Int) + case triviaButtonTapped + case triviaResponse(Result) +} + +struct TriviaApiError: Error, Equatable {} + +struct EffectsCancellationEnvironment { + var mainQueue: AnySchedulerOf + var trivia: (Int) -> Effect + + static let live = EffectsCancellationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler(), + trivia: liveTrivia(for:) + ) +} + +// MARK: - Business logic + +let effectsCancellationReducer = Reducer< + EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment +> { state, action, environment in + + struct TriviaRequestId: Hashable {} + + switch action { + case .cancelButtonTapped: + state.isTriviaRequestInFlight = false + return .cancel(id: TriviaRequestId()) + + case let .stepperChanged(value): + state.count = value + state.currentTrivia = nil + state.isTriviaRequestInFlight = false + return .cancel(id: TriviaRequestId()) + + case .triviaButtonTapped: + state.currentTrivia = nil + state.isTriviaRequestInFlight = true + + return environment.trivia(state.count) + .receive(on: environment.mainQueue) + .catchToEffect() + .map(EffectsCancellationAction.triviaResponse) + .cancellable(id: TriviaRequestId()) + + case let .triviaResponse(.success(response)): + state.isTriviaRequestInFlight = false + state.currentTrivia = response + return .none + + case .triviaResponse(.failure): + state.isTriviaRequestInFlight = false + return .none + } +} + +// MARK: - Application view + +struct EffectsCancellationView: View { + let store: Store + + init(store: Store) { + self.store = store + } + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section( + header: Text(readMe), + footer: Button("Number facts provided by numbersapi.com") { + UIApplication.shared.open(URL(string: "http://numbersapi.com")!) + } + ) { + Stepper( + value: viewStore.binding(get: \.count, send: EffectsCancellationAction.stepperChanged) + ) { + Text("\(viewStore.count)") + } + + if viewStore.isTriviaRequestInFlight { + HStack { + Button("Cancel") { viewStore.send(.cancelButtonTapped) } + Spacer() + ActivityIndicator() + } + } else { + Button("Number fact") { viewStore.send(.triviaButtonTapped) } + .disabled(viewStore.isTriviaRequestInFlight) + } + + viewStore.currentTrivia.map { + Text($0).padding([.top, .bottom], 8) + } + } + } + } + .navigationBarTitle("Effect cancellation") + } +} + +// MARK: - SwiftUI previews + +struct EffectsCancellation_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EffectsCancellationView( + store: Store( + initialState: EffectsCancellationState(), + reducer: effectsCancellationReducer, + environment: EffectsCancellationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler(), + trivia: liveTrivia(for:) + ) + ) + ) + } + } +} + +// This is the "live" trivia dependency that reaches into the outside world to fetch trivia. +// Typically this live implementation of the dependency would live in its own module so that the +// main feature doesn't need to compile it. +private func liveTrivia(for n: Int) -> Effect { + URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(n)/trivia")!) + .map { data, _ in String.init(decoding: data, as: UTF8.self) } + .catch { _ in + // Sometimes numbersapi.com can be flakey, so if it ever fails we will just + // default to a mock response. + Just("\(n) is a good number Brent") + .delay(for: 1, scheduler: DispatchQueue.main) + } + .mapError { _ in TriviaApiError() } + .eraseToEffect() +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift new file mode 100644 index 000000000000..c3012b4c5802 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift @@ -0,0 +1,123 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to handle long-living effects, for example notifications from \ + Notification Center. + + Run this application in the simulator, and take a few screenshots by going to \ + *Device › Screenshot* in the menu, and observe that the UI counts the number of times that \ + happens. + + Then, navigate to another screen and take screenshots there, and observe that this screen does \ + *not* count those screenshots. + """ + +// MARK: - Application domain + +struct LongLivingEffectsState: Equatable { + var screenshotCount = 0 +} + +enum LongLivingEffectsAction { + case userDidTakeScreenshotNotification + case onAppear + case onDisappear +} + +struct LongLivingEffectsEnvironment { + // An effect that emits Void whenever the user takes a screenshot of the device. We use this + // instead of `NotificationCenter.default.publisher` directly in the reducer so that we can test + // it. + var userDidTakeScreenshot: Effect + + static let live = LongLivingEffectsEnvironment( + userDidTakeScreenshot: NotificationCenter.default + .publisher(for: UIApplication.userDidTakeScreenshotNotification) + .map { _ in () } + .eraseToEffect() + ) +} + +// MARK: - Business logic + +let longLivingEffectsReducer = Reducer< + LongLivingEffectsState, LongLivingEffectsAction, LongLivingEffectsEnvironment +> { state, action, environment in + + struct UserDidTakeScreenshotNotificationId: Hashable {} + + switch action { + case .userDidTakeScreenshotNotification: + state.screenshotCount += 1 + return .none + + case .onAppear: + // When the view appears, start the effect that emits when screenshots are taken. + return environment.userDidTakeScreenshot + .map { LongLivingEffectsAction.userDidTakeScreenshotNotification } + .cancellable(id: UserDidTakeScreenshotNotificationId()) + + case .onDisappear: + // When view disappears, stop the effect. + return .cancel(id: UserDidTakeScreenshotNotificationId()) + } +} + +// MARK: - SwiftUI view + +struct LongLivingEffectsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(template: readMe, .body)) { + Text("A screenshot of this screen has been taken \(viewStore.screenshotCount) times.") + .font(Font.headline) + } + + Section { + NavigationLink(destination: self.detailView) { + Text("Navigate to another screen") + } + } + } + .navigationBarTitle("Long-living effects") + .onAppear { viewStore.send(.onAppear) } + .onDisappear { viewStore.send(.onDisappear) } + } + } + + var detailView: some View { + Text( + """ + Take a screenshot of this screen a few times, and then go back to the previous screen to see \ + that those screenshots were not counted. + """ + ) + .padding([.leading, .trailing], 64) + } +} + +// MARK: - SwiftUI previews + +struct EffectsLongLiving_Previews: PreviewProvider { + static var previews: some View { + let appView = LongLivingEffectsView( + store: Store( + initialState: LongLivingEffectsState(), + reducer: longLivingEffectsReducer, + environment: LongLivingEffectsEnvironment( + userDidTakeScreenshot: .none + ) + ) + ) + + return Group { + NavigationView { appView } + NavigationView { appView.detailView } + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift new file mode 100644 index 000000000000..254260183c47 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift @@ -0,0 +1,136 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to work with timers in the Composable Architecture. + + Although the Combine framework comes with a `Timer.publisher` API, and it is possible to use \ + that API in the Composable Architecture, it is not easy to test. That is why we have provided an \ + `Effect.timer` API that works with schedulers and can be tested. + """ + +// MARK: - Timer feature domain + +struct TimersState: Equatable { + var isTimerActive = false + var secondsElapsed = 0 +} + +enum TimersAction { + case timerTicked + case toggleTimerButtonTapped +} + +struct TimersEnvironment { + var mainQueue: AnySchedulerOf +} + +let timersReducer = Reducer { + state, action, environment in + struct TimerId: Hashable {} + + switch action { + case .timerTicked: + state.secondsElapsed += 1 + return .none + + case .toggleTimerButtonTapped: + state.isTimerActive.toggle() + return state.isTimerActive + ? Effect.timer(id: TimerId(), every: 1, tolerance: .zero, on: environment.mainQueue) + .map { _ in TimersAction.timerTicked } + : Effect.cancel(id: TimerId()) + } +} + +// MARK: - Timer feature view + +struct TimersView: View { + // NB: We are using an explicit `ObservedObject` for the view store here instead of + // `WithViewStore` due to a SwiftUI bug where `GeometryReader`s inside `WithViewStore` will + // not properly update. + // + // Feedback filed: https://gist.github.com/mbrandonw/cc5da3d487bcf7c4f21c27019a440d18 + @ObservedObject var viewStore: ViewStore + + init(store: Store) { + self.viewStore = ViewStore(store) + } + + var body: some View { + VStack { + Text(template: readMe, .body) + + ZStack { + Circle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [ + Color.blue.opacity(0.3), + .blue, + .blue, + .green, + .green, + .yellow, + .yellow, + .red, + .red, + .purple, + .purple, + Color.purple.opacity(0.3), + ] + ), + center: .center + ) + ) + .rotationEffect(Angle(degrees: -90)) + + GeometryReader { proxy in + Path { path in + path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0)) + } + .stroke(Color.black, lineWidth: 3) + .rotationEffect(.degrees(Double(self.viewStore.secondsElapsed) * 360 / 60)) + .animation(Animation.interpolatingSpring(stiffness: 3000, damping: 40)) + } + } + .frame(width: 280, height: 280) + .padding([.bottom], 16) + + Button(action: { self.viewStore.send(.toggleTimerButtonTapped) }) { + HStack { + Text(self.viewStore.isTimerActive ? "Stop" : "Start") + } + .foregroundColor(.white) + .padding() + .background(self.viewStore.isTimerActive ? Color.red : .blue) + .cornerRadius(16) + } + + Spacer() + } + .padding() + .navigationBarTitle("Timers") + } +} + +// MARK: - SwiftUI previews + +struct TimersView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TimersView( + store: Store( + initialState: TimersState(), + reducer: timersReducer, + environment: TimersEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-GettingStarted-SharedState.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-GettingStarted-SharedState.swift new file mode 100644 index 000000000000..788b08dbf290 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-GettingStarted-SharedState.swift @@ -0,0 +1,281 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how multiple independent screens can share state in the Composable \ + Architecture. Each tab manages its own state, and could be in separate modules, but changes in \ + one tab are immediately reflected in the other. + + This tab has its own state, consisting of a count value that can be incremented and decremented, \ + as well as an alert value that is set when asking if the current count is prime. + + Internally, it is also keeping track of various stats, such as min and max counts and total \ + number of count events that occurred. Those states are viewable in the other tab, and the stats \ + can be reset from the other tab. + """ + +struct SharedState: Equatable { + var counter = CounterState() + var currentTab = Tab.counter + + enum Tab { case counter, profile } + + struct CounterState: Equatable { + var alert: String? + var count = 0 + var maxCount = 0 + var minCount = 0 + var numberOfCounts = 0 + } + + // The ProfileState can be derived from the CounterState by getting and setting the parts it cares + // about. This allows the profile feature to operate on a subset of app state instead of the whole + // thing. + var profile: ProfileState { + get { + ProfileState( + currentTab: self.currentTab, + count: self.counter.count, + maxCount: self.counter.maxCount, + minCount: self.counter.minCount, + numberOfCounts: self.counter.numberOfCounts + ) + } + set { + self.currentTab = newValue.currentTab + self.counter.count = newValue.count + self.counter.maxCount = newValue.maxCount + self.counter.minCount = newValue.minCount + self.counter.numberOfCounts = newValue.numberOfCounts + } + } + + struct ProfileState: Equatable { + private(set) var currentTab: Tab + private(set) var count = 0 + private(set) var maxCount: Int + private(set) var minCount: Int + private(set) var numberOfCounts: Int + + fileprivate mutating func resetCount() { + self.currentTab = .counter + self.count = 0 + self.maxCount = 0 + self.minCount = 0 + self.numberOfCounts = 0 + } + } +} + +enum SharedStateAction { + case counter(CounterAction) + case profile(ProfileAction) + case selectTab(SharedState.Tab) + + enum CounterAction { + case alertDismissed + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + } + + enum ProfileAction { + case resetCounterButtonTapped + } +} + +let sharedStateCounterReducer = Reducer< + SharedState.CounterState, SharedStateAction.CounterAction, Void +> { state, action, _ in + switch action { + case .alertDismissed: + state.alert = nil + return .none + + case .decrementButtonTapped: + state.count -= 1 + state.numberOfCounts += 1 + state.minCount = min(state.minCount, state.count) + return .none + + case .incrementButtonTapped: + state.count += 1 + state.numberOfCounts += 1 + state.maxCount = max(state.maxCount, state.count) + return .none + + case .isPrimeButtonTapped: + state.alert = + isPrime(state.count) + ? "👍 The number \(state.count) is prime!" + : "👎 The number \(state.count) is not prime :(" + return .none + } +} + +let sharedStateProfileReducer = Reducer< + SharedState.ProfileState, SharedStateAction.ProfileAction, Void +> { state, action, _ in + switch action { + case .resetCounterButtonTapped: + state.resetCount() + return .none + } +} + +let sharedStateReducer: Reducer = .combine( + sharedStateCounterReducer.pullback( + state: \SharedState.counter, + action: /SharedStateAction.counter, + environment: { _ in () } + ), + sharedStateProfileReducer.pullback( + state: \SharedState.profile, + action: /SharedStateAction.profile, + environment: { _ in () } + ), + Reducer { state, action, _ in + switch action { + case .counter, .profile: + return .none + case let .selectTab(tab): + state.currentTab = tab + return .none + } + } +) + +struct SharedStateView: View { + let store: Store + + var body: some View { + WithViewStore(self.store.scope(state: \.currentTab)) { viewStore in + VStack { + Picker( + "Tab", + selection: viewStore.binding(send: SharedStateAction.selectTab) + ) { + Text("Counter") + .tag(SharedState.Tab.counter) + + Text("Profile") + .tag(SharedState.Tab.profile) + } + .pickerStyle(SegmentedPickerStyle()) + + if viewStore.state == .counter { + SharedStateCounterView( + store: self.store.scope(state: \.counter, action: SharedStateAction.counter)) + } + + if viewStore.state == .profile { + SharedStateProfileView( + store: self.store.scope(state: \.profile, action: SharedStateAction.profile)) + } + + Spacer() + } + } + .padding() + } +} + +struct SharedStateCounterView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(spacing: 64) { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button("−") { viewStore.send(.decrementButtonTapped) } + + Text("\(viewStore.count)") + .font(Font.body.monospacedDigit()) + + Button("+") { viewStore.send(.incrementButtonTapped) } + } + + Button("Is this prime?") { viewStore.send(.isPrimeButtonTapped) } + } + } + .padding(16) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top) + .navigationBarTitle("Shared State Demo") + .alert( + item: viewStore.binding( + get: { $0.alert.map(PrimeAlert.init(title:)) }, + send: .alertDismissed + ) + ) { alert in + SwiftUI.Alert(title: Text(alert.title)) + } + } + } +} + +struct SharedStateProfileView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(spacing: 64) { + Text( + template: """ + This tab shows state from the previous tab, and it is capable of reseting all of the \ + state back to 0. + + This shows that it is possible to for each screen to model its state in the way that \ + makes the most sense for it, while still allowing the state and mutations to be shared \ + across independent screens. + """, + .caption + ) + + VStack(spacing: 16) { + Text("Current count: \(viewStore.count)") + Text("Max count: \(viewStore.maxCount)") + Text("Min count: \(viewStore.minCount)") + Text("Total number of count events: \(viewStore.numberOfCounts)") + Button("Reset") { viewStore.send(.resetCounterButtonTapped) } + } + } + .padding(16) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top) + .navigationBarTitle("Profile") + } + } +} + +private struct PrimeAlert: Equatable, Identifiable { + let title: String + var id: String { self.title } +} + +// MARK: - SwiftUI previews + +struct SharedState_Previews: PreviewProvider { + static var previews: some View { + SharedStateView( + store: Store( + initialState: SharedState(), + reducer: sharedStateReducer, + environment: () + ) + ) + } +} + +// MARK: - Private helpers + +/// Checks if a number is prime or not. +private func isPrime(_ p: Int) -> Bool { + if p <= 1 { return false } + if p <= 3 { return true } + for i in 2...Int(sqrtf(Float(p))) { + if p % i == 0 { return false } + } + return true +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-SystemEnvironment.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-SystemEnvironment.swift new file mode 100644 index 000000000000..aea56a0bcbcf --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Effects-SystemEnvironment.swift @@ -0,0 +1,237 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can share system-wide dependencies across many features with \ + very little work. The idea is to create a `SystemEnvironment` generic type that wraps an \ + environment, and then implement dynamic member lookup so that you can seamlessly use the \ + dependencies in both environments. + + Then, throughout your application you can wrap your environments in the `SystemEnvironment` \ + to get instant access to all of the shared dependencies. Some good candidates for dependencies \ + to share are things like date initializers, schedulers (especially `DispatchQueue.main`), `UUID` \ + initializers, and any other dependency in your application that you want every reducer to have \ + access to. + """ + +struct MultipleDependenciesState: Equatable { + var alertTitle: String? + var dateString: String? + var fetchedNumberString: String? + var isFetchInFlight = false + var uuidString: String? +} + +enum MultipleDependenciesAction { + case alertButtonTapped + case alertDelayReceived + case alertDismissed + case dateButtonTapped + case fetchNumberButtonTapped + case fetchNumberResponse(Int) + case uuidButtonTapped +} + +struct MultipleDependenciesEnvironment { + var fetchNumber: () -> Effect +} + +let multipleDependenciesReducer = Reducer< + MultipleDependenciesState, + MultipleDependenciesAction, + SystemEnvironment +> { state, action, environment in + + switch action { + case .alertButtonTapped: + return Effect(value: .alertDelayReceived) + .delay(for: 1, scheduler: environment.mainQueue()) + .eraseToEffect() + + case .alertDelayReceived: + state.alertTitle = "Here's an alert after a delay!" + return .none + + case .alertDismissed: + state.alertTitle = nil + return .none + + case .dateButtonTapped: + state.dateString = "\(environment.date())" + return .none + + case .fetchNumberButtonTapped: + state.isFetchInFlight = true + return environment.fetchNumber() + .map(MultipleDependenciesAction.fetchNumberResponse) + + case let .fetchNumberResponse(number): + state.isFetchInFlight = false + state.fetchedNumberString = "\(number)" + return .none + + case .uuidButtonTapped: + state.uuidString = "\(environment.uuid())" + return .none + } +} + +struct MultipleDependenciesView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section( + header: Text(template: readMe, .caption) + ) { + EmptyView() + } + + Section( + header: Text( + template: """ + The actions below make use of the dependencies in the `SystemEnvironment`. + """, .caption) + ) { + HStack { + Button("Date") { viewStore.send(.dateButtonTapped) } + viewStore.dateString.map(Text.init) + } + + HStack { + Button("UUID") { viewStore.send(.uuidButtonTapped) } + viewStore.uuidString.map(Text.init) + } + + Button("Delayed Alert") { viewStore.send(.alertButtonTapped) } + .alert( + item: viewStore.binding( + get: { $0.alertTitle.map(Alert.init(title:)) }, + send: { _ in .alertDismissed } + ) + ) { + SwiftUI.Alert(title: Text($0.title)) + } + } + + Section( + header: Text( + template: """ + The actions below make use of the custom environment for this screen, which holds a \ + dependency for fetching a random number. + """, .caption) + ) { + HStack { + Button("Fetch Number") { viewStore.send(.fetchNumberButtonTapped) } + viewStore.fetchedNumberString.map(Text.init) + + Spacer() + + if viewStore.isFetchInFlight { + ActivityIndicator() + } + } + } + } + .buttonStyle(BorderlessButtonStyle()) + } + .navigationBarTitle("System Environment") + } + + struct Alert: Identifiable { + var title: String + var id: String { self.title } + } +} + +struct MultipleDependenciesView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + MultipleDependenciesView( + store: Store( + initialState: .init(), + reducer: multipleDependenciesReducer, + environment: .live( + environment: MultipleDependenciesEnvironment( + fetchNumber: { + Effect(value: Int.random(in: 1...1_000)) + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToEffect() + }) + ) + ) + ) + } + } +} + +@dynamicMemberLookup +struct SystemEnvironment { + var date: () -> Date + var environment: Environment + var mainQueue: () -> AnySchedulerOf + var uuid: () -> UUID + + subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Dependency { + get { self.environment[keyPath: keyPath] } + set { self.environment[keyPath: keyPath] = newValue } + } + + /// Creates a live system environment with the wrapped environment provided. + /// + /// - Parameter environment: An environment to be wrapped in the system environment. + /// - Returns: A new system environment. + static func live(environment: Environment) -> Self { + Self( + date: Date.init, + environment: environment, + mainQueue: { DispatchQueue.main.eraseToAnyScheduler() }, + uuid: UUID.init + ) + } + + /// Transforms the underlying wrapped environment. + func map( + _ transform: @escaping (Environment) -> NewEnvironment + ) -> SystemEnvironment { + .init( + date: self.date, + environment: transform(self.environment), + mainQueue: self.mainQueue, + uuid: self.uuid + ) + } +} + +#if DEBUG + extension SystemEnvironment { + static func mock( + date: @escaping () -> Date = { fatalError("date dependency is unimplemented.") }, + environment: Environment, + mainQueue: @escaping () -> AnySchedulerOf = { fatalError() }, + uuid: @escaping () -> UUID = { fatalError("UUID dependency is unimplemented.") } + ) -> Self { + Self( + date: date, + environment: environment, + mainQueue: { mainQueue().eraseToAnyScheduler() }, + uuid: uuid + ) + } + } +#endif + +extension UUID { + /// A deterministic, auto-incrementing "UUID" generator for testing. + static var incrementing: () -> UUID { + var uuid = 0 + return { + defer { uuid += 1 } + return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", uuid))")! + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift new file mode 100644 index 000000000000..7090ad4c79ea --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift @@ -0,0 +1,133 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state from a list element. + + Tapping a row fires off an effect that will load its associated counter state a second later. \ + When the counter state is present, you will be programmatically navigated to the screen that \ + depends on this data. + """ + +struct LazyListNavigationState: Equatable { + var rows: IdentifiedArrayOf = [] + var selection: Identified? + + struct Row: Equatable, Identifiable { + var count: Int + let id: UUID + var isActivityIndicatorVisible = false + } +} + +enum LazyListNavigationAction: Equatable { + case counter(CounterAction) + case setNavigation(selection: UUID?) + case setNavigationSelectionDelayCompleted(UUID) +} + +struct LazyListNavigationEnvironment { + var mainQueue: AnySchedulerOf +} + +let lazyListNavigationReducer = Reducer< + LazyListNavigationState, LazyListNavigationAction, LazyListNavigationEnvironment +>.combine( + Reducer { state, action, environment in + switch action { + case .counter: + return .none + + case let .setNavigation(selection: .some(id)): + for index in state.rows.indices { + state.rows[index].isActivityIndicatorVisible = state.rows[index].id == id + } + + struct CancelId: Hashable {} + + return Effect(value: .setNavigationSelectionDelayCompleted(id)) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId(), cancelInFlight: true) + + case .setNavigation(selection: .none): + if let selection = state.selection { + state.rows[selection.id]?.count = selection.count + state.selection = nil + } + return .none + + case let .setNavigationSelectionDelayCompleted(id): + state.rows[id]?.isActivityIndicatorVisible = false + state.selection = Identified( + CounterState(count: state.rows[id]?.count ?? 0), + id: id + ) + return .none + } + }, + counterReducer.optional.pullback( + state: \.selection[ifLet: \.value], + action: /LazyListNavigationAction.counter, + environment: { _ in CounterEnvironment() } + ) +) + +struct LazyListNavigationView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + ForEach(viewStore.rows) { row in + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.selection?.value, action: LazyListNavigationAction.counter), + then: CounterView.init(store:) + ), + tag: row.id, + selection: viewStore.binding( + get: \.selection?.id, + send: LazyListNavigationAction.setNavigation(selection:) + ) + ) { + HStack { + Text("Load optional counter that starts from \(row.count)") + if row.isActivityIndicatorVisible { + Spacer() + ActivityIndicator() + } + } + } + } + } + } + .navigationBarTitle("Load then navigate") + } + } +} + +struct LazyListNavigationView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LazyListNavigationView( + store: Store( + initialState: LazyListNavigationState( + rows: [ + .init(count: 1, id: UUID()), + .init(count: 42, id: UUID()), + .init(count: 100, id: UUID()), + ] + ), + reducer: lazyListNavigationReducer, + environment: LazyListNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift new file mode 100644 index 000000000000..3d0993c7e80f --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift @@ -0,0 +1,122 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state from a list element. + + Tapping a row simultaneously navigates to a screen that depends on its associated counter state \ + and fires off an effect that will load this state a second later. + """ + +struct EagerListNavigationState: Equatable { + var rows: IdentifiedArrayOf = [] + var selection: Identified? + + struct Row: Equatable, Identifiable { + var count: Int + let id: UUID + } +} + +enum EagerListNavigationAction: Equatable { + case counter(CounterAction) + case setNavigation(selection: UUID?) + case setNavigationSelectionDelayCompleted +} + +struct EagerListNavigationEnvironment { + var mainQueue: AnySchedulerOf +} + +let eagerListNavigationReducer = Reducer< + EagerListNavigationState, EagerListNavigationAction, EagerListNavigationEnvironment +>.combine( + Reducer { state, action, environment in + + struct CancelId: Hashable {} + + switch action { + case .counter: + return .none + + case let .setNavigation(selection: .some(id)): + state.selection = Identified(nil, id: id) + + return Effect(value: .setNavigationSelectionDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId()) + + case .setNavigation(selection: .none): + if let selection = state.selection, let count = selection.value?.count { + state.rows[selection.id]?.count = count + state.selection = nil + } + return .cancel(id: CancelId()) + + case .setNavigationSelectionDelayCompleted: + guard let id = state.selection?.id else { return .none } + state.selection?.value = CounterState(count: state.rows[id]?.count ?? 0) + return .none + } + }, + counterReducer.optional.optional.pullback( + state: \.selection[ifLet: \.value], + action: /EagerListNavigationAction.counter, + environment: { _ in CounterEnvironment() } + ) +) + +struct EagerListNavigationView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + ForEach(viewStore.rows) { row in + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.selection?.value, action: EagerListNavigationAction.counter), + then: CounterView.init(store:), + else: ActivityIndicator() + ), + tag: row.id, + selection: viewStore.binding( + get: \.selection?.id, + send: EagerListNavigationAction.setNavigation(selection:) + ) + ) { + Text("Load optional counter that starts from \(row.count)") + } + } + } + } + } + .navigationBarTitle("Navigate and load") + } +} + +struct EagerListNavigationView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EagerListNavigationView( + store: Store( + initialState: EagerListNavigationState( + rows: [ + .init(count: 1, id: UUID()), + .init(count: 42, id: UUID()), + .init(count: 100, id: UUID()), + ] + ), + reducer: eagerListNavigationReducer, + environment: EagerListNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift new file mode 100644 index 000000000000..e54a048a643f --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift @@ -0,0 +1,108 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state. + + Tapping "Load optional counter" fires off an effect that will load the counter state a second \ + later. When the counter state is present, you will be programmatically navigated to the screen \ + that depends on this data. + """ + +struct LazyNavigationState: Equatable { + var optionalCounter: CounterState? + var isActivityIndicatorVisible = false + + var isNavigationActive: Bool { self.optionalCounter != nil } +} + +enum LazyNavigationAction: Equatable { + case optionalCounter(CounterAction) + case setNavigation(isActive: Bool) + case setNavigationIsActiveDelayCompleted +} + +struct LazyNavigationEnvironment { + var mainQueue: AnySchedulerOf +} + +let lazyNavigationReducer = Reducer< + LazyNavigationState, LazyNavigationAction, LazyNavigationEnvironment +>.combine( + Reducer { state, action, environment in + switch action { + case .setNavigation(isActive: true): + state.isActivityIndicatorVisible = true + return Effect(value: .setNavigationIsActiveDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + + case .setNavigation(isActive: false): + state.optionalCounter = nil + return .none + + case .setNavigationIsActiveDelayCompleted: + state.isActivityIndicatorVisible = false + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + }, + counterReducer.optional.pullback( + state: \.optionalCounter, + action: /LazyNavigationAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) +) + +struct LazyNavigationView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.optionalCounter, action: LazyNavigationAction.optionalCounter), + then: CounterView.init(store:) + ), + isActive: viewStore.binding( + get: \.isNavigationActive, + send: LazyNavigationAction.setNavigation(isActive:) + ) + ) { + HStack { + Text("Load optional counter") + if viewStore.isActivityIndicatorVisible { + Spacer() + ActivityIndicator() + } + } + } + } + } + } + .navigationBarTitle("Load then navigate") + } +} + +struct LazyNavigationView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LazyNavigationView( + store: Store( + initialState: LazyNavigationState(), + reducer: lazyNavigationReducer, + environment: LazyNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift new file mode 100644 index 000000000000..6e3e889441f9 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift @@ -0,0 +1,104 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import UIKit + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state. + + Tapping "Load optional counter" simultaneously navigates to a screen that depends on optional \ + counter state and fires off an effect that will load this state a second later. + """ + +struct EagerNavigationState: Equatable { + var isNavigationActive = false + var optionalCounter: CounterState? +} + +enum EagerNavigationAction: Equatable { + case optionalCounter(CounterAction) + case setNavigation(isActive: Bool) + case setNavigationIsActiveDelayCompleted +} + +struct EagerNavigationEnvironment { + var mainQueue: AnySchedulerOf +} + +let eagerNavigationReducer = Reducer< + EagerNavigationState, EagerNavigationAction, EagerNavigationEnvironment +>.combine( + Reducer { state, action, environment in + switch action { + case .setNavigation(isActive: true): + state.isNavigationActive = true + return Effect(value: .setNavigationIsActiveDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + + case .setNavigation(isActive: false): + state.isNavigationActive = false + state.optionalCounter = nil + return .none + + case .setNavigationIsActiveDelayCompleted: + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + }, + counterReducer.optional.pullback( + state: \.optionalCounter, + action: /EagerNavigationAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) +) + +struct EagerNavigationView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.optionalCounter, action: EagerNavigationAction.optionalCounter), + then: CounterView.init(store:), + else: ActivityIndicator() + ), + isActive: viewStore.binding( + get: \.isNavigationActive, + send: EagerNavigationAction.setNavigation(isActive:) + ) + ) { + HStack { + Text("Load optional counter") + } + } + } + } + } + .navigationBarTitle("Navigate and load") + } +} + +struct EagerNavigationView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EagerNavigationView( + store: Store( + initialState: EagerNavigationState(), + reducer: eagerNavigationReducer, + environment: EagerNavigationEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift new file mode 100644 index 000000000000..aabd1a315fe5 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift @@ -0,0 +1,108 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional data into state. + + Tapping "Load optional counter" fires off an effect that will load the counter state a second \ + later. When the counter state is present, you will be programmatically presented a sheet that \ + depends on this data. + """ + +struct LazySheetState: Equatable { + var optionalCounter: CounterState? + var isActivityIndicatorVisible = false + + var isSheetPresented: Bool { self.optionalCounter != nil } +} + +enum LazySheetAction { + case optionalCounter(CounterAction) + case setSheet(isPresented: Bool) + case setSheetIsPresentedDelayCompleted +} + +struct LazySheetEnvironment { + var mainQueue: AnySchedulerOf +} + +let lazySheetReducer = Reducer< + LazySheetState, LazySheetAction, LazySheetEnvironment +>.combine( + Reducer { state, action, environment in + switch action { + case .setSheet(isPresented: true): + state.isActivityIndicatorVisible = true + return Effect(value: .setSheetIsPresentedDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + + case .setSheet(isPresented: false): + state.optionalCounter = nil + return .none + + case .setSheetIsPresentedDelayCompleted: + state.isActivityIndicatorVisible = false + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + }, + counterReducer.optional.pullback( + state: \.optionalCounter, + action: /LazySheetAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) +) + +struct LazySheetView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + Button(action: { viewStore.send(.setSheet(isPresented: true)) }) { + HStack { + Text("Load optional counter") + if viewStore.isActivityIndicatorVisible { + Spacer() + ActivityIndicator() + } + } + } + } + } + .sheet( + isPresented: viewStore.binding( + get: \.isSheetPresented, + send: LazySheetAction.setSheet(isPresented:) + ) + ) { + IfLetStore( + self.store.scope(state: \.optionalCounter, action: LazySheetAction.optionalCounter), + then: CounterView.init(store:) + ) + } + .navigationBarTitle("Load and present") + } + } +} + +struct LazySheetView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LazySheetView( + store: Store( + initialState: LazySheetState(), + reducer: lazySheetReducer, + environment: LazySheetEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift new file mode 100644 index 000000000000..74206372b705 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift @@ -0,0 +1,100 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional data into state. + + Tapping "Load optional counter" simultaneously presents a sheet that depends on optional counter \ + state and fires off an effect that will load this state a second later. + """ + +struct EagerSheetState: Equatable { + var optionalCounter: CounterState? + var isSheetPresented = false +} + +enum EagerSheetAction { + case optionalCounter(CounterAction) + case setSheet(isPresented: Bool) + case setSheetIsPresentedDelayCompleted +} + +struct EagerSheetEnvironment { + var mainQueue: AnySchedulerOf +} + +let eagerSheetReducer = Reducer< + EagerSheetState, EagerSheetAction, EagerSheetEnvironment +>.combine( + Reducer { state, action, environment in + switch action { + case .setSheet(isPresented: true): + state.isSheetPresented = true + return Effect(value: .setSheetIsPresentedDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + + case .setSheet(isPresented: false): + state.isSheetPresented = false + state.optionalCounter = nil + return .none + + case .setSheetIsPresentedDelayCompleted: + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + }, + counterReducer.optional.pullback( + state: \.optionalCounter, + action: /EagerSheetAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) +) + +struct EagerSheetView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(readMe)) { + Button("Load optional counter") { + viewStore.send(.setSheet(isPresented: true)) + } + } + } + .sheet( + isPresented: viewStore.binding( + get: \.isSheetPresented, + send: EagerSheetAction.setSheet(isPresented:) + ) + ) { + IfLetStore( + self.store.scope(state: \.optionalCounter, action: EagerSheetAction.optionalCounter), + then: CounterView.init(store:), + else: ActivityIndicator() + ) + } + .navigationBarTitle("Present and load") + } + } +} + +struct EagerSheetView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EagerSheetView( + store: Store( + initialState: EagerSheetState(), + reducer: eagerSheetReducer, + environment: EagerSheetEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift new file mode 100644 index 000000000000..96a0d73fd7e9 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift @@ -0,0 +1,159 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how the `Reducer` struct can be extended to enhance reducers with \ + extra functionality. + + In this example we introduce a declarative interface for describing long-running effects, \ + inspired by Elm's `subscriptions` API. + """ + +extension Reducer { + static func subscriptions( + _ subscriptions: @escaping (State, Environment) -> [AnyHashable: Effect] + ) -> Reducer { + var activeSubscriptions: [AnyHashable: Effect] = [:] + + return Reducer { state, _, environment in + let currentSubscriptions = subscriptions(state, environment) + defer { activeSubscriptions = currentSubscriptions } + return .merge( + Set(activeSubscriptions.keys).union(currentSubscriptions.keys).map { id in + switch (activeSubscriptions[id], currentSubscriptions[id]) { + case (.some, .none): + return .cancel(id: id) + case let (.none, .some(effect)): + return effect.cancellable(id: id) + default: + return .none + } + } + ) + } + } +} + +struct ClockState: Equatable { + var isTimerActive = false + var secondsElapsed = 0 +} + +enum ClockAction: Equatable { + case timerTicked + case toggleTimerButtonTapped +} + +struct ClockEnvironment { + var mainQueue: AnySchedulerOf +} + +let clockReducer = Reducer.combine( + Reducer { state, action, environment in + switch action { + case .timerTicked: + state.secondsElapsed += 1 + return .none + case .toggleTimerButtonTapped: + state.isTimerActive.toggle() + return .none + } + }, + .subscriptions { state, environment in + struct TimerId: Hashable {} + guard state.isTimerActive else { return [:] } + return [ + TimerId(): + Effect + .timer(id: TimerId(), every: 1, tolerance: .zero, on: environment.mainQueue) + .map { _ in .timerTicked } + ] + } +) + +struct ClockView: View { + // NB: We are using an explicit `ObservedObject` for the view store here instead of + // `WithViewStore` due to a SwiftUI bug where `GeometryReader`s inside `WithViewStore` will + // not properly update. + // + // Feedback filed: https://gist.github.com/mbrandonw/cc5da3d487bcf7c4f21c27019a440d18 + @ObservedObject var viewStore: ViewStore + + init(store: Store) { + self.viewStore = ViewStore(store) + } + + var body: some View { + VStack { + Text(template: readMe, .body) + + ZStack { + Circle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [ + Color.blue.opacity(0.3), + .blue, + .blue, + .green, + .green, + .yellow, + .yellow, + .red, + .red, + .purple, + .purple, + Color.purple.opacity(0.3), + ] + ), + center: .center + ) + ) + .rotationEffect(Angle(degrees: -90)) + + GeometryReader { proxy in + Path { path in + path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0)) + } + .stroke(Color.black, lineWidth: 3) + .rotationEffect(.degrees(Double(self.viewStore.secondsElapsed) * 360 / 60)) + .animation(Animation.interpolatingSpring(stiffness: 3000, damping: 40)) + } + } + .frame(width: 280, height: 280) + .padding([.bottom], 64) + + Button(action: { self.viewStore.send(.toggleTimerButtonTapped) }) { + HStack { + Text(self.viewStore.isTimerActive ? "Stop" : "Start") + } + .foregroundColor(.white) + .padding() + .background(self.viewStore.isTimerActive ? Color.red : .blue) + .cornerRadius(16) + } + + Spacer() + } + .padding() + .navigationBarTitle("Elm-like subscriptions") + } +} + +struct Subscriptions_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ClockView( + store: Store( + initialState: ClockState(), + reducer: clockReducer, + environment: ClockEnvironment( + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift new file mode 100644 index 000000000000..47fef12aa100 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift @@ -0,0 +1,164 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how the `Reducer` struct can be extended to enhance reducers with extra \ + functionality. + + In it we introduce an interface for constructing reducers that need to be called recursively in \ + order to handle nested state and actions. It is handed itself as its first argument. + + Tap "Add row" to add a row to the current screen's list. Tap the left-hand side of a row to edit \ + its description, or tap the right-hand side of a row to navigate to its own associated list of \ + rows. + """ + +extension Reducer { + static func recurse( + _ reducer: @escaping (Reducer, inout State, Action, Environment) -> Effect + ) -> Reducer { + + var `self`: Reducer! + self = Reducer { state, action, environment in + reducer(self, &state, action, environment) + } + return self + } +} + +struct NestedState: Equatable, Identifiable { + var children: [NestedState] = [] + let id: UUID + var description: String = "" +} + +indirect enum NestedAction: Equatable { + case append + case node(index: Int, action: NestedAction) + case remove(IndexSet) + case rename(String) +} + +struct NestedEnvironment { + var uuid: () -> UUID +} + +let nestedReducer = Reducer< + NestedState, NestedAction, NestedEnvironment +>.recurse { `self`, state, action, environment in + switch action { + case .append: + state.children.append(NestedState(id: environment.uuid())) + return .none + + case let .node(index, action): + return self(&state.children[index], action, environment) + + case let .remove(indexSet): + state.children.remove(atOffsets: indexSet) + return .none + + case let .rename(name): + state.description = name + return .none + } +} +.debug() + +struct NestedView: View { + let store: Store + + var body: some View { + WithViewStore(self.store.scope(state: \.description)) { viewStore in + Form { + Section(header: Text(template: readMe, .caption)) { + + ForEachStore( + self.store.scope(state: \.children, action: NestedAction.node(index:action:)) + ) { childStore in + WithViewStore(childStore) { childViewStore in + HStack { + TextField( + "Untitled", + text: childViewStore.binding(get: \.description, send: NestedAction.rename) + ) + + Spacer() + + NavigationLink( + destination: NestedView(store: childStore) + ) { + Text("") + } + } + } + } + .onDelete { viewStore.send(.remove($0)) } + } + } + .navigationBarTitle(viewStore.state.isEmpty ? "Untitled" : viewStore.state) + .navigationBarItems( + trailing: Button("Add row") { viewStore.send(.append) } + ) + } + } +} + +#if DEBUG + extension NestedState { + static let mock = NestedState( + children: [ + NestedState( + children: [ + NestedState( + children: [], + id: UUID(), + description: "" + ), + ], + id: UUID(), + description: "Bar" + ), + NestedState( + children: [ + NestedState( + children: [], + id: UUID(), + description: "Fizz" + ), + NestedState( + children: [], + id: UUID(), + description: "Buzz" + ), + ], + id: UUID(), + description: "Baz" + ), + NestedState( + children: [], + id: UUID(), + description: "" + ), + ], + id: UUID(), + description: "Foo" + ) + } +#endif + +struct NestedView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NestedView( + store: Store( + initialState: .mock, + reducer: nestedReducer, + environment: NestedEnvironment( + uuid: UUID.init + ) + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift new file mode 100644 index 000000000000..a12b215680f6 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift @@ -0,0 +1,65 @@ +import Combine +import ComposableArchitecture +import Foundation + +struct DownloadClient { + var cancel: (AnyHashable) -> Effect + var download: (AnyHashable, URL) -> Effect + + struct Error: Swift.Error, Equatable {} + + enum Action: Equatable { + case response(Data) + case updateProgress(Double) + } +} + +extension DownloadClient { + static let live = DownloadClient( + cancel: { id in + .fireAndForget { + dependencies[id]?.observation.invalidate() + dependencies[id]?.task.cancel() + dependencies[id] = nil + } + }, + download: { id, url in + Effect.async { subscriber in + let task = URLSession.shared.dataTask(with: url) { data, _, error in + switch (data, error) { + case let (.some(data), _): + subscriber.send(.response(data)) + subscriber.send(completion: .finished) + case let (_, .some(error)): + subscriber.send(completion: .failure(Error())) + case (.none, .none): + fatalError("Data and Error should not both be nil") + } + } + + let observation = task.progress.observe(\.fractionCompleted) { progress, _ in + subscriber.send(.updateProgress(progress.fractionCompleted)) + } + + dependencies[id] = Dependencies( + observation: observation, + task: task + ) + + task.resume() + + return AnyCancellable { + observation.invalidate() + task.cancel() + dependencies[id] = nil + } + } + }) +} + +private struct Dependencies { + let observation: NSKeyValueObservation + let task: URLSessionDataTask +} + +private var dependencies: [AnyHashable: Dependencies] = [:] diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift new file mode 100644 index 000000000000..e482a4c7a2de --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -0,0 +1,233 @@ +import ComposableArchitecture +import SwiftUI + +struct DownloadComponentState: Equatable { + var alert: DownloadAlert? + let id: ID + var mode: Mode + let url: URL +} + +struct DownloadAlert: Equatable, Identifiable { + var primaryButton: Button + var secondaryButton: Button + var title: String + + var id: String { self.title } + + struct Button: Equatable { + var action: DownloadComponentAction + var label: String + var type: `Type` + + enum `Type` { + case cancel + case `default` + case destructive + } + + func toSwiftUI(action: @escaping (DownloadComponentAction) -> Void) -> Alert.Button { + switch self.type { + case .cancel: + return .cancel(Text(self.label)) { action(self.action) } + case .default: + return .default(Text(self.label)) { action(self.action) } + case .destructive: + return .destructive(Text(self.label)) { action(self.action) } + } + } + } +} + +enum Mode: Equatable { + case downloaded + case downloading(progress: Double) + case notDownloaded + case startingToDownload + + var progress: Double { + if case let .downloading(progress) = self { return progress } + return 0 + } + + var isDownloading: Bool { + switch self { + case .downloaded, .notDownloaded: + return false + case .downloading, .startingToDownload: + return true + } + } +} + +enum DownloadComponentAction: Equatable { + case alert(AlertAction) + case buttonTapped + case downloadClient(Result) + + enum AlertAction: Equatable { + case cancelButtonTapped + case deleteButtonTapped + case dismiss + case nevermindButtonTapped + } +} + +struct DownloadComponentEnvironment { + var downloadClient: DownloadClient + var mainQueue: AnySchedulerOf +} + +extension Reducer { + func downloadable( + state: WritableKeyPath>, + action: CasePath, + environment: @escaping (Environment) -> DownloadComponentEnvironment + ) -> Reducer { + .combine( + Reducer, DownloadComponentAction, DownloadComponentEnvironment> { + state, action, environment in + switch action { + case .alert(.cancelButtonTapped): + state.mode = .notDownloaded + state.alert = nil + return environment.downloadClient.cancel(state.id) + .fireAndForget() + + case .alert(.deleteButtonTapped): + state.alert = nil + state.mode = .notDownloaded + return .none + + case .alert(.nevermindButtonTapped), + .alert(.dismiss): + state.alert = nil + return .none + + case .buttonTapped: + switch state.mode { + case .downloaded: + state.alert = deleteAlert + return .none + + case .downloading: + state.alert = cancelAlert + return .none + + case .notDownloaded: + state.mode = .startingToDownload + return environment.downloadClient + .download(state.id, state.url) + .throttle(for: 1, scheduler: environment.mainQueue, latest: true) + .catchToEffect() + .map(DownloadComponentAction.downloadClient) + + case .startingToDownload: + state.alert = cancelAlert + return .none + } + + case .downloadClient(.success(.response)): + state.mode = .downloaded + state.alert = nil + return .cancel(id: ThrottleId(id: state.id)) + + case let .downloadClient(.success(.updateProgress(progress))): + state.mode = .downloading(progress: progress) + return .none + + case .downloadClient(.failure): + state.mode = .notDownloaded + state.alert = nil + return .cancel(id: ThrottleId(id: state.id)) + } + } + .pullback(state: state, action: action, environment: environment), + self + ) + } +} + +private struct ThrottleId: Hashable where ID: Hashable { + var id: ID +} + +private let deleteAlert = DownloadAlert( + primaryButton: .init( + action: .alert(.deleteButtonTapped), + label: "Delete", + type: .destructive + ), + secondaryButton: nevermindButton, + title: "Do you want to delete this map from your offline storage?" +) + +private let cancelAlert = DownloadAlert( + primaryButton: .init( + action: .alert(.cancelButtonTapped), + label: "Cancel", + type: .destructive + ), + secondaryButton: nevermindButton, + title: "Do you want to cancel downloading this map?" +) + +let nevermindButton = DownloadAlert.Button( + action: .alert(.nevermindButtonTapped), + label: "Nevermind", + type: .default +) + +struct DownloadComponent: View { + let store: Store, DownloadComponentAction> + + init(store: Store, DownloadComponentAction>) { + self.store = store + } + + var body: some View { + WithViewStore(self.store) { viewStore in + Button(action: { viewStore.send(.buttonTapped) }) { + if viewStore.mode == .downloaded { + Image(systemName: "checkmark.circle") + .accentColor(Color.blue) + } else if viewStore.mode.progress > 0 { + ZStack { + CircularProgressView(value: viewStore.mode.progress) + .frame(width: 16, height: 16) + + Rectangle() + .frame(width: 6, height: 6) + .foregroundColor(Color.black) + } + } else if viewStore.mode == .notDownloaded { + Image(systemName: "icloud.and.arrow.down") + .accentColor(Color.black) + } else if viewStore.mode == .startingToDownload { + ZStack { + ActivityIndicator() + + Rectangle() + .frame(width: 6, height: 6) + .foregroundColor(Color.black) + } + } + } + .alert( + item: viewStore.binding(get: \.alert, send: .alert(.dismiss)) + ) { alert in + Alert( + title: Text(alert.title), + primaryButton: alert.primaryButton.toSwiftUI(action: viewStore.send), + secondaryButton: alert.secondaryButton.toSwiftUI(action: viewStore.send) + ) + } + } + } +} + +struct DownloadComponent_Previews: PreviewProvider { + static var previews: some View { + DownloadList_Previews.previews + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift new file mode 100644 index 000000000000..db30afe48cbd --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -0,0 +1,302 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can create reusable components in the Composable Architecture. + + The "download component" is a component that can be added to any view to enhance it with the \ + concept of downloading offline content. It facilitates downloading the data, displaying a \ + progress view while downloading, canceling an active download, and deleting previously \ + downloaded data. + + Tap the download icon to start a download, and tap again to cancel an in-flight download or to \ + remove a finished download. While a file is downloading you can tap a row to go to another \ + screen to see that the state is carried over. + """ + +struct CityMap: Equatable, Identifiable { + var blurb: String + var downloadVideoUrl: URL + let id: UUID + var title: String +} + +struct CityMapState: Equatable, Identifiable { + var downloadAlert: DownloadAlert? + var downloadMode: Mode + var cityMap: CityMap + + var id: UUID { self.cityMap.id } + + var downloadComponent: DownloadComponentState { + get { + DownloadComponentState( + alert: self.downloadAlert, + id: self.cityMap.id, + mode: self.downloadMode, + url: self.cityMap.downloadVideoUrl + ) + } + set { + self.downloadAlert = newValue.alert + self.downloadMode = newValue.mode + } + } +} + +enum CityMapAction { + case downloadComponent(DownloadComponentAction) +} + +struct CityMapEnvironment { + var downloadClient: DownloadClient + var mainQueue: AnySchedulerOf +} + +let cityMapReducer = Reducer { + state, action, environment in + switch action { + case let .downloadComponent(.downloadClient(.success(.response(data)))): + // TODO: save to disk + return .none + + case .downloadComponent(.alert(.deleteButtonTapped)): + // TODO: delete file from disk + return .none + + case .downloadComponent: + return .none + } +} +.downloadable( + state: \.downloadComponent, + action: /CityMapAction.downloadComponent, + environment: { + DownloadComponentEnvironment( + downloadClient: $0.downloadClient, + mainQueue: $0.mainQueue + ) + }) + +struct CityMapRowView: View { + let store: Store + + init(store: Store) { + self.store = store + } + + var body: some View { + WithViewStore(self.store) { viewStore in + HStack { + NavigationLink( + destination: CityMapDetailView(store: self.store) + ) { + HStack { + Image(systemName: "map") + Text(viewStore.cityMap.title) + } + .layoutPriority(1) + + Spacer() + + DownloadComponent( + store: self.store.scope( + state: { $0.downloadComponent }, + action: CityMapAction.downloadComponent + ) + ) + .padding([.trailing], 8) + } + } + } + } +} + +struct CityMapDetailView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(spacing: 32) { + Text(viewStore.cityMap.blurb) + + HStack { + if viewStore.downloadMode == .notDownloaded { + Text("Download for offline viewing") + } else if viewStore.downloadMode == .downloaded { + Text("Downloaded") + } else { + Text("Downloading \(Int(100 * viewStore.downloadComponent.mode.progress))%") + } + + Spacer() + + DownloadComponent( + store: self.store.scope( + state: { $0.downloadComponent }, + action: CityMapAction.downloadComponent + ) + ) + } + + Spacer() + } + .navigationBarTitle(viewStore.cityMap.title) + .padding() + } + } +} + +struct MapAppState { + var cityMaps: [CityMapState] +} + +enum MapAppAction { + case cityMaps(index: Int, action: CityMapAction) +} + +struct MapAppEnvironment { + var downloadClient: DownloadClient + var mainQueue: AnySchedulerOf +} + +let mapAppReducer: Reducer = cityMapReducer.forEach( + state: \MapAppState.cityMaps, + action: /MapAppAction.cityMaps(index:action:), + environment: { + CityMapEnvironment( + downloadClient: $0.downloadClient, + mainQueue: $0.mainQueue + ) + } +).debug() + +struct CitiesView: View { + let store: Store + + var body: some View { + Form { + Section( + header: Text(readMe) + ) { + ForEachStore( + self.store.scope(state: \.cityMaps, action: MapAppAction.cityMaps(index:action:)) + ) { cityMapStore in + CityMapRowView(store: cityMapStore) + .buttonStyle(BorderlessButtonStyle()) + } + } + } + .navigationBarTitle("Offline Downloads") + } +} + +struct DownloadList_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + CitiesView( + store: Store( + initialState: .init(cityMaps: .mocks), + reducer: mapAppReducer, + environment: .init( + downloadClient: .live, + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + + NavigationView { + CityMapDetailView( + store: Store( + initialState: [CityMapState].mocks.first!, + reducer: .empty, + environment: () + ) + ) + } + } + } +} + +extension Array where Element == CityMapState { + static let mocks: Self = [ + .init( + downloadMode: .notDownloaded, + cityMap: .init( + blurb: """ + New York City (NYC), known colloquially as New York (NY) and officially as the City of \ + New York, is the most populous city in the United States. With an estimated 2018 \ + population of 8,398,748 distributed over about 302.6 square miles (784 km2), New York \ + is also the most densely populated major city in the United States. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "New York, NY" + ) + ), + .init( + downloadMode: .notDownloaded, + cityMap: .init( + blurb: """ + Los Angeles, officially the City of Los Angeles and often known by its initials L.A., \ + is the largest city in the U.S. state of California. With an estimated population of \ + nearly four million people, it is the country's second most populous city (after New \ + York City) and the third most populous city in North America (after Mexico City and \ + New York City). Los Angeles is known for its Mediterranean climate, ethnic diversity, \ + Hollywood entertainment industry, and its sprawling metropolis. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Los Angeles, LA" + ) + ), + .init( + downloadMode: .notDownloaded, + cityMap: .init( + blurb: """ + Paris is the capital and most populous city of France, with a population of 2,148,271 \ + residents (official estimate, 1 January 2020) in an area of 105 square kilometres (41 \ + square miles). Since the 17th century, Paris has been one of Europe's major centres of \ + finance, diplomacy, commerce, fashion, science and arts. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Paris, France" + ) + ), + .init( + downloadMode: .notDownloaded, + cityMap: .init( + blurb: """ + Tokyo, officially Tokyo Metropolis (東京都, Tōkyō-to), is the capital of Japan and the \ + most populous of the country's 47 prefectures. Located at the head of Tokyo Bay, the \ + prefecture forms part of the Kantō region on the central Pacific coast of Japan's main \ + island, Honshu. Tokyo is the political, economic, and cultural center of Japan, and \ + houses the seat of the Emperor and the national government. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Tokyo, Japan" + ) + ), + .init( + downloadMode: .notDownloaded, + cityMap: .init( + blurb: """ + Buenos Aires is the capital and largest city of Argentina. The city is located on the \ + western shore of the estuary of the Río de la Plata, on the South American continent's \ + southeastern coast. "Buenos Aires" can be translated as "fair winds" or "good airs", \ + but the former was the meaning intended by the founders in the 16th century, by the \ + use of the original name "Real de Nuestra Señora Santa María del Buen Ayre", named \ + after the Madonna of Bonaria in Sardinia. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Buenos Aires, Argentina" + ) + ), + ] +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift new file mode 100644 index 000000000000..c69b7c3c1d4b --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -0,0 +1,242 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can create reusable components in the Composable Architecture. + + It introduces the domain, logic, and view around "favoriting" something, which is considerably \ + complex. + + A feature can give itself the ability to "favorite" part of its state by embedding the domain of \ + favoriting, using the `favorite` higher-order reducer, and passing an appropriately scoped store \ + to `FavoriteButton`. + + Tapping the favorite button on a row will instantly reflect in the UI and fire off an effect to \ + do any necessary work, like writing to a database or making an API request. We have simulated a \ + request that takes 1 second to run and may fail 25% of the time. Failures result in rolling back \ + favorite state and rendering an alert. + """ + +// MARK: - Favorite domain + +struct FavoriteState: Equatable, Identifiable where ID: Hashable { + let id: ID + var isFavorite: Bool + var error: FavoriteError? +} + +enum FavoriteAction: Equatable { + case buttonTapped + case errorDismissed + case response(Result) +} + +struct FavoriteEnvironment { + var request: (ID, Bool) -> Effect + var mainQueue: AnySchedulerOf +} + +/// A cancellation token that cancels in-flight favoriting requests. +struct FavoriteCancelId: Hashable where ID: Hashable { + var id: ID +} + +/// A wrapper for errors that occur when favoriting. +struct FavoriteError: Equatable, Error, Identifiable { + let error: Error + var localizedDescription: String { self.error.localizedDescription } + var id: String { self.error.localizedDescription } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } +} + +extension Reducer { + /// Enhances a reducer with favoriting logic. + func favorite( + state: WritableKeyPath>, + action: CasePath, + environment: @escaping (Environment) -> FavoriteEnvironment + ) -> Reducer where ID: Hashable { + .combine( + self, + Reducer, FavoriteAction, FavoriteEnvironment> { + state, action, environment in + switch action { + case .buttonTapped: + state.isFavorite.toggle() + + return environment.request(state.id, state.isFavorite) + .receive(on: environment.mainQueue) + .mapError(FavoriteError.init(error:)) + .catchToEffect() + .map(FavoriteAction.response) + .cancellable(id: FavoriteCancelId(id: state.id), cancelInFlight: true) + + case .errorDismissed: + state.error = nil + state.isFavorite.toggle() + return .none + + case let .response(.failure(error)): + state.error = error + return .none + + case let .response(.success(isFavorite)): + state.isFavorite = isFavorite + return .none + } + } + .pullback(state: state, action: action, environment: environment) + ) + } +} + +struct FavoriteButton: View where ID: Hashable { + let store: Store, FavoriteAction> + + var body: some View { + WithViewStore(self.store) { viewStore in + Button(action: { viewStore.send(.buttonTapped) }) { + Image(systemName: viewStore.isFavorite ? "heart.fill" : "heart") + } + .alert(item: viewStore.binding(get: \.error, send: .errorDismissed)) { + Alert(title: Text($0.localizedDescription)) + } + } + } +} + +// MARK: Feature domain - + +struct EpisodeState: Equatable, Identifiable { + var error: FavoriteError? + let id: UUID + var isFavorite: Bool + let title: String + + var favorite: FavoriteState { + get { .init(id: self.id, isFavorite: self.isFavorite, error: self.error) } + set { (self.isFavorite, self.error) = (newValue.isFavorite, newValue.error) } + } +} + +enum EpisodeAction: Equatable { + case favorite(FavoriteAction) +} + +struct EpisodeEnvironment { + var favorite: (EpisodeState.ID, Bool) -> Effect + var mainQueue: AnySchedulerOf +} + +struct EpisodeView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + HStack(alignment: .firstTextBaseline) { + Text(viewStore.title) + + Spacer() + + FavoriteButton(store: self.store.scope(state: \.favorite, action: EpisodeAction.favorite)) + } + } + } +} + +let episodeReducer = Reducer.empty.favorite( + state: \.favorite, + action: /EpisodeAction.favorite, + environment: { FavoriteEnvironment(request: $0.favorite, mainQueue: $0.mainQueue) } +) + +struct EpisodesState: Equatable { + var episodes: [EpisodeState] = [] + var episodeSelection: EpisodeState? +} + +enum EpisodesAction: Equatable { + case episode(index: Int, action: EpisodeAction) + case episodeSelection(EpisodeAction) +} + +struct EpisodesEnvironment { + var favorite: (UUID, Bool) -> Effect + var mainQueue: AnySchedulerOf +} + +let episodesReducer = Reducer.combine( + episodeReducer.forEach( + state: \EpisodesState.episodes, + action: /EpisodesAction.episode(index:action:), + environment: { EpisodeEnvironment(favorite: $0.favorite, mainQueue: $0.mainQueue) } + ) +) + +struct EpisodesView: View { + let store: Store + + var body: some View { + Form { + Section(header: Text(template: readMe, .caption)) { + ForEachStore( + self.store.scope(state: \.episodes, action: EpisodesAction.episode(index:action:)) + ) { rowStore in + EpisodeView(store: rowStore) + .buttonStyle(BorderlessButtonStyle()) + } + } + } + .navigationBarTitle("Favoriting") + } +} + +struct EpisodesView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EpisodesView( + store: Store( + initialState: EpisodesState( + episodes: .mocks + ), + reducer: episodesReducer, + environment: EpisodesEnvironment( + favorite: favorite(id:isFavorite:), + mainQueue: DispatchQueue.main.eraseToAnyScheduler() + ) + ) + ) + } + } +} + +func favorite(id: ID, isFavorite: Bool) -> Effect { + Effect.future { callback in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if .random(in: 0...1) > 0.25 { + callback(.success(isFavorite)) + } else { + callback( + .failure( + NSError( + domain: "co.pointfree", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Something went wrong!"] + ) + ) + ) + } + } + } +} + +extension Array where Element == EpisodeState { + static let mocks = [ + EpisodeState(id: UUID(), isFavorite: false, title: "Functions"), + EpisodeState(id: UUID(), isFavorite: false, title: "Side Effects"), + EpisodeState(id: UUID(), isFavorite: false, title: "Algebraic Data Types"), + EpisodeState(id: UUID(), isFavorite: false, title: "DSLs"), + EpisodeState(id: UUID(), isFavorite: false, title: "Parsers"), + EpisodeState(id: UUID(), isFavorite: false, title: "Composable Architecture"), + ] +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-StrictReducers.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-StrictReducers.swift new file mode 100644 index 000000000000..65439cbbbc46 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-StrictReducers.swift @@ -0,0 +1,98 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how the `Reducer` struct can be extended to enhance reducers with extra \ + functionality. + + In it we introduce a stricter interface for constructing reducers that takes state and action as \ + its only two arguments, and returns a new function that takes the environment as its only \ + argument and returns an effect: + ``` + (inout State, Action) + -> (Environment) -> Effect + ``` + This form of reducer is useful if you want to be very strict in not allowing the reducer to have \ + access to the environment when it is computing state changes, and only allowing access to the \ + environment when computing effects. + + Tapping "Roll die" below with update die state to a random side using the environment. It uses \ + the strict interface and so it cannot synchronously evaluate its environment to update state. \ + Instead, it introduces a new action to feed the random number back into the system. + """ + +extension Reducer { + static func strict( + _ reducer: @escaping (inout State, Action) -> (Environment) -> Effect + ) -> Reducer { + Self { state, action, environment in + reducer(&state, action)(environment) + } + } +} + +struct DieRollState: Equatable { + var dieSide = 1 +} + +enum DieRollAction { + case rollDie + case dieRolled(side: Int) +} + +struct DieRollEnvironment { + var rollDie: () -> Int +} + +let dieRollReducer = Reducer.strict { + state, action in + switch action { + case .rollDie: + return { environment in + Effect(value: .dieRolled(side: environment.rollDie())) + } + + case let .dieRolled(side): + state.dieSide = side + return { _ in .none } + } +} + +struct DieRollView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(template: readMe, .caption)) { + HStack { + Button("Roll die") { viewStore.send(.rollDie) } + + Spacer() + + Text("\(viewStore.dieSide)") + .font(Font.body.monospacedDigit()) + } + .buttonStyle(BorderlessButtonStyle()) + } + } + .navigationBarTitle("Strict reducers") + } + } +} + +struct DieRollView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + DieRollView( + store: Store( + initialState: DieRollState(), + reducer: dieRollReducer, + environment: DieRollEnvironment( + rollDie: { .random(in: 1...6) } + ) + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..186b90e3fe4c070ff0bbef31ad85baa47a055d38 GIT binary patch literal 8152 zcmXAu2T&8=6URYX=pZ!;gd$Bkks=DAS3v}%iZl(qh*W_DL3&jXqy_~=dItd^^cq3B z^b+YU2uOgC{NwL`Gj})h?!Ddp?%TV$*-xCYkq$l04H^;>5_&ydO%vk1{@)3>M!eQm zy?stXLh9hEp<(Q+t&_dIiJrK~fP;g*{lE_~ zQ5xSsli1iq6MOhT2g951bdGd1af7`S$)uzzs)BUVH`qd3`#J!hq;?}vN`i6?dJRuI zW*om@rV^?&bEKyVNh;GP*PiiSQN^03(Z=g@a&nr6d#wL?ca5o)Y=B+#ni9=GKAB^& z#;=>Id$$0W082yNRB&n-*?_t3h%R|{CzfnrT2KA@LmfwreF{>qOw?LLZjXKxiPN=8 zL9+BB^Yn0+O0bu~;^Fn?!wifvI!QI}$vtMr8|-v+5esy5!U62J*aah^KD_=QDv|Vhb;6gDa!Hqx za+Ow9d92s9Puo6Nn@5KR{T_q<5h=rrQKXPDa)XizKub~5Rm?pyv~-T0=L zK?gP{@zK?T{Jcdt)Hy}d;jAI^xCUIj+c z>JQk_JU^QlL5NeRA|z)eGx^`5p^Q%29ljgD>eiixp~An-knA#SMXZt-fb6-6#Mi zR4hm`N~iAD8Ho*WiF8T<|2@i7`4o5loU`w(=r_gBcXxR@oLPu6=YNxkIXvB|ot{qg zhu>k&{7*^~2013zRGx6&z^}>3g6qN_y`-&bOyJ1)>X0KI?c$h(W>QJ@K7=+QeGD)L z{V53BAi1En;p^rPA_rsxnac%i7NV+V{##ggZqZp8PgqNy^r7^`KJkn|FhcNZ_{SjA zK0uf5Nmuwxyxb=zi4oaeY8ViERCjJ?Rr8jUf5`reEUxtyb{!3uCs_NKB@32+(xm3Q z7VppKZ6?zxl?Wp660Gm#**j9L&GX*W${nI-a?!^abf&Nt@B3~{R_S2O0VWJZAC z@B@Fu)=lr8B!%l#`i?Z97@+b4%y35v5<=Kbz4PqA$My}1#VUv8zo@}4ksG>C(YAi~ z4gyz6%eSo*wDj8zFvlxRgRjYu55A?ohy2RYc{M=~I-SJhcd+`s} zF5nr0(;rmEo3*I`_5W>fRx^S2=}ylJnM}(^J=PI+@-Q4~!Fw)>8mz}wTi1U#`5t9= z5PNa;e}WNNS4jncYXHfNpOnF88K0r9rxaEO9v^Rp4UmO0m+&d`S;;i6$JN4p$nAf{v+Gg&1TRRKE#gKNGj4 z6>}Lt+avbPQZV7yy_oQ?pLVUpkz3wsEI1Pk`fvo7@7%4em={h!QT$F6Ig2)pK#(B~ z8GQhf=U*C}sf2_MrHu8uJz^)CLav)Hr5l4HTJxbl zwbhMeLK@PKlPPQeOc`EwvfA0>Zge&N@KJHBtc88~E>1zz^JT#&0cZ#_gSQ8H)HE`4 z6ICI#kcSVDv%wB*KI^RA-E=oK7Tv@r;~$rGCUizF8&9SbQ<)g%tPZEdTHFISV%7F_ zSaB~#k-K9L^WZE_J`$p-2&eZZbP6x-Y8_6Ei__6h4p`8>*A|$(UyE_qZRTqgi;|m` zsGB;iTW52?0j$KK_1L4euA0vm(VRpH%Vpa6N`#CQYRBC2wdC5PEnmBw)#e)m*;TA= zUsn~>_^|%vPY^&%P@4T^XKUp*(WZOhKn8+ri}B)0FX#++X_dU)#PlJK*{}s3M|u&% zC4>s!snaOwGO_035pnu-h9opBYWhXvPR6NTa9o!%|)O{yM$zt7#ilBV$c0!WMB00~A+m2}|M77sWg1c65TTiAi~0EIJ>a5#CG8jg7BMlH6{hOo@U_Fg-K@^zow_x9E&B z)wm@(jJyFP_7Usp?dv|;AJkub;p9;RBz!YF20aUE55s$MS@xtq58&mIIC;kQxIkz| zaRiRCP`GWbOoB{)e{-Bn^s)P>Q}{~LIdiD&-j{_`60kK6JKM#Xgm{{L&S*8H;*aN+ zF_L{~DDyBh1mX@R^D{FZfc>Jmvv1i@`_7w88g)IarR(BAVKZQPPHccK=8E&I2_!QL z-*P@`8bwBGmI7+r^<+?mn^apSi99=o{N|7A2QGSt858#(i#cf5|Gv=zD`6MXAKoW# z5mgZJ0Du20aJZ&JwE?M@qw*IsJp=?WIfjzOwv3dtI8EKI{kR^AmU#1L%JK6fZ)Jo2 z9r`TTe2(tyU~U-`Owqgbt^(^AwEozs4{IU2JMKIxrOmSb#MDPSWQ1^ zh-KzS8Q(dlkW7`c5Y3mASkAq9K|718u2OHi;9u7XJqE4mFL(hUcTahA*nreqLy1_>yp0oi&fxNuEb>>`Wnkcpqs@<1}D) zRlJ{ncL3%VD5Oi5k(>ABHOwnv$2|1tG89kb9Nn<`Q<%@Wbsv)PX7knd!N@?k5M-!! z1A?jBD;OgBz5GG#Jjnl7sqQoQ)16VefZ*Fg^4)$xEIACXI8ff6uW`drG^ zv3Haiz;UTgiyy;QBv@&8~eB(EWNJ{N{DgH21Tq#npr-zYE; ze|D@P?e8`!_&hoiB&=}in%?7&KNtQDNJ^*nyPQ1{D!*kpedc(-?CRc*r;>m@jONrW z4=Fpc-a$tNF=B@K1lq*kAzIo0yIFeF@S-PYI z2g?-prXWxV>(UJIPCo|xIbnJ>^1jyO)x*Jho(1b1jOMQ8Fcd_p<*(f|b|UoTR6_hZ z{MN;tZ%?i@0ByHwzXKo}Mh*QC-U>af4T6m`Ax)_|bg?fCY_hOoWjPV3b!W8YxKD6Pd<3i&qIq~811?HC_(%pk8 zQEsB&{8;KTZ)zZdDYlzNc#8iuqBD;*aqcF45demy{A%NP~*b%6H?D0zoBTmXCO7P&RUZxhrV#hQB!disxg zcA?P{r}xl?MjSIs)=w4fBpbcSp{%DDPY#PisA_8&pEMk!`#DC8uR3jlmcs}N!pYHp zpBBq^r>aMEJTBwU^cg85TY6SueuX$MDfy#aO`=fkYp`4i*`EbMAR|g>&^2`)xYL?T zHNann%)XVcxM9J+1@oFN9jG4A1JzcV2068ug}Qo`{`^Yn1XK9^3f~B~?A2eu4Ok3W z)@#Y*FSZLwwI-G2$xNE&4SQ16k0znNw@TS~1Xk+=xT}E%Q-9zO9;V-<&uG9Hj^i~e z&kh6k7}=q6+9OISoWT zL5)T7o2%Xd3}L0tI#k#iV~Lj>^G$=R?isCS4bAcn9*J6F`K4*P76GkbGQN|T1X^U9 zqSW-pgju@OL7RL<>5F4@RMyLc27MQ23Pu>wW#Rsp9PJjrWF|%DTV|%|WfNAWi+@of zayz3&`TPt~3fE`8K0B&2Gb1l3&E3PdY-BMXP2;O(uLa&QTt>=Iwb(RG(n}vkW0>=^n>`}a96*Mocc<5Uan7jf+H3J zR>bu;C+96!wMm1ss?1c63BO8Y?28OeFgX)BJr$}+t7e-YUNc=B%%=6e5^z+GLoPM% zj=l=?qkH+${BjIc6gi>h^?U(8;RXKO4?I7dIBhH8MALJA+^y|#@PEhk@Img^mL*2G z+6}J5^D=HS#KA7t-5VLF<9n#MwU#PJKkE2?qony|qh6QHx3cM+=VMjoP8oOarlZu+0g^hj>B1S2HjV6Sel+IBKf7{Pft2vD9OOPs~;2beBxB zb*F$va}gr!*ty`wxQS7QlsyAyrQSqZBj_7(-u3|Bh2S~8nx6%(H&A&DOX}9*moK6) z=!yh`Dk(`HwwkkCc`#GUaYut2&gNVm+&4Y*8Yy5^5xW2TMM_uEUx9Ftc*t;vk=mW!n1i9S?;KwpLTtk{L?p2kI22|fdx;{fN z#3@B^C}8Iz-|&jhPg-G5JTdxx890jpgYKmT_tR43GZ<8^{k8km>YeG@H<~zGbK3Az z=rs>c{%+_S>c*eO@xK_YijC)@7;S_5_U#-ub=pnvO@o8c#<$K`Y;6hBij$+6}v!CTO0$C?vJKKiiuUBHgAUi{DXh60g9mn zzgTmfOT0FdJ&|xR#!{TkA7!O&)f@Cv+D~hh{*O;jKV8*T?2|7G{)~&$ad{wv_dDL1 z8OfCuHPxqXa~1aj@p=}L(|7!+z&%c8LeqgrF0}-IljemxN&K)xb&{^?_X$mrJ8qJ@It=!ZpnljeK$9xox zHzsfxQ}mFUE~m+|z&f({0`Y@o1k6!fby_1{;8B>{mZx)L&}A`I!GXdlGXu=Wd*!Xb z<5@rGz2it}Cnzj&j&A!n1;hhk@#V1D6lGtJDbAoQ2XXp%&OMx#g4U<`pvo+(1m1Y< z4L)xYhJ5`ozyj?76R;nN9=srG6IHT%FXbm04Ft%veXU5ti{0jQuC zLd%mtlN%n5c|jpAI64Lo5susPIe5VloVpa`)6*!W67{)@-A1&|V(rsxD^6Rf*oLEP z_0|XRNlc1Y7+@}Y0n$;Z$MGjZFB6Pd2QTRmb|whn`MBZYy>Vq4SA}8-o$#) z7bYacmv^TBV0=UvV}@a6yD2^F*ZcDKo{umNB~lb$i=xcN(yFY*^o^WrU)N|_nZJ4B z3Shh*79U#I%S*p&8TRzqQKT#x#r@2=l;KQJ4$4xD#xI}JztcA}yo)h8k9Hy<@roA0 z{|@Uq%XaP zsZ&|`1oZ)>Pn2B+usn=-$BIX!n^!kD;eTY&I(g-W?0wVnih->BKOit44-=1++^gwv zTvxi$`Knv@U{fjm!s6nIRP~!=$2Ny5YrfT-;Z)EY;6ysG`uyx_oniw7QB(ppPVsmZ z&%p)gO)p=+2-+5XG^U9&=%c&xs4-uz1>XO3DZ5J508Ge{k8HY6Q*>gXTD(cs{{_d> zcl%`rQz9%cPaf?=^S5k{al36~D|?U0@D?mv<{Hf7;r&R3hK`OQ;Sg|UDrgcao-E%+ zJ%ghPFeD*{Ywvg6i$BOPP5UIqYZFmxXN~B(eC_P{M1Y?2w)@riGwq^Ao*vi}e%PI? zN7wV=mQ6P|aA+#+;!m_DPm z(VyYxn{M1Y+<#v?nPQ^33gU5|LI%A-BA<5|t~w15r3*J!bv{miwU#aP;ohKf=sOnz z^N%83y4eyziUIkB!x>u<*;|aJ!sk)yXw`8ODvv8>GO!XV#W1JF`kf*nf_a=6Oz`e2 ztJkKZEyxmxPmgK!gCniKXH(o?j4m$JYCkGEk$sTwZm$uZ_*^CfMz7UO(QhA1y(2&A zsM$_EmAO@uTf?%Iin_1Q{vAYhRYkJu+ z>Ezfq8ml1MdxwyUKdn4R61M8?ZP|@$o6h>pQ?*WX(l68!o!5sOu8EFDX^$(d$FSmT zn#3)3bcyb80yj>;x zj(JC2EPf!nt9aeKG^tyxx22ag`mSNKCxO8MX6#+NFbT}dhl`b^DJ?3g~-R%nVN1H4P-E`W&S+$1NNdvNt#Jk4%JdB?Nc1vL~1fUp}oY;JQDT z%WO?Q2Gu4zS`|*JxWh#J+Hl!O&`xj1JLALpJMGS0BvE5W-q(%+sOii{GPPF;y*w}5 z&Ae|LV=tCILdGxrZM8{8rrh%8=$LT;z8$*=tXfvhE02a-(U9qL@1`E?u+D}w9IXp3cbOm+p>XPq8;xq)Ub2ir+!XC zl#|IJ9)wyGLDb5qJ?Sb)&s{_GM;Z9mYSY#5X5~(BSf!6g!wo9D<^Q|BMKoMi4deH& zb9x~7nz4CgeM@p0O883^)Frh8!HX*OF`FiNr(4{`OsOM8jEG@~ZR%?K)dl{@bYBur z(97hkf2d*kY&D_)v%1tDw<1}nm`sk8#$P6jH+oZ1$AmtNgxo_{uM`JW9sbSmL zqcD(UykOo@w)o!G)$?yu>JlqmMt3uB+J6#oW1co4;P=zJ+*zAvRsr58?! ze$S(eG3ZD+R1TG~EW+gTBxn?L3Mv1CN$s0Iwbb1E|0KLKw|u)}B}g>TG{|hpc}w4w z^6A*`rL>Km86p!)1}%qe@iU06_gkdpEBxwGy!E52L)<-mB{K=M{L-aaqsEFP!AkU) zsV3vU$uC`cX*>%07i^!AJkL@bkQ5y5Q5&Ma^^XYzf~=Wsd?Pc?=xS@9fa^_VQ{)UW zwExDR9NkSHRPr5l-{(aS#2*l2A&zoC>CI!-e^L9ewH@WqrS%e)c(lEIo;A26hVXB0y(Id*+G95P{ zol+D8?n93C=$&AN_vnGohC~*oJ-{p;yY|ath24E&G3Gk%ItG1jX&E5;7De~HnpH{w z+imrcQmCCoFrNkY3G3;rIy1pkIAGJB+muUMwgru_Q^C(K&jZTxN1IpxqK&sDTBu6# zh!u{W6mNJHt8xp6o{yGpVLDsh1I*=`@bA>zA6-vxMoD4R{M+vm`6fs2zj@+nmapqA zzOPCC+X%iGK{JE+nfku4heYd*Qf)RFKQpd(^cW?l{YTxl7ir#w4(^1D>6{1>6H9=| YhpMf=!oN%>5_=LoEhEiJb^D0_0Whjby#N3J literal 0 HcmV?d00001 diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..e4b96da01595 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,99 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AppIcon.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json b/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Info.plist b/Examples/CaseStudies/SwiftUICaseStudies/Info.plist new file mode 100644 index 000000000000..162e5aaa42dc --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Info.plist @@ -0,0 +1,65 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Internal/ActivityIndicator.swift b/Examples/CaseStudies/SwiftUICaseStudies/Internal/ActivityIndicator.swift new file mode 100644 index 000000000000..c4590ec6b60f --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Internal/ActivityIndicator.swift @@ -0,0 +1,14 @@ +import SwiftUI + +// SwiftUI doesn't have a view for activity indicators, so we make `UIActivityIndicatorView` +// accessible from SwiftUI. + +struct ActivityIndicator: View { + var body: some View { + UIViewRepresented(makeUIView: { _ in + let view = UIActivityIndicatorView() + view.startAnimating() + return view + }) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift b/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift new file mode 100644 index 000000000000..86f17b1485cb --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct CircularProgressView: View { + private let value: Double + + init(value: Double) { + self.value = value + } + + var body: some View { + Circle() + .trim(from: 0, to: CGFloat(self.value)) + .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .foregroundColor(Color.black) + .rotationEffect(Angle(degrees: -90)) + .animation(.easeIn) + } +} + +struct CircularProgressView_Previews: PreviewProvider { + static var previews: some View { + CircularProgressView(value: 0.3).frame(width: 44, height: 44) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Internal/IfLetSubscript.swift b/Examples/CaseStudies/SwiftUICaseStudies/Internal/IfLetSubscript.swift new file mode 100644 index 000000000000..92fe9951cf3f --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Internal/IfLetSubscript.swift @@ -0,0 +1,11 @@ +extension Optional { + public subscript(ifLet keyPath: WritableKeyPath) -> Value? { + get { + self.map { $0[keyPath: keyPath] } + } + set { + guard let newValue = newValue else { return } + self?[keyPath: keyPath] = newValue + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift b/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift new file mode 100644 index 000000000000..9ceaa6d637d0 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift @@ -0,0 +1,59 @@ +import SwiftUI + +extension Text { + init(template: String, _ style: Font.TextStyle) { + enum Style: Hashable { + case code + case emphasis + case strong + } + + var segments: [Text] = [] + var currentValue = "" + var currentStyles: Set