SwiftUI Navigation

Co-authored-by: Brandon Williams <mbrandonw@hey.com>
This commit is contained in:
Stephen Celis 2021-09-07 13:25:36 -04:00
commit 2694c03284
57 changed files with 5184 additions and 0 deletions

84
.github/CODE_OF_CONDUCT.md vendored 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.

34
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,34 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
Give a clear and concise description of what the bug is.
**To Reproduce**
Zip up a project that reproduces the behavior and attach it by dragging it here.
```swift
// And/or enter code that reproduces the behavior here.
```
**Expected behavior**
Give a clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment**
- swiftui-navigation version [e.g. 0.1.0]
- Xcode [e.g. 13.1]
- Swift [e.g. 5.5]
- OS: [e.g. iOS 15]
**Additional context**
Add any more context about the problem here.

10
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View File

@ -0,0 +1,10 @@
---
name: Question
about: Have a question about the SwiftUI Navigation?
title: ''
labels: ''
assignees: ''
---
SwiftUI Navigation uses GitHub issues for bugs. For more general discussion and help, please use [GitHub Discussions](https://github.com/pointfreeco/swiftui-navigation/discussions).

28
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
library:
runs-on: macos-11.0
strategy:
matrix:
xcode:
- '12.4'
- '12.5.1'
- '13.1'
steps:
- uses: actions/checkout@v2
- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
- name: Run tests
run: make test
- name: Compile documentation
if: ${{ matrix.xcode == '13.1' }}
run: make test-docs

29
.github/workflows/documentation.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: Documentation
on:
release:
types:
- published
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Generate Documentation
uses: SwiftDocOrg/swift-doc@master
with:
base-url: /swiftui-navigation/
format: html
inputs: Sources/SwiftUINavigation
module-name: SwiftUINavigation
output: Documentation
- name: Update Permissions
run: 'sudo chown --recursive $USER Documentation'
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@releases/v3
with:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
BRANCH: gh-pages
FOLDER: Documentation

27
.github/workflows/format.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Format
on:
push:
branches:
- main
jobs:
swift_format:
name: swift-format
runs-on: macOS-11
steps:
- uses: actions/checkout@v2
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_13.0.app
- name: Tap
run: brew tap pointfreeco/formulae
- name: Install
run: brew install Formulae/swift-format@5.5
- name: Format
run: make format
- uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Run swift-format
branch: 'main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

5
.spi.yml Normal file
View File

@ -0,0 +1,5 @@
version: 1
builder:
configs:
- platform: watchos
scheme: SwiftUINavigation_watchOS

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SwiftUINavigation"
BuildableName = "SwiftUINavigation"
BlueprintName = "SwiftUINavigation"
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 = "SwiftUINavigationTests"
BuildableName = "SwiftUINavigationTests"
BlueprintName = "SwiftUINavigationTests"
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 = "SwiftUINavigation"
BuildableName = "SwiftUINavigation"
BlueprintName = "SwiftUINavigation"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SwiftUINavigation"
BuildableName = "SwiftUINavigation"
BlueprintName = "SwiftUINavigation"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</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 = "SwiftUINavigation"
BuildableName = "SwiftUINavigation"
BlueprintName = "SwiftUINavigation"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SwiftUINavigation"
BuildableName = "SwiftUINavigation"
BlueprintName = "SwiftUINavigation"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SwiftUINavigationTests"
BuildableName = "SwiftUINavigationTests"
BlueprintName = "SwiftUINavigationTests"
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 = "SwiftUINavigationTests"
BuildableName = "SwiftUINavigationTests"
BlueprintName = "SwiftUINavigationTests"
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 = "SwiftUINavigation"
BuildableName = "SwiftUINavigation"
BlueprintName = "SwiftUINavigation"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,50 @@
import SwiftUINavigation
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
struct OptionalAlerts: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
List {
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
Button(action: { self.viewModel.numberFactButtonTapped() }) {
HStack {
Text("Get number fact")
if self.viewModel.isLoading {
Spacer()
ProgressView()
}
}
}
.disabled(self.viewModel.isLoading)
}
.alert(
title: { Text("Fact about \($0.number)") },
unwrapping: self.$viewModel.fact,
actions: {
Button("Get another fact about \($0.number)") {
self.viewModel.numberFactButtonTapped()
}
Button("Cancel", role: .cancel) {
self.viewModel.fact = nil
}
},
message: { Text($0.description) }
)
.navigationTitle("Alerts")
}
}
private class ViewModel: ObservableObject {
@Published var count = 0
@Published var isLoading = false
@Published var fact: Fact?
func numberFactButtonTapped() {
self.isLoading = true
Task { @MainActor in
self.fact = await getNumberFact(self.count)
self.isLoading = false
}
}
}

View File

@ -0,0 +1,49 @@
import SwiftUI
import SwiftUINavigation
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
struct OptionalConfirmationDialogs: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
List {
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
Button(action: { self.viewModel.numberFactButtonTapped() }) {
HStack {
Text("Get number fact")
if self.viewModel.isLoading {
Spacer()
ProgressView()
}
}
}
.disabled(self.viewModel.isLoading)
}
.confirmationDialog(
title: { Text("Fact about \($0.number)") },
titleVisibility: .visible,
unwrapping: self.$viewModel.fact,
actions: {
Button("Get another fact about \($0.number)") {
self.viewModel.numberFactButtonTapped()
}
},
message: { Text($0.description) }
)
.navigationTitle("Confirmation dialogs")
}
}
private class ViewModel: ObservableObject {
@Published var count = 0
@Published var isLoading = false
@Published var fact: Fact?
func numberFactButtonTapped() {
self.isLoading = true
Task { @MainActor in
self.fact = await getNumberFact(self.count)
self.isLoading = false
}
}
}

View File

@ -0,0 +1,104 @@
import SwiftUI
import SwiftUINavigation
struct OptionalSheets: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
List {
Section {
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
HStack {
Button("Get number fact") {
self.viewModel.numberFactButtonTapped()
}
if self.viewModel.isLoading {
Spacer()
ProgressView()
}
}
} header: {
Text("Fact Finder")
}
Section {
ForEach(self.viewModel.savedFacts) { fact in
Text(fact.description)
}
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
} header: {
Text("Saved Facts")
}
}
.sheet(unwrapping: self.$viewModel.fact) { $fact in
NavigationView {
FactEditor(fact: $fact.description)
.disabled(self.viewModel.isLoading)
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.viewModel.cancelButtonTapped()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
self.viewModel.saveButtonTapped(fact: fact)
}
}
}
}
}
.navigationTitle("Sheets")
}
}
private struct FactEditor: View {
@Binding var fact: String
var body: some View {
VStack {
TextEditor(text: self.$fact)
}
.padding()
.navigationTitle("Fact Editor")
}
}
private class ViewModel: ObservableObject {
@Published var count = 0
@Published var fact: Fact?
@Published var isLoading = false
@Published var savedFacts: [Fact] = []
private var task: Task<Void, Error>?
func numberFactButtonTapped() {
self.isLoading = true
self.fact = Fact(description: "\(self.count) is still loading...", number: self.count)
self.task = Task { @MainActor in
let fact = await getNumberFact(self.count)
self.isLoading = false
try Task.checkCancellation()
self.fact = fact
}
}
func cancelButtonTapped() {
self.task?.cancel()
self.task = nil
self.fact = nil
}
func saveButtonTapped(fact: Fact) {
self.task?.cancel()
self.task = nil
self.savedFacts.append(fact)
self.fact = nil
}
func removeSavedFacts(atOffsets offsets: IndexSet) {
self.savedFacts.remove(atOffsets: offsets)
}
}

View File

@ -0,0 +1,104 @@
import SwiftUI
import SwiftUINavigation
struct OptionalPopovers: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
List {
Section {
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
HStack {
Button("Get number fact") {
self.viewModel.numberFactButtonTapped()
}
if self.viewModel.isLoading {
Spacer()
ProgressView()
}
}
} header: {
Text("Fact Finder")
}
Section {
ForEach(self.viewModel.savedFacts) { fact in
Text(fact.description)
}
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
} header: {
Text("Saved Facts")
}
}
.popover(unwrapping: self.$viewModel.fact) { $fact in
NavigationView {
FactEditor(fact: $fact.description)
.disabled(self.viewModel.isLoading)
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.viewModel.cancelButtonTapped()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
self.viewModel.saveButtonTapped(fact: fact)
}
}
}
}
}
.navigationTitle("Sheets")
}
}
private struct FactEditor: View {
@Binding var fact: String
var body: some View {
VStack {
TextEditor(text: self.$fact)
}
.padding()
.navigationTitle("Fact Editor")
}
}
private class ViewModel: ObservableObject {
@Published var count = 0
@Published var fact: Fact?
@Published var isLoading = false
@Published var savedFacts: [Fact] = []
private var task: Task<Void, Error>?
func numberFactButtonTapped() {
self.isLoading = true
self.fact = Fact(description: "\(self.count) is still loading...", number: self.count)
self.task = Task { @MainActor in
let fact = await getNumberFact(self.count)
self.isLoading = false
try Task.checkCancellation()
self.fact = fact
}
}
func cancelButtonTapped() {
self.task?.cancel()
self.task = nil
self.fact = nil
}
func saveButtonTapped(fact: Fact) {
self.task?.cancel()
self.task = nil
self.savedFacts.append(fact)
self.fact = nil
}
func removeSavedFacts(atOffsets offsets: IndexSet) {
self.savedFacts.remove(atOffsets: offsets)
}
}

View File

@ -0,0 +1,104 @@
import SwiftUI
import SwiftUINavigation
struct OptionalFullScreenCovers: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
List {
Section {
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
HStack {
Button("Get number fact") {
self.viewModel.numberFactButtonTapped()
}
if self.viewModel.isLoading {
Spacer()
ProgressView()
}
}
} header: {
Text("Fact Finder")
}
Section {
ForEach(self.viewModel.savedFacts) { fact in
Text(fact.description)
}
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
} header: {
Text("Saved Facts")
}
}
.fullScreenCover(unwrapping: self.$viewModel.fact) { $fact in
NavigationView {
FactEditor(fact: $fact.description)
.disabled(self.viewModel.isLoading)
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.viewModel.cancelButtonTapped()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
self.viewModel.saveButtonTapped(fact: fact)
}
}
}
}
}
.navigationTitle("Sheets")
}
}
private struct FactEditor: View {
@Binding var fact: String
var body: some View {
VStack {
TextEditor(text: self.$fact)
}
.padding()
.navigationTitle("Fact Editor")
}
}
private class ViewModel: ObservableObject {
@Published var count = 0
@Published var fact: Fact?
@Published var isLoading = false
@Published var savedFacts: [Fact] = []
private var task: Task<Void, Error>?
func numberFactButtonTapped() {
self.isLoading = true
self.fact = Fact(description: "\(self.count) is still loading...", number: self.count)
self.task = Task { @MainActor in
let fact = await getNumberFact(self.count)
self.isLoading = false
try Task.checkCancellation()
self.fact = fact
}
}
func cancelButtonTapped() {
self.task?.cancel()
self.task = nil
self.fact = nil
}
func saveButtonTapped(fact: Fact) {
self.task?.cancel()
self.task = nil
self.savedFacts.append(fact)
self.fact = nil
}
func removeSavedFacts(atOffsets offsets: IndexSet) {
self.savedFacts.remove(atOffsets: offsets)
}
}

View File

