The Composable Architecture

Co-authored-by: Stephen Celis <stephen.celis@gmail.com>
This commit is contained in:
Brandon Williams
2020-05-03 16:19:55 -07:00
commit d2240d0e76
243 changed files with 28515 additions and 0 deletions

20
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

View File

@@ -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
View 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.

View 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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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.

View 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()
}
}

View File

@@ -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()
)
)
}
}

View File

@@ -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()
)
)
}
}
}

View File

@@ -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()
)
)
}
}
}

View File

@@ -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()
)
)
}
}
}

View File

@@ -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()
)
)
}
}
}
}

View 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()
}

View File

@@ -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()
}

View File

@@ -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 }
}
}
}

View 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()
)
)
)
}
}
}

View File

@@ -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
}

View File

@@ -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))")!
}
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}

View File

@@ -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()
)
)
)
}
}
}

View File

@@ -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()
)
)
)
}
}
}

View File

@@ -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()
)
)
)
}
}
}

View File

@@ -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
)
)
)
}
}
}

View File

@@ -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] = [:]

View File

@@ -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
}
}

View File

@@ -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"
)
),
]
}

View File

@@ -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"),
]
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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>

View File

@@ -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
})
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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"
}
)
}
}

View File

@@ -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()
}
)
}
}

View File

@@ -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() }
)
}
}

View File

@@ -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
}
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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>

View File

@@ -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 })
}
}

View 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>

View File

@@ -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),
])
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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 })
}
}

View 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
}
}

View 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>

View File

@@ -0,0 +1,8 @@
import XCTest
@testable import UIKitCaseStudies
class UIKitCaseStudiesTests: XCTestCase {
func testExample() {
}
}

View 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 */;
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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>

View 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>

View File

@@ -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>
}

View 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] = [:]

View 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

View File

@@ -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
}
}

View 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)
)
)
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

View 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>

View File

@@ -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
}
)
}
}

View 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
View 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
View 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
View 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).

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

View 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

View File

@@ -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