mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-20 09:11:33 +01:00
The Composable Architecture
Co-authored-by: Stephen Celis <stephen.celis@gmail.com>
This commit is contained in:
20
.github/workflows/ci.yml
vendored
Normal file
20
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
25
.github/workflows/format.yml
vendored
Normal file
25
.github/workflows/format.yml
vendored
Normal file
@@ -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 }}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ComposableArchitecture"
|
||||
BuildableName = "ComposableArchitecture"
|
||||
BlueprintName = "ComposableArchitecture"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ComposableArchitectureTests"
|
||||
BuildableName = "ComposableArchitectureTests"
|
||||
BlueprintName = "ComposableArchitectureTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ComposableArchitecture"
|
||||
BuildableName = "ComposableArchitecture"
|
||||
BlueprintName = "ComposableArchitecture"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
84
CODE_OF_CONDUCT.md
Normal file
84
CODE_OF_CONDUCT.md
Normal file
@@ -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.
|
||||
28
ComposableArchitecture.xcworkspace/contents.xcworkspacedata
generated
Normal file
28
ComposableArchitecture.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/CaseStudies/CaseStudies.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/MotionManager/MotionManager.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/Search/Search.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/SpeechRecognition/SpeechRecognition.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/TicTacToe/TicTacToe.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/Todos/Todos.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Examples/VoiceMemos/VoiceMemos.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
7
Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
1021
Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj
Normal file
1021
Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load Diff
7
Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC89C41224460F95006900B9"
|
||||
BuildableName = "SwiftUICaseStudies.app"
|
||||
BlueprintName = "SwiftUICaseStudies"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC89C42824460F96006900B9"
|
||||
BuildableName = "SwiftUICaseStudiesTests.xctest"
|
||||
BlueprintName = "SwiftUICaseStudiesTests"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC89C41224460F95006900B9"
|
||||
BuildableName = "SwiftUICaseStudies.app"
|
||||
BlueprintName = "SwiftUICaseStudies"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC89C41224460F95006900B9"
|
||||
BuildableName = "SwiftUICaseStudies.app"
|
||||
BlueprintName = "SwiftUICaseStudies"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC4C6EA62450DD380066A05D"
|
||||
BuildableName = "UIKitCaseStudies.app"
|
||||
BlueprintName = "UIKitCaseStudies"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC4C6EBB2450DD390066A05D"
|
||||
BuildableName = "UIKitCaseStudiesTests.xctest"
|
||||
BlueprintName = "UIKitCaseStudiesTests"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC4C6EA62450DD380066A05D"
|
||||
BuildableName = "UIKitCaseStudies.app"
|
||||
BlueprintName = "UIKitCaseStudies"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DC4C6EA62450DD380066A05D"
|
||||
BuildableName = "UIKitCaseStudies.app"
|
||||
BlueprintName = "UIKitCaseStudies"
|
||||
ReferencedContainer = "container:CaseStudies.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
3
Examples/CaseStudies/README.md
Normal file
3
Examples/CaseStudies/README.md
Normal file
@@ -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.
|
||||
316
Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift
Normal file
316
Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<AnimationsState, AnimationsAction, AnimationsEnvironment> {
|
||||
state, action, environment in
|
||||
|
||||
switch action {
|
||||
case let .tapped(point):
|
||||
state.circleCenter = point
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimationsView: View {
|
||||
let store: Store<AnimationsState, AnimationsAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<BindingBasicsState, BindingBasicsAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TwoCountersState, TwoCountersAction, TwoCountersEnvironment>
|
||||
.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<TwoCountersState, TwoCountersAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CounterState, CounterAction, CounterEnvironment> { 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<CounterState, CounterAction>
|
||||
|
||||
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<CounterState, CounterAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<OptionalBasicsState, OptionalBasicsAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift
Normal file
165
Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift
Normal file
@@ -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<String, NumbersApiError>)
|
||||
}
|
||||
|
||||
struct NumbersApiError: Error, Equatable {}
|
||||
|
||||
struct EffectsBasicsEnvironment {
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
var numberFact: (Int) -> Effect<String, NumbersApiError>
|
||||
|
||||
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<EffectsBasicsState, EffectsBasicsAction>
|
||||
|
||||
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<String, NumbersApiError> {
|
||||
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()
|
||||
}
|
||||
@@ -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<String, TriviaApiError>)
|
||||
}
|
||||
|
||||
struct TriviaApiError: Error, Equatable {}
|
||||
|
||||
struct EffectsCancellationEnvironment {
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
var trivia: (Int) -> Effect<String, TriviaApiError>
|
||||
|
||||
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<EffectsCancellationState, EffectsCancellationAction>
|
||||
|
||||
init(store: Store<EffectsCancellationState, EffectsCancellationAction>) {
|
||||
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<String, TriviaApiError> {
|
||||
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()
|
||||
}
|
||||
@@ -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<Void, Never>
|
||||
|
||||
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<LongLivingEffectsState, LongLivingEffectsAction>
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
136
Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift
Normal file
136
Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift
Normal file
@@ -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<DispatchQueue>
|
||||
}
|
||||
|
||||
let timersReducer = Reducer<TimersState, TimersAction, TimersEnvironment> {
|
||||
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<TimersState, TimersAction>
|
||||
|
||||
init(store: Store<TimersState, TimersAction>) {
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SharedState, SharedStateAction, Void> = .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<SharedState, SharedStateAction>
|
||||
|
||||
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<SharedState.CounterState, SharedStateAction.CounterAction>
|
||||
|
||||
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<SharedState.ProfileState, SharedStateAction.ProfileAction>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<Int, Never>
|
||||
}
|
||||
|
||||
let multipleDependenciesReducer = Reducer<
|
||||
MultipleDependenciesState,
|
||||
MultipleDependenciesAction,
|
||||
SystemEnvironment<MultipleDependenciesEnvironment>
|
||||
> { 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<MultipleDependenciesState, MultipleDependenciesAction>
|
||||
|
||||
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<Environment> {
|
||||
var date: () -> Date
|
||||
var environment: Environment
|
||||
var mainQueue: () -> AnySchedulerOf<DispatchQueue>
|
||||
var uuid: () -> UUID
|
||||
|
||||
subscript<Dependency>(
|
||||
dynamicMember keyPath: WritableKeyPath<Environment, Dependency>
|
||||
) -> 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<NewEnvironment>(
|
||||
_ transform: @escaping (Environment) -> NewEnvironment
|
||||
) -> SystemEnvironment<NewEnvironment> {
|
||||
.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<DispatchQueue> = { 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))")!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Row> = []
|
||||
var selection: Identified<Row.ID, CounterState>?
|
||||
|
||||
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<DispatchQueue>
|
||||
}
|
||||
|
||||
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<LazyListNavigationState, LazyListNavigationAction>
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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<Row> = []
|
||||
var selection: Identified<Row.ID, CounterState?>?
|
||||
|
||||
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<DispatchQueue>
|
||||
}
|
||||
|
||||
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<EagerListNavigationState, EagerListNavigationAction>
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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<DispatchQueue>
|
||||
}
|
||||
|
||||
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<LazyNavigationState, LazyNavigationAction>
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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<DispatchQueue>
|
||||
}
|
||||
|
||||
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<EagerNavigationState, EagerNavigationAction>
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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<DispatchQueue>
|
||||
}
|
||||
|
||||
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<LazySheetState, LazySheetAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DispatchQueue>
|
||||
}
|
||||
|
||||
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<EagerSheetState, EagerSheetAction>
|
||||
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Action, Never>]
|
||||
) -> Reducer {
|
||||
var activeSubscriptions: [AnyHashable: Effect<Action, Never>] = [:]
|
||||
|
||||
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<DispatchQueue>
|
||||
}
|
||||
|
||||
let clockReducer = Reducer<ClockState, ClockAction, ClockEnvironment>.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<ClockState, ClockAction>
|
||||
|
||||
init(store: Store<ClockState, ClockAction>) {
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Action, Never>
|
||||
) -> 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<NestedState, NestedAction>
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import Foundation
|
||||
|
||||
struct DownloadClient {
|
||||
var cancel: (AnyHashable) -> Effect<Never, Never>
|
||||
var download: (AnyHashable, URL) -> Effect<Action, Error>
|
||||
|
||||
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] = [:]
|
||||
@@ -0,0 +1,233 @@
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
|
||||
struct DownloadComponentState<ID: Equatable>: 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<DownloadClient.Action, DownloadClient.Error>)
|
||||
|
||||
enum AlertAction: Equatable {
|
||||
case cancelButtonTapped
|
||||
case deleteButtonTapped
|
||||
case dismiss
|
||||
case nevermindButtonTapped
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadComponentEnvironment {
|
||||
var downloadClient: DownloadClient
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
}
|
||||
|
||||
extension Reducer {
|
||||
func downloadable<ID: Hashable>(
|
||||
state: WritableKeyPath<State, DownloadComponentState<ID>>,
|
||||
action: CasePath<Action, DownloadComponentAction>,
|
||||
environment: @escaping (Environment) -> DownloadComponentEnvironment
|
||||
) -> Reducer {
|
||||
.combine(
|
||||
Reducer<DownloadComponentState<ID>, 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<ID>: 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<ID: Equatable>: View {
|
||||
let store: Store<DownloadComponentState<ID>, DownloadComponentAction>
|
||||
|
||||
init(store: Store<DownloadComponentState<ID>, 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
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> {
|
||||
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<DispatchQueue>
|
||||
}
|
||||
|
||||
let cityMapReducer = Reducer<CityMapState, CityMapAction, CityMapEnvironment> {
|
||||
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<CityMapState, CityMapAction>
|
||||
|
||||
init(store: Store<CityMapState, CityMapAction>) {
|
||||
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<CityMapState, CityMapAction>
|
||||
|
||||
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<DispatchQueue>
|
||||
}
|
||||
|
||||
let mapAppReducer: Reducer<MapAppState, MapAppAction, MapAppEnvironment> = 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<MapAppState, MapAppAction>
|
||||
|
||||
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"
|
||||
)
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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<ID>: Equatable, Identifiable where ID: Hashable {
|
||||
let id: ID
|
||||
var isFavorite: Bool
|
||||
var error: FavoriteError?
|
||||
}
|
||||
|
||||
enum FavoriteAction: Equatable {
|
||||
case buttonTapped
|
||||
case errorDismissed
|
||||
case response(Result<Bool, FavoriteError>)
|
||||
}
|
||||
|
||||
struct FavoriteEnvironment<ID> {
|
||||
var request: (ID, Bool) -> Effect<Bool, Error>
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
}
|
||||
|
||||
/// A cancellation token that cancels in-flight favoriting requests.
|
||||
struct FavoriteCancelId<ID>: 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<ID>(
|
||||
state: WritableKeyPath<State, FavoriteState<ID>>,
|
||||
action: CasePath<Action, FavoriteAction>,
|
||||
environment: @escaping (Environment) -> FavoriteEnvironment<ID>
|
||||
) -> Reducer where ID: Hashable {
|
||||
.combine(
|
||||
self,
|
||||
Reducer<FavoriteState<ID>, 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<ID>: View where ID: Hashable {
|
||||
let store: Store<FavoriteState<ID>, 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<ID> {
|
||||
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<Bool, Error>
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
}
|
||||
|
||||
struct EpisodeView: View {
|
||||
let store: Store<EpisodeState, EpisodeAction>
|
||||
|
||||
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<EpisodeState, EpisodeAction, EpisodeEnvironment>.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<Bool, Error>
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
}
|
||||
|
||||
let episodesReducer = Reducer<EpisodesState, EpisodesAction, EpisodesEnvironment>.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<EpisodesState, EpisodesAction>
|
||||
|
||||
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: ID, isFavorite: Bool) -> Effect<Bool, Error> {
|
||||
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"),
|
||||
]
|
||||
}
|
||||
@@ -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<Action, Never>
|
||||
```
|
||||
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<Action, Never>
|
||||
) -> 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<DieRollState, DieRollAction, DieRollEnvironment>.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<DieRollState, DieRollAction>
|
||||
|
||||
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) }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
65
Examples/CaseStudies/SwiftUICaseStudies/Info.plist
Normal file
65
Examples/CaseStudies/SwiftUICaseStudies/Info.plist
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
extension Optional {
|
||||
public subscript<Value>(ifLet keyPath: WritableKeyPath<Wrapped, Value>) -> Value? {
|
||||
get {
|
||||
self.map { $0[keyPath: keyPath] }
|
||||
}
|
||||
set {
|
||||
guard let newValue = newValue else { return }
|
||||
self?[keyPath: keyPath] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Style> = []
|
||||
|
||||
func flushSegment() {
|
||||
var text = Text(currentValue)
|
||||
if currentStyles.contains(.code) {
|
||||
text = text.font(.system(style, design: .monospaced))
|
||||
}
|
||||
if currentStyles.contains(.emphasis) {
|
||||
text = text.italic()
|
||||
}
|
||||
if currentStyles.contains(.strong) {
|
||||
text = text.bold()
|
||||
}
|
||||
segments.append(text)
|
||||
currentValue.removeAll()
|
||||
}
|
||||
|
||||
for character in template {
|
||||
switch character {
|
||||
case "*":
|
||||
flushSegment()
|
||||
currentStyles.toggle(.strong)
|
||||
case "_":
|
||||
flushSegment()
|
||||
currentStyles.toggle(.emphasis)
|
||||
case "`":
|
||||
flushSegment()
|
||||
currentStyles.toggle(.code)
|
||||
default:
|
||||
currentValue.append(character)
|
||||
}
|
||||
}
|
||||
flushSegment()
|
||||
|
||||
self = segments.reduce(Text(""), +)
|
||||
}
|
||||
}
|
||||
|
||||
extension Set {
|
||||
fileprivate mutating func toggle(_ element: Element) {
|
||||
if self.contains(element) {
|
||||
self.remove(element)
|
||||
} else {
|
||||
self.insert(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UIViewRepresented<UIViewType>: UIViewRepresentable where UIViewType: UIView {
|
||||
let makeUIView: (Context) -> UIViewType
|
||||
let updateUIView: (UIViewType, Context) -> Void = { _, _ in }
|
||||
|
||||
func makeUIView(context: Context) -> UIViewType {
|
||||
self.makeUIView(context)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
self.updateUIView(uiView, context)
|
||||
}
|
||||
}
|
||||
26
Examples/CaseStudies/SwiftUICaseStudies/SceneDelegate.swift
Normal file
26
Examples/CaseStudies/SwiftUICaseStudies/SceneDelegate.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
self.window = (scene as? UIWindowScene).map(UIWindow.init(windowScene:))
|
||||
self.window?.rootViewController = UIHostingController(rootView: RootView())
|
||||
self.window?.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import ComposableArchitecture
|
||||
import ComposableArchitectureTestSupport
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUICaseStudies
|
||||
|
||||
class EffectsBasicsTests: XCTestCase {
|
||||
let scheduler = DispatchQueue.testScheduler
|
||||
|
||||
func testCountDown() {
|
||||
let store = TestStore(
|
||||
initialState: EffectsBasicsState(),
|
||||
reducer: effectsBasicsReducer,
|
||||
environment: EffectsBasicsEnvironment(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler(),
|
||||
numberFact: { _ in fatalError("Unimplemented") }
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.incrementButtonTapped) {
|
||||
$0.count = 1
|
||||
},
|
||||
.send(.decrementButtonTapped) {
|
||||
$0.count = 0
|
||||
},
|
||||
.do { self.scheduler.advance(by: 1) },
|
||||
.receive(.incrementButtonTapped) {
|
||||
$0.count = 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testNumberFact() {
|
||||
let store = TestStore(
|
||||
initialState: EffectsBasicsState(),
|
||||
reducer: effectsBasicsReducer,
|
||||
environment: EffectsBasicsEnvironment(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler(),
|
||||
numberFact: { n in Effect(value: "\(n) is a good number Brent") }
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.incrementButtonTapped) {
|
||||
$0.count = 1
|
||||
},
|
||||
.send(.numberFactButtonTapped) {
|
||||
$0.isNumberFactRequestInFlight = true
|
||||
},
|
||||
.do { self.scheduler.advance() },
|
||||
.receive(.numberFactResponse(.success("1 is a good number Brent"))) {
|
||||
$0.isNumberFactRequestInFlight = false
|
||||
$0.numberFact = "1 is a good number Brent"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import ComposableArchitectureTestSupport
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUICaseStudies
|
||||
|
||||
class EffectsCancellationTests: XCTestCase {
|
||||
let scheduler = DispatchQueue.testScheduler
|
||||
|
||||
func testTrivia_SuccessfulRequest() throws {
|
||||
let store = TestStore(
|
||||
initialState: .init(),
|
||||
reducer: effectsCancellationReducer,
|
||||
environment: .init(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler(),
|
||||
trivia: { n in Effect(value: "\(n) is a good number Brent") }
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.stepperChanged(1)) {
|
||||
$0.count = 1
|
||||
},
|
||||
.send(.stepperChanged(0)) {
|
||||
$0.count = 0
|
||||
},
|
||||
.send(.triviaButtonTapped) {
|
||||
$0.isTriviaRequestInFlight = true
|
||||
},
|
||||
.do {
|
||||
self.scheduler.advance()
|
||||
},
|
||||
.receive(.triviaResponse(.success("0 is a good number Brent"))) {
|
||||
$0.currentTrivia = "0 is a good number Brent"
|
||||
$0.isTriviaRequestInFlight = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testTrivia_FailedRequest() throws {
|
||||
let store = TestStore(
|
||||
initialState: .init(),
|
||||
reducer: effectsCancellationReducer,
|
||||
environment: .init(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler(),
|
||||
trivia: { _ in Fail(error: TriviaApiError()).eraseToEffect() }
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.triviaButtonTapped) {
|
||||
$0.isTriviaRequestInFlight = true
|
||||
},
|
||||
.do {
|
||||
self.scheduler.advance()
|
||||
},
|
||||
.receive(.triviaResponse(.failure(TriviaApiError()))) {
|
||||
$0.isTriviaRequestInFlight = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// NB: This tests that the cancel button really does cancel the in-flight API request.
|
||||
//
|
||||
// To see the real power of this test, try replacing the `.cancel` effect with a `.none` effect
|
||||
// in the `.cancelButtonTapped` action of the `effectsCancellationReducer`. This will cause the
|
||||
// test to fail, showing that we are exhaustively asserting that the effect truly is canceled and
|
||||
// will never emit.
|
||||
func testTrivia_CancelButtonCancelsRequest() throws {
|
||||
let store = TestStore(
|
||||
initialState: .init(),
|
||||
reducer: effectsCancellationReducer,
|
||||
environment: .init(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler(),
|
||||
trivia: { n in Effect(value: "\(n) is a good number Brent") }
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.triviaButtonTapped) {
|
||||
$0.isTriviaRequestInFlight = true
|
||||
},
|
||||
.send(.cancelButtonTapped) {
|
||||
$0.isTriviaRequestInFlight = false
|
||||
},
|
||||
.do {
|
||||
self.scheduler.run()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testTrivia_PlusMinusButtonsCancelsRequest() throws {
|
||||
let store = TestStore(
|
||||
initialState: .init(),
|
||||
reducer: effectsCancellationReducer,
|
||||
environment: .init(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler(),
|
||||
trivia: { n in Effect(value: "\(n) is a good number Brent") }
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.triviaButtonTapped) {
|
||||
$0.isTriviaRequestInFlight = true
|
||||
},
|
||||
.send(.stepperChanged(1)) {
|
||||
$0.count = 1
|
||||
$0.isTriviaRequestInFlight = false
|
||||
},
|
||||
.do {
|
||||
self.scheduler.advance()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import ComposableArchitectureTestSupport
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUICaseStudies
|
||||
|
||||
class LongLivingEffectsTests: XCTestCase {
|
||||
func testReducer() {
|
||||
// A passthrough subject to simulate the screenshot notification
|
||||
let screenshotTaken = PassthroughSubject<Void, Never>()
|
||||
|
||||
let store = TestStore(
|
||||
initialState: .init(),
|
||||
reducer: longLivingEffectsReducer,
|
||||
environment: .init(
|
||||
userDidTakeScreenshot: Effect(screenshotTaken)
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.onAppear),
|
||||
|
||||
// Simulate a screenshot being taken
|
||||
.do { screenshotTaken.send() },
|
||||
.receive(.userDidTakeScreenshotNotification) {
|
||||
$0.screenshotCount = 1
|
||||
},
|
||||
|
||||
.send(.onDisappear),
|
||||
|
||||
// Simulate a screenshot being taken to show not effects
|
||||
// are executed.
|
||||
.do { screenshotTaken.send() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import ComposableArchitecture
|
||||
import ComposableArchitectureTestSupport
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUICaseStudies
|
||||
|
||||
class TimersTests: XCTestCase {
|
||||
let scheduler = DispatchQueue.testScheduler
|
||||
|
||||
func testStart() {
|
||||
let store = TestStore(
|
||||
initialState: TimersState(),
|
||||
reducer: timersReducer,
|
||||
environment: TimersEnvironment(
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.toggleTimerButtonTapped) {
|
||||
$0.isTimerActive = true
|
||||
},
|
||||
.do { self.scheduler.advance(by: 1) },
|
||||
.receive(.timerTicked) {
|
||||
$0.secondsElapsed = 1
|
||||
},
|
||||
.do { self.scheduler.advance(by: 5) },
|
||||
.receive(.timerTicked) {
|
||||
$0.secondsElapsed = 2
|
||||
},
|
||||
.receive(.timerTicked) {
|
||||
$0.secondsElapsed = 3
|
||||
},
|
||||
.receive(.timerTicked) {
|
||||
$0.secondsElapsed = 4
|
||||
},
|
||||
.receive(.timerTicked) {
|
||||
$0.secondsElapsed = 5
|
||||
},
|
||||
.receive(.timerTicked) {
|
||||
$0.secondsElapsed = 6
|
||||
},
|
||||
.send(.toggleTimerButtonTapped) {
|
||||
$0.isTimerActive = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import ComposableArchitectureTestSupport
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUICaseStudies
|
||||
|
||||
class ReusableComponentsDownloadComponentTests: XCTestCase {
|
||||
let downloadSubject = PassthroughSubject<DownloadClient.Action, DownloadClient.Error>()
|
||||
let reducer = Reducer<
|
||||
DownloadComponentState<Int>, DownloadComponentAction, DownloadComponentEnvironment
|
||||
>
|
||||
.empty
|
||||
.downloadable(
|
||||
state: \.self,
|
||||
action: .self,
|
||||
environment: { $0 }
|
||||
)
|
||||
let scheduler = DispatchQueue.testScheduler
|
||||
|
||||
func testDownloadFlow() {
|
||||
let store = TestStore(
|
||||
initialState: DownloadComponentState(
|
||||
alert: nil,
|
||||
id: 1,
|
||||
mode: .notDownloaded,
|
||||
url: URL(string: "https://www.pointfree.co")!
|
||||
),
|
||||
reducer: reducer,
|
||||
environment: DownloadComponentEnvironment(
|
||||
downloadClient: .mock(
|
||||
download: { _, _ in self.downloadSubject.eraseToEffect() }
|
||||
),
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.buttonTapped) {
|
||||
$0.mode = .startingToDownload
|
||||
},
|
||||
|
||||
.do { self.downloadSubject.send(.updateProgress(0.2)) },
|
||||
.do { self.scheduler.advance() },
|
||||
.receive(.downloadClient(.success(.updateProgress(0.2)))) {
|
||||
$0.mode = .downloading(progress: 0.2)
|
||||
},
|
||||
|
||||
.do { self.downloadSubject.send(.response(Data())) },
|
||||
.do { self.downloadSubject.send(completion: .finished) },
|
||||
.do { self.scheduler.advance(by: 1) },
|
||||
.receive(.downloadClient(.success(.response(Data())))) {
|
||||
$0.mode = .downloaded
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testDownloadThrottling() {
|
||||
let store = TestStore(
|
||||
initialState: DownloadComponentState(
|
||||
alert: nil,
|
||||
id: 1,
|
||||
mode: .notDownloaded,
|
||||
url: URL(string: "https://www.pointfree.co")!
|
||||
),
|
||||
reducer: reducer,
|
||||
environment: DownloadComponentEnvironment(
|
||||
downloadClient: .mock(
|
||||
download: { _, _ in self.downloadSubject.eraseToEffect() }
|
||||
),
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.buttonTapped) {
|
||||
$0.mode = .startingToDownload
|
||||
},
|
||||
|
||||
.do { self.downloadSubject.send(.updateProgress(0.5)) },
|
||||
.do { self.scheduler.advance() },
|
||||
.receive(.downloadClient(.success(.updateProgress(0.5)))) {
|
||||
$0.mode = .downloading(progress: 0.5)
|
||||
},
|
||||
|
||||
.do { self.downloadSubject.send(.updateProgress(0.6)) },
|
||||
.do { self.scheduler.advance(by: 0.5) },
|
||||
|
||||
.do { self.downloadSubject.send(.updateProgress(0.7)) },
|
||||
.do { self.scheduler.advance(by: 0.5) },
|
||||
.receive(.downloadClient(.success(.updateProgress(0.7)))) {
|
||||
$0.mode = .downloading(progress: 0.7)
|
||||
},
|
||||
|
||||
.do { self.downloadSubject.send(completion: .finished) },
|
||||
.do { self.scheduler.run() }
|
||||
)
|
||||
}
|
||||
|
||||
func testCancelDownloadFlow() {
|
||||
let store = TestStore(
|
||||
initialState: DownloadComponentState(
|
||||
alert: nil,
|
||||
id: 1,
|
||||
mode: .notDownloaded,
|
||||
url: URL(string: "https://www.pointfree.co")!
|
||||
),
|
||||
reducer: reducer,
|
||||
environment: DownloadComponentEnvironment(
|
||||
downloadClient: .mock(
|
||||
cancel: { _ in .fireAndForget { self.downloadSubject.send(completion: .finished) } },
|
||||
download: { _, _ in self.downloadSubject.eraseToEffect() }
|
||||
),
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.buttonTapped) {
|
||||
$0.mode = .startingToDownload
|
||||
},
|
||||
|
||||
.send(.buttonTapped) {
|
||||
$0.alert = DownloadAlert(
|
||||
primaryButton: .init(
|
||||
action: .alert(.cancelButtonTapped), label: "Cancel", type: .destructive
|
||||
),
|
||||
secondaryButton: .init(
|
||||
action: .alert(.nevermindButtonTapped), label: "Nevermind", type: .default
|
||||
),
|
||||
title: "Do you want to cancel downloading this map?"
|
||||
)
|
||||
},
|
||||
|
||||
.send(.alert(.cancelButtonTapped)) {
|
||||
$0.alert = nil
|
||||
$0.mode = .notDownloaded
|
||||
},
|
||||
|
||||
.do { self.scheduler.run() }
|
||||
)
|
||||
}
|
||||
|
||||
func testDownloadFinishesWhileTryingToCancel() {
|
||||
let store = TestStore(
|
||||
initialState: DownloadComponentState(
|
||||
alert: nil,
|
||||
id: 1,
|
||||
mode: .notDownloaded,
|
||||
url: URL(string: "https://www.pointfree.co")!
|
||||
),
|
||||
reducer: reducer,
|
||||
environment: DownloadComponentEnvironment(
|
||||
downloadClient: .mock(
|
||||
cancel: { _ in .fireAndForget { self.downloadSubject.send(completion: .finished) } },
|
||||
download: { _, _ in self.downloadSubject.eraseToEffect() }
|
||||
),
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.buttonTapped) {
|
||||
$0.mode = .startingToDownload
|
||||
},
|
||||
|
||||
.send(.buttonTapped) {
|
||||
$0.alert = DownloadAlert(
|
||||
primaryButton: .init(
|
||||
action: .alert(.cancelButtonTapped), label: "Cancel", type: .destructive
|
||||
),
|
||||
secondaryButton: .init(
|
||||
action: .alert(.nevermindButtonTapped), label: "Nevermind", type: .default
|
||||
),
|
||||
title: "Do you want to cancel downloading this map?"
|
||||
)
|
||||
},
|
||||
|
||||
.do { self.downloadSubject.send(.response(Data())) },
|
||||
.do { self.downloadSubject.send(completion: .finished) },
|
||||
.do { self.scheduler.advance(by: 1) },
|
||||
.receive(.downloadClient(.success(.response(Data())))) {
|
||||
$0.alert = nil
|
||||
$0.mode = .downloaded
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testDeleteDownloadFlow() {
|
||||
let store = TestStore(
|
||||
initialState: DownloadComponentState(
|
||||
alert: nil,
|
||||
id: 1,
|
||||
mode: .downloaded,
|
||||
url: URL(string: "https://www.pointfree.co")!
|
||||
),
|
||||
reducer: reducer,
|
||||
environment: DownloadComponentEnvironment(
|
||||
downloadClient: .mock(
|
||||
cancel: { _ in .fireAndForget { self.downloadSubject.send(completion: .finished) } },
|
||||
download: { _, _ in self.downloadSubject.eraseToEffect() }
|
||||
),
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.buttonTapped) {
|
||||
$0.alert = DownloadAlert(
|
||||
primaryButton: .init(
|
||||
action: .alert(.deleteButtonTapped), label: "Delete", type: .destructive
|
||||
),
|
||||
secondaryButton: .init(
|
||||
action: .alert(.nevermindButtonTapped), label: "Nevermind", type: .default
|
||||
),
|
||||
title: "Do you want to delete this map from your offline storage?"
|
||||
)
|
||||
},
|
||||
|
||||
.send(.alert(.deleteButtonTapped)) {
|
||||
$0.alert = nil
|
||||
$0.mode = .notDownloaded
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadClient {
|
||||
static func mock(
|
||||
cancel: @escaping (AnyHashable) -> Effect<Never, Never> = { _ in fatalError() },
|
||||
download: @escaping (AnyHashable, URL) -> Effect<Action, Error> = { _, _ in fatalError() }
|
||||
) -> Self {
|
||||
Self(
|
||||
cancel: cancel,
|
||||
download: download
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import ComposableArchitectureTestSupport
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUICaseStudies
|
||||
|
||||
class ReusableComponentsFavoritingTests: XCTestCase {
|
||||
let scheduler = DispatchQueue.testScheduler
|
||||
|
||||
func testFavoriteButton() {
|
||||
let store = TestStore(
|
||||
initialState: EpisodesState(
|
||||
episodes: [
|
||||
.init(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
|
||||
isFavorite: false,
|
||||
title: "Functions"
|
||||
),
|
||||
.init(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
|
||||
isFavorite: false,
|
||||
title: "Functions"
|
||||
),
|
||||
.init(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
|
||||
isFavorite: false,
|
||||
title: "Functions"
|
||||
),
|
||||
]
|
||||
),
|
||||
reducer: episodesReducer,
|
||||
environment: EpisodesEnvironment(
|
||||
favorite: { _, isFavorite in Effect.future { $0(.success(isFavorite)) } },
|
||||
mainQueue: self.scheduler.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
|
||||
let error = NSError(domain: "co.pointfree", code: -1, userInfo: nil)
|
||||
store.assert(
|
||||
.send(.episode(index: 0, action: .favorite(.buttonTapped))) {
|
||||
$0.episodes[0].isFavorite = true
|
||||
},
|
||||
|
||||
.do { self.scheduler.advance() },
|
||||
.receive(.episode(index: 0, action: .favorite(.response(.success(true))))),
|
||||
|
||||
.send(.episode(index: 1, action: .favorite(.buttonTapped))) {
|
||||
$0.episodes[1].isFavorite = true
|
||||
},
|
||||
.send(.episode(index: 1, action: .favorite(.buttonTapped))) {
|
||||
$0.episodes[1].isFavorite = false
|
||||
},
|
||||
|
||||
.do { self.scheduler.advance() },
|
||||
.receive(.episode(index: 1, action: .favorite(.response(.success(false))))),
|
||||
|
||||
.environment {
|
||||
$0.favorite = { _, _ in Effect.future { $0(.failure(error)) } }
|
||||
},
|
||||
.send(.episode(index: 2, action: .favorite(.buttonTapped))) {
|
||||
$0.episodes[2].isFavorite = true
|
||||
},
|
||||
|
||||
.do { self.scheduler.advance() },
|
||||
.receive(
|
||||
.episode(index: 2, action: .favorite(.response(.failure(FavoriteError(error: error)))))
|
||||
) {
|
||||
$0.episodes[2].error = FavoriteError(error: error)
|
||||
},
|
||||
|
||||
.send(.episode(index: 2, action: .favorite(.errorDismissed))) {
|
||||
$0.episodes[2].error = nil
|
||||
$0.episodes[2].isFavorite = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,96 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct CounterState: Equatable {
|
||||
var count = 0
|
||||
}
|
||||
|
||||
enum CounterAction: Equatable {
|
||||
case decrementButtonTapped
|
||||
case incrementButtonTapped
|
||||
}
|
||||
|
||||
struct CounterEnvironment {}
|
||||
|
||||
let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
|
||||
switch action {
|
||||
case .decrementButtonTapped:
|
||||
state.count -= 1
|
||||
return .none
|
||||
case .incrementButtonTapped:
|
||||
state.count += 1
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
final class CounterViewController: UIViewController {
|
||||
let viewStore: ViewStore<CounterState, CounterAction>
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(store: Store<CounterState, CounterAction>) {
|
||||
self.viewStore = ViewStore(store)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = .white
|
||||
|
||||
let decrementButton = UIButton(type: .system)
|
||||
decrementButton.addTarget(self, action: #selector(decrementButtonTapped), for: .touchUpInside)
|
||||
decrementButton.setTitle("−", for: .normal)
|
||||
|
||||
let countLabel = UILabel()
|
||||
countLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular)
|
||||
|
||||
let incrementButton = UIButton(type: .system)
|
||||
incrementButton.addTarget(self, action: #selector(incrementButtonTapped), for: .touchUpInside)
|
||||
incrementButton.setTitle("+", for: .normal)
|
||||
|
||||
let rootStackView = UIStackView(arrangedSubviews: [
|
||||
decrementButton,
|
||||
countLabel,
|
||||
incrementButton,
|
||||
])
|
||||
rootStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.addSubview(rootStackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor),
|
||||
rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor),
|
||||
])
|
||||
|
||||
self.viewStore.publisher
|
||||
.map { "\($0.count)" }
|
||||
.assign(to: \.text, on: countLabel)
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
@objc func decrementButtonTapped() {
|
||||
self.viewStore.send(.decrementButtonTapped)
|
||||
}
|
||||
|
||||
@objc func incrementButtonTapped() {
|
||||
self.viewStore.send(.incrementButtonTapped)
|
||||
}
|
||||
}
|
||||
|
||||
struct CounterViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let vc = CounterViewController(
|
||||
store: Store(
|
||||
initialState: CounterState(),
|
||||
reducer: counterReducer,
|
||||
environment: CounterEnvironment()
|
||||
)
|
||||
)
|
||||
return UIViewRepresented(makeUIView: { _ in vc.view })
|
||||
}
|
||||
}
|
||||
60
Examples/CaseStudies/UIKitCaseStudies/Info.plist
Normal file
60
Examples/CaseStudies/UIKitCaseStudies/Info.plist
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,21 @@
|
||||
import UIKit
|
||||
|
||||
final class ActivityIndicatorViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = .white
|
||||
|
||||
let activityIndicator = UIActivityIndicatorView()
|
||||
activityIndicator.startAnimating()
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.addSubview(activityIndicator)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
activityIndicator.centerXAnchor.constraint(
|
||||
equalTo: self.view.safeAreaLayoutGuide.centerXAnchor),
|
||||
activityIndicator.centerYAnchor.constraint(
|
||||
equalTo: self.view.safeAreaLayoutGuide.centerYAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import UIKit
|
||||
|
||||
final class IfLetStoreController<State, Action>: UIViewController {
|
||||
let store: Store<State?, Action>
|
||||
let ifDestination: (Store<State, Action>) -> UIViewController
|
||||
let elseDestination: () -> UIViewController
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
private var viewController = UIViewController() {
|
||||
willSet {
|
||||
self.viewController.willMove(toParent: nil)
|
||||
self.viewController.view.removeFromSuperview()
|
||||
self.viewController.removeFromParent()
|
||||
self.addChild(newValue)
|
||||
self.view.addSubview(newValue.view)
|
||||
newValue.didMove(toParent: self)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
store: Store<State?, Action>,
|
||||
then ifDestination: @escaping (Store<State, Action>) -> UIViewController,
|
||||
else elseDestination: @autoclosure @escaping () -> UIViewController
|
||||
) {
|
||||
self.store = store
|
||||
self.ifDestination = ifDestination
|
||||
self.elseDestination = elseDestination
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.store.ifLet(
|
||||
then: { [weak self] store in
|
||||
guard let self = self else { return }
|
||||
self.viewController = self.ifDestination(store)
|
||||
},
|
||||
else: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.viewController = self.elseDestination()
|
||||
}
|
||||
)
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UIViewRepresented<UIViewType>: UIViewRepresentable where UIViewType: UIView {
|
||||
let makeUIView: (Context) -> UIViewType
|
||||
let updateUIView: (UIViewType, Context) -> Void = { _, _ in }
|
||||
|
||||
func makeUIView(context: Context) -> UIViewType {
|
||||
self.makeUIView(context)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
self.updateUIView(uiView, context)
|
||||
}
|
||||
}
|
||||
101
Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift
Normal file
101
Examples/CaseStudies/UIKitCaseStudies/ListsOfState.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct CounterListState: Equatable {
|
||||
var counters: [CounterState] = []
|
||||
}
|
||||
|
||||
enum CounterListAction: Equatable {
|
||||
case counter(index: Int, action: CounterAction)
|
||||
}
|
||||
|
||||
struct CounterListEnvironment {}
|
||||
|
||||
let counterListReducer: Reducer<CounterListState, CounterListAction, CounterListEnvironment> =
|
||||
counterReducer.forEach(
|
||||
state: \CounterListState.counters,
|
||||
action: /CounterListAction.counter(index:action:),
|
||||
environment: { _ in CounterEnvironment() }
|
||||
)
|
||||
|
||||
let cellIdentifier = "Cell"
|
||||
|
||||
final class CountersTableViewController: UITableViewController {
|
||||
let store: Store<CounterListState, CounterListAction>
|
||||
let viewStore: ViewStore<CounterListState, CounterListAction>
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
var dataSource: [CounterState] = [] {
|
||||
didSet { self.tableView.reloadData() }
|
||||
}
|
||||
|
||||
init(store: Store<CounterListState, CounterListAction>) {
|
||||
self.store = store
|
||||
self.viewStore = ViewStore(store)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = "Lists"
|
||||
|
||||
self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier)
|
||||
|
||||
self.viewStore.publisher.counters
|
||||
.sink(receiveValue: { [weak self] in self?.dataSource = $0 })
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
self.dataSource.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
|
||||
-> UITableViewCell
|
||||
{
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.textLabel?.text = "\(self.dataSource[indexPath.row].count)"
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
self.navigationController?.pushViewController(
|
||||
CounterViewController(
|
||||
store: self.store.scope(
|
||||
state: \.counters[indexPath.row],
|
||||
action: { .counter(index: indexPath.row, action: $0) }
|
||||
)
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct CountersTableViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let vc = UINavigationController(
|
||||
rootViewController: CountersTableViewController(
|
||||
store: Store(
|
||||
initialState: CounterListState(
|
||||
counters: [
|
||||
CounterState(),
|
||||
CounterState(),
|
||||
CounterState(),
|
||||
]
|
||||
),
|
||||
reducer: counterListReducer,
|
||||
environment: CounterListEnvironment()
|
||||
)
|
||||
)
|
||||
)
|
||||
return UIViewRepresented(makeUIView: { _ in vc.view })
|
||||
}
|
||||
}
|
||||
137
Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift
Normal file
137
Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct LazyNavigationState: Equatable {
|
||||
var optionalCounter: CounterState?
|
||||
var isActivityIndicatorHidden = true
|
||||
}
|
||||
|
||||
enum LazyNavigationAction: Equatable {
|
||||
case optionalCounter(CounterAction)
|
||||
case setNavigation(isActive: Bool)
|
||||
case setNavigationIsActiveDelayCompleted
|
||||
}
|
||||
|
||||
struct LazyNavigationEnvironment {
|
||||
var mainQueue: AnySchedulerOf<DispatchQueue>
|
||||
}
|
||||
|
||||
let lazyNavigationReducer = Reducer<
|
||||
LazyNavigationState, LazyNavigationAction, LazyNavigationEnvironment
|
||||
>.combine(
|
||||
Reducer { state, action, environment in
|
||||
switch action {
|
||||
case .setNavigation(isActive: true):
|
||||
state.isActivityIndicatorHidden = false
|
||||
return Effect(value: .setNavigationIsActiveDelayCompleted)
|
||||
.delay(for: 1, scheduler: environment.mainQueue)
|
||||
.eraseToEffect()
|
||||
case .setNavigation(isActive: false):
|
||||
state.optionalCounter = nil
|
||||
return .none
|
||||
case .setNavigationIsActiveDelayCompleted:
|
||||
state.isActivityIndicatorHidden = true
|
||||
state.optionalCounter = CounterState()
|
||||
return .none
|
||||
case .optionalCounter:
|
||||
return .none
|
||||
}
|
||||
},
|
||||
counterReducer.optional.pullback(
|
||||
state: \.optionalCounter,
|
||||
action: /LazyNavigationAction.optionalCounter,
|
||||
environment: { _ in CounterEnvironment() }
|
||||
)
|
||||
)
|
||||
|
||||
class LazyNavigationViewController: UIViewController {
|
||||
var cancellables: [AnyCancellable] = []
|
||||
let store: Store<LazyNavigationState, LazyNavigationAction>
|
||||
let viewStore: ViewStore<LazyNavigationState, LazyNavigationAction>
|
||||
|
||||
init(store: Store<LazyNavigationState, LazyNavigationAction>) {
|
||||
self.store = store
|
||||
self.viewStore = ViewStore(store)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = "Load then navigate"
|
||||
|
||||
self.view.backgroundColor = .white
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside)
|
||||
button.setTitle("Load optional counter", for: .normal)
|
||||
|
||||
let activityIndicator = UIActivityIndicatorView()
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
let rootStackView = UIStackView(arrangedSubviews: [
|
||||
button,
|
||||
activityIndicator,
|
||||
])
|
||||
rootStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.addSubview(rootStackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor),
|
||||
rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor),
|
||||
])
|
||||
|
||||
self.viewStore.publisher.isActivityIndicatorHidden
|
||||
.assign(to: \.isHidden, on: activityIndicator)
|
||||
.store(in: &self.cancellables)
|
||||
|
||||
self.store
|
||||
.scope(state: \.optionalCounter, action: LazyNavigationAction.optionalCounter)
|
||||
.ifLet(
|
||||
then: { [weak self] store in
|
||||
self?.navigationController?.pushViewController(
|
||||
CounterViewController(store: store), animated: true)
|
||||
},
|
||||
else: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.navigationController?.popToViewController(self, animated: true)
|
||||
}
|
||||
)
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if !self.isMovingToParent {
|
||||
self.viewStore.send(.setNavigation(isActive: false))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func loadOptionalCounterTapped() {
|
||||
self.viewStore.send(.setNavigation(isActive: true))
|
||||
}
|
||||
}
|
||||
|
||||
struct LazyNavigationViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let vc = UINavigationController(
|
||||
rootViewController: LazyNavigationViewController(
|
||||
store: Store(
|
||||
initialState: LazyNavigationState(),
|
||||
reducer: lazyNavigationReducer,
|
||||
environment: LazyNavigationEnvironment(
|
||||
mainQueue: DispatchQueue.main.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return UIViewRepresented(makeUIView: { _ in vc.view })
|
||||
}
|
||||
}
|
||||
129
Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift
Normal file
129
Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
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<DispatchQueue>
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
)
|
||||
|
||||
class EagerNavigationViewController: UIViewController {
|
||||
var cancellables: [AnyCancellable] = []
|
||||
let store: Store<EagerNavigationState, EagerNavigationAction>
|
||||
let viewStore: ViewStore<EagerNavigationState, EagerNavigationAction>
|
||||
|
||||
init(store: Store<EagerNavigationState, EagerNavigationAction>) {
|
||||
self.store = store
|
||||
self.viewStore = ViewStore(store)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = "Navigate and load"
|
||||
|
||||
self.view.backgroundColor = .white
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.addTarget(self, action: #selector(loadOptionalCounterTapped), for: .touchUpInside)
|
||||
button.setTitle("Load optional counter", for: .normal)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.addSubview(button)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
button.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor),
|
||||
button.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor),
|
||||
])
|
||||
|
||||
self.viewStore.publisher.isNavigationActive.sink { [weak self] isNavigationActive in
|
||||
guard let self = self else { return }
|
||||
if isNavigationActive {
|
||||
self.navigationController?.pushViewController(
|
||||
IfLetStoreController(
|
||||
store: self.store
|
||||
.scope(state: \.optionalCounter, action: EagerNavigationAction.optionalCounter),
|
||||
then: CounterViewController.init(store:),
|
||||
else: ActivityIndicatorViewController()
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
} else {
|
||||
self.navigationController?.popToViewController(self, animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if !self.isMovingToParent {
|
||||
self.viewStore.send(.setNavigation(isActive: false))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func loadOptionalCounterTapped() {
|
||||
self.viewStore.send(.setNavigation(isActive: true))
|
||||
}
|
||||
}
|
||||
|
||||
struct EagerNavigationViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let vc = UINavigationController(
|
||||
rootViewController: EagerNavigationViewController(
|
||||
store: Store(
|
||||
initialState: EagerNavigationState(),
|
||||
reducer: eagerNavigationReducer,
|
||||
environment: EagerNavigationEnvironment(
|
||||
mainQueue: DispatchQueue.main.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return UIViewRepresented(makeUIView: { _ in vc.view })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
101
Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift
Normal file
101
Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct CaseStudy {
|
||||
let title: String
|
||||
let viewController: () -> UIViewController
|
||||
|
||||
init(title: String, viewController: @autoclosure @escaping () -> UIViewController) {
|
||||
self.title = title
|
||||
self.viewController = viewController
|
||||
}
|
||||
}
|
||||
|
||||
let dataSource: [CaseStudy] = [
|
||||
CaseStudy(
|
||||
title: "Basics",
|
||||
viewController: CounterViewController(
|
||||
store: Store(
|
||||
initialState: CounterState(),
|
||||
reducer: counterReducer,
|
||||
environment: CounterEnvironment()
|
||||
)
|
||||
)
|
||||
),
|
||||
CaseStudy(
|
||||
title: "Lists",
|
||||
viewController: CountersTableViewController(
|
||||
store: Store(
|
||||
initialState: CounterListState(
|
||||
counters: [
|
||||
CounterState(),
|
||||
CounterState(),
|
||||
CounterState(),
|
||||
]
|
||||
),
|
||||
reducer: counterListReducer,
|
||||
environment: CounterListEnvironment()
|
||||
)
|
||||
)
|
||||
),
|
||||
CaseStudy(
|
||||
title: "Navigate and load",
|
||||
viewController: EagerNavigationViewController(
|
||||
store: Store(
|
||||
initialState: EagerNavigationState(),
|
||||
reducer: eagerNavigationReducer,
|
||||
environment: EagerNavigationEnvironment(
|
||||
mainQueue: DispatchQueue.main.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
CaseStudy(
|
||||
title: "Load then navigate",
|
||||
viewController: LazyNavigationViewController(
|
||||
store: Store(
|
||||
initialState: LazyNavigationState(),
|
||||
reducer: lazyNavigationReducer,
|
||||
environment: LazyNavigationEnvironment(
|
||||
mainQueue: DispatchQueue.main.eraseToAnyScheduler()
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
final class RootViewController: UITableViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = "Case Studies"
|
||||
self.navigationController?.navigationBar.prefersLargeTitles = true
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
dataSource.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
|
||||
-> UITableViewCell
|
||||
{
|
||||
let caseStudy = dataSource[indexPath.row]
|
||||
let cell = UITableViewCell()
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.textLabel?.text = caseStudy.title
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let caseStudy = dataSource[indexPath.row]
|
||||
self.navigationController?.pushViewController(caseStudy.viewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct RootViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let vc = UINavigationController(rootViewController: RootViewController())
|
||||
return UIViewRepresented(makeUIView: { _ in vc.view })
|
||||
}
|
||||
}
|
||||
27
Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift
Normal file
27
Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
self.window = (scene as? UIWindowScene).map(UIWindow.init(windowScene:))
|
||||
self.window?.rootViewController = UINavigationController(
|
||||
rootViewController: RootViewController())
|
||||
self.window?.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
22
Examples/CaseStudies/UIKitCaseStudiesTests/Info.plist
Normal file
22
Examples/CaseStudies/UIKitCaseStudiesTests/Info.plist
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
|
||||
@testable import UIKitCaseStudies
|
||||
|
||||
class UIKitCaseStudiesTests: XCTestCase {
|
||||
func testExample() {
|
||||
}
|
||||
}
|
||||
550
Examples/MotionManager/MotionManager.xcodeproj/project.pbxproj
Normal file
550
Examples/MotionManager/MotionManager.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,550 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 52;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
5A405A2E244F410F002D4C76 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A405A2D244F410F002D4C76 /* SceneDelegate.swift */; };
|
||||
5A405A30244F410F002D4C76 /* MotionManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A405A2F244F410F002D4C76 /* MotionManagerView.swift */; };
|
||||
5A405A32244F4110002D4C76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A405A31244F4110002D4C76 /* Assets.xcassets */; };
|
||||
5A405A35244F4110002D4C76 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A405A34244F4110002D4C76 /* Preview Assets.xcassets */; };
|
||||
5A405A38244F4110002D4C76 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5A405A36244F4110002D4C76 /* LaunchScreen.storyboard */; };
|
||||
5A89A1FA244F43DC00EF81FA /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 5A89A1F9244F43DC00EF81FA /* ComposableArchitecture */; };
|
||||
5A89A1FB244F43DC00EF81FA /* ComposableArchitecture in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5A89A1F9244F43DC00EF81FA /* ComposableArchitecture */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
5A89A1FE244F441700EF81FA /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A89A1FD244F441700EF81FA /* Client.swift */; };
|
||||
CA0F931F244FE3F000E85985 /* MotionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0F931E244FE3F000E85985 /* MotionManagerTests.swift */; };
|
||||
CA0F9327244FE4EF00E85985 /* ComposableArchitectureTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA0F9326244FE4EF00E85985 /* ComposableArchitectureTestSupport */; };
|
||||
CA0F9329244FE4EF00E85985 /* ComposableArchitectureTestSupport in Embed Frameworks */ = {isa = PBXBuildFile; productRef = CA0F9326244FE4EF00E85985 /* ComposableArchitectureTestSupport */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
CA6AC253244FE92100C71CB3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC252244FE92100C71CB3 /* Models.swift */; };
|
||||
CA6AC255244FE92E00C71CB3 /* Live.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC254244FE92E00C71CB3 /* Live.swift */; };
|
||||
CA6AC257244FE98300C71CB3 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC256244FE98300C71CB3 /* Mock.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
CA0F9321244FE3F000E85985 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5A405A20244F410F002D4C76 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5A405A27244F410F002D4C76;
|
||||
remoteInfo = MotionManager;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
5A89A1FC244F43DD00EF81FA /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
5A89A1FB244F43DC00EF81FA /* ComposableArchitecture in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA0F9328244FE4EF00E85985 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
CA0F9329244FE4EF00E85985 /* ComposableArchitectureTestSupport in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
5A405A28244F410F002D4C76 /* MotionManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MotionManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5A405A2D244F410F002D4C76 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
5A405A2F244F410F002D4C76 /* MotionManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionManagerView.swift; sourceTree = "<group>"; };
|
||||
5A405A31244F4110002D4C76 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
5A405A34244F4110002D4C76 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
5A405A37244F4110002D4C76 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
5A405A39244F4110002D4C76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
5A89A1FD244F441700EF81FA /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
|
||||
CA0F931C244FE3F000E85985 /* MotionManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MotionManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CA0F931E244FE3F000E85985 /* MotionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionManagerTests.swift; sourceTree = "<group>"; };
|
||||
CA0F9320244FE3F000E85985 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CA6AC252244FE92100C71CB3 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
CA6AC254244FE92E00C71CB3 /* Live.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Live.swift; sourceTree = "<group>"; };
|
||||
CA6AC256244FE98300C71CB3 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5A405A25244F410F002D4C76 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5A89A1FA244F43DC00EF81FA /* ComposableArchitecture in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA0F9319244FE3F000E85985 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA0F9327244FE4EF00E85985 /* ComposableArchitectureTestSupport in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
5A405A1F244F410F002D4C76 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A405A2A244F410F002D4C76 /* MotionManager */,
|
||||
CA0F931D244FE3F000E85985 /* MotionManagerTests */,
|
||||
5A405A29244F410F002D4C76 /* Products */,
|
||||
5A89A1F8244F43DC00EF81FA /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5A405A29244F410F002D4C76 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A405A28244F410F002D4C76 /* MotionManager.app */,
|
||||
CA0F931C244FE3F000E85985 /* MotionManagerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5A405A2A244F410F002D4C76 /* MotionManager */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA6AC251244FE90B00C71CB3 /* MotionClient */,
|
||||
5A405A39244F4110002D4C76 /* Info.plist */,
|
||||
5A405A2F244F410F002D4C76 /* MotionManagerView.swift */,
|
||||
5A405A2D244F410F002D4C76 /* SceneDelegate.swift */,
|
||||
5A405A31244F4110002D4C76 /* Assets.xcassets */,
|
||||
5A405A36244F4110002D4C76 /* LaunchScreen.storyboard */,
|
||||
5A405A33244F4110002D4C76 /* Preview Content */,
|
||||
);
|
||||
path = MotionManager;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5A405A33244F4110002D4C76 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A405A34244F4110002D4C76 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5A89A1F8244F43DC00EF81FA /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA0F931D244FE3F000E85985 /* MotionManagerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA0F931E244FE3F000E85985 /* MotionManagerTests.swift */,
|
||||
CA0F9320244FE3F000E85985 /* Info.plist */,
|
||||
);
|
||||
path = MotionManagerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA6AC251244FE90B00C71CB3 /* MotionClient */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A89A1FD244F441700EF81FA /* Client.swift */,
|
||||
CA6AC256244FE98300C71CB3 /* Mock.swift */,
|
||||
CA6AC254244FE92E00C71CB3 /* Live.swift */,
|
||||
CA6AC252244FE92100C71CB3 /* Models.swift */,
|
||||
);
|
||||
path = MotionClient;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
5A405A27244F410F002D4C76 /* MotionManager */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5A405A3C244F4110002D4C76 /* Build configuration list for PBXNativeTarget "MotionManager" */;
|
||||
buildPhases = (
|
||||
5A405A24244F410F002D4C76 /* Sources */,
|
||||
5A405A25244F410F002D4C76 /* Frameworks */,
|
||||
5A405A26244F410F002D4C76 /* Resources */,
|
||||
5A89A1FC244F43DD00EF81FA /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = MotionManager;
|
||||
packageProductDependencies = (
|
||||
5A89A1F9244F43DC00EF81FA /* ComposableArchitecture */,
|
||||
);
|
||||
productName = MotionManager;
|
||||
productReference = 5A405A28244F410F002D4C76 /* MotionManager.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
CA0F931B244FE3F000E85985 /* MotionManagerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CA0F9325244FE3F000E85985 /* Build configuration list for PBXNativeTarget "MotionManagerTests" */;
|
||||
buildPhases = (
|
||||
CA0F9318244FE3F000E85985 /* Sources */,
|
||||
CA0F9319244FE3F000E85985 /* Frameworks */,
|
||||
CA0F931A244FE3F000E85985 /* Resources */,
|
||||
CA0F9328244FE4EF00E85985 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
CA0F9322244FE3F000E85985 /* PBXTargetDependency */,
|
||||
);
|
||||
name = MotionManagerTests;
|
||||
packageProductDependencies = (
|
||||
CA0F9326244FE4EF00E85985 /* ComposableArchitectureTestSupport */,
|
||||
);
|
||||
productName = MotionManagerTests;
|
||||
productReference = CA0F931C244FE3F000E85985 /* MotionManagerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
5A405A20244F410F002D4C76 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1140;
|
||||
LastUpgradeCheck = 1140;
|
||||
ORGANIZATIONNAME = finestructure;
|
||||
TargetAttributes = {
|
||||
5A405A27244F410F002D4C76 = {
|
||||
CreatedOnToolsVersion = 11.4.1;
|
||||
};
|
||||
CA0F931B244FE3F000E85985 = {
|
||||
CreatedOnToolsVersion = 11.4.1;
|
||||
TestTargetID = 5A405A27244F410F002D4C76;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 5A405A23244F410F002D4C76 /* Build configuration list for PBXProject "MotionManager" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 5A405A1F244F410F002D4C76;
|
||||
productRefGroup = 5A405A29244F410F002D4C76 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
5A405A27244F410F002D4C76 /* MotionManager */,
|
||||
CA0F931B244FE3F000E85985 /* MotionManagerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
5A405A26244F410F002D4C76 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5A405A38244F4110002D4C76 /* LaunchScreen.storyboard in Resources */,
|
||||
5A405A35244F4110002D4C76 /* Preview Assets.xcassets in Resources */,
|
||||
5A405A32244F4110002D4C76 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA0F931A244FE3F000E85985 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
5A405A24244F410F002D4C76 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5A405A2E244F410F002D4C76 /* SceneDelegate.swift in Sources */,
|
||||
5A405A30244F410F002D4C76 /* MotionManagerView.swift in Sources */,
|
||||
5A89A1FE244F441700EF81FA /* Client.swift in Sources */,
|
||||
CA6AC255244FE92E00C71CB3 /* Live.swift in Sources */,
|
||||
CA6AC253244FE92100C71CB3 /* Models.swift in Sources */,
|
||||
CA6AC257244FE98300C71CB3 /* Mock.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA0F9318244FE3F000E85985 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA0F931F244FE3F000E85985 /* MotionManagerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
CA0F9322244FE3F000E85985 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5A405A27244F410F002D4C76 /* MotionManager */;
|
||||
targetProxy = CA0F9321244FE3F000E85985 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
5A405A36244F4110002D4C76 /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
5A405A37244F4110002D4C76 /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
5A405A3A244F4110002D4C76 /* 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;
|
||||
};
|
||||
5A405A3B244F4110002D4C76 /* 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;
|
||||
};
|
||||
5A405A3D244F4110002D4C76 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MotionManager/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = A42K5AU657;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = MotionManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.MotionManager;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5A405A3E244F4110002D4C76 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MotionManager/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = A42K5AU657;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = MotionManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.MotionManager;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CA0F9323244FE3F000E85985 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = MotionManagerTests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.MotionManagerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MotionManager.app/MotionManager";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CA0F9324244FE3F000E85985 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = MotionManagerTests/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.MotionManagerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MotionManager.app/MotionManager";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
5A405A23244F410F002D4C76 /* Build configuration list for PBXProject "MotionManager" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5A405A3A244F4110002D4C76 /* Debug */,
|
||||
5A405A3B244F4110002D4C76 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5A405A3C244F4110002D4C76 /* Build configuration list for PBXNativeTarget "MotionManager" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5A405A3D244F4110002D4C76 /* Debug */,
|
||||
5A405A3E244F4110002D4C76 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CA0F9325244FE3F000E85985 /* Build configuration list for PBXNativeTarget "MotionManagerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CA0F9323244FE3F000E85985 /* Debug */,
|
||||
CA0F9324244FE3F000E85985 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
5A89A1F9244F43DC00EF81FA /* ComposableArchitecture */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposableArchitecture;
|
||||
};
|
||||
CA0F9326244FE4EF00E85985 /* ComposableArchitectureTestSupport */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposableArchitectureTestSupport;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 5A405A20244F410F002D4C76 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5A405A27244F410F002D4C76"
|
||||
BuildableName = "MotionManager.app"
|
||||
BlueprintName = "MotionManager"
|
||||
ReferencedContainer = "container:MotionManager.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA0F931B244FE3F000E85985"
|
||||
BuildableName = "MotionManagerTests.xctest"
|
||||
BlueprintName = "MotionManagerTests"
|
||||
ReferencedContainer = "container:MotionManager.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5A405A27244F410F002D4C76"
|
||||
BuildableName = "MotionManager.app"
|
||||
BlueprintName = "MotionManager"
|
||||
ReferencedContainer = "container:MotionManager.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5A405A27244F410F002D4C76"
|
||||
BuildableName = "MotionManager.app"
|
||||
BlueprintName = "MotionManager"
|
||||
ReferencedContainer = "container:MotionManager.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
60
Examples/MotionManager/MotionManager/Info.plist
Normal file
60
Examples/MotionManager/MotionManager/Info.plist
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,30 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import CoreMotion
|
||||
|
||||
struct MotionClient {
|
||||
enum Action: Equatable {
|
||||
case motionUpdate(DeviceMotion)
|
||||
}
|
||||
|
||||
enum Error: Swift.Error, Equatable {
|
||||
case motionUpdateFailed(String)
|
||||
case notAvailable
|
||||
}
|
||||
|
||||
func create(id: AnyHashable) -> Effect<Action, Error> {
|
||||
self.create(id)
|
||||
}
|
||||
|
||||
func startDeviceMotionUpdates(id: AnyHashable) -> Effect<Never, Never> {
|
||||
self.startDeviceMotionUpdates(id)
|
||||
}
|
||||
|
||||
func stopDeviceMotionUpdates(id: AnyHashable) -> Effect<Never, Never> {
|
||||
self.stopDeviceMotionUpdates(id)
|
||||
}
|
||||
|
||||
var create: (AnyHashable) -> Effect<Action, Error>
|
||||
var startDeviceMotionUpdates: (AnyHashable) -> Effect<Never, Never>
|
||||
var stopDeviceMotionUpdates: (AnyHashable) -> Effect<Never, Never>
|
||||
}
|
||||
61
Examples/MotionManager/MotionManager/MotionClient/Live.swift
Normal file
61
Examples/MotionManager/MotionManager/MotionClient/Live.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import CoreMotion
|
||||
|
||||
extension MotionClient {
|
||||
static let live = MotionClient(
|
||||
create: { id in
|
||||
Effect.async { subscriber in
|
||||
let manager = MotionManager(
|
||||
manager: CMMotionManager(),
|
||||
handler: { motion, error in
|
||||
switch (motion, error) {
|
||||
case let (.some(motion), .none):
|
||||
subscriber.send(.motionUpdate(DeviceMotion(deviceMotion: motion)))
|
||||
case let (_, .some(error)):
|
||||
subscriber.send(completion: .failure(.motionUpdateFailed("\(error)")))
|
||||
case (.none, .none):
|
||||
fatalError("It should not be possible to have both a nil result and nil error.")
|
||||
}
|
||||
})
|
||||
guard manager.isDeviceMotionAvailable else {
|
||||
subscriber.send(completion: .failure(.notAvailable))
|
||||
return AnyCancellable {}
|
||||
}
|
||||
motionManagers[id] = manager
|
||||
return AnyCancellable { motionManagers[id] = nil }
|
||||
}
|
||||
},
|
||||
startDeviceMotionUpdates: { id in
|
||||
.fireAndForget { motionManagers[id]?.startMotionUpdates() }
|
||||
},
|
||||
stopDeviceMotionUpdates: { id in
|
||||
.fireAndForget { motionManagers[id]?.stopMotionUpdates() }
|
||||
})
|
||||
}
|
||||
|
||||
private final class MotionManager {
|
||||
init(manager: CMMotionManager, handler: @escaping CMDeviceMotionHandler) {
|
||||
self.manager = manager
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
var manager: CMMotionManager
|
||||
var handler: CMDeviceMotionHandler
|
||||
|
||||
var isDeviceMotionAvailable: Bool { self.manager.isDeviceMotionAvailable }
|
||||
|
||||
func startMotionUpdates() {
|
||||
self.manager.startDeviceMotionUpdates(
|
||||
using: .xArbitraryZVertical,
|
||||
to: .main,
|
||||
withHandler: self.handler
|
||||
)
|
||||
}
|
||||
|
||||
func stopMotionUpdates() {
|
||||
self.manager.stopDeviceMotionUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private var motionManagers: [AnyHashable: MotionManager] = [:]
|
||||
23
Examples/MotionManager/MotionManager/MotionClient/Mock.swift
Normal file
23
Examples/MotionManager/MotionManager/MotionClient/Mock.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import ComposableArchitecture
|
||||
|
||||
#if DEBUG
|
||||
extension MotionClient {
|
||||
static func mock(
|
||||
create: @escaping (AnyHashable) -> Effect<Action, Error> = { _ in
|
||||
fatalError("Unimplemented")
|
||||
},
|
||||
startDeviceMotionUpdates: @escaping (AnyHashable) -> Effect<Never, Never> = { _ in
|
||||
fatalError("Unimplemented")
|
||||
},
|
||||
stopDeviceMotionUpdates: @escaping (AnyHashable) -> Effect<Never, Never> = { _ in
|
||||
fatalError("Unimplemented")
|
||||
}
|
||||
) -> Self {
|
||||
Self(
|
||||
create: create,
|
||||
startDeviceMotionUpdates: startDeviceMotionUpdates,
|
||||
stopDeviceMotionUpdates: stopDeviceMotionUpdates
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,22 @@
|
||||
import CoreMotion
|
||||
|
||||
public struct DeviceMotion: Equatable {
|
||||
public var gravity: CMAcceleration
|
||||
public var userAcceleration: CMAcceleration
|
||||
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.gravity.x == rhs.gravity.x
|
||||
&& lhs.gravity.y == rhs.gravity.y
|
||||
&& lhs.gravity.z == rhs.gravity.z
|
||||
&& lhs.userAcceleration.x == rhs.userAcceleration.x
|
||||
&& lhs.userAcceleration.y == rhs.userAcceleration.y
|
||||
&& lhs.userAcceleration.z == rhs.userAcceleration.z
|
||||
}
|
||||
}
|
||||
|
||||
extension DeviceMotion {
|
||||
public init(deviceMotion: CMDeviceMotion) {
|
||||
self.gravity = deviceMotion.gravity
|
||||
self.userAcceleration = deviceMotion.userAcceleration
|
||||
}
|
||||
}
|
||||
132
Examples/MotionManager/MotionManager/MotionManagerView.swift
Normal file
132
Examples/MotionManager/MotionManager/MotionManagerView.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import ComposableArchitecture
|
||||
import CoreMotion
|
||||
import SwiftUI
|
||||
|
||||
struct AppState: Equatable {
|
||||
var alertTitle: String?
|
||||
var isRecording = false
|
||||
var z: [Double] = []
|
||||
}
|
||||
|
||||
enum AppAction: Equatable {
|
||||
case alertDismissed
|
||||
case motionClient(Result<MotionClient.Action, MotionClient.Error>)
|
||||
case onAppear
|
||||
case recordingButtonTapped
|
||||
}
|
||||
|
||||
struct AppEnvironment {
|
||||
var motionClient: MotionClient
|
||||
}
|
||||
|
||||
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
|
||||
struct MotionClientId: Hashable {}
|
||||
|
||||
switch action {
|
||||
case .alertDismissed:
|
||||
state.alertTitle = nil
|
||||
return .none
|
||||
|
||||
case .motionClient(.failure):
|
||||
state.alertTitle =
|
||||
"We encountered a problem with the motion manager. Make sure you run this demo on a real device, not the simulator."
|
||||
state.isRecording = false
|
||||
return .none
|
||||
|
||||
case let .motionClient(.success(.motionUpdate(motion))):
|
||||
state.z.append(
|
||||
motion.gravity.x * motion.userAcceleration.x
|
||||
+ motion.gravity.y * motion.userAcceleration.y
|
||||
+ motion.gravity.z * motion.userAcceleration.z
|
||||
)
|
||||
state.z.removeFirst(max(0, state.z.count - 350))
|
||||
return .none
|
||||
|
||||
case .onAppear:
|
||||
return environment.motionClient.create(id: MotionClientId())
|
||||
.catchToEffect()
|
||||
.map(AppAction.motionClient)
|
||||
|
||||
case .recordingButtonTapped:
|
||||
state.isRecording.toggle()
|
||||
return state.isRecording
|
||||
? environment.motionClient.startDeviceMotionUpdates(id: MotionClientId())
|
||||
.fireAndForget()
|
||||
: environment.motionClient.stopDeviceMotionUpdates(id: MotionClientId())
|
||||
.fireAndForget()
|
||||
}
|
||||
}
|
||||
|
||||
struct AppView: View {
|
||||
let store: Store<AppState, AppAction>
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(self.store) { viewStore in
|
||||
VStack {
|
||||
Spacer(minLength: 100)
|
||||
|
||||
ZStack {
|
||||
plot(buffer: viewStore.z, scale: 40)
|
||||
}
|
||||
|
||||
Button(action: { viewStore.send(.recordingButtonTapped) }) {
|
||||
HStack {
|
||||
Image(
|
||||
systemName: viewStore.isRecording
|
||||
? "stop.circle.fill" : "arrowtriangle.right.circle.fill"
|
||||
)
|
||||
.font(.title)
|
||||
Text(viewStore.isRecording ? "Stop Recording" : "Start Recording")
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.background(viewStore.isRecording ? Color.red : .blue)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.onAppear { viewStore.send(.onAppear) }
|
||||
.alert(
|
||||
item: viewStore.binding(
|
||||
get: { $0.alertTitle.map(AppAlert.init(title:)) },
|
||||
send: .alertDismissed
|
||||
)
|
||||
) { alert in
|
||||
Alert(title: Text(alert.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppAlert: Identifiable {
|
||||
var title: String
|
||||
var id: String { self.title }
|
||||
}
|
||||
|
||||
func plot(buffer: [Double], scale: Double) -> Path {
|
||||
Path { path in
|
||||
let baseline: Double = 50
|
||||
let size: Double = 3
|
||||
for (offset, value) in buffer.enumerated() {
|
||||
let point = CGPoint(x: Double(offset) - size / 2, y: baseline - value * scale - size / 2)
|
||||
let rect = CGRect(origin: point, size: CGSize(width: size, height: size))
|
||||
path.addEllipse(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AppView(
|
||||
store: Store(
|
||||
initialState: AppState(
|
||||
isRecording: false,
|
||||
z: (1...350)
|
||||
.map { sin(Double($0) / 10) }
|
||||
),
|
||||
reducer: appReducer,
|
||||
environment: .init(motionClient: .live)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
35
Examples/MotionManager/MotionManager/SceneDelegate.swift
Normal file
35
Examples/MotionManager/MotionManager/SceneDelegate.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
self.window = (scene as? UIWindowScene).map(UIWindow.init(windowScene:))
|
||||
self.window?.rootViewController = UIHostingController(
|
||||
rootView: AppView(
|
||||
store: Store(
|
||||
initialState: AppState(),
|
||||
reducer: appReducer,
|
||||
environment: .init(motionClient: .live)
|
||||
)
|
||||
)
|
||||
)
|
||||
self.window?.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
22
Examples/MotionManager/MotionManagerTests/Info.plist
Normal file
22
Examples/MotionManager/MotionManagerTests/Info.plist
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,47 @@
|
||||
import Combine
|
||||
import ComposableArchitectureTestSupport
|
||||
import CoreMotion
|
||||
import XCTest
|
||||
|
||||
@testable import MotionManager
|
||||
|
||||
class MotionManagerTests: XCTestCase {
|
||||
func testExample() {
|
||||
let motionSubject = PassthroughSubject<MotionClient.Action, MotionClient.Error>()
|
||||
var motionUpdatesStarted = false
|
||||
|
||||
let store = TestStore(
|
||||
initialState: .init(),
|
||||
reducer: appReducer,
|
||||
environment: .init(
|
||||
motionClient: .mock(
|
||||
create: { _ in motionSubject.eraseToEffect() },
|
||||
startDeviceMotionUpdates: { _ in .fireAndForget { motionUpdatesStarted = true } },
|
||||
stopDeviceMotionUpdates: { _ in
|
||||
.fireAndForget { motionSubject.send(completion: .finished) }
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
let deviceMotion = DeviceMotion(
|
||||
gravity: CMAcceleration(x: 1, y: 2, z: 3),
|
||||
userAcceleration: CMAcceleration(x: 4, y: 5, z: 6)
|
||||
)
|
||||
|
||||
store.assert(
|
||||
.send(.onAppear),
|
||||
.send(.recordingButtonTapped) {
|
||||
$0.isRecording = true
|
||||
XCTAssertTrue(motionUpdatesStarted)
|
||||
},
|
||||
.do { motionSubject.send(.motionUpdate(deviceMotion)) },
|
||||
.receive(.motionClient(.success(.motionUpdate(deviceMotion)))) {
|
||||
$0.z = [32]
|
||||
},
|
||||
.send(.recordingButtonTapped) {
|
||||
$0.isRecording = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
5
Examples/MotionManager/README.md
Normal file
5
Examples/MotionManager/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Motion Manager
|
||||
|
||||
This application demonstrates how to work with a complex dependency in the Composable Architecture. It uses the `CMMotionManager` API from the `CoreMotion` framework to read device movements and display that data as a graph on the screen.
|
||||
|
||||
This demo only works on a real device, not in the simulator.
|
||||
9
Examples/Package.swift
Normal file
9
Examples/Package.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
// swift-tools-version:5.2
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Examples",
|
||||
products: [],
|
||||
targets: []
|
||||
)
|
||||
24
Examples/README.md
Normal file
24
Examples/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Examples
|
||||
|
||||
This directory holds many case studies and applications to demonstrate solving various problems with the Composable Architecture. Open the `ComposableArchitecture.xcworkspace` at the root of the repo to see all example projects in one single workspace, or you can open each example application individually.
|
||||
|
||||
* **Case Studies**
|
||||
<br> Demonstrates how to solve some common application problems in an isolated environment, in both SwiftUI and UIKit. Things like bindings, navigation, effects, and reusable components.
|
||||
|
||||
* **Motion Manager**
|
||||
<br> This application uses Apple's CoreMotion framework to show a graph of device movements. It's a demonstration of how to wrap complex dependencies in the `Effect` type so that you can interact with them in the reducer _and_ write tests for its logic.
|
||||
|
||||
* **Search**
|
||||
<br> Demonstrates how to build a search feature, with debouncing of typing events, and comes with a full test suite to performs end-to-end testing from user actions to running side effects.
|
||||
|
||||
* **Speech Recognition**
|
||||
<br> This application uses Apple's Speech framework to demonstrate how to wrap complex dependencies in the `Effect` type of the Composable Architecture. Doing a little bit of upfront work allows you to interact with the dependencies in a controlled, understandable way, and you can write tests on how the dependency interacts with your application logic.
|
||||
|
||||
* **Tic-Tac-Toe**
|
||||
<br> Builds a moderately complex application in both SwiftUI and UIKit that is fully controlled by the Composable Architecture. The core application logic is put into its own modules, with no UI, and then both of the SwiftUI and UIKit applications are run off of that single source of logic. This demonstrates how one can hyper-modularize an application, which for a big enough application can greatly help compile times and developer productivity. This demo was inspired by the equivalent demos in [RIBs](http://github.com/uber/RIBs) (see [here](https://github.com/uber/RIBs/tree/master/ios/tutorials/tutorial4-completed)) and [Workflow](https://github.com/square/workflow/) (see [here](https://github.com/square/workflow/tree/master/swift/Samples/TicTacToe)).
|
||||
|
||||
* **Todo**
|
||||
<br> A simple todo application with a few bells and whistles, and a comprehensive test suite.
|
||||
|
||||
* **Voice Memos**
|
||||
<br> A more complex demo that demonstrates how to work with many complex dependencies at once, and how to manage a complex state machine driven off of timers.
|
||||
13
Examples/Search/README.md
Normal file
13
Examples/Search/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Search
|
||||
|
||||
This application demonstrates how to build a moderately complex search feature in the Composable Architecture:
|
||||
|
||||
* Typing into the search field executes an API request to search for locations.
|
||||
* Tapping a location runs another API request to fetch the weather for that location, and when a response is received the data is displayed inline in that row.
|
||||
|
||||
In addition to those basic features, the following extra things are implemented:
|
||||
|
||||
* Search API requests are debounced so that one is run only after the user stops typing for 300ms.
|
||||
* If you tap a location while a weather API request is already in-flight it will cancel that request and start a new one.
|
||||
* Dependencies and side effects are fully controlled. The reducer that runs this application needs a [weather API client](Search/WeatherClient.swift) and a scheduler to run effects.
|
||||
* A full [test suite](SearchTests/SearchTests.swift) is implemented. Not only is core functionality tested, but also failure flows and subtle edge cases (e.g. clearing the search query cancels any in-flight search requests).
|
||||
525
Examples/Search/Search.xcodeproj/project.pbxproj
Normal file
525
Examples/Search/Search.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,525 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 52;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
CA5C0ECA2425661800C1F9AB /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5C0EC92425661800C1F9AB /* ActivityIndicator.swift */; };
|
||||
CA66690B242547B000A639B3 /* WeatherClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA66690A242547B000A639B3 /* WeatherClient.swift */; };
|
||||
CA86E49D24253C2500357AD9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E49C24253C2500357AD9 /* SceneDelegate.swift */; };
|
||||
CA86E49F24253C2500357AD9 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E49E24253C2500357AD9 /* SearchView.swift */; };
|
||||
CA86E4A124253C2700357AD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA86E4A024253C2700357AD9 /* Assets.xcassets */; };
|
||||
CA86E4B224253C2700357AD9 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA86E4B124253C2700357AD9 /* SearchTests.swift */; };
|
||||
DC85B4AB242D07B4009784B0 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC85B4AA242D07B4009784B0 /* ComposableArchitecture */; };
|
||||
DC85B4AC242D07B4009784B0 /* ComposableArchitecture in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DC85B4AA242D07B4009784B0 /* ComposableArchitecture */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
DC85B4B0242D07BA009784B0 /* ComposableArchitectureTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = DC85B4AF242D07BA009784B0 /* ComposableArchitectureTestSupport */; };
|
||||
DC85B4B1242D07BA009784B0 /* ComposableArchitectureTestSupport in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DC85B4AF242D07BA009784B0 /* ComposableArchitectureTestSupport */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
CA86E4AE24253C2700357AD9 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = CA86E48F24253C2500357AD9 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = CA86E49624253C2500357AD9;
|
||||
remoteInfo = Search;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
CA86E4C324253C5400357AD9 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
DC85B4AC242D07B4009784B0 /* ComposableArchitecture in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA86E4C724253CE200357AD9 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
DC85B4B1242D07BA009784B0 /* ComposableArchitectureTestSupport in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
CA5C0EC92425661800C1F9AB /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = "<group>"; };
|
||||
CA66690A242547B000A639B3 /* WeatherClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherClient.swift; sourceTree = "<group>"; };
|
||||
CA86E49724253C2500357AD9 /* Search.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Search.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CA86E49C24253C2500357AD9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
CA86E49E24253C2500357AD9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
CA86E4A024253C2700357AD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
CA86E4A824253C2700357AD9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CA86E4AD24253C2700357AD9 /* SearchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SearchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CA86E4B124253C2700357AD9 /* SearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||
DC85B4A7242D07A6009784B0 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swift-composable-architecture"; path = ../..; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
CA86E49424253C2500357AD9 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DC85B4AB242D07B4009784B0 /* ComposableArchitecture in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA86E4AA24253C2700357AD9 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DC85B4B0242D07BA009784B0 /* ComposableArchitectureTestSupport in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
CA86E48E24253C2500357AD9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DC85B4A7242D07A6009784B0 /* swift-composable-architecture */,
|
||||
CA86E4C424253CE200357AD9 /* Frameworks */,
|
||||
CA86E49824253C2500357AD9 /* Products */,
|
||||
CA86E49924253C2500357AD9 /* Search */,
|
||||
CA86E4B024253C2700357AD9 /* SearchTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA86E49824253C2500357AD9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA86E49724253C2500357AD9 /* Search.app */,
|
||||
CA86E4AD24253C2700357AD9 /* SearchTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA86E49924253C2500357AD9 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA86E4A824253C2700357AD9 /* Info.plist */,
|
||||
CA86E49E24253C2500357AD9 /* SearchView.swift */,
|
||||
CA5C0EC92425661800C1F9AB /* ActivityIndicator.swift */,
|
||||
CA86E49C24253C2500357AD9 /* SceneDelegate.swift */,
|
||||
CA66690A242547B000A639B3 /* WeatherClient.swift */,
|
||||
CA86E4A024253C2700357AD9 /* Assets.xcassets */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA86E4B024253C2700357AD9 /* SearchTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA86E4B124253C2700357AD9 /* SearchTests.swift */,
|
||||
);
|
||||
path = SearchTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA86E4C424253CE200357AD9 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
CA86E49624253C2500357AD9 /* Search */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CA86E4B624253C2700357AD9 /* Build configuration list for PBXNativeTarget "Search" */;
|
||||
buildPhases = (
|
||||
CA86E49324253C2500357AD9 /* Sources */,
|
||||
CA86E49424253C2500357AD9 /* Frameworks */,
|
||||
CA86E49524253C2500357AD9 /* Resources */,
|
||||
CA86E4C324253C5400357AD9 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
DC85B4A9242D07B1009784B0 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Search;
|
||||
packageProductDependencies = (
|
||||
DC85B4AA242D07B4009784B0 /* ComposableArchitecture */,
|
||||
);
|
||||
productName = Search;
|
||||
productReference = CA86E49724253C2500357AD9 /* Search.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
CA86E4AC24253C2700357AD9 /* SearchTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CA86E4B924253C2700357AD9 /* Build configuration list for PBXNativeTarget "SearchTests" */;
|
||||
buildPhases = (
|
||||
CA86E4A924253C2700357AD9 /* Sources */,
|
||||
CA86E4AA24253C2700357AD9 /* Frameworks */,
|
||||
CA86E4AB24253C2700357AD9 /* Resources */,
|
||||
CA86E4C724253CE200357AD9 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
DC85B4AE242D07B7009784B0 /* PBXTargetDependency */,
|
||||
CA86E4AF24253C2700357AD9 /* PBXTargetDependency */,
|
||||
);
|
||||
name = SearchTests;
|
||||
packageProductDependencies = (
|
||||
DC85B4AF242D07BA009784B0 /* ComposableArchitectureTestSupport */,
|
||||
);
|
||||
productName = SearchTests;
|
||||
productReference = CA86E4AD24253C2700357AD9 /* SearchTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
CA86E48F24253C2500357AD9 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1140;
|
||||
LastUpgradeCheck = 1140;
|
||||
ORGANIZATIONNAME = "Brandon Williams";
|
||||
TargetAttributes = {
|
||||
CA86E49624253C2500357AD9 = {
|
||||
CreatedOnToolsVersion = 11.4;
|
||||
};
|
||||
CA86E4AC24253C2700357AD9 = {
|
||||
CreatedOnToolsVersion = 11.4;
|
||||
TestTargetID = CA86E49624253C2500357AD9;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = CA86E49224253C2500357AD9 /* Build configuration list for PBXProject "Search" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = CA86E48E24253C2500357AD9;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = CA86E49824253C2500357AD9 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
CA86E49624253C2500357AD9 /* Search */,
|
||||
CA86E4AC24253C2700357AD9 /* SearchTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
CA86E49524253C2500357AD9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA86E4A124253C2700357AD9 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA86E4AB24253C2700357AD9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
CA86E49324253C2500357AD9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA5C0ECA2425661800C1F9AB /* ActivityIndicator.swift in Sources */,
|
||||
CA66690B242547B000A639B3 /* WeatherClient.swift in Sources */,
|
||||
CA86E49D24253C2500357AD9 /* SceneDelegate.swift in Sources */,
|
||||
CA86E49F24253C2500357AD9 /* SearchView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA86E4A924253C2700357AD9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA86E4B224253C2700357AD9 /* SearchTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
CA86E4AF24253C2700357AD9 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = CA86E49624253C2500357AD9 /* Search */;
|
||||
targetProxy = CA86E4AE24253C2700357AD9 /* PBXContainerItemProxy */;
|
||||
};
|
||||
DC85B4A9242D07B1009784B0 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = DC85B4A8242D07B1009784B0 /* ComposableArchitecture */;
|
||||
};
|
||||
DC85B4AE242D07B7009784B0 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = DC85B4AD242D07B7009784B0 /* ComposableArchitectureTestSupport */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
CA86E4B424253C2700357AD9 /* 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;
|
||||
};
|
||||
CA86E4B524253C2700357AD9 /* 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;
|
||||
};
|
||||
CA86E4B724253C2700357AD9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = Search/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Search;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CA86E4B824253C2700357AD9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = Search/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Search;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CA86E4BA24253C2700357AD9 /* 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.SearchTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Search.app/Search";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CA86E4BB24253C2700357AD9 /* 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.SearchTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Search.app/Search";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
CA86E49224253C2500357AD9 /* Build configuration list for PBXProject "Search" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CA86E4B424253C2700357AD9 /* Debug */,
|
||||
CA86E4B524253C2700357AD9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CA86E4B624253C2700357AD9 /* Build configuration list for PBXNativeTarget "Search" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CA86E4B724253C2700357AD9 /* Debug */,
|
||||
CA86E4B824253C2700357AD9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CA86E4B924253C2700357AD9 /* Build configuration list for PBXNativeTarget "SearchTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CA86E4BA24253C2700357AD9 /* Debug */,
|
||||
CA86E4BB24253C2700357AD9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
DC85B4A8242D07B1009784B0 /* ComposableArchitecture */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposableArchitecture;
|
||||
};
|
||||
DC85B4AA242D07B4009784B0 /* ComposableArchitecture */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposableArchitecture;
|
||||
};
|
||||
DC85B4AD242D07B7009784B0 /* ComposableArchitectureTestSupport */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposableArchitectureTestSupport;
|
||||
};
|
||||
DC85B4AF242D07BA009784B0 /* ComposableArchitectureTestSupport */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ComposableArchitectureTestSupport;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = CA86E48F24253C2500357AD9 /* Project object */;
|
||||
}
|
||||
7
Examples/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Examples/Search/Search.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA86E49624253C2500357AD9"
|
||||
BuildableName = "Search.app"
|
||||
BlueprintName = "Search"
|
||||
ReferencedContainer = "container:Search.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA86E4AC24253C2700357AD9"
|
||||
BuildableName = "SearchTests.xctest"
|
||||
BlueprintName = "SearchTests"
|
||||
ReferencedContainer = "container:Search.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA86E49624253C2500357AD9"
|
||||
BuildableName = "Search.app"
|
||||
BlueprintName = "Search"
|
||||
ReferencedContainer = "container:Search.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA86E49624253C2500357AD9"
|
||||
BuildableName = "Search.app"
|
||||
BlueprintName = "Search"
|
||||
ReferencedContainer = "container:Search.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
16
Examples/Search/Search/ActivityIndicator.swift
Normal file
16
Examples/Search/Search/ActivityIndicator.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
// SwiftUI doesn't have a view for activity indicators, so we make UIActivityIndicatorView
|
||||
// accessible from SwiftUI.
|
||||
|
||||
public struct ActivityIndicator: UIViewRepresentable {
|
||||
public init() {}
|
||||
|
||||
public func makeUIView(context: Context) -> UIActivityIndicatorView {
|
||||
let view = UIActivityIndicatorView()
|
||||
view.startAnimating()
|
||||
return view
|
||||
}
|
||||
|
||||
public func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user