@ -0,0 +1,109 @@
import SwiftUI
import SwiftUINavigation
struct OptionalNavigationLinks: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
List {
Section {
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
HStack {
NavigationLink(
unwrapping: self.$viewModel.fact,
destination: { $fact in
let _ = print(fact)
FactEditor(fact: $fact.description)
.disabled(self.viewModel.isLoading)
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.viewModel.cancelButtonTapped()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
self.viewModel.saveButtonTapped(fact: fact)
}
}
}
},
onNavigate: { self.viewModel.setFactNavigation(isActive: $0) }
) {
Text("Get number fact")
}
if self.viewModel.isLoading {
Spacer()
ProgressView()
}
}
} header: {
Text("Fact Finder")
}
Section {
ForEach(self.viewModel.savedFacts) { fact in
Text(fact.description)
}
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
} header: {
Text("Saved Facts")
}
}
.navigationTitle("Links")
}
}
private struct FactEditor: View {
@Binding var fact: String
var body: some View {
VStack {
TextEditor(text: self.$fact)
}
.padding()
.navigationTitle("Fact Editor")
}
}
private class ViewModel: ObservableObject {
@Published var count = 0
@Published var fact: Fact?
@Published var isLoading = false
@Published var savedFacts: [Fact] = []
private var task: Task<Void, Error>?
func setFactNavigation(isActive: Bool) {
if isActive {
self.isLoading = true
self.fact = Fact(description: "\(self.count) is still loading...", number: self.count)
self.task = Task { @MainActor in
let fact = await getNumberFact(self.count)
self.isLoading = false
try Task.checkCancellation()
self.fact = fact
}
} else {
self.task?.cancel()
self.task = nil
self.fact = nil
}
}
func cancelButtonTapped() {
self.setFactNavigation(isActive: false)
}
func saveButtonTapped(fact: Fact) {
self.savedFacts.append(fact)
self.setFactNavigation(isActive: false)
}
func removeSavedFacts(atOffsets offsets: IndexSet) {
self.savedFacts.remove(atOffsets: offsets)
}
}

View File

@ -0,0 +1,82 @@
import SwiftUINavigation
private let readMe = """
This case study demonstrates how to power multiple forms of navigation from a single route enum \
that describes all of the possible destinations one can travel to from this screen.
The screen has three navigation destinations: an alert, a navigation link to a count stepper, \
and a modal sheet to a count stepper. The state for each of these destinations is held as \
associated data of an enum, and bindings to the cases of that enum are derived using \
the tools in this library.
"""
enum Route {
case alert(String)
case link(Int)
case sheet(Int)
}
struct Routing: View {
@State var route: Route?
var body: some View {
Form {
Section {
Text(readMe)
}
Button("Alert") {
self.route = .alert("Hello world!")
}
.alert(
title: { Text($0) },
unwrapping: self.$route,
case: /Route.alert,
actions: { _ in
Button("Activate link") {
self.route = .link(0)
}
Button("Activate sheet") {
self.route = .sheet(0)
}
Button("Cancel", role: .cancel) {
}
},
message: { _ in
}
)
NavigationLink(unwrapping: self.$route, case: /Route.link) { $count in
Form {
Stepper("Number: \(count)", value: $count)
}
} onNavigate: {
self.route = $0 ? .link(0) : nil
} label: {
Text("Link")
}
Button("Sheet") {
self.route = .sheet(0)
}
.sheet(
unwrapping: self.$route,
case: /Route.sheet
) { $count in
Form {
Stepper("Number: \(count)", value: $count)
}
}
}
.navigationTitle("Routing")
}
}
struct Routing_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
Routing()
}
}
}

View File

@ -0,0 +1,123 @@
import SwiftUINavigation
private let readMe = """
This case study demonstrates how to enhance an existing SwiftUI component so that it can be driven \
off of optional and enum state.
The BottomMenuModifier component in this is file is primarily powered by a simple boolean binding, \
which means its content cannot be dynamic based off of the source of truth that drives its \
presentation, and it cannot make mutations to the source of truth.
However, by leveraging the binding transformations that come with this library we can extend the \
bottom menu component with additional APIs that allow presentation and dismissal to be powered by \
optionals and enums.
"""
struct CustomComponents: View {
@State var count: Int?
var body: some View {
Form {
Section {
Text(readMe)
}
Button("Show bottom menu") {
withAnimation {
self.count = 0
}
}
if let count = self.count, count > 0 {
Text("Current count: \(count)")
.transition(.opacity)
}
}
.bottomMenu(unwrapping: self.$count) { $count in
Stepper("Number: \(count)", value: $count.animation())
}
.navigationTitle("Custom components")
}
}
private struct BottomMenuModifier<BottomMenuContent>: ViewModifier
where BottomMenuContent: View {
@Binding var isActive: Bool
let content: () -> BottomMenuContent
func body(content: Content) -> some View {
content.overlay(
ZStack(alignment: .bottom) {
if self.isActive {
Rectangle()
.fill(Color.black.opacity(0.4))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
withAnimation {
self.isActive = false
}
}
.zIndex(1)
.transition(.opacity)
self.content()
.padding()
.background(Color.white)
.cornerRadius(10)
.frame(maxWidth: .infinity)
.padding(24)
.padding(.bottom)
.zIndex(2)
.transition(.move(edge: .bottom))
}
}
.ignoresSafeArea()
)
}
}
extension View {
fileprivate func bottomMenu<Content>(
isActive: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content
) -> some View
where Content: View {
self.modifier(
BottomMenuModifier(
isActive: isActive,
content: content
)
)
}
fileprivate func bottomMenu<Value, Content>(
unwrapping value: Binding<Value?>,
@ViewBuilder content: @escaping (Binding<Value>) -> Content
) -> some View
where Content: View {
self.modifier(
BottomMenuModifier(
isActive: value.isPresent(),
content: { Binding(unwrapping: value).map(content) }
)
)
}
fileprivate func bottomMenu<Enum, Case, Content>(
unwrapping value: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
@ViewBuilder content: @escaping (Binding<Case>) -> Content
) -> some View
where Content: View {
self.bottomMenu(
unwrapping: value.case(casePath),
content: content
)
}
}
struct CustomComponents_Previews: PreviewProvider {
static var previews: some View {
CustomComponents()
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,98 @@
{
"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"
},
{
"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,10 @@
import SwiftUI
@main
struct CaseStudiesApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}

View File

@ -0,0 +1,26 @@
import Foundation
struct Fact: Identifiable {
var description: String
let number: Int
var id: AnyHashable {
[self.description as AnyHashable, self.number]
}
}
func getNumberFact(_ count: Int) async -> Fact {
let fact: String
do {
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(count)/trivia")!
)
fact = String(decoding: data, as: UTF8.self)
} catch {
// Sometimes numbersapi.com can be flakey, so if it ever fails we will just
// default to a mock response.
fact = "\(count) is a good number Brent"
}
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
return Fact(description: fact, number: count)
}

View File

@ -0,0 +1,11 @@
<?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>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,63 @@
import SwiftUINavigation
struct RootView: View {
var body: some View {
NavigationView {
List {
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
Section {
NavigationLink("Optional-driven alerts") {
OptionalAlerts()
}
NavigationLink("Optional confirmation dialogs") {
OptionalConfirmationDialogs()
}
} header: {
Text("Alerts and confirmation dialogs")
}
}
Section {
NavigationLink("Optional sheets") {
OptionalSheets()
}
NavigationLink("Optional popovers") {
OptionalPopovers()
}
NavigationLink("Optional full-screen covers") {
OptionalFullScreenCovers()
}
} header: {
Text("Sheets and full-screen covers")
}
Section {
NavigationLink("Optional navigation links") {
OptionalNavigationLinks()
}
} header: {
Text("Navigation links")
}
Section {
NavigationLink("Routing") {
Routing()
}
NavigationLink("Custom components") {
CustomComponents()
}
} header: {
Text("Advanced")
}
}
.navigationTitle("Case studies")
}
.navigationViewStyle(.stack)
}
}
struct RootView_Previews: PreviewProvider {
static var previews: some View {
RootView()
}
}

View File

@ -0,0 +1,566 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 55;
objects = {
/* Begin PBXBuildFile section */
CA4737CF272F09600012CAC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA4737CE272F09600012CAC3 /* Assets.xcassets */; };
CA4737F4272F09780012CAC3 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA4737F3272F09780012CAC3 /* SwiftUINavigation */; };
CA4737F9272F09D00012CAC3 /* ItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4737F5272F09D00012CAC3 /* ItemRow.swift */; };
CA4737FA272F09D00012CAC3 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4737F6272F09D00012CAC3 /* Item.swift */; };
CA4737FB272F09D00012CAC3 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4737F7272F09D00012CAC3 /* App.swift */; };
CA4737FC272F09D00012CAC3 /* Inventory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4737F8272F09D00012CAC3 /* Inventory.swift */; };
CA4737FF272F09F20012CAC3 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CA4737FE272F09F20012CAC3 /* IdentifiedCollections */; };
CA47380B272F0D340012CAC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA47380A272F0D340012CAC3 /* Assets.xcassets */; };
CA473834272F0D860012CAC3 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA47382E272F0D860012CAC3 /* RootView.swift */; };
CA473835272F0D860012CAC3 /* 02-ConfirmationDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA47382F272F0D860012CAC3 /* 02-ConfirmationDialogs.swift */; };
CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */; };
CA473837272F0D860012CAC3 /* FactClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473831272F0D860012CAC3 /* FactClient.swift */; };
CA473838272F0D860012CAC3 /* 03-Sheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473832272F0D860012CAC3 /* 03-Sheets.swift */; };
CA473839272F0D860012CAC3 /* 01-Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473833272F0D860012CAC3 /* 01-Alerts.swift */; };
CA47383B272F0DD60012CAC3 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA47383A272F0DD60012CAC3 /* SwiftUINavigation */; };
CA47383E272F0F9B0012CAC3 /* 08-CustomComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA47383D272F0F9B0012CAC3 /* 08-CustomComponents.swift */; };
CABE9FC1272F2C0000AFC150 /* 07-Routing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABE9FC0272F2C0000AFC150 /* 07-Routing.swift */; };
DCD4E685273B300F00CDF3BD /* 05-FullScreenCovers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD4E684273B300F00CDF3BD /* 05-FullScreenCovers.swift */; };
DCD4E687273B30DA00CDF3BD /* 04-Popovers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD4E686273B30DA00CDF3BD /* 04-Popovers.swift */; };
DCD4E68B274180F500CDF3BD /* 06-NavigationLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD4E68A274180F500CDF3BD /* 06-NavigationLinks.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
CA4737C3272F090F0012CAC3 /* swiftui-navigation */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swiftui-navigation"; path = ..; sourceTree = "<group>"; };
CA4737C8272F095F0012CAC3 /* Inventory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Inventory.app; sourceTree = BUILT_PRODUCTS_DIR; };
CA4737CE272F09600012CAC3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
CA4737F5272F09D00012CAC3 /* ItemRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemRow.swift; sourceTree = "<group>"; };
CA4737F6272F09D00012CAC3 /* Item.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
CA4737F7272F09D00012CAC3 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
CA4737F8272F09D00012CAC3 /* Inventory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Inventory.swift; sourceTree = "<group>"; };
CA473804272F0D330012CAC3 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; };
CA47380A272F0D340012CAC3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
CA47382E272F0D860012CAC3 /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
CA47382F272F0D860012CAC3 /* 02-ConfirmationDialogs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "02-ConfirmationDialogs.swift"; sourceTree = "<group>"; };
CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseStudiesApp.swift; sourceTree = "<group>"; };
CA473831272F0D860012CAC3 /* FactClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FactClient.swift; sourceTree = "<group>"; };
CA473832272F0D860012CAC3 /* 03-Sheets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "03-Sheets.swift"; sourceTree = "<group>"; };
CA473833272F0D860012CAC3 /* 01-Alerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "01-Alerts.swift"; sourceTree = "<group>"; };
CA47383C272F0F0D0012CAC3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
CA47383D272F0F9B0012CAC3 /* 08-CustomComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "08-CustomComponents.swift"; sourceTree = "<group>"; };
CABE9FC0272F2C0000AFC150 /* 07-Routing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "07-Routing.swift"; sourceTree = "<group>"; };
DCD4E684273B300F00CDF3BD /* 05-FullScreenCovers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-FullScreenCovers.swift"; sourceTree = "<group>"; };
DCD4E686273B30DA00CDF3BD /* 04-Popovers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-Popovers.swift"; sourceTree = "<group>"; };
DCD4E68A274180F500CDF3BD /* 06-NavigationLinks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "06-NavigationLinks.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
CA4737C5272F095F0012CAC3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CA4737F4272F09780012CAC3 /* SwiftUINavigation in Frameworks */,
CA4737FF272F09F20012CAC3 /* IdentifiedCollections in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CA473801272F0D330012CAC3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CA47383B272F0DD60012CAC3 /* SwiftUINavigation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
CA47378B272F08EF0012CAC3 = {
isa = PBXGroup;
children = (
CA4737C3272F090F0012CAC3 /* swiftui-navigation */,
CA4737C9272F095F0012CAC3 /* Inventory */,
CA473805272F0D330012CAC3 /* CaseStudies */,
CA473795272F08EF0012CAC3 /* Products */,
CA4737F2272F09780012CAC3 /* Frameworks */,
);
sourceTree = "<group>";
};
CA473795272F08EF0012CAC3 /* Products */ = {
isa = PBXGroup;
children = (
CA4737C8272F095F0012CAC3 /* Inventory.app */,
CA473804272F0D330012CAC3 /* CaseStudies.app */,
);
name = Products;
sourceTree = "<group>";
};
CA4737C9272F095F0012CAC3 /* Inventory */ = {
isa = PBXGroup;
children = (
CA4737F7272F09D00012CAC3 /* App.swift */,
CA4737F8272F09D00012CAC3 /* Inventory.swift */,
CA4737F6272F09D00012CAC3 /* Item.swift */,
CA4737F5272F09D00012CAC3 /* ItemRow.swift */,
CA4737CE272F09600012CAC3 /* Assets.xcassets */,
);
path = Inventory;
sourceTree = "<group>";
};
CA4737F2272F09780012CAC3 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
CA473805272F0D330012CAC3 /* CaseStudies */ = {
isa = PBXGroup;
children = (
CA47383C272F0F0D0012CAC3 /* Info.plist */,
CA473833272F0D860012CAC3 /* 01-Alerts.swift */,
CA47382F272F0D860012CAC3 /* 02-ConfirmationDialogs.swift */,
CA473832272F0D860012CAC3 /* 03-Sheets.swift */,
DCD4E686273B30DA00CDF3BD /* 04-Popovers.swift */,
DCD4E684273B300F00CDF3BD /* 05-FullScreenCovers.swift */,
DCD4E68A274180F500CDF3BD /* 06-NavigationLinks.swift */,
CABE9FC0272F2C0000AFC150 /* 07-Routing.swift */,
CA47383D272F0F9B0012CAC3 /* 08-CustomComponents.swift */,
CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */,
CA473831272F0D860012CAC3 /* FactClient.swift */,
CA47382E272F0D860012CAC3 /* RootView.swift */,
CA47380A272F0D340012CAC3 /* Assets.xcassets */,
);
path = CaseStudies;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
CA4737C7272F095F0012CAC3 /* Inventory */ = {
isa = PBXNativeTarget;
buildConfigurationList = CA4737E9272F09610012CAC3 /* Build configuration list for PBXNativeTarget "Inventory" */;
buildPhases = (
CA4737C4272F095F0012CAC3 /* Sources */,
CA4737C5272F095F0012CAC3 /* Frameworks */,
CA4737C6272F095F0012CAC3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Inventory;
packageProductDependencies = (
CA4737F3272F09780012CAC3 /* SwiftUINavigation */,
CA4737FE272F09F20012CAC3 /* IdentifiedCollections */,
);
productName = Inventory;
productReference = CA4737C8272F095F0012CAC3 /* Inventory.app */;
productType = "com.apple.product-type.application";
};
CA473803272F0D330012CAC3 /* CaseStudies */ = {
isa = PBXNativeTarget;
buildConfigurationList = CA473825272F0D350012CAC3 /* Build configuration list for PBXNativeTarget "CaseStudies" */;
buildPhases = (
CA473800272F0D330012CAC3 /* Sources */,
CA473801272F0D330012CAC3 /* Frameworks */,
CA473802272F0D330012CAC3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CaseStudies;
packageProductDependencies = (
CA47383A272F0DD60012CAC3 /* SwiftUINavigation */,
);
productName = CaseStudies;
productReference = CA473804272F0D330012CAC3 /* CaseStudies.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
CA47378C272F08EF0012CAC3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1310;
LastUpgradeCheck = 1310;
TargetAttributes = {
CA4737C7272F095F0012CAC3 = {
CreatedOnToolsVersion = 13.1;
LastSwiftMigration = 1310;
};
CA473803272F0D330012CAC3 = {
CreatedOnToolsVersion = 13.1;
LastSwiftMigration = 1310;
};
};
};
buildConfigurationList = CA47378F272F08EF0012CAC3 /* Build configuration list for PBXProject "Examples" */;
compatibilityVersion = "Xcode 13.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = CA47378B272F08EF0012CAC3;
packageReferences = (
CA4737FD272F09F20012CAC3 /* XCRemoteSwiftPackageReference "swift-identified-collections" */,
);
productRefGroup = CA473795272F08EF0012CAC3 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
CA473803272F0D330012CAC3 /* CaseStudies */,
CA4737C7272F095F0012CAC3 /* Inventory */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
CA4737C6272F095F0012CAC3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CA4737CF272F09600012CAC3 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CA473802272F0D330012CAC3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CA47380B272F0D340012CAC3 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
CA4737C4272F095F0012CAC3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CA4737FC272F09D00012CAC3 /* Inventory.swift in Sources */,
CA4737FB272F09D00012CAC3 /* App.swift in Sources */,
CA4737FA272F09D00012CAC3 /* Item.swift in Sources */,
CA4737F9272F09D00012CAC3 /* ItemRow.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CA473800272F0D330012CAC3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CABE9FC1272F2C0000AFC150 /* 07-Routing.swift in Sources */,
CA473837272F0D860012CAC3 /* FactClient.swift in Sources */,
CA473835272F0D860012CAC3 /* 02-ConfirmationDialogs.swift in Sources */,
CA47383E272F0F9B0012CAC3 /* 08-CustomComponents.swift in Sources */,
CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */,
DCD4E687273B30DA00CDF3BD /* 04-Popovers.swift in Sources */,
DCD4E685273B300F00CDF3BD /* 05-FullScreenCovers.swift in Sources */,
CA473834272F0D860012CAC3 /* RootView.swift in Sources */,
CA473839272F0D860012CAC3 /* 01-Alerts.swift in Sources */,
DCD4E68B274180F500CDF3BD /* 06-NavigationLinks.swift in Sources */,
CA473838272F0D860012CAC3 /* 03-Sheets.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
CA4737B6272F08F10012CAC3 /* 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++17";
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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
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 = 15.0;
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;
};
CA4737B7272F08F10012CAC3 /* 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++17";
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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
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 = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
CA4737EA272F09610012CAC3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Inventory;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
CA4737EB272F09610012CAC3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Inventory;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
CA473826272F0D350012CAC3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CaseStudies/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudies;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
CA473827272F0D350012CAC3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CaseStudies/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudies;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CA47378F272F08EF0012CAC3 /* Build configuration list for PBXProject "Examples" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CA4737B6272F08F10012CAC3 /* Debug */,
CA4737B7272F08F10012CAC3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CA4737E9272F09610012CAC3 /* Build configuration list for PBXNativeTarget "Inventory" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CA4737EA272F09610012CAC3 /* Debug */,
CA4737EB272F09610012CAC3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CA473825272F0D350012CAC3 /* Build configuration list for PBXNativeTarget "CaseStudies" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CA473826272F0D350012CAC3 /* Debug */,
CA473827272F0D350012CAC3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
CA4737FD272F09F20012CAC3 /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pointfreeco/swift-identified-collections.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.2.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
CA4737F3272F09780012CAC3 /* SwiftUINavigation */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUINavigation;
};
CA4737FE272F09F20012CAC3 /* IdentifiedCollections */ = {
isa = XCSwiftPackageProductDependency;
package = CA4737FD272F09F20012CAC3 /* XCRemoteSwiftPackageReference "swift-identified-collections" */;
productName = IdentifiedCollections;
};
CA47383A272F0DD60012CAC3 /* SwiftUINavigation */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUINavigation;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = CA47378C272F08EF0012CAC3 /* 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,34 @@
{
"object": {
"pins": [
{
"package": "swift-case-paths",
"repositoryURL": "https://github.com/pointfreeco/swift-case-paths",
"state": {
"branch": null,
"revision": "d226d167bd4a68b51e352af5655c92bce8ee0463",
"version": "0.7.0"
}
},
{
"package": "swift-collections",
"repositoryURL": "https://github.com/apple/swift-collections",
"state": {
"branch": null,
"revision": "2d33a0ea89c961dcb2b3da2157963d9c0370347e",
"version": "1.0.1"
}
},
{
"package": "swift-identified-collections",
"repositoryURL": "https://github.com/pointfreeco/swift-identified-collections.git",
"state": {
"branch": null,
"revision": "680bf440178a78a627b1c2c64c0855f6523ad5b9",
"version": "0.3.2"
}
}
]
},
"version": 1
}

View File

@ -0,0 +1,48 @@
import SwiftUI
class AppViewModel: ObservableObject {
@Published var inventoryViewModel: InventoryViewModel
@Published var selectedTab: Tab
init(
inventoryViewModel: InventoryViewModel = .init(),
selectedTab: Tab = .inventory
) {
self.inventoryViewModel = inventoryViewModel
self.selectedTab = selectedTab
}
enum Tab {
case inventory
}
}
@main
struct InventoryApp: App {
@ObservedObject var viewModel = AppViewModel(
inventoryViewModel: InventoryViewModel(
inventory: [],
route: .add(
.init(
name: "Keyboard",
color: .blue,
status: .outOfStock(isOnBackOrder: true)
)
)
)
)
var body: some Scene {
WindowGroup {
TabView(selection: self.$viewModel.selectedTab) {
NavigationView {
InventoryView(viewModel: self.viewModel.inventoryViewModel)
.tag(AppViewModel.Tab.inventory)
.tabItem {
Label("Inventory", systemImage: "building.2")
}
}
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,98 @@
{
"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"
},
{
"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,143 @@
import IdentifiedCollections
import SwiftUINavigation
class InventoryViewModel: ObservableObject {
@Published var inventory: IdentifiedArrayOf<ItemRowViewModel>
@Published var route: Route?
enum Route: Equatable {
case add(Item)
case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route)
}
init(
inventory: IdentifiedArrayOf<ItemRowViewModel> = [],
route: Route? = nil
) {
self.inventory = []
self.route = route
for itemRowViewModel in inventory {
self.bind(itemRowViewModel: itemRowViewModel)
}
}
func delete(item: Item) {
withAnimation {
_ = self.inventory.remove(id: item.id)
}
}
func add(item: Item) {
withAnimation {
self.bind(itemRowViewModel: .init(item: item))
self.route = nil
}
}
func addButtonTapped() {
self.route = .add(.init(name: "", color: nil, status: .inStock(quantity: 1)))
Task { @MainActor in
try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC)
try (/Route.add).modify(&self.route) {
$0.name = "Bluetooth Keyboard"
}
}
}
func cancelButtonTapped() {
self.route = nil
}
private func bind(itemRowViewModel: ItemRowViewModel) {
itemRowViewModel.onDelete = { [weak self, item = itemRowViewModel.item] in
withAnimation {
self?.delete(item: item)
}
}
itemRowViewModel.onDuplicate = { [weak self] item in
withAnimation {
self?.add(item: item)
}
}
itemRowViewModel.$route
.map { [id = itemRowViewModel.id] route in
route.map { Route.row(id: id, route: $0) }
}
.removeDuplicates()
.dropFirst()
.assign(to: &self.$route)
self.$route
.map { [id = itemRowViewModel.id] route in
guard
case let .row(id: routeRowId, route: route) = route,
routeRowId == id
else { return nil }
return route
}
.removeDuplicates()
.assign(to: &itemRowViewModel.$route)
self.inventory.append(itemRowViewModel)
}
}
struct InventoryView: View {
@ObservedObject var viewModel: InventoryViewModel
var body: some View {
List {
ForEach(
self.viewModel.inventory,
content: ItemRowView.init(viewModel:)
)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add") { self.viewModel.addButtonTapped() }
}
}
.navigationTitle("Inventory")
.sheet(unwrapping: self.$viewModel.route, case: /InventoryViewModel.Route.add) { $itemToAdd in
NavigationView {
ItemView(item: $itemToAdd)
.navigationTitle("Add")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { self.viewModel.cancelButtonTapped() }
}
ToolbarItem(placement: .primaryAction) {
Button("Save") { self.viewModel.add(item: itemToAdd) }
}
}
}
}
}
}
struct InventoryView_Previews: PreviewProvider {
static var previews: some View {
let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100))
NavigationView {
InventoryView(
viewModel: .init(
inventory: [
.init(item: keyboard),
.init(item: Item(name: "Charger", color: .yellow, status: .inStock(quantity: 20))),
.init(
item: Item(name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true))),
.init(
item: Item(
name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false))),
],
route: nil
)
)
}
}
}

View File

@ -0,0 +1,104 @@
import SwiftUINavigation
struct Item: Equatable, Identifiable {
let id = UUID()
var name: String
var color: Color?
var status: Status
enum Status: Equatable {
case inStock(quantity: Int)
case outOfStock(isOnBackOrder: Bool)
var isInStock: Bool {
guard case .inStock = self else { return false }
return true
}
}
struct Color: Equatable, Hashable {
var name: String
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
static var defaults: [Self] = [
.red,
.green,
.blue,
.black,
.yellow,
.white,
]
static let red = Self(name: "Red", red: 1)
static let green = Self(name: "Green", green: 1)
static let blue = Self(name: "Blue", blue: 1)
static let black = Self(name: "Black")
static let yellow = Self(name: "Yellow", red: 1, green: 1)
static let white = Self(name: "White", red: 1, green: 1, blue: 1)
var swiftUIColor: SwiftUI.Color {
.init(red: self.red, green: self.green, blue: self.blue)
}
}
}
struct ItemView: View {
@Binding var item: Item
var body: some View {
Form {
TextField("Name", text: self.$item.name)
Picker(selection: self.$item.color, label: Text("Color")) {
Text("None")
.tag(Item.Color?.none)
ForEach(Item.Color.defaults, id: \.name) { color in
Text(color.name)
.tag(Optional(color))
}
}
Switch(self.$item.status) {
CaseLet(/Item.Status.inStock) { $quantity in
Section(header: Text("In stock")) {
Stepper("Quantity: \(quantity)", value: $quantity)
Button("Mark as sold out") {
withAnimation {
self.item.status = .outOfStock(isOnBackOrder: false)
}
}
}
.transition(.opacity)
}
CaseLet(/Item.Status.outOfStock) { $isOnBackOrder in
Section(header: Text("Out of stock")) {
Toggle("Is on back order?", isOn: $isOnBackOrder)
Button("Is back in stock!") {
withAnimation {
self.item.status = .inStock(quantity: 1)
}
}
}
.transition(.opacity)
}
}
}
}
}
struct ItemView_Previews: PreviewProvider, View {
@State var item = Item(name: "", color: nil, status: .inStock(quantity: 1))
static var previews: some View {
NavigationView {
ItemView_Previews()
}
}
var body: some View {
ItemView(item: self.$item)
}
}

View File

@ -0,0 +1,153 @@
import SwiftUINavigation
class ItemRowViewModel: Identifiable, ObservableObject {
@Published var item: Item
@Published var route: Route?
enum Route: Equatable {
case deleteAlert
case duplicate(Item)
case edit(Item)
}
var onDelete: () -> Void = {}
var onDuplicate: (Item) -> Void = { _ in }
var id: Item.ID { self.item.id }
init(
item: Item
) {
self.item = item
}
func deleteButtonTapped() {
self.route = .deleteAlert
}
func deleteConfirmationButtonTapped() {
self.onDelete()
}
func setEditNavigation(isActive: Bool) {
self.route = isActive ? .edit(self.item) : nil
}
func edit(item: Item) {
self.item = item
self.route = nil
}
func cancelButtonTapped() {
self.route = nil
}
func duplicateButtonTapped() {
self.route = .duplicate(self.item.duplicate())
}
func duplicate(item: Item) {
self.onDuplicate(item)
self.route = nil
}
}
extension Item {
func duplicate() -> Self {
.init(name: self.name, color: self.color, status: self.status)
}
}
struct ItemRowView: View {
@ObservedObject var viewModel: ItemRowViewModel
var body: some View {
NavigationLink(unwrapping: self.$viewModel.route, case: /ItemRowViewModel.Route.edit) { $item in
ItemView(item: $item)
.navigationBarTitle("Edit")
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.viewModel.cancelButtonTapped()
}
}
ToolbarItem(placement: .primaryAction) {
Button("Save") {
self.viewModel.edit(item: item)
}
}
}
} onNavigate: {
self.viewModel.setEditNavigation(isActive: $0)
} label: {
HStack {
VStack(alignment: .leading) {
Text(self.viewModel.item.name)
switch self.viewModel.item.status {
case let .inStock(quantity):
Text("In stock: \(quantity)")
case let .outOfStock(isOnBackOrder):
Text("Out of stock\(isOnBackOrder ? ": on back order" : "")")
}
}
Spacer()
if let color = self.viewModel.item.color {
Rectangle()
.frame(width: 30, height: 30)
.foregroundColor(color.swiftUIColor)
.border(Color.black, width: 1)
}
Button(action: { self.viewModel.duplicateButtonTapped() }) {
Image(systemName: "square.fill.on.square.fill")
}
.padding(.leading)
Button(action: { self.viewModel.deleteButtonTapped() }) {
Image(systemName: "trash.fill")
}
.padding(.leading)
}
.buttonStyle(.plain)
.foregroundColor(self.viewModel.item.status.isInStock ? nil : Color.gray)
.alert(
self.viewModel.item.name,
isPresented: self.$viewModel.route.isPresent(/ItemRowViewModel.Route.deleteAlert),
actions: {
Button("Delete", role: .destructive) {
self.viewModel.deleteConfirmationButtonTapped()
}
},
message: {
Text("Are you sure you want to delete this item?")
}
)
.popover(
unwrapping: self.$viewModel.route,
case: /ItemRowViewModel.Route.duplicate
) { $item in
NavigationView {
ItemView(item: $item)
.navigationBarTitle("Duplicate")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.viewModel.cancelButtonTapped()
}
}
ToolbarItem(placement: .primaryAction) {
Button("Add") {
self.viewModel.duplicate(item: item)
}
}
}
}
.frame(minWidth: 300, minHeight: 500)
}
}
}
}

10
Examples/Package.swift Normal file
View File

@ -0,0 +1,10 @@
// swift-tools-version:5.1
import PackageDescription
let package = Package(
name: "",
products: [],
dependencies: [],
targets: []
)

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Point-Free, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
Makefile Normal file
View File

@ -0,0 +1,43 @@
PLATFORM_IOS = iOS Simulator,name=iPhone 11 Pro Max
PLATFORM_MACOS = macOS
PLATFORM_TVOS = tvOS Simulator,name=Apple TV 4K (at 1080p)
PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 4 - 44mm
default: test
test:
xcodebuild test \
-scheme SwiftUINavigation \
-destination platform="$(PLATFORM_IOS)"
xcodebuild test \
-scheme SwiftUINavigation \
-destination platform="$(PLATFORM_MACOS)"
xcodebuild test \
-scheme SwiftUINavigation \
-destination platform="$(PLATFORM_TVOS)"
xcodebuild \
-scheme SwiftUINavigation_watchOS \
-destination platform="$(PLATFORM_WATCHOS)"
DOC_WARNINGS := $(shell xcodebuild clean docbuild \
-scheme SwiftUINavigation \
-destination platform="$(PLATFORM_MACOS)" \
-quiet \
2>&1 \
| grep "couldn't be resolved to known documentation" \
| sed 's|$(PWD)|.|g' \
| tr '\n' '\1')
test-docs:
@test "$(DOC_WARNINGS)" = "" \
|| (echo "xcodebuild docbuild failed:\n\n$(DOC_WARNINGS)" | tr '\1' '\n' \
&& exit 1)
format:
swift format \
--ignore-unparsable-files \
--in-place \
--parallel \
--recursive \
./Examples ./Package.swift ./Sources ./Tests
.PHONY: format test-all test-docs

16
Package.resolved Normal file
View File

@ -0,0 +1,16 @@
{
"object": {
"pins": [
{
"package": "swift-case-paths",
"repositoryURL": "https://github.com/pointfreeco/swift-case-paths",
"state": {
"branch": null,
"revision": "d226d167bd4a68b51e352af5655c92bce8ee0463",
"version": "0.7.0"
}
}
]
},
"version": 1
}

36
Package.swift Normal file
View File

@ -0,0 +1,36 @@
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "swiftui-navigation",
platforms: [
.iOS(.v13),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v6),
],
products: [
.library(
name: "SwiftUINavigation",
targets: ["SwiftUINavigation"]
)
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.7.0")
],
targets: [
.target(
name: "SwiftUINavigation",
dependencies: [
.product(name: "CasePaths", package: "swift-case-paths")
]
),
.testTarget(
name: "SwiftUINavigationTests",
dependencies: [
"SwiftUINavigation"
]
),
]
)

346
README.md Normal file
View File

@ -0,0 +1,346 @@
# SwiftUI Navigation
<!--
[![CI](https://github.com/pointfreeco/swiftui-navigation/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/swiftui-navigation/actions/workflows/ci.yml)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswiftui-navigation%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swiftui-navigation)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswiftui-navigation%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swiftui-navigation)
-->
Tools for making SwiftUI navigation simpler, more ergonomic and more precise.
* [Motivation](#motivation)
* [Tools](#tools)
* [Navigation overloads](#navigation-overloads)
* [Navigation views](#navigation-views)
* [Binding transformations](#binding-transformations)
* [Examples](#examples)
* [Learn more](#learn-more)
* [Installation](#installation)
* [Documentation](#documentation)
* [License](#license)
## Motivation
SwiftUI comes with many forms of navigation (tabs, alerts, dialogs, modal sheets, popovers, navigation links, and more), and each comes with a few ways to construct them. These ways roughly fall in two categories:
* "Fire-and-forget": These are initializers and methods that do not take binding arguments, which means SwiftUI fully manages navigation state internally. This makes it is easy to get something on the screen quickly, but you also have no programmatic control over the navigation. Examples of this are the initializers on [`TabView`][TabView.init] and [`NavigationLink`][NavigationLink.init] that do not take a binding.
[NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s
[TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:)
* "State-driven": Most other initializers and methods do take a binding, which means you can mutate state in your domain to tell SwiftUI when it should activate or deactivate navigation. Using these APIs is more complicated than the "fire-and-forget" style, but doing so instantly gives you the ability to deep-link into any state of your application by just constructing a piece of data, handing it to a SwiftUI view, and letting SwiftUI handle the rest.
Navigation that is "state-driven" is the more powerful form of navigation, albeit slightly more complicated, but unfortunately SwiftUI does not ship with all the tools necessary to model our domains as concisely as possible and use these navigation APIs.
For example, to show a modal sheet in SwiftUI you can provide a binding of some optional state so that when the state flips to non-`nil` the modal is presented. However, the content closure of the sheet is handed a plain value, not a binding:
```swift
struct ContentView: View {
@State var draft: Post?
var body: some View {
Button("Edit") {
self.draft = Post()
}
.sheet(item: self.$draft) { (draft: Post) in
EditPostView(post: draft)
}
}
}
struct EditPostView: View {
let post: Post
var body: some View { ... }
}
```
This means that the `Post` handed to the `EditPostView` is fully disconnected from the source of truth `draft` that powers the presentation of the modal. Ideally we should be able to derive a `Binding<Post>` for the draft so that any mutations `EditPostView` makes will be instantly visible in `ContentView`.
Another problem arises when trying to model multiple navigation destinations as multiple optional values. For example, suppose there are 3 different sheets that can be shown in a screen:
```swift
struct ContentView: View {
var draft: Post?
var settings: Settings?
var userProfile: UserProfile?
var body: some View {
/* Main view omitted */
.sheet(item: self.$draft) { (draft: Post) in
EditPostView(post: draft)
}
.sheet(item: self.$settings) { (settings: Settings) in
SettingsView(settings: settings)
}
.sheet(item: self.$userProfile) { (userProfile: Profile) in
UserProfile(profile: userProfile)
}
}
}
```
This forces us to hold 3 optional values in state, which has 2^3=8 different states, 4 of which are invalid. The only valid states is for all values to be `nil` or exactly one be non-`nil`. It makes no sense if two or more values are non-`nil`, for that would representing wanting to show two modal sheets at the same time.
Ideally we'd like to represent these navigation destinations as 3 mutually exclusive states so that we could guarantee at compile time that only one can be active at a time. Luckily for us Swifts enums are perfect for this:
```swift
enum Route {
case draft(Post)
case settings(Settings)
case userProfile(Profile)
}
```
And then we could hold an optional `Route` in state to represent that we are either navigating to a specific destination or we are not navigating anywhere:
```swift
@State var route: Route?
```
This would be the most optimal way to model our navigation domain, but unfortunately SwiftUI's tools do not make easy for us to drive navigation off of enums.
This library comes with a number of `Binding` transformations and navigation API overloads that allow you to model your domain as concisely as possible, using enums, while still allowing you to use SwiftUI's navigation tools.
For example, powering multiple modal sheets off a single `Route` enum looks like this with the tools in this library:
```swift
struct ContentView {
@State var route: Route?
enum Route {
case draft(Post)
case settings(Settings)
case userProfile(Profile)
}
var body: some View {
/* Main view omitted */
.sheet(unwrapping: self.$route, case: /Route.draft) { $draft in
EditPostView(post: $draft)
}
.sheet(unwrapping: self.$route, case: /Route.settings) { $settings in
SettingsView(settings: $settings)
}
.sheet(unwrapping: self.$route, case: /Route.userProfile) { $userProfile in
UserProfile(profile: $userProfile)
}
}
}
```
The forward-slash syntax you see above represents a [case path](https://github.com/pointfreeco/swift-case-path) to a particular case of an enum. Case paths are our imagining of what key paths could look like for enums, and every concept for key paths has an analogous concept for case paths:
* Each property of an struct is naturally endowed with a key path, and so each case of an enum is endowed with a case path.
* Key paths are constructed using a back slash, name of the type and name of the property (_e.g._, `\User.name`), and case paths are constructed similarly, but with a forward slash (_e.g._, `/Route.draft`).
* Key paths describe how to get and set a value in some root structure, whereas case paths describe how to extract and embed a value into a root structure.
Case paths are crucial for allowing us to build the tools to drive navigation off of enum state.
## Tools
This library comes with many tools that allow you to model your domain as concisely as possible, using enums, while still allowing you to use SwiftUI's navigation APIs.
### Navigation API overloads
This library provides additional overloads for all of SwiftUI's "state-driven" navigation APIs that allow you to activate navigation based on a particular case of an enum. Further, all overloads unify presentation in a single, consistent API:
* `NavigationLink.init(unwrapping:case:)`
* `View.alert(unwrapping:case:)`
* `View.confirmationDialog(unwrapping:case:)`
* `View.fullScreenCover(unwrapping:case:)`
* `View.popover(unwrapping:case:)`
* `View.sheet(unwrapping:case:)`
For example, here is how a navigation link, a modal sheet and an alert can all be driven off a single enum with 3 cases:
```swift
enum Route {
case add(Post)
case alert(Alert)
case edit(Post)
}
struct ContentView {
@State var posts: [Post]
@State var route: Route?
var body: some View {
ForEach(self.posts) { post in
NavigationLink(unwrapping: self.$route, case: /Route.edit) { $post in
EditPostView(post: $post)
} onNavigate: { isActive in
self.route = isActive ? .edit(post) : nil
} label: {
Text(post.title)
}
}
.sheet(unwrapping: self.$route, case: /Route.add) { $post in
EditPostView(post: $post)
}
.alert(
title: { Text("Delete \($0.title)?") },
unwrapping: self.$route,
case: /Route.alert
actions: { post in
Button("Delete") { self.posts.remove(post) }
},
message: { Text($0.summary) }
)
}
}
struct EditPostView: View {
@Binding var post: Post
var body: some View { ... }
}
```
### Navigation views
This library comes with additional SwiftUI views that transform and destructure bindings, allowing you to better handle optional and enum state:
* `IfLet`
* `IfCaseLet`
* `Switch`/`CaseLet`
For example, suppose you were working on an inventory application that modeled in-stock and out-of-stock as an enum:
```swift
enum ItemStatus {
case inStock(quantity: Int)
case outOfStock(isOnBackorder: Bool)
}
```
If you want to conditionally show a stepper view for the quantity when in-stock and a toggle for the backorder when out-of-stock, you're out of luck when it comes to using SwiftUI's standard tools. However, the `Switch` view that comes with this library allows you to destructure a `Binding<ItemStatus>` into bindings of each case so that you can present different views:
```swift
struct InventoryItemView {
@State var status: ItemStatus?
var body: some View {
Switch(self.$status) {
CaseLet(/ItemStatus.inStock) { $quantity in
HStack {
Text("Quantity: \(quantity)")
Stepper("Quantity", value: $quantity)
}
Button("Out of stock") { self.status = .outOfStock(isOnBackorder: false) }
}
CaseLet(/ItemStatus.outOfStock) { $isOnBackorder in
Toggle("Is on back order?", isOn: $isOnBackorder)
Button("In stock") { self.status = .inStock(quantity: 1) }
}
}
}
}
```
### Binding transformations
This library comes with tools that transform and destructure bindings of optional and enum state, which allows you to build your own navigation views similar to the ones that ship in this library.
* `Binding.init(unwrapping:)`
* `Binding.case(_:)`
* `Binding.isPresent()` and `Binding.isPresent(_:)`
For example, suppose you have built a `BottomSheet` view for presenting a modal-like view that only takes up the bottom half of the screen. You can build the entire view using the most simplistic domain modeling where navigation is driven off a single boolean binding:
```swift
struct BottomSheet<Content>: View where Content: View {
@Binding var isActive: Bool
let content: () -> Content
var body: some View {
...
}
}
```
Then, additional convenience initializers can be introduced that allow the bottom sheet to be created with a more concisely modeled domain.
For example, an initializer that allows the bottom sheet to be presented and dismissed with optional state, and further the content closure is provided a binding of the non-optional state. We can accomplish this using the `isPresent()` method and `Binding.init(unwrapping:)`:
```swift
extension BottomSheet {
init<Value, WrappedContent>(
unwrapping value: Binding<Value?>,
@ViewBuilder content: @escaping (Binding<Value>) -> WrappedContent
)
where Content == WrappedContent?
{
self.init(
isActive: value.isPresent(),
content: { Binding(unwrapping: value).map(content) }
)
}
}
```
An even more robust initializer can be provided by providing a binding to an optional enum _and_ a case path to specify which case of the enum triggers navigation. This can be accomplished using the `case(_:)` method on binding:
```swift
extension BottomSheet {
init<Enum, Case, WrappedContent>(
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
@ViewBuilder content: @escaping (Binding<Case>) -> WrappedContent
)
where Content == WrappedContent?
{
self.init(
unwrapping: `enum`.case(casePath),
content: content
)
}
}
```
Both of these more powerful initializers are just conveniences. If the user of `BottomSheet` does not want to worry about concise domain modeling they are free to continue using the `isActive` boolean binding. But the day they need the more powerful APIs they will be available.
## Examples
This repo comes with lots of examples to demonstrate how to solve common and complex navigation problems with the library. Check out [this](./Examples) directory to see them all, including:
* [Case Studies](./Examples/CaseStudies)
* Alerts & Confirmation Dialogs
* Sheets & Popovers & Fullscreen Covers
* Navigation Links
* Routing
* Custom Components
* [Inventory](./Examples/Inventory): A multi-screen application with lists, sheets, popovers and alerts, all driven by state and deep-linkable.
## Learn More
SwiftUI Navigation's tools were motivated and designed over the course of many episodes on [Point-Free](https://www.pointfree.co), a video series exploring functional programming and the Swift language, hosted by [Brandon Williams](https://twitter.com/mbrandonw) and [Stephen Celis](https://twitter.com/stephencelis).
You can watch all of the episodes [here](https://www.pointfree.co/collections/swiftui/navigation).
<a href="https://www.pointfree.co/collections/swiftui/navigation">
<img alt="video poster image" src="https://d3rccdn33rt8ze.cloudfront.net/episodes/0160.jpeg" width="600">
</a>
## Installation
You can add SwiftUI Navigation to an Xcode project by adding it as a package dependency.
> https://github.com/pointfreeco/swiftui-navigation
If you want to use SwiftUI Navigation in a [SwiftPM](https://swift.org/package-manager/) project, it's as simple as adding it to a `dependencies` clause in your `Package.swift`:
``` swift
dependencies: [
.package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.1.0")
]
```
## Documentation
The latest documentation for the SwiftUI Navigation APIs is available [here](https://pointfreeco.github.io/swiftui-navigation/).
## License
This library is released under the MIT license. See [LICENSE](LICENSE) for details.

View File

@ -0,0 +1,102 @@
#if compiler(>=5.5)
extension View {
/// Presents an alert from a binding to optional alert state.
///
/// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an
/// `isPresented` binding to a boolean that determines if the alert should be presented, and
/// optional alert `data` that is used to customize its actions and message.
///
/// Modeling the domain in this way unfortunately introduces a couple invalid runtime states:
///
/// * `isPresented` can be `true`, but `data` can be `nil`.
/// * `isPresented` can be `false`, but `data` can be non-`nil`.
///
/// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot
/// be dynamically computed from the alert data.
///
/// This overload addresses these shortcomings with a streamlined API. First, it eliminates the
/// invalid runtime states at compile time by driving the alert's presentation from a single,
/// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the
/// title can be customized from the alert data.
///
/// ```swift
/// struct AlertDemo: View {
/// @State var randomMovie: Movie?
///
/// var body: some View {
/// Button("Pick a random movie", action: self.getRandomMovie)
/// .alert(
/// title: { Text($0.title) },
/// unwrapping: self.$randomMovie,
/// actions: { _ in
/// Button("Pick another", action: self.getRandomMovie)
/// },
/// message: { Text($0.summary) }
/// )
/// }
///
/// func getRandomMovie() {
/// self.randomMovie = Movie.allCases.randomElement()
/// }
/// }
/// ```
///
/// - Parameters:
/// - title: A closure returning the alert's title given the current alert state.
/// - value: A binding to an optional value that determines whether an alert should be
/// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed
/// to the modifier's closures. You can use this data to populate the fields of an alert
/// that the system displays to the user. When the user presses or taps one of the alert's
/// actions, the system sets this value to `nil` and dismisses the alert.
/// - actions: A view builder returning the alert's actions given the current alert state.
/// - message: A view builder returning the message for the alert given the current alert
/// state.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Value, A: View, M: View>(
title: (Value) -> Text,
unwrapping value: Binding<Value?>,
@ViewBuilder actions: @escaping (Value) -> A,
@ViewBuilder message: @escaping (Value) -> M
) -> some View {
self.alert(
value.wrappedValue.map(title) ?? Text(""),
isPresented: value.isPresent(),
presenting: value.wrappedValue,
actions: actions,
message: message
)
}
/// Presents an alert from a binding to an optional enum, and a case path to a specific case.
///
/// A version of `alert(unwrapping:)` that works with enum state.
///
/// - Parameters:
/// - title: A closure returning the alert's title given the current alert state.
/// - enum: A binding to an optional enum that holds alert state at a particular case. When
/// the binding is updated with a non-`nil` enum, the case path will attempt to extract this
/// state
/// and then pass it to the modifier's closures. You can use it to populate the fields of an
/// alert that the system displays to the user. When the user presses or taps one of the
/// alert's actions, the system sets this value to `nil` and dismisses the alert.
/// - casePath: A case path that identifies a particular case that holds alert state.
/// - actions: A view builder returning the alert's actions given the current alert state.
/// - message: A view builder returning the message for the alert given the current alert
/// state.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Enum, Case, A: View, M: View>(
title: (Case) -> Text,
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
@ViewBuilder actions: @escaping (Case) -> A,
@ViewBuilder message: @escaping (Case) -> M
) -> some View {
self.alert(
title: title,
unwrapping: `enum`.case(casePath),
actions: actions,
message: message
)
}
}
#endif

View File

@ -0,0 +1,136 @@
extension Binding {
/// Creates a binding by projecting the base value to an unwrapped value.
///
/// Useful for producing non-optional bindings from optional ones.
///
/// See ``IfLet`` for a view builder-friendly version of this initializer.
///
/// > Note: SwiftUI comes with an equivalent failable initializer, `Binding.init(_:)`, but using
/// > it can lead to crashes at runtime. [Feedback][FB8367784] has been filed, but in the meantime
/// > this initializer exists as a workaround.
///
/// [FB8367784]: https://gist.github.com/stephencelis/3a232a1b718bab0ae1127ebd5fcf6f97
///
/// - Parameter base: A value to project to an unwrapped value.
/// - Returns: A new binding or `nil` when `base` is `nil`.
public init?(unwrapping base: Binding<Value?>) {
self.init(unwrapping: base, case: /Optional.some)
}
/// Creates a binding by projecting the base enum value to an unwrapped case.
///
/// Useful for extracting bindings of non-optional state from the case of an enum.
///
/// See ``IfCaseLet`` for a view builder-friendly version of this initializer.
///
/// - Parameters:
/// - enum: An enum to project to a particular case.
/// - casePath: A case path that identifies a particular case to unwrap.
/// - Returns: A new binding or `nil` when `base` is `nil`.
public init?<Enum>(unwrapping enum: Binding<Enum>, case casePath: CasePath<Enum, Value>) {
guard var `case` = casePath.extract(from: `enum`.wrappedValue)
else { return nil }
self.init(
get: {
`case` = casePath.extract(from: `enum`.wrappedValue) ?? `case`
return `case`
},
set: {
`case` = $0
`enum`.transaction($1).wrappedValue = casePath.embed($0)
}
)
}
/// Creates a binding by projecting the current optional enum value to the value at a particular
/// case.
///
/// > Note: This method is constrained to optionals so that the projected value can write `nil`
/// > back to the parent, which is useful for navigation, particularly dismissal.
///
/// - Parameter casePath: A case path that identifies a particular case to unwrap.
/// - Returns: A binding to an enum case.
public func `case`<Enum, Case>(_ casePath: CasePath<Enum, Case>) -> Binding<Case?>
where Value == Enum? {
.init(
get: { self.wrappedValue.flatMap(casePath.extract(from:)) },
set: { newValue, transaction in
self.transaction(transaction).wrappedValue = newValue.map(casePath.embed)
}
)
}
/// Creates a binding by projecting the current optional value to a boolean describing if it's
/// non-`nil`.
///
/// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing.
///
/// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`.
public func isPresent<Wrapped>() -> Binding<Bool>
where Value == Wrapped? {
.init(
get: { self.wrappedValue != nil },
set: { isPresent, transaction in
if !isPresent {
self.transaction(transaction).wrappedValue = nil
}
}
)
}
/// Creates a binding by projecting the current optional enum value to a boolean describing
/// whether or not it matches the given case path.
///
/// Writing `false` to the binding will `nil` out the base enum value. Writing `true` does
/// nothing.
///
/// Useful for interacting with APIs that take a binding of a boolean that you want to drive with
/// with an enum case that has no associated data.
///
/// For example, a view may model all of its presentations in a single route enum to prevent the
/// invalid states that can be introduced by holding onto many booleans and optionals, instead.
/// Even the simple case of two booleans driving two alerts introduces a potential runtime state
/// where both alerts are presented at the same time. By modeling these alerts using a two-case
/// enum instead of two booleans, we can eliminate this invalid state at compile time. Then we
/// can transform a binding to the route enum into a boolean binding using `isPresent`, so that it
/// can be passed to various presentation APIs.
///
/// ```swift
/// enum Route {
/// case deleteAlert
/// ...
/// }
///
/// struct ProductView: View {
/// @State var route: Route?
/// @State var product: Product
///
/// var body: some View {
/// Button("Delete") {
/// self.viewModel.route = .deleteAlert
/// }
/// // SwiftUI's vanilla alert modifier
/// .alert(
/// self.product.name
/// isPresented: self.$viewModel.route.isPresent(/Route.deleteAlert),
/// actions: {
/// Button("Delete", role: .destructive) {
/// self.viewModel.deleteConfirmationButtonTapped()
/// }
/// },
/// message: {
/// Text("Are you sure you want to delete this product?")
/// }
/// )
/// }
/// }
/// ```
///
/// - Parameter casePath: A case path that identifies a particular case to match.
/// - Returns: A binding to a boolean.
public func isPresent<Enum, Case>(_ casePath: CasePath<Enum, Case>) -> Binding<Bool>
where Value == Enum? {
self.case(casePath).isPresent()
}
}

View File

@ -0,0 +1,109 @@
#if compiler(>=5.5)
extension View {
/// Presents a confirmation dialog from a binding to optional dialog state.
///
/// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of
/// state: an `isPresented` binding to a boolean that determines if the dialog should be
/// presented, and optional dialog `data` that is used to customize its actions and message.
///
/// Modeling the domain in this way unfortunately introduces a couple invalid runtime states:
///
/// * `isPresented` can be `true`, but `data` can be `nil`.
/// * `isPresented` can be `false`, but `data` can be non-`nil`.
///
/// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the
/// title cannot be dynamically computed from the dialog data.
///
/// This overload addresses these shortcomings with a streamlined API. First, it eliminates the
/// invalid runtime states at compile time by driving the dialog's presentation from a single,
/// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the
/// title can be customized from the dialog data.
///
/// ```swift
/// struct DialogDemo: View {
/// @State var randomMovie: Movie?
///
/// var body: some View {
/// Button("Pick a random movie", action: self.getRandomMovie)
/// .confirmationDialog(
/// title: { Text($0.title) },
/// titleVisibility: .always,
/// unwrapping: self.$randomMovie,
/// actions: { _ in
/// Button("Pick another", action: self.getRandomMovie)
/// },
/// message: { Text($0.summary) }
/// )
/// }
///
/// func getRandomMovie() {
/// self.randomMovie = Movie.allCases.randomElement()
/// }
/// }
/// ```
///
/// - Parameters:
/// - title: A closure returning the dialog's title given the current dialog state.
/// - titleVisibility: The visibility of the dialog's title.
/// - value: A binding to an optional value that determines whether a dialog should be
/// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed
/// to the modifier's closures. You can use this data to populate the fields of a dialog
/// that the system displays to the user. When the user presses or taps one of the dialog's
/// actions, the system sets this value to `nil` and dismisses the dialog.
/// - actions: A view builder returning the dialog's actions given the current dialog state.
/// - message: A view builder returning the message for the dialog given the current dialog
/// state.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Value, A: View, M: View>(
title: (Value) -> Text,
titleVisibility: Visibility = .automatic,
unwrapping value: Binding<Value?>,
@ViewBuilder actions: @escaping (Value) -> A,
@ViewBuilder message: @escaping (Value) -> M
) -> some View {
self.confirmationDialog(
value.wrappedValue.map(title) ?? Text(""),
isPresented: value.isPresent(),
titleVisibility: titleVisibility,
presenting: value.wrappedValue,
actions: actions,
message: message
)
}
/// Presents a confirmation dialog from a binding to an optional enum, and a case path to a
/// specific case.
///
/// A version of `confirmationDialog(unwrapping:)` that works with enum state.
///
/// - Parameters:
/// - title: A closure returning the dialog's title given the current dialog case.
/// - titleVisibility: The visibility of the dialog's title.
/// - enum: A binding to an optional enum that holds dialog state at a particular case. When
/// the binding is updated with a non-`nil` enum, the case path will attempt to extract this
/// state and then pass it to the modifier's closures. You can use it to populate the fields
/// of a dialog that the system displays to the user. When the user presses or taps one of
/// the dialog's actions, the system sets this value to `nil` and dismisses the dialog.
/// - casePath: A case path that identifies a particular dialog case to handle.
/// - actions: A view builder returning the dialog's actions given the current dialog case.
/// - message: A view builder returning the message for the dialog given the current dialog
/// case.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Enum, Case, A: View, M: View>(
title: (Case) -> Text,
titleVisibility: Visibility = .automatic,
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
@ViewBuilder actions: @escaping (Case) -> A,
@ViewBuilder message: @escaping (Case) -> M
) -> some View {
self.confirmationDialog(
title: title,
titleVisibility: titleVisibility,
unwrapping: `enum`.case(casePath),
actions: actions,
message: message
)
}
}
#endif

View File

@ -0,0 +1,84 @@
extension View {
/// Presents a full-screen cover using a binding as a data source for the sheet's content.
///
/// SwiftUI comes with a `fullScreenCover(item:)` view modifier that is powered by a binding to
/// some hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the
/// content closure. This value, however, is completely static, which prevents the sheet from
/// modifying it.
///
/// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This
/// gives the sheet the ability to write changes back to its source of truth.
///
/// Also unlike `fullScreenCover(item:)`, the binding's value does _not_ need to be hashable.
///
/// ```swift
/// struct TimelineView: View {
/// @State var draft: Post?
///
/// var body: Body {
/// Button("Compose") {
/// self.draft = Post()
/// }
/// .fullScreenCover(unwrapping: self.$draft) { $draft in
/// ComposeView(post: $draft, onSubmit: { ... })
/// }
/// }
/// }
///
/// struct ComposeView: View {
/// @Binding var post: Post
/// var body: some View { ... }
/// }
/// ```
///
/// - Parameters:
/// - value: A binding to a source of truth for the sheet. When `value` is non-`nil`, a
/// non-optional binding to the value is passed to the `content` closure. You use this binding
/// to produce content that the system presents to the user in a sheet. Changes made to the
/// sheet's binding will be reflected back in the source or truth. Likewise, changes to
/// `value` are instantly reflected in the sheet. If `value` becomes `nil`, the sheet is
/// dismissed.
/// - onDismiss: The closure to execute when dismissing the sheet.
/// - content: A closure returning the content of the sheet.
@available(iOS 14, tvOS 14, watchOS 7, *)
@available(macOS, unavailable)
public func fullScreenCover<Value, Content>(
unwrapping value: Binding<Value?>,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping (Binding<Value>) -> Content
) -> some View
where Content: View {
self.fullScreenCover(isPresented: value.isPresent(), onDismiss: onDismiss) {
Binding(unwrapping: value).map(content)
}
}
/// Presents a full-screen cover using a binding and case path as a data source for the sheet's
/// content.
///
/// A version of `fullScreenCover(unwrapping:)` that works with enum state.
///
/// - Parameters:
/// - enum: A binding to an optional enum that holds the source of truth for the sheet at a
/// particular case. When `enum` is non-`nil`, and `casePath` successfully extracts a value, a
/// non-optional binding to the value is passed to the `content` closure. You use this binding
/// to produce content that the system presents to the user in a sheet. Changes made to the
/// sheet's binding will be reflected back in the source of truth. Likewise, change to `enum`
/// at the given case are instantly reflected in the sheet. If `enum` becomes `nil`, or
/// becomes a case other than the one identified by `casePath`, the sheet is dismissed.
/// - casePath: A case path that identifies a case of `enum` that holds a source of truth for
/// the sheet.
/// - onDismiss: The closure to execute when dismissing the sheet.
/// - content: A closure returning the content of the sheet.
@available(iOS 14, tvOS 14, watchOS 7, *)
@available(macOS, unavailable)
public func fullScreenCover<Enum, Case, Content>(
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping (Binding<Case>) -> Content
) -> some View
where Content: View {
self.fullScreenCover(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content)
}
}

View File

@ -0,0 +1,87 @@
import SwiftUI
/// A view that computes content by extracting a case from a binding to an enum and passing a
/// non-optional binding to the case's associated value to its content closure.
///
/// Useful when working with enum state and building views that require the associated value at a
/// particular case.
///
/// For example, a warehousing application may model the status of an inventory item using an enum.
/// ``IfCaseLet`` can be used to produce bindings to the associated values of each case.
///
/// ```swift
/// enum ItemStatus {
/// case inStock(quantity: Int)
/// case outOfStock(isOnBackOrder: Bool)
/// }
///
/// struct InventoryItemView {
/// @State var status: ItemStatus
///
/// var body: some View {
/// IfCaseLet(self.$status, pattern: /ItemStatus.inStock) { $quantity in
/// HStack {
/// Text("Quantity: \(quantity)")
/// Stepper("Quantity", value: $quantity)
/// }
/// Button("Out of stock") { self.status = .outOfStock(isOnBackOrder: false) }
/// }
/// IfCaseLet(self.$status, pattern: /ItemStatus.outOfStock) { $isOnBackOrder in
/// Toggle("Is on back order?", isOn: $isOnBackOrder)
/// Button("In stock") { self.status = .inStock(quantity: 1) }
/// }
/// }
/// }
/// ```
///
/// To exhaustively handle every case of a binding to an enum, see ``Switch``. Or, to unwrap a
/// binding to an optional, see ``IfLet``.
public struct IfCaseLet<Enum, Case, IfContent, ElseContent>: View where IfContent: View {
public let `enum`: Binding<Enum>
public let casePath: CasePath<Enum, Case>
public let ifContent: (Binding<Case>) -> IfContent
public let elseContent: () -> ElseContent
/// Computes content by extracting a case from a binding to an enum and passing a non-optional
/// binding to the case's associated value to its content closure.
///
/// - Parameters:
/// - enum: A binding to an enum that holds the source of truth for the content at a particular
/// case. When `casePath` successfully extracts a value from `enum`, a non-optional binding to
/// the value is passed to the `content` closure. The closure can use this binding to produce
/// its content and write changes back to the source of truth. Upstream changes to the case's
/// value will also be instantly reflected in the presented content. If `enum` becomes a
/// different case, nothing is computed.
/// - casePath: A case path that identifies a case of `enum` that holds a source of truth for
/// the content.
/// - ifContent: A closure for computing content when `enum` matches a particular case.
/// - elseContent: A closure for computing content when `enum` does not match the case.
public init(
_ `enum`: Binding<Enum>,
pattern casePath: CasePath<Enum, Case>,
@ViewBuilder ifContent: @escaping (Binding<Case>) -> IfContent,
@ViewBuilder elseContent: @escaping () -> ElseContent
) {
self.casePath = casePath
self.elseContent = elseContent
self.enum = `enum`
self.ifContent = ifContent
}
public var body: some View {
Binding(unwrapping: self.enum, case: self.casePath).map(self.ifContent)
}
}
extension IfCaseLet where ElseContent == EmptyView {
public init(
_ `enum`: Binding<Enum>,
pattern casePath: CasePath<Enum, Case>,
@ViewBuilder ifContent: @escaping (Binding<Case>) -> IfContent
) {
self.casePath = casePath
self.elseContent = { EmptyView() }
self.enum = `enum`
self.ifContent = ifContent
}
}

View File

@ -0,0 +1,84 @@
/// A view that computes content by unwrapping a binding to an optional and passing a non-optional
/// binding to its content closure.
///
/// Useful when working with optional state and building views that require non-optional state.
///
/// For example, a warehousing application may model the quantity of an inventory item using an
/// optional integer, where a `nil` value denotes an item that is out-of-stock. In order to produce
/// a binding to a non-optional integer for a stepper, ``IfLet`` can be used to safely unwrap the
/// optional binding.
///
/// ```swift
/// struct InventoryItemView {
/// @State var quantity: Int?
///
/// var body: some View {
/// IfLet(self.$quantity) { $quantity in
/// HStack {
/// Text("Quantity: \(quantity)")
/// Stepper("Quantity", value: $quantity)
/// }
/// Button("Out of stock") { self.quantity = nil }
/// } else: {
/// Button("In stock") { self.quantity = 1 }
/// }
/// }
/// }
/// ```
///
/// To unwrap a particular case of a binding to an enum, see ``IfCaseLet``, or, to exhaustively
/// handle every case, see ``Switch``.
public struct IfLet<Value, IfContent, ElseContent>: View where IfContent: View, ElseContent: View {
public let value: Binding<Value?>
public let ifContent: (Binding<Value>) -> IfContent
public let elseContent: () -> ElseContent
/// Computes content by unwrapping a binding to an optional and passing a non-optional binding to
/// its content closure.
///
/// - Parameters:
/// - value: A binding to an optional source of truth for the content. When `value` is
/// non-`nil`, a non-optional binding to the value is passed to the `ifContent` closure. The
/// closure can use this binding to produce its content and write changes back to the source
/// of truth. Upstream changes to `value` will also be instantly reflected in the presented
/// content. If `value` becomes `nil`, the `elseContent` closure is used to produce content
/// instead.
/// - ifContent: A closure for computing content when `value` is non-`nil`.
/// - elseContent: A closure for computing content when `value` is `nil`.
public init(
_ value: Binding<Value?>,
@ViewBuilder then ifContent: @escaping (Binding<Value>) -> IfContent,
@ViewBuilder else elseContent: @escaping () -> ElseContent
) {
self.value = value
self.ifContent = ifContent
self.elseContent = elseContent
}
public var body: some View {
if let $value = Binding(unwrapping: self.value) {
self.ifContent($value)
} else {
self.elseContent()
}
}
}
extension IfLet where ElseContent == EmptyView {
/// Computes content by unwrapping a binding to an optional and passing a non-optional binding to
/// its content closure.
///
/// - Parameters:
/// - value: A binding to an optional source of truth for the content. When `value` is
/// non-`nil`, a non-optional binding to the value is passed to the `ifContent` closure. The
/// closure can use this binding to produce its content and write changes back to the source
/// of truth. Upstream changes to `value` will also be instantly reflected in the presented
/// content. If `value` becomes `nil`, nothing is computed.
/// - ifContent: A closure for computing content when `value` is non-`nil`.
public init(
_ value: Binding<Value?>,
@ViewBuilder then ifContent: @escaping (Binding<Value>) -> IfContent
) {
self.init(value, then: ifContent, else: { EmptyView() })
}
}

View File

@ -0,0 +1,11 @@
extension Binding {
func didSet(_ perform: @escaping (Value) -> Void) -> Self {
.init(
get: { self.wrappedValue },
set: { newValue, transaction in
self.transaction(transaction).wrappedValue = newValue
perform(newValue)
}
)
}
}

View File

@ -0,0 +1,30 @@
/// Raises a debug breakpoint if a debugger is attached.
@inline(__always) func breakpoint(_ message: @autoclosure () -> String = "") {
#if DEBUG
// https://github.com/bitstadium/HockeySDK-iOS/blob/c6e8d1e940299bec0c0585b1f7b86baf3b17fc82/Classes/BITHockeyHelper.m#L346-L370
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var info: kinfo_proc = kinfo_proc()
var info_size = MemoryLayout<kinfo_proc>.size
let isDebuggerAttached = name.withUnsafeMutableBytes {
$0.bindMemory(to: Int32.self).baseAddress
.map {
sysctl($0, 4, &info, &info_size, nil, 0) != -1 && info.kp_proc.p_flag & P_TRACED != 0
}
?? false
}
if isDebuggerAttached {
fputs(
"""
\(message())
Caught debug breakpoint. Type "continue" ("c") to resume execution.
""",
stderr
)
raise(SIGTRAP)
}
#endif
}

View File

@ -0,0 +1,2 @@
@_exported import CasePaths
@_exported import SwiftUI

View File

@ -0,0 +1,125 @@
extension NavigationLink {
/// Creates a navigation link that presents the destination view when a bound value is non-`nil`.
///
/// This allows you to drive navigation to a destination from an optional value. When the
/// optional value becomes non-`nil` a binding to an honest value is derived and passed to the
/// destination. Any edits made to the binding in the destination are automatically reflected
/// in the parent.
///
/// ```swift
/// struct ContentView: View {
/// @State var postToEdit: Post?
/// @State var posts: [Post]
///
/// var body: some View {
/// ForEach(self.posts) { post in
/// NavigationLink(unwrapping: self.$postToEdit) { $draft in
/// EditPostView(post: $draft)
/// } onNavigate: { isActive in
/// self.postToEdit = isActive ? post : nil
/// } label: {
/// Text(post.title)
/// }
/// }
/// }
/// }
///
/// struct EditPostView: View {
/// @Binding var post: Post
/// var body: some View { ... }
/// }
/// ```
///
/// - Parameters:
/// - value: A binding to an optional source of truth for the destination. When `value` is
/// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. The
/// destination can use this binding to produce its content and write changes back to the
/// source of truth. Upstream changes to `value` will also be instantly reflected in the
/// destination. If `value` becomes `nil`, the destination is dismissed.
/// - destination: A view for the navigation link to present.
/// - onNavigate: A closure that executes when the link becomes active or inactive with a
/// boolean that describes if the link was activated or not. Use this closure to populate the
/// source of truth when it is passed a value of `true`. When passed `false`, the system will
/// automatically write `nil` to `value`.
/// - label: A view builder to produce a label describing the `destination` to present.
public init<Value, WrappedDestination>(
unwrapping value: Binding<Value?>,
@ViewBuilder destination: @escaping (Binding<Value>) -> WrappedDestination,
onNavigate: @escaping (_ isActive: Bool) -> Void,
@ViewBuilder label: @escaping () -> Label
) where Destination == WrappedDestination? {
self.init(
destination: Binding(unwrapping: value).map(destination),
isActive: value.isPresent().didSet(onNavigate),
label: label
)
}
/// Creates a navigation link that presents the destination view when a bound enum is non-`nil`
/// and matches a particular case.
///
/// This allows you to drive navigation to a destination from an enum of values. When the
/// optional value becomes non-`nil` _and_ matches a particular case of the enum, a binding to an
/// honest value is derived and passed to the destination. Any edits made to the binding in the
/// destination are automatically reflected in the parent.
///
/// ```swift
/// struct ContentView: View {
/// @State var route: Route?
/// @State var posts: [Post]
///
/// enum Route {
/// case edit(Post)
/// /* other routes */
/// }
///
/// var body: some View {
/// ForEach(self.posts) { post in
/// NavigationLink(unwrapping: self.$route, case: /Route.edit) { $draft in
/// EditPostView(post: $draft)
/// } onNavigate: { isActive in
/// self.route = isActive ? .edit(post) : nil
/// } label: {
/// Text(post.title)
/// }
/// }
/// }
/// }
///
/// struct EditPostView: View {
/// @Binding var post: Post
/// var body: some View { ... }
/// }
/// ```
///
/// See `NavigationLink.init(unwrapping:destination:onNavigate:label)` for a version of this
/// initializer that works with optional state instead of enum state.
///
/// - Parameters:
/// - enum: A binding to an optional source of truth for the destination. When `enum` is
/// non-`nil`, and `casePath` successfully extracts a value, a non-optional binding to the
/// value is passed to the `destination` closure. The destination can use this binding to
/// produce its content and write changes back to the source of truth. Upstream changes to
/// `enum` will also be instantly reflected in the destination. If `enum` becomes `nil`, the
/// destination is dismissed.
/// - destination: A view for the navigation link to present.
/// - onNavigate: A closure that executes when the link becomes active or inactive with a
/// boolean that describes if the link was activated or not. Use this closure to populate the
/// source of truth when it is passed a value of `true`. When passed `false`, the system will
/// automatically write `nil` to `enum`.
/// - label: A view builder to produce a label describing the `destination` to present.
public init<Enum, Case, WrappedDestination>(
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
@ViewBuilder destination: @escaping (Binding<Case>) -> WrappedDestination,
onNavigate: @escaping (Bool) -> Void,
@ViewBuilder label: @escaping () -> Label
) where Destination == WrappedDestination? {
self.init(
unwrapping: `enum`.case(casePath),
destination: destination,
onNavigate: onNavigate,
label: label
)
}
}

View File

@ -0,0 +1,94 @@
extension View {
/// Presents a popover using a binding as a data source for the popover's content.
///
/// SwiftUI comes with a `popover(item:)` view modifier that is powered by a binding to some
/// hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the content
/// closure. This value, however, is completely static, which prevents the popover from modifying
/// it.
///
/// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This
/// gives the popover the ability to write changes back to its source of truth.
///
/// Also unlike `popover(item:)`, the binding's value does _not_ need to be hashable.
///
/// ```swift
/// struct TimelineView: View {
/// @State var draft: Post?
///
/// var body: Body {
/// Button("Compose") {
/// self.draft = Post()
/// }
/// .popover(unwrapping: self.$draft) { $draft in
/// ComposeView(post: $draft, onSubmit: { ... })
/// }
/// }
/// }
///
/// struct ComposeView: View {
/// @Binding var post: Post
/// var body: some View { ... }
/// }
/// ```
///
/// - Parameters:
/// - value: A binding to an optional source of truth for the popover. When `value` is
/// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You use
/// this binding to produce content that the system presents to the user in a popover. Changes
/// made to the popover's binding will be reflected back in the source or truth. Likewise,
/// changes to `value` are instantly reflected in the popover. If `value` becomes `nil`, the
/// popover is dismissed.
/// - attachmentAnchor: The positioning anchor that defines the attachment point of the popover.
/// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's
/// arrow.
/// - content: A closure returning the content of the popover.
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public func popover<Value, Content>(
unwrapping value: Binding<Value?>,
attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds),
arrowEdge: Edge = .top,
@ViewBuilder content: @escaping (Binding<Value>) -> Content
) -> some View where Content: View {
self.popover(
isPresented: value.isPresent(), attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge
) {
Binding(unwrapping: value).map(content)
}
}
/// Presents a popover using a binding and case path as the data source for the popover's content.
///
/// A version of `popover(unwrapping:)` that works with enum state.
///
/// - Parameters:
/// - enum: A binding to an optional enum that holds the source of truth for the popover at a
/// particular case. When `enum` is non-`nil`, and `casePath` successfully extracts a value, a
/// non-optional binding to the value is passed to the `content` closure. You use this binding
/// to produce content that the system presents to the user in a popover. Changes made to the
/// popover's binding will be reflected back in the source of truth. Likewise, change to
/// `enum` at the given case are instantly reflected in the popover. If `enum` becomes `nil`,
/// or becomes a case other than the one identified by `casePath`, the popover is dismissed.
/// - casePath: A case path that identifies a case of `enum` that holds a source of truth for
/// the popover.
/// - attachmentAnchor: The positioning anchor that defines the attachment point of the popover.
/// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's
/// arrow.
/// - content: A closure returning the content of the popover.
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public func popover<Enum, Case, Content>(
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds),
arrowEdge: Edge = .top,
@ViewBuilder content: @escaping (Binding<Case>) -> Content
) -> some View where Content: View {
self.popover(
unwrapping: `enum`.case(casePath),
attachmentAnchor: attachmentAnchor,
arrowEdge: arrowEdge,
content: content
)
}
}

View File

@ -0,0 +1,79 @@
extension View {
/// Presents a sheet using a binding as a data source for the sheet's content.
///
/// SwiftUI comes with a `sheet(item:)` view modifier that is powered by a binding to some
/// hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the content
/// closure. This value, however, is completely static, which prevents the sheet from modifying
/// it.
///
/// This overload differs in that it passes a _binding_ to the content closure, instead. This
/// gives the sheet the ability to write changes back to its source of truth.
///
/// Also unlike `sheet(item:)`, the binding's value does _not_ need to be hashable.
///
/// ```swift
/// struct TimelineView: View {
/// @State var draft: Post?
///
/// var body: Body {
/// Button("Compose") {
/// self.draft = Post()
/// }
/// .sheet(unwrapping: self.$draft) { $draft in
/// ComposeView(post: $draft, onSubmit: { ... })
/// }
/// }
/// }
///
/// struct ComposeView: View {
/// @Binding var post: Post
/// var body: some View { ... }
/// }
/// ```
///
/// - Parameters:
/// - value: A binding to an optional source of truth for the sheet. When `value` is non-`nil`,
/// a non-optional binding to the value is passed to the `content` closure. You use this
/// binding to produce content that the system presents to the user in a sheet. Changes made
/// to the sheet's binding will be reflected back in the source or truth. Likewise, changes
/// to `value` are instantly reflected in the sheet. If `value` becomes `nil`, the sheet is
/// dismissed.
/// - onDismiss: The closure to execute when dismissing the sheet.
/// - content: A closure returning the content of the sheet.
public func sheet<Value, Content>(
unwrapping value: Binding<Value?>,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping (Binding<Value>) -> Content
) -> some View
where Content: View {
self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) {
Binding(unwrapping: value).map(content)
}
}
/// Presents a sheet using a binding and case path as the data source for the sheet's content.
///
/// A version of `View.sheet(unwrapping:)` that works with enum state.
///
/// - Parameters:
/// - enum: A binding to an optional enum that holds the source of truth for the sheet at a
/// particular case. When `enum` is non-`nil`, and `casePath` successfully extracts a value, a
/// non-optional binding to the value is passed to the `content` closure. You use this binding
/// to produce content that the system presents to the user in a sheet. Changes made to the
/// sheet's binding will be reflected back in the source of truth. Likewise, change to `enum`
/// at the given case are instantly reflected in the sheet. If `enum` becomes `nil`, or
/// becomes a case other than the one identified by `casePath`, the sheet is dismissed.
/// - casePath: A case path that identifies a case of `enum` that holds a source of truth for
/// the sheet.
/// - onDismiss: The closure to execute when dismissing the sheet.
/// - content: A closure returning the content of the sheet.
public func sheet<Enum, Case, Content>(
unwrapping enum: Binding<Enum?>,
case casePath: CasePath<Enum, Case>,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping (Binding<Case>) -> Content
) -> some View
where Content: View {
self.sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
import XCTest
@testable import SwiftUINavigation
final class SwiftUINavigationTests: XCTestCase {
func testBindingUnwrap() throws {
var value: Int?
let binding = Binding(get: { value }, set: { value = $0 })
XCTAssertNil(Binding(unwrapping: binding))
binding.wrappedValue = 1
let unwrapped = try XCTUnwrap(Binding(unwrapping: binding))
XCTAssertEqual(binding.wrappedValue, 1)
XCTAssertEqual(unwrapped.wrappedValue, 1)
unwrapped.wrappedValue = 42
XCTAssertEqual(binding.wrappedValue, 42)
XCTAssertEqual(unwrapped.wrappedValue, 42)
binding.wrappedValue = 1729
XCTAssertEqual(binding.wrappedValue, 1729)
XCTAssertEqual(unwrapped.wrappedValue, 1729)
binding.wrappedValue = nil
XCTAssertEqual(binding.wrappedValue, nil)
XCTAssertEqual(unwrapped.wrappedValue, 1729)
}
func testBindingCase() throws {
struct MyError: Error, Equatable {}
var value: Result<Int, MyError>? = nil
let binding = Binding(get: { value }, set: { value = $0 })
let success = binding.case(/Result.success)
let failure = binding.case(/Result.failure)
XCTAssertEqual(binding.wrappedValue, nil)
XCTAssertEqual(success.wrappedValue, nil)
XCTAssertEqual(failure.wrappedValue, nil)
binding.wrappedValue = .success(1)
XCTAssertEqual(binding.wrappedValue, .success(1))
XCTAssertEqual(success.wrappedValue, 1)
XCTAssertEqual(failure.wrappedValue, nil)
success.wrappedValue = 42
XCTAssertEqual(binding.wrappedValue, .success(42))
XCTAssertEqual(success.wrappedValue, 42)
XCTAssertEqual(failure.wrappedValue, nil)
failure.wrappedValue = MyError()
XCTAssertEqual(binding.wrappedValue, .failure(MyError()))
XCTAssertEqual(success.wrappedValue, nil)
XCTAssertEqual(failure.wrappedValue, MyError())
success.wrappedValue = nil
XCTAssertEqual(binding.wrappedValue, nil)
XCTAssertEqual(success.wrappedValue, nil)
XCTAssertEqual(failure.wrappedValue, nil)
}
}