Support navigationDestination, alerts, confirmation dialogs, and more! (#28)
* wip * wip * Update APIs * wip * wip * docs * wip * readme tweaks * TextState tests * alert tests * updates * dont export swiftui * wip * wip * update ci * docc * doc updates * docs * docs * wip * Update documentation.yml * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip; * wip * modernize inventory demo * more modernization * wip * wip * wip * wip * wip * wip * wip * bring back navigationDestination hack; * wip * wip * wip * wip * wip; * wip; * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Fix typos in README and CaseStudies (#30) * Fix typos in README * Fix mismatched navigation titles in CaseStudies * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * some <5.7 fixes * another <5.7 fix Co-authored-by: Brandon Williams <mbrandonw@hey.com>
This commit is contained in:
parent
5bf9dadd08
commit
102ab45e10
|
@ -7,13 +7,18 @@ on:
|
|||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
library:
|
||||
runs-on: macos-12
|
||||
strategy:
|
||||
matrix:
|
||||
xcode: ['14.0.1']
|
||||
xcode: ['14.1']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
|
|
@ -1,29 +1,74 @@
|
|||
# Build and deploy DocC to GitHub pages. Based off of @karwa's work here:
|
||||
# https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml
|
||||
name: Documentation
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docs-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
runs-on: macos-12
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Generate Documentation
|
||||
uses: SwiftDocOrg/swift-doc@master
|
||||
- name: Select Xcode 14.1
|
||||
run: sudo xcode-select -s /Applications/Xcode_14.1.app
|
||||
|
||||
- name: Checkout Package
|
||||
uses: actions/checkout@v2
|
||||
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
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout gh-pages Branch
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
BRANCH: gh-pages
|
||||
FOLDER: Documentation
|
||||
ref: gh-pages
|
||||
path: docs-out
|
||||
|
||||
- name: Build documentation
|
||||
run: >
|
||||
rm -rf docs-out/.git;
|
||||
rm -rf docs-out/main;
|
||||
git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {};
|
||||
|
||||
for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6);
|
||||
do
|
||||
if [ -d "docs-out/$tag/data/documentation/swiftuinavigation" ]
|
||||
then
|
||||
echo "✅ Documentation for "$tag" already exists.";
|
||||
else
|
||||
echo "⏳ Generating documentation for SwiftUINavigation @ "$tag" release.";
|
||||
rm -rf "docs-out/$tag";
|
||||
|
||||
git checkout .;
|
||||
git checkout "$tag";
|
||||
|
||||
swift package \
|
||||
--allow-writing-to-directory docs-out/"$tag" \
|
||||
generate-documentation \
|
||||
--target SwiftUINavigation \
|
||||
--output-path docs-out/"$tag" \
|
||||
--transform-for-static-hosting \
|
||||
--hosting-base-path /swiftui-navigation/"$tag" \
|
||||
&& echo "✅ Documentation generated for SwiftUINavigation @ "$tag" release." \
|
||||
|| echo "⚠️ Documentation skipped for SwiftUINavigation @ "$tag".";
|
||||
fi;
|
||||
done
|
||||
|
||||
- name: Fix permissions
|
||||
run: 'sudo chown -R $USER docs-out'
|
||||
|
||||
- name: Publish documentation to GitHub Pages
|
||||
uses: JamesIves/github-pages-deploy-action@4.1.7
|
||||
with:
|
||||
branch: gh-pages
|
||||
folder: docs-out
|
||||
single-commit: true
|
||||
|
|
|
@ -1,32 +1,33 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
struct OptionalAlerts: View {
|
||||
@ObservedObject private var viewModel = ViewModel()
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
|
||||
Button(action: { self.viewModel.numberFactButtonTapped() }) {
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
Button(action: { self.model.numberFactButtonTapped() }) {
|
||||
HStack {
|
||||
Text("Get number fact")
|
||||
if self.viewModel.isLoading {
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(self.viewModel.isLoading)
|
||||
.disabled(self.model.isLoading)
|
||||
}
|
||||
.alert(
|
||||
title: { Text("Fact about \($0.number)") },
|
||||
unwrapping: self.$viewModel.fact,
|
||||
unwrapping: self.$model.fact,
|
||||
actions: {
|
||||
Button("Get another fact about \($0.number)") {
|
||||
self.viewModel.numberFactButtonTapped()
|
||||
self.model.numberFactButtonTapped()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
self.viewModel.fact = nil
|
||||
self.model.fact = nil
|
||||
}
|
||||
},
|
||||
message: { Text($0.description) }
|
||||
|
@ -35,16 +36,16 @@ struct OptionalAlerts: View {
|
|||
}
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject {
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var isLoading = false
|
||||
@Published var fact: Fact?
|
||||
|
||||
func numberFactButtonTapped() {
|
||||
self.isLoading = true
|
||||
Task { @MainActor in
|
||||
self.isLoading = true
|
||||
defer { self.isLoading = false }
|
||||
self.fact = await getNumberFact(self.count)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,47 +3,47 @@ import SwiftUINavigation
|
|||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
struct OptionalConfirmationDialogs: View {
|
||||
@ObservedObject private var viewModel = ViewModel()
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
|
||||
Button(action: { self.viewModel.numberFactButtonTapped() }) {
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
Button(action: { self.model.numberFactButtonTapped() }) {
|
||||
HStack {
|
||||
Text("Get number fact")
|
||||
if self.viewModel.isLoading {
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(self.viewModel.isLoading)
|
||||
.disabled(self.model.isLoading)
|
||||
}
|
||||
.confirmationDialog(
|
||||
title: { Text("Fact about \($0.number)") },
|
||||
titleVisibility: .visible,
|
||||
unwrapping: self.$viewModel.fact,
|
||||
unwrapping: self.$model.fact,
|
||||
actions: {
|
||||
Button("Get another fact about \($0.number)") {
|
||||
self.viewModel.numberFactButtonTapped()
|
||||
self.model.numberFactButtonTapped()
|
||||
}
|
||||
},
|
||||
message: { Text($0.description) }
|
||||
)
|
||||
.navigationTitle("Confirmation dialogs")
|
||||
.navigationTitle("Dialogs")
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject {
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var isLoading = false
|
||||
@Published var fact: Fact?
|
||||
|
||||
func numberFactButtonTapped() {
|
||||
self.isLoading = true
|
||||
Task { @MainActor in
|
||||
self.isLoading = true
|
||||
defer { self.isLoading = false }
|
||||
self.fact = await getNumberFact(self.count)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,19 +2,19 @@ import SwiftUI
|
|||
import SwiftUINavigation
|
||||
|
||||
struct OptionalSheets: View {
|
||||
@ObservedObject private var viewModel = ViewModel()
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
|
||||
HStack {
|
||||
Button("Get number fact") {
|
||||
self.viewModel.numberFactButtonTapped()
|
||||
self.model.numberFactButtonTapped()
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading {
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
|
@ -24,28 +24,28 @@ struct OptionalSheets: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
ForEach(self.viewModel.savedFacts) { fact in
|
||||
ForEach(self.model.savedFacts) { fact in
|
||||
Text(fact.description)
|
||||
}
|
||||
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
|
||||
.onDelete { self.model.removeSavedFacts(atOffsets: $0) }
|
||||
} header: {
|
||||
Text("Saved Facts")
|
||||
}
|
||||
}
|
||||
.sheet(unwrapping: self.$viewModel.fact) { $fact in
|
||||
.sheet(unwrapping: self.$model.fact) { $fact in
|
||||
NavigationView {
|
||||
FactEditor(fact: $fact.description)
|
||||
.disabled(self.viewModel.isLoading)
|
||||
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
|
||||
.disabled(self.model.isLoading)
|
||||
.foregroundColor(self.model.isLoading ? .gray : nil)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
self.viewModel.cancelButtonTapped()
|
||||
self.model.cancelButtonTapped()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
self.viewModel.saveButtonTapped(fact: fact)
|
||||
self.model.saveButtonTapped(fact: fact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -63,17 +63,21 @@ private struct FactEditor: View {
|
|||
TextEditor(text: self.$fact)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Fact Editor")
|
||||
.navigationTitle("Fact editor")
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject {
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var fact: Fact?
|
||||
@Published var isLoading = false
|
||||
@Published var savedFacts: [Fact] = []
|
||||
private var task: Task<Void, Error>?
|
||||
|
||||
deinit {
|
||||
self.task?.cancel()
|
||||
}
|
||||
|
||||
func numberFactButtonTapped() {
|
||||
self.isLoading = true
|
||||
self.fact = Fact(description: "\(self.count) is still loading...", number: self.count)
|
||||
|
|
|
@ -2,19 +2,34 @@ import SwiftUI
|
|||
import SwiftUINavigation
|
||||
|
||||
struct OptionalPopovers: View {
|
||||
@ObservedObject private var viewModel = ViewModel()
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
|
||||
HStack {
|
||||
Button("Get number fact") {
|
||||
self.viewModel.numberFactButtonTapped()
|
||||
self.model.numberFactButtonTapped()
|
||||
}
|
||||
.popover(unwrapping: self.$model.fact, arrowEdge: .bottom) { $fact in
|
||||
NavigationView {
|
||||
FactEditor(fact: $fact.description)
|
||||
.disabled(self.model.isLoading)
|
||||
.foregroundColor(self.model.isLoading ? .gray : nil)
|
||||
.navigationBarItems(
|
||||
leading: Button("Cancel") {
|
||||
self.model.cancelButtonTapped()
|
||||
},
|
||||
trailing: Button("Save") {
|
||||
self.model.saveButtonTapped(fact: fact)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading {
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
|
@ -24,33 +39,14 @@ struct OptionalPopovers: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
ForEach(self.viewModel.savedFacts) { fact in
|
||||
ForEach(self.model.savedFacts) { fact in
|
||||
Text(fact.description)
|
||||
}
|
||||
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
|
||||
.onDelete { self.model.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("Popovers")
|
||||
}
|
||||
}
|
||||
|
@ -63,17 +59,21 @@ private struct FactEditor: View {
|
|||
TextEditor(text: self.$fact)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Fact Editor")
|
||||
.navigationTitle("Fact editor")
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject {
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var fact: Fact?
|
||||
@Published var isLoading = false
|
||||
@Published var savedFacts: [Fact] = []
|
||||
private var task: Task<Void, Error>?
|
||||
|
||||
deinit {
|
||||
self.task?.cancel()
|
||||
}
|
||||
|
||||
func numberFactButtonTapped() {
|
||||
self.isLoading = true
|
||||
self.fact = Fact(description: "\(self.count) is still loading...", number: self.count)
|
||||
|
|
|
@ -2,19 +2,19 @@ import SwiftUI
|
|||
import SwiftUINavigation
|
||||
|
||||
struct OptionalFullScreenCovers: View {
|
||||
@ObservedObject private var viewModel = ViewModel()
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
|
||||
HStack {
|
||||
Button("Get number fact") {
|
||||
self.viewModel.numberFactButtonTapped()
|
||||
self.model.numberFactButtonTapped()
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading {
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
|
@ -24,34 +24,34 @@ struct OptionalFullScreenCovers: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
ForEach(self.viewModel.savedFacts) { fact in
|
||||
ForEach(self.model.savedFacts) { fact in
|
||||
Text(fact.description)
|
||||
}
|
||||
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
|
||||
.onDelete { self.model.removeSavedFacts(atOffsets: $0) }
|
||||
} header: {
|
||||
Text("Saved Facts")
|
||||
}
|
||||
}
|
||||
.fullScreenCover(unwrapping: self.$viewModel.fact) { $fact in
|
||||
.fullScreenCover(unwrapping: self.$model.fact) { $fact in
|
||||
NavigationView {
|
||||
FactEditor(fact: $fact.description)
|
||||
.disabled(self.viewModel.isLoading)
|
||||
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
|
||||
.disabled(self.model.isLoading)
|
||||
.foregroundColor(self.model.isLoading ? .gray : nil)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
self.viewModel.cancelButtonTapped()
|
||||
self.model.cancelButtonTapped()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
self.viewModel.saveButtonTapped(fact: fact)
|
||||
self.model.saveButtonTapped(fact: fact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Full-Screen Covers")
|
||||
.navigationTitle("Full-screen covers")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,11 +63,11 @@ private struct FactEditor: View {
|
|||
TextEditor(text: self.$fact)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Fact Editor")
|
||||
.navigationTitle("Fact editor")
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject {
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var fact: Fact?
|
||||
@Published var isLoading = false
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
@available(iOS 16, *)
|
||||
struct NavigationDestinations: View {
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
|
||||
HStack {
|
||||
Button("Get number fact") {
|
||||
self.model.numberFactButtonTapped()
|
||||
}
|
||||
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Fact Finder")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(self.model.savedFacts) { fact in
|
||||
Text(fact.description)
|
||||
}
|
||||
.onDelete { self.model.removeSavedFacts(atOffsets: $0) }
|
||||
} header: {
|
||||
Text("Saved Facts")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Destinations")
|
||||
.navigationDestination(unwrapping: self.$model.fact) { $fact in
|
||||
FactEditor(fact: $fact.description)
|
||||
.disabled(self.model.isLoading)
|
||||
.foregroundColor(self.model.isLoading ? .gray : nil)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
self.model.cancelButtonTapped()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
self.model.saveButtonTapped(fact: fact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FactEditor: View {
|
||||
@Binding var fact: String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if #available(iOS 14, *) {
|
||||
TextEditor(text: self.$fact)
|
||||
} else {
|
||||
TextField("Untitled", text: self.$fact)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationBarTitle("Fact Editor")
|
||||
}
|
||||
}
|
||||
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var fact: Fact?
|
||||
@Published var isLoading = false
|
||||
@Published var savedFacts: [Fact] = []
|
||||
private var task: Task<Void, Error>?
|
||||
|
||||
deinit {
|
||||
self.task?.cancel()
|
||||
}
|
||||
|
||||
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 numberFactButtonTapped() {
|
||||
self.setFactNavigation(isActive: true)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -2,30 +2,30 @@ import SwiftUI
|
|||
import SwiftUINavigation
|
||||
|
||||
struct OptionalNavigationLinks: View {
|
||||
@ObservedObject private var viewModel = ViewModel()
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Stepper("Number: \(self.viewModel.count)", value: self.$viewModel.count)
|
||||
Stepper("Number: \(self.model.count)", value: self.$model.count)
|
||||
|
||||
HStack {
|
||||
NavigationLink(unwrapping: self.$viewModel.fact) {
|
||||
self.viewModel.setFactNavigation(isActive: $0)
|
||||
NavigationLink(unwrapping: self.$model.fact) {
|
||||
self.model.setFactNavigation(isActive: $0)
|
||||
} destination: { $fact in
|
||||
FactEditor(fact: $fact.description)
|
||||
.disabled(self.viewModel.isLoading)
|
||||
.foregroundColor(self.viewModel.isLoading ? .gray : nil)
|
||||
.disabled(self.model.isLoading)
|
||||
.foregroundColor(self.model.isLoading ? .gray : nil)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
self.viewModel.cancelButtonTapped()
|
||||
self.model.cancelButtonTapped()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
self.viewModel.saveButtonTapped(fact: fact)
|
||||
self.model.saveButtonTapped(fact: fact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ struct OptionalNavigationLinks: View {
|
|||
Text("Get number fact")
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading {
|
||||
if self.model.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
|
@ -43,10 +43,10 @@ struct OptionalNavigationLinks: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
ForEach(self.viewModel.savedFacts) { fact in
|
||||
ForEach(self.model.savedFacts) { fact in
|
||||
Text(fact.description)
|
||||
}
|
||||
.onDelete { self.viewModel.removeSavedFacts(atOffsets: $0) }
|
||||
.onDelete { self.model.removeSavedFacts(atOffsets: $0) }
|
||||
} header: {
|
||||
Text("Saved Facts")
|
||||
}
|
||||
|
@ -63,17 +63,21 @@ private struct FactEditor: View {
|
|||
TextEditor(text: self.$fact)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Fact Editor")
|
||||
.navigationTitle("Fact editor")
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject {
|
||||
private class FeatureModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
@Published var fact: Fact?
|
||||
@Published var isLoading = false
|
||||
@Published var savedFacts: [Fact] = []
|
||||
private var task: Task<Void, Error>?
|
||||
|
||||
deinit {
|
||||
self.task?.cancel()
|
||||
}
|
||||
|
||||
func setFactNavigation(isActive: Bool) {
|
||||
if isActive {
|
||||
self.isLoading = true
|
|
@ -5,12 +5,12 @@ private let readMe = """
|
|||
This case study demonstrates how to model a list of navigation links. Tap a row to drill down \
|
||||
and edit a counter. Edit screen allows cancelling or saving the edits.
|
||||
|
||||
The domain for a row in the list has its own ObservableObject and Route enum, and it uses the \
|
||||
library's NavigationLink initializer to drive navigation from the route enum.
|
||||
The domain for a row in the list has its own ObservableObject and Destination enum, and it uses \
|
||||
the library's NavigationLink initializer to drive navigation from the destination enum.
|
||||
"""
|
||||
|
||||
struct ListOfNavigationLinks: View {
|
||||
@ObservedObject var viewModel: ListOfNavigationLinksViewModel
|
||||
@ObservedObject var model: ListOfNavigationLinksModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
@ -19,27 +19,27 @@ struct ListOfNavigationLinks: View {
|
|||
}
|
||||
|
||||
List {
|
||||
ForEach(self.viewModel.rows) { rowViewModel in
|
||||
RowView(viewModel: rowViewModel)
|
||||
ForEach(self.model.rows) { rowModel in
|
||||
RowView(model: rowModel)
|
||||
}
|
||||
.onDelete(perform: self.viewModel.deleteButtonTapped(indexSet:))
|
||||
.onDelete(perform: self.model.deleteButtonTapped(indexSet:))
|
||||
}
|
||||
}
|
||||
.navigationTitle("List of Links")
|
||||
.navigationTitle("List of links")
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button("Add") {
|
||||
self.viewModel.addButtonTapped()
|
||||
self.model.addButtonTapped()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ListOfNavigationLinksViewModel: ObservableObject {
|
||||
@Published var rows: [ListOfNavigationLinksRowViewModel]
|
||||
class ListOfNavigationLinksModel: ObservableObject {
|
||||
@Published var rows: [ListOfNavigationLinksRowModel]
|
||||
|
||||
init(rows: [ListOfNavigationLinksRowViewModel] = []) {
|
||||
init(rows: [ListOfNavigationLinksRowModel] = []) {
|
||||
self.rows = rows
|
||||
}
|
||||
|
||||
|
@ -55,59 +55,59 @@ class ListOfNavigationLinksViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
private struct RowView: View {
|
||||
@ObservedObject var viewModel: ListOfNavigationLinksRowViewModel
|
||||
@ObservedObject var model: ListOfNavigationLinksRowModel
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(
|
||||
unwrapping: self.$viewModel.route,
|
||||
case: /ListOfNavigationLinksRowViewModel.Route.edit
|
||||
) {
|
||||
self.viewModel.setEditNavigation(isActive: $0)
|
||||
unwrapping: self.$model.destination,
|
||||
case: /ListOfNavigationLinksRowModel.Destination.edit
|
||||
) { isActive in
|
||||
self.model.setEditNavigation(isActive: isActive)
|
||||
} destination: { $counter in
|
||||
EditView(counter: $counter)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Save") { self.viewModel.saveButtonTapped(counter: counter) }
|
||||
Button("Save") { self.model.saveButtonTapped(counter: counter) }
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { self.viewModel.cancelButtonTapped() }
|
||||
Button("Cancel") { self.model.cancelButtonTapped() }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("\(self.viewModel.counter)")
|
||||
Text("\(self.model.counter)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ListOfNavigationLinksRowViewModel: Identifiable, ObservableObject {
|
||||
class ListOfNavigationLinksRowModel: Identifiable, ObservableObject {
|
||||
let id = UUID()
|
||||
@Published var counter: Int
|
||||
@Published var route: Route?
|
||||
@Published var destination: Destination?
|
||||
|
||||
enum Route {
|
||||
enum Destination {
|
||||
case edit(Int)
|
||||
}
|
||||
|
||||
init(
|
||||
counter: Int = 0,
|
||||
route: Route? = nil
|
||||
destination: Destination? = nil
|
||||
) {
|
||||
self.counter = counter
|
||||
self.route = route
|
||||
self.destination = destination
|
||||
}
|
||||
|
||||
func setEditNavigation(isActive: Bool) {
|
||||
self.route = isActive ? .edit(self.counter) : nil
|
||||
self.destination = isActive ? .edit(self.counter) : nil
|
||||
}
|
||||
|
||||
func saveButtonTapped(counter: Int) {
|
||||
self.counter = counter
|
||||
self.route = nil
|
||||
self.destination = nil
|
||||
}
|
||||
|
||||
func cancelButtonTapped() {
|
||||
self.route = nil
|
||||
self.destination = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,7 +131,7 @@ struct ListOfNavigationLinks_Previews: PreviewProvider {
|
|||
static var previews: some View {
|
||||
NavigationView {
|
||||
ListOfNavigationLinks(
|
||||
viewModel: .init(
|
||||
model: .init(
|
||||
rows: [
|
||||
.init(counter: 0),
|
||||
.init(counter: 0),
|
|
@ -1,82 +0,0 @@
|
|||
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) {
|
||||
self.route = $0 ? .link(0) : nil
|
||||
} destination: { $count in
|
||||
Form {
|
||||
Stepper("Number: \(count)", value: $count)
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
private let readMe = """
|
||||
This case study demonstrates how to power multiple forms of navigation from a single destination \
|
||||
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 Destination {
|
||||
case alert(AlertState<AlertAction>)
|
||||
case confirmationDialog(ConfirmationDialogState<DialogAction>)
|
||||
case link(Int)
|
||||
case sheet(Int)
|
||||
|
||||
enum AlertAction {
|
||||
case randomize
|
||||
case reset
|
||||
}
|
||||
enum DialogAction {
|
||||
case decrement
|
||||
case increment
|
||||
}
|
||||
}
|
||||
|
||||
struct Routing: View {
|
||||
@State var count = 0
|
||||
@State var destination: Destination?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(readMe)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Count: \(self.count)")
|
||||
}
|
||||
|
||||
Button("Alert") {
|
||||
self.destination = .alert(
|
||||
AlertState {
|
||||
TextState("Update count?")
|
||||
} actions: {
|
||||
ButtonState(action: .send(.randomize)) {
|
||||
TextState("Randomize")
|
||||
}
|
||||
ButtonState(role: .destructive, action: .send(.reset)) {
|
||||
TextState("Reset")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Button("Confirmation dialog") {
|
||||
self.destination = .confirmationDialog(
|
||||
ConfirmationDialogState(titleVisibility: .visible) {
|
||||
TextState("Update count?")
|
||||
} actions: {
|
||||
ButtonState(action: .send(.increment)) {
|
||||
TextState("Increment")
|
||||
}
|
||||
ButtonState(action: .send(.decrement)) {
|
||||
TextState("Decrement")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
NavigationLink(unwrapping: self.$destination, case: /Destination.link) { isActive in
|
||||
if isActive {
|
||||
self.destination = .link(self.count)
|
||||
}
|
||||
} destination: { $count in
|
||||
Form {
|
||||
Stepper("Number: \(count)", value: $count)
|
||||
}
|
||||
.navigationTitle("Routing link")
|
||||
} label: {
|
||||
Text("Link")
|
||||
}
|
||||
|
||||
Button("Sheet") {
|
||||
self.destination = .sheet(self.count)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Routing")
|
||||
.alert(unwrapping: self.$destination, case: /Destination.alert) { action in
|
||||
switch action {
|
||||
case .randomize:
|
||||
self.count = .random(in: 0...1_000)
|
||||
case .reset:
|
||||
self.count = 0
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
unwrapping: self.$destination,
|
||||
case: /Destination.confirmationDialog
|
||||
) { action in
|
||||
switch action {
|
||||
case .decrement:
|
||||
self.count -= 1
|
||||
case .increment:
|
||||
self.count += 1
|
||||
}
|
||||
}
|
||||
.sheet(unwrapping: self.$destination, case: /Destination.sheet) { $count in
|
||||
NavigationView {
|
||||
Form {
|
||||
Stepper("Number: \(count)", value: $count)
|
||||
}
|
||||
.navigationTitle("Routing sheet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Routing_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
Routing()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
import SwiftUI
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
bottom menu component with additional APIs that allow presentation and dismissal to be powered \
|
||||
by optionals and enums.
|
||||
"""
|
||||
|
||||
struct CustomComponents: View {
|
|
@ -0,0 +1,65 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
private let readMe = """
|
||||
This demonstrates how to synchronize model state with view state using the "bind" view modifier. \
|
||||
The model starts focused on the "Username" field, which is immediately focused when the form \
|
||||
first appears. When you tap the "Sign in" button, the focus will change to the first non-empty \
|
||||
field.
|
||||
"""
|
||||
|
||||
struct SynchronizedBindings: View {
|
||||
@FocusState private var focusedField: FeatureModel.Field?
|
||||
@ObservedObject private var model = FeatureModel()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(readMe)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Username", text: self.$model.username)
|
||||
.focused(self.$focusedField, equals: .username)
|
||||
|
||||
SecureField("Password", text: self.$model.password)
|
||||
.focused(self.$focusedField, equals: .password)
|
||||
|
||||
Button("Sign In") {
|
||||
self.model.signInButtonTapped()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
.bind(self.$model.focusedField, to: self.$focusedField)
|
||||
.navigationTitle("Synchronized focus")
|
||||
}
|
||||
}
|
||||
|
||||
private class FeatureModel: ObservableObject {
|
||||
enum Field: String {
|
||||
case username
|
||||
case password
|
||||
}
|
||||
|
||||
@Published var focusedField: Field? = .username
|
||||
@Published var password: String = ""
|
||||
@Published var username: String = ""
|
||||
|
||||
func signInButtonTapped() {
|
||||
if self.username.isEmpty {
|
||||
self.focusedField = .username
|
||||
} else if self.password.isEmpty {
|
||||
self.focusedField = .password
|
||||
} else {
|
||||
self.focusedField = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SynchronizedBindings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SynchronizedBindings()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
private let readMe = """
|
||||
This demonstrates to use the IfLet view to unwrap a binding of an optional into a binding of \
|
||||
an honest value.
|
||||
|
||||
Tap the "Edit" button to put the form into edit mode. Then you can make changes to the message \
|
||||
and either commit the changes by tapping "Save", or discard the changes by tapping "Discard".
|
||||
"""
|
||||
|
||||
struct IfLetCaseStudy: View {
|
||||
@State var string: String = "Hello"
|
||||
@State var editableString: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(readMe)
|
||||
}
|
||||
IfLet(self.$editableString) { $string in
|
||||
TextField("Edit string", text: $string)
|
||||
HStack {
|
||||
Button("Discard") {
|
||||
self.editableString = nil
|
||||
}
|
||||
Button("Save") {
|
||||
self.string = string
|
||||
self.editableString = nil
|
||||
}
|
||||
}
|
||||
} else: {
|
||||
Text("\(self.string)")
|
||||
Button("Edit") {
|
||||
self.editableString = self.string
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IfLetCaseStudy_EditStringView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
IfLetCaseStudy()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import CasePaths
|
||||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
private let readMe = """
|
||||
This demonstrates to use the IfCaseLet view to destructure a binding of an enum into a binding \
|
||||
of one of its cases.
|
||||
|
||||
Tap the "Edit" button to put the form into edit mode. Then you can make changes to the message \
|
||||
and either commit the changes by tapping "Save", or discard the changes by tapping "Discard".
|
||||
"""
|
||||
|
||||
struct IfCaseLetCaseStudy: View {
|
||||
@State var string: String = "Hello"
|
||||
@State var editableString: EditableString = .inactive
|
||||
|
||||
enum EditableString {
|
||||
case active(String)
|
||||
case inactive
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(readMe)
|
||||
}
|
||||
IfCaseLet(self.$editableString, pattern: /EditableString.active) { $string in
|
||||
TextField("Edit string", text: $string)
|
||||
HStack {
|
||||
Button("Discard") {
|
||||
self.editableString = .inactive
|
||||
}
|
||||
Button("Save") {
|
||||
self.string = string
|
||||
self.editableString = .inactive
|
||||
}
|
||||
}
|
||||
} else: {
|
||||
Text("\(self.string)")
|
||||
Button("Edit") {
|
||||
self.editableString = .active(self.string)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IfCaseLetCaseStudy_EditStringView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
IfCaseLetCaseStudy()
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import SwiftUI
|
||||
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()
|
||||
|
@ -15,7 +15,6 @@ struct RootView: View {
|
|||
} header: {
|
||||
Text("Alerts and confirmation dialogs")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Optional sheets") {
|
||||
|
@ -32,11 +31,17 @@ struct RootView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Optional destinations") {
|
||||
NavigationStack {
|
||||
NavigationDestinations()
|
||||
}
|
||||
.navigationTitle("Navigation stack")
|
||||
}
|
||||
NavigationLink("Optional navigation links") {
|
||||
OptionalNavigationLinks()
|
||||
}
|
||||
NavigationLink("List of navigation links") {
|
||||
ListOfNavigationLinks(viewModel: .init())
|
||||
ListOfNavigationLinks(model: ListOfNavigationLinksModel())
|
||||
}
|
||||
} header: {
|
||||
Text("Navigation links")
|
||||
|
@ -49,6 +54,15 @@ struct RootView: View {
|
|||
NavigationLink("Custom components") {
|
||||
CustomComponents()
|
||||
}
|
||||
NavigationLink("Synchronized bindings") {
|
||||
SynchronizedBindings()
|
||||
}
|
||||
NavigationLink("IfLet view") {
|
||||
IfLetCaseStudy()
|
||||
}
|
||||
NavigationLink("IfCaseLet view") {
|
||||
IfCaseLetCaseStudy()
|
||||
}
|
||||
} header: {
|
||||
Text("Advanced")
|
||||
}
|
||||
|
|
|
@ -22,12 +22,16 @@
|
|||
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 /* 09-CustomComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA47383D272F0F9B0012CAC3 /* 09-CustomComponents.swift */; };
|
||||
CA70FED7274B1907005A0D53 /* 07-NavigationLinkList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA70FED6274B1907005A0D53 /* 07-NavigationLinkList.swift */; };
|
||||
CABE9FC1272F2C0000AFC150 /* 08-Routing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABE9FC0272F2C0000AFC150 /* 08-Routing.swift */; };
|
||||
CA47383E272F0F9B0012CAC3 /* 10-CustomComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA47383D272F0F9B0012CAC3 /* 10-CustomComponents.swift */; };
|
||||
CA70FED7274B1907005A0D53 /* 08-NavigationLinkList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA70FED6274B1907005A0D53 /* 08-NavigationLinkList.swift */; };
|
||||
CA93236B292BE733004B1130 /* 13-IfCaseLet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA93236A292BE733004B1130 /* 13-IfCaseLet.swift */; };
|
||||
CAAC0072292BDE660083F2FF /* 12-IfLet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAAC0071292BDE660083F2FF /* 12-IfLet.swift */; };
|
||||
CABE9FC1272F2C0000AFC150 /* 09-Routing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABE9FC0272F2C0000AFC150 /* 09-Routing.swift */; };
|
||||
DC609AD6291D76150052647F /* 06-NavigationDestinations.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC609AD5291D76150052647F /* 06-NavigationDestinations.swift */; };
|
||||
DC6A8411291F227400B3F6C9 /* 11-SynchronizedBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6A8410291F227400B3F6C9 /* 11-SynchronizedBindings.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 */; };
|
||||
DCD4E68B274180F500CDF3BD /* 07-NavigationLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD4E68A274180F500CDF3BD /* 07-NavigationLinks.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
@ -47,12 +51,16 @@
|
|||
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 /* 09-CustomComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "09-CustomComponents.swift"; sourceTree = "<group>"; };
|
||||
CA70FED6274B1907005A0D53 /* 07-NavigationLinkList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "07-NavigationLinkList.swift"; sourceTree = "<group>"; };
|
||||
CABE9FC0272F2C0000AFC150 /* 08-Routing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "08-Routing.swift"; sourceTree = "<group>"; };
|
||||
CA47383D272F0F9B0012CAC3 /* 10-CustomComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "10-CustomComponents.swift"; sourceTree = "<group>"; };
|
||||
CA70FED6274B1907005A0D53 /* 08-NavigationLinkList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "08-NavigationLinkList.swift"; sourceTree = "<group>"; };
|
||||
CA93236A292BE733004B1130 /* 13-IfCaseLet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "13-IfCaseLet.swift"; sourceTree = "<group>"; };
|
||||
CAAC0071292BDE660083F2FF /* 12-IfLet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "12-IfLet.swift"; sourceTree = "<group>"; };
|
||||
CABE9FC0272F2C0000AFC150 /* 09-Routing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "09-Routing.swift"; sourceTree = "<group>"; };
|
||||
DC609AD5291D76150052647F /* 06-NavigationDestinations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "06-NavigationDestinations.swift"; sourceTree = "<group>"; };
|
||||
DC6A8410291F227400B3F6C9 /* 11-SynchronizedBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "11-SynchronizedBindings.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>"; };
|
||||
DCD4E68A274180F500CDF3BD /* 07-NavigationLinks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "07-NavigationLinks.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -124,10 +132,14 @@
|
|||
CA473832272F0D860012CAC3 /* 03-Sheets.swift */,
|
||||
DCD4E686273B30DA00CDF3BD /* 04-Popovers.swift */,
|
||||
DCD4E684273B300F00CDF3BD /* 05-FullScreenCovers.swift */,
|
||||
DCD4E68A274180F500CDF3BD /* 06-NavigationLinks.swift */,
|
||||
CA70FED6274B1907005A0D53 /* 07-NavigationLinkList.swift */,
|
||||
CABE9FC0272F2C0000AFC150 /* 08-Routing.swift */,
|
||||
CA47383D272F0F9B0012CAC3 /* 09-CustomComponents.swift */,
|
||||
DC609AD5291D76150052647F /* 06-NavigationDestinations.swift */,
|
||||
DCD4E68A274180F500CDF3BD /* 07-NavigationLinks.swift */,
|
||||
CA70FED6274B1907005A0D53 /* 08-NavigationLinkList.swift */,
|
||||
CABE9FC0272F2C0000AFC150 /* 09-Routing.swift */,
|
||||
CA47383D272F0F9B0012CAC3 /* 10-CustomComponents.swift */,
|
||||
DC6A8410291F227400B3F6C9 /* 11-SynchronizedBindings.swift */,
|
||||
CAAC0071292BDE660083F2FF /* 12-IfLet.swift */,
|
||||
CA93236A292BE733004B1130 /* 13-IfCaseLet.swift */,
|
||||
CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */,
|
||||
CA473831272F0D860012CAC3 /* FactClient.swift */,
|
||||
CA47382E272F0D860012CAC3 /* RootView.swift */,
|
||||
|
@ -257,17 +269,21 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CABE9FC1272F2C0000AFC150 /* 08-Routing.swift in Sources */,
|
||||
CABE9FC1272F2C0000AFC150 /* 09-Routing.swift in Sources */,
|
||||
DC6A8411291F227400B3F6C9 /* 11-SynchronizedBindings.swift in Sources */,
|
||||
CA473837272F0D860012CAC3 /* FactClient.swift in Sources */,
|
||||
CA473835272F0D860012CAC3 /* 02-ConfirmationDialogs.swift in Sources */,
|
||||
CA47383E272F0F9B0012CAC3 /* 09-CustomComponents.swift in Sources */,
|
||||
CA70FED7274B1907005A0D53 /* 07-NavigationLinkList.swift in Sources */,
|
||||
CA47383E272F0F9B0012CAC3 /* 10-CustomComponents.swift in Sources */,
|
||||
CA70FED7274B1907005A0D53 /* 08-NavigationLinkList.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 */,
|
||||
DCD4E68B274180F500CDF3BD /* 07-NavigationLinks.swift in Sources */,
|
||||
CAAC0072292BDE660083F2FF /* 12-IfLet.swift in Sources */,
|
||||
DC609AD6291D76150052647F /* 06-NavigationDestinations.swift in Sources */,
|
||||
CA93236B292BE733004B1130 /* 13-IfCaseLet.swift in Sources */,
|
||||
CA473838272F0D860012CAC3 /* 03-Sheets.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -406,6 +422,7 @@
|
|||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -435,6 +452,7 @@
|
|||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -464,6 +482,7 @@
|
|||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -494,6 +513,7 @@
|
|||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
"repositoryURL": "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "d226d167bd4a68b51e352af5655c92bce8ee0463",
|
||||
"version": "0.7.0"
|
||||
"revision": "bb436421f57269fbcfe7360735985321585a86e5",
|
||||
"version": "0.10.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -19,6 +19,24 @@
|
|||
"version": "1.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-custom-dump",
|
||||
"repositoryURL": "https://github.com/pointfreeco/swift-custom-dump",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "819d9d370cd721c9d87671e29d947279292e4541",
|
||||
"version": "0.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftDocCPlugin",
|
||||
"repositoryURL": "https://github.com/apple/swift-docc-plugin",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-identified-collections",
|
||||
"repositoryURL": "https://github.com/pointfreeco/swift-identified-collections.git",
|
||||
|
@ -27,6 +45,15 @@
|
|||
"revision": "680bf440178a78a627b1c2c64c0855f6523ad5b9",
|
||||
"version": "0.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "xctest-dynamic-overlay",
|
||||
"repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "16e6409ee82e1b81390bdffbf217b9c08ab32784",
|
||||
"version": "0.5.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1410"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA4737C7272F095F0012CAC3"
|
||||
BuildableName = "Inventory.app"
|
||||
BlueprintName = "Inventory"
|
||||
ReferencedContainer = "container:Examples.xcodeproj">
|
||||
</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">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA4737C7272F095F0012CAC3"
|
||||
BuildableName = "Inventory.app"
|
||||
BlueprintName = "Inventory"
|
||||
ReferencedContainer = "container:Examples.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CA4737C7272F095F0012CAC3"
|
||||
BuildableName = "Inventory.app"
|
||||
BlueprintName = "Inventory"
|
||||
ReferencedContainer = "container:Examples.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -1,47 +1,72 @@
|
|||
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)
|
||||
)
|
||||
)
|
||||
let model = AppModel(
|
||||
inventoryModel: InventoryModel(
|
||||
inventory: [
|
||||
ItemRowModel(
|
||||
item: Item(color: .red, name: "Keyboard", status: .inStock(quantity: 100))
|
||||
),
|
||||
ItemRowModel(
|
||||
item: Item(color: .blue, name: "Mouse", status: .inStock(quantity: 200))
|
||||
),
|
||||
ItemRowModel(
|
||||
item: Item(color: .green, name: "Monitor", status: .inStock(quantity: 20))
|
||||
),
|
||||
ItemRowModel(
|
||||
item: Item(color: .yellow, name: "Chair", status: .outOfStock(isOnBackOrder: true))
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
TabView(selection: self.$viewModel.selectedTab) {
|
||||
NavigationView {
|
||||
InventoryView(viewModel: self.viewModel.inventoryViewModel)
|
||||
.tag(AppViewModel.Tab.inventory)
|
||||
AppView(model: self.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppModel: ObservableObject {
|
||||
@Published var inventoryModel: InventoryModel
|
||||
@Published var selectedTab: Tab
|
||||
|
||||
init(
|
||||
inventoryModel: InventoryModel,
|
||||
selectedTab: Tab = .first
|
||||
) {
|
||||
self.inventoryModel = inventoryModel
|
||||
self.selectedTab = selectedTab
|
||||
}
|
||||
|
||||
enum Tab {
|
||||
case first
|
||||
case inventory
|
||||
}
|
||||
}
|
||||
|
||||
struct AppView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: self.$model.selectedTab) {
|
||||
Button {
|
||||
self.model.selectedTab = .inventory
|
||||
} label: {
|
||||
Text("Go to inventory tab")
|
||||
}
|
||||
.tag(AppModel.Tab.first)
|
||||
.tabItem {
|
||||
Label("Inventory", systemImage: "building.2")
|
||||
Label("First", systemImage: "arrow.forward")
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
InventoryView(model: self.model.inventoryModel)
|
||||
}
|
||||
.tag(AppModel.Tab.inventory)
|
||||
.tabItem {
|
||||
Label("Inventory", systemImage: "list.clipboard.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import IdentifiedCollections
|
||||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
class InventoryViewModel: ObservableObject {
|
||||
@Published var inventory: IdentifiedArrayOf<ItemRowViewModel>
|
||||
@Published var route: Route?
|
||||
class InventoryModel: ObservableObject {
|
||||
@Published var inventory: IdentifiedArrayOf<ItemRowModel> {
|
||||
didSet { self.bind() }
|
||||
}
|
||||
@Published var destination: Destination?
|
||||
|
||||
enum Route: Equatable {
|
||||
enum Destination: Equatable {
|
||||
case add(Item)
|
||||
case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route)
|
||||
case edit(Item)
|
||||
}
|
||||
|
||||
init(
|
||||
inventory: IdentifiedArrayOf<ItemRowViewModel> = [],
|
||||
route: Route? = nil
|
||||
inventory: IdentifiedArrayOf<ItemRowModel> = [],
|
||||
destination: Destination? = nil
|
||||
) {
|
||||
self.inventory = []
|
||||
self.route = route
|
||||
|
||||
for itemRowViewModel in inventory {
|
||||
self.bind(itemRowViewModel: itemRowViewModel)
|
||||
}
|
||||
self.inventory = inventory
|
||||
self.destination = destination
|
||||
self.bind()
|
||||
}
|
||||
|
||||
func delete(item: Item) {
|
||||
|
@ -30,88 +30,99 @@ class InventoryViewModel: ObservableObject {
|
|||
|
||||
func add(item: Item) {
|
||||
withAnimation {
|
||||
self.bind(itemRowViewModel: .init(item: item))
|
||||
self.route = nil
|
||||
self.inventory.append(ItemRowModel(item: item))
|
||||
self.destination = 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"
|
||||
}
|
||||
}
|
||||
self.destination = .add(Item(color: nil, name: "", status: .inStock(quantity: 1)))
|
||||
}
|
||||
|
||||
func cancelButtonTapped() {
|
||||
self.route = nil
|
||||
self.destination = nil
|
||||
}
|
||||
|
||||
private func bind(itemRowViewModel: ItemRowViewModel) {
|
||||
itemRowViewModel.onDelete = { [weak self, item = itemRowViewModel.item] in
|
||||
func cancelEditButtonTapped() {
|
||||
self.destination = nil
|
||||
}
|
||||
|
||||
func commitEdit(item: Item) {
|
||||
self.inventory[id: item.id]?.item = item
|
||||
self.destination = nil
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
for itemRowModel in self.inventory {
|
||||
itemRowModel.onDelete = { [weak self, weak itemRowModel] in
|
||||
guard let self, let itemRowModel else { return }
|
||||
withAnimation {
|
||||
self?.delete(item: item)
|
||||
self.delete(item: itemRowModel.item)
|
||||
}
|
||||
}
|
||||
|
||||
itemRowViewModel.onDuplicate = { [weak self] item in
|
||||
itemRowModel.onDuplicate = { [weak self] item in
|
||||
guard let self else { return }
|
||||
withAnimation {
|
||||
self?.add(item: item)
|
||||
self.add(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
itemRowViewModel.$route
|
||||
.map { [id = itemRowViewModel.id] route in
|
||||
route.map { Route.row(id: id, route: $0) }
|
||||
itemRowModel.onTap = { [weak self, weak itemRowModel] in
|
||||
guard let self, let itemRowModel else { return }
|
||||
self.destination = .edit(itemRowModel.item)
|
||||
}
|
||||
.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
|
||||
@ObservedObject var model: InventoryModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(
|
||||
self.viewModel.inventory,
|
||||
content: ItemRowView.init(viewModel:)
|
||||
self.model.inventory,
|
||||
content: ItemRowView.init(model:)
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Add") { self.viewModel.addButtonTapped() }
|
||||
Button("Add") { self.model.addButtonTapped() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Inventory")
|
||||
.sheet(unwrapping: self.$viewModel.route, case: /InventoryViewModel.Route.add) { $itemToAdd in
|
||||
NavigationView {
|
||||
.navigationDestination(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /InventoryModel.Destination.edit
|
||||
) { $item in
|
||||
ItemView(item: $item)
|
||||
.navigationBarTitle("Edit")
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
self.model.cancelEditButtonTapped()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Save") {
|
||||
self.model.commitEdit(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /InventoryModel.Destination.add
|
||||
) { $itemToAdd in
|
||||
NavigationStack {
|
||||
ItemView(item: $itemToAdd)
|
||||
.navigationTitle("Add")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { self.viewModel.cancelButtonTapped() }
|
||||
Button("Cancel") { self.model.cancelButtonTapped() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Save") { self.viewModel.add(item: itemToAdd) }
|
||||
Button("Save") { self.model.add(item: itemToAdd) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -121,21 +132,27 @@ struct InventoryView: View {
|
|||
|
||||
struct InventoryView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100))
|
||||
let keyboard = Item(color: .blue, name: "Keyboard", status: .inStock(quantity: 100))
|
||||
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
InventoryView(
|
||||
viewModel: .init(
|
||||
model: InventoryModel(
|
||||
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(
|
||||
ItemRowModel(
|
||||
item: keyboard
|
||||
),
|
||||
ItemRowModel(
|
||||
item: Item(color: .yellow, name: "Charger", status: .inStock(quantity: 20))
|
||||
),
|
||||
ItemRowModel(
|
||||
item: Item(color: .green, name: "Phone", status: .outOfStock(isOnBackOrder: true))
|
||||
),
|
||||
ItemRowModel(
|
||||
item: Item(
|
||||
name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false))),
|
||||
],
|
||||
route: nil
|
||||
color: .green, name: "Headphones", status: .outOfStock(isOnBackOrder: false)
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
struct Item: Equatable, Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var color: Color?
|
||||
var name: String
|
||||
var status: Status
|
||||
|
||||
enum Status: Equatable {
|
||||
|
@ -39,7 +40,7 @@ struct Item: Equatable, Identifiable {
|
|||
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)
|
||||
SwiftUI.Color(red: self.red, green: self.green, blue: self.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -90,10 +91,10 @@ struct ItemView: View {
|
|||
}
|
||||
|
||||
struct ItemView_Previews: PreviewProvider, View {
|
||||
@State var item = Item(name: "", color: nil, status: .inStock(quantity: 1))
|
||||
@State var item = Item(color: nil, name: "", status: .inStock(quantity: 1))
|
||||
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ItemView_Previews()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,91 +1,88 @@
|
|||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
import XCTestDynamicOverlay
|
||||
|
||||
class ItemRowViewModel: Identifiable, ObservableObject {
|
||||
class ItemRowModel: Identifiable, ObservableObject {
|
||||
@Published var item: Item
|
||||
@Published var route: Route?
|
||||
@Published var destination: Destination?
|
||||
|
||||
enum Route: Equatable {
|
||||
case deleteAlert
|
||||
enum Destination: Equatable {
|
||||
case alert(AlertState<AlertAction>)
|
||||
case duplicate(Item)
|
||||
case edit(Item)
|
||||
}
|
||||
|
||||
var onDelete: () -> Void = {}
|
||||
var onDuplicate: (Item) -> Void = { _ in }
|
||||
enum AlertAction {
|
||||
case deleteConfirmation
|
||||
}
|
||||
|
||||
var onDelete: () -> Void = unimplemented("ItemRowModel.onDelete")
|
||||
var onDuplicate: (Item) -> Void = unimplemented("ItemRowModel.onDuplicate")
|
||||
var onTap: () -> Void = unimplemented("ItemRowModel.onTap")
|
||||
|
||||
var id: Item.ID { self.item.id }
|
||||
|
||||
init(
|
||||
item: Item
|
||||
) {
|
||||
init(item: Item) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
func deleteButtonTapped() {
|
||||
self.route = .deleteAlert
|
||||
self.destination = .alert(
|
||||
AlertState {
|
||||
TextState(self.item.name)
|
||||
} actions: {
|
||||
ButtonState(role: .destructive, action: .send(.deleteConfirmation, animation: .default)) {
|
||||
TextState("Delete")
|
||||
}
|
||||
} message: {
|
||||
TextState("Are you sure you want to delete this item?")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func deleteConfirmationButtonTapped() {
|
||||
func alertButtonTapped(_ action: AlertAction) {
|
||||
switch action {
|
||||
case .deleteConfirmation:
|
||||
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
|
||||
self.destination = nil
|
||||
}
|
||||
|
||||
func duplicateButtonTapped() {
|
||||
self.route = .duplicate(self.item.duplicate())
|
||||
self.destination = .duplicate(self.item.duplicate())
|
||||
}
|
||||
|
||||
func duplicate(item: Item) {
|
||||
self.onDuplicate(item)
|
||||
self.route = nil
|
||||
self.destination = nil
|
||||
}
|
||||
|
||||
func rowTapped() {
|
||||
self.onTap()
|
||||
}
|
||||
}
|
||||
|
||||
extension Item {
|
||||
func duplicate() -> Self {
|
||||
.init(name: self.name, color: self.color, status: self.status)
|
||||
Self(color: self.color, name: self.name, status: self.status)
|
||||
}
|
||||
}
|
||||
|
||||
struct ItemRowView: View {
|
||||
@ObservedObject var viewModel: ItemRowViewModel
|
||||
@ObservedObject var model: ItemRowModel
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(unwrapping: self.$viewModel.route, case: /ItemRowViewModel.Route.edit) {
|
||||
self.viewModel.setEditNavigation(isActive: $0)
|
||||
} destination: { $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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
self.model.rowTapped()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(self.viewModel.item.name)
|
||||
Text(self.model.item.name)
|
||||
.font(.title3)
|
||||
|
||||
switch self.viewModel.item.status {
|
||||
switch self.model.item.status {
|
||||
case let .inStock(quantity):
|
||||
Text("In stock: \(quantity)")
|
||||
case let .outOfStock(isOnBackOrder):
|
||||
|
@ -95,54 +92,46 @@ struct ItemRowView: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
if let color = self.viewModel.item.color {
|
||||
if let color = self.model.item.color {
|
||||
Rectangle()
|
||||
.frame(width: 30, height: 30)
|
||||
.foregroundColor(color.swiftUIColor)
|
||||
.border(Color.black, width: 1)
|
||||
}
|
||||
|
||||
Button(action: { self.viewModel.duplicateButtonTapped() }) {
|
||||
Button(action: { self.model.duplicateButtonTapped() }) {
|
||||
Image(systemName: "square.fill.on.square.fill")
|
||||
}
|
||||
.padding(.leading)
|
||||
|
||||
Button(action: { self.viewModel.deleteButtonTapped() }) {
|
||||
Button(action: { self.model.deleteButtonTapped() }) {
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.padding(.leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(self.viewModel.item.status.isInStock ? nil : Color.gray)
|
||||
.foregroundColor(self.model.item.status.isInStock ? nil : Color.gray)
|
||||
.alert(
|
||||
title: { Text(self.viewModel.item.name) },
|
||||
unwrapping: self.$viewModel.route,
|
||||
case: /ItemRowViewModel.Route.deleteAlert,
|
||||
actions: {
|
||||
Button("Delete", role: .destructive) {
|
||||
self.viewModel.deleteConfirmationButtonTapped()
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text("Are you sure you want to delete this item?")
|
||||
}
|
||||
unwrapping: self.$model.destination,
|
||||
case: /ItemRowModel.Destination.alert,
|
||||
action: self.model.alertButtonTapped
|
||||
)
|
||||
.popover(
|
||||
unwrapping: self.$viewModel.route,
|
||||
case: /ItemRowViewModel.Route.duplicate
|
||||
unwrapping: self.$model.destination,
|
||||
case: /ItemRowModel.Destination.duplicate
|
||||
) { $item in
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ItemView(item: $item)
|
||||
.navigationBarTitle("Duplicate")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
self.viewModel.cancelButtonTapped()
|
||||
self.model.cancelButtonTapped()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Add") {
|
||||
self.viewModel.duplicate(item: item)
|
||||
self.model.duplicate(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,24 @@
|
|||
"version": "0.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-custom-dump",
|
||||
"repositoryURL": "https://github.com/pointfreeco/swift-custom-dump",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "819d9d370cd721c9d87671e29d947279292e4541",
|
||||
"version": "0.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftDocCPlugin",
|
||||
"repositoryURL": "https://github.com/apple/swift-docc-plugin",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "xctest-dynamic-overlay",
|
||||
"repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
|
|
|
@ -17,14 +17,25 @@ let package = Package(
|
|||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.10.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.6.0"),
|
||||
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.5.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SwiftUINavigation",
|
||||
name: "_SwiftUINavigationState",
|
||||
dependencies: [
|
||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||
.product(name: "CustomDump", package: "swift-custom-dump"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "SwiftUINavigation",
|
||||
dependencies: [
|
||||
"_SwiftUINavigationState",
|
||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||
.product(name: "CustomDump", package: "swift-custom-dump"),
|
||||
.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"),
|
||||
]
|
||||
),
|
||||
|
|
335
README.md
335
README.md
|
@ -6,302 +6,64 @@
|
|||
|
||||
Tools for making SwiftUI navigation simpler, more ergonomic and more precise.
|
||||
|
||||
* [Motivation](#motivation)
|
||||
* [Tools](#tools)
|
||||
* [Navigation overloads](#navigation-api-overloads)
|
||||
* [Navigation views](#navigation-views)
|
||||
* [Binding transformations](#binding-transformations)
|
||||
* [Overview](#overview)
|
||||
* [Examples](#examples)
|
||||
* [Learn more](#learn-more)
|
||||
* [Installation](#installation)
|
||||
* [Documentation](#documentation)
|
||||
* [License](#license)
|
||||
|
||||
## Motivation
|
||||
## Overview
|
||||
|
||||
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:
|
||||
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 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.
|
||||
* "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 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.
|
||||
|
||||
* "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.
|
||||
|
||||
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.
|
||||
Unfortunately, SwiftUI does not ship with all of the tools necessary to model our domains with
|
||||
enums and make use of navigation APIs. This library bridges that gap by providing APIs that allow
|
||||
you to model your navigation destinations as an enum, and then drive navigation by a binding
|
||||
to that enum.
|
||||
|
||||
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:
|
||||
Explore all of the tools this library comes with by checking out the [documentation][docs], and
|
||||
reading these articles:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var draft: Post?
|
||||
* **[What is navigation?][what-is-article]**:
|
||||
Learn how one can think of navigation as a domain modeling problem, and how that leads to the
|
||||
creation of concise and testable APIs for navigation.
|
||||
|
||||
var body: some View {
|
||||
Button("Edit") {
|
||||
self.draft = Post()
|
||||
}
|
||||
.sheet(item: self.$draft) { (draft: Post) in
|
||||
EditPostView(post: draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
* **[Navigation links and destinations][nav-links-dests-article]**:
|
||||
Learn how to drive navigation in NavigationView and NavigationStack in a concise and testable
|
||||
manner.
|
||||
|
||||
struct EditPostView: View {
|
||||
let post: Post
|
||||
var body: some View { ... }
|
||||
}
|
||||
```
|
||||
* **[Sheets, popovers, and covers][sheets-popovers-covers-article]**:
|
||||
Learn how to present sheets, popovers and covers in a concise and testable manner.
|
||||
|
||||
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`.
|
||||
* **[Alerts and dialogs][alerts-dialogs-article]**:
|
||||
Learn how to present alerts and confirmation dialogs in a concise and testable manner.
|
||||
|
||||
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 {
|
||||
@State var draft: Post?
|
||||
@State var settings: Settings?
|
||||
@State 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 state 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 represent 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 Swift’s 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 it 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-paths) 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 a 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) { isActive in
|
||||
self.route = isActive ? .edit(post) : nil
|
||||
} destination: { $post in
|
||||
EditPostView(post: $post)
|
||||
} 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.
|
||||
* **[Bindings][bindings]**:
|
||||
Learn how to manage certain view state, such as `@FocusState` directly in your observable object.
|
||||
|
||||
## 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:
|
||||
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
|
||||
|
@ -309,11 +71,13 @@ This repo comes with lots of examples to demonstrate how to solve common and com
|
|||
* 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.
|
||||
* [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).
|
||||
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).
|
||||
|
||||
|
@ -327,7 +91,8 @@ You can add SwiftUI Navigation to an Xcode project by adding it as a package dep
|
|||
|
||||
> 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`:
|
||||
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: [
|
||||
|
@ -337,8 +102,18 @@ dependencies: [
|
|||
|
||||
## Documentation
|
||||
|
||||
The latest documentation for the SwiftUI Navigation APIs is available [here](https://pointfreeco.github.io/swiftui-navigation/).
|
||||
The latest documentation for the SwiftUI Navigation APIs is available [here](http://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/).
|
||||
|
||||
## License
|
||||
|
||||
This library is released under the MIT license. See [LICENSE](LICENSE) for details.
|
||||
|
||||
[NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s
|
||||
[TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:)
|
||||
[case-paths-gh]: https://github.com/pointfreeco/swift-case-paths
|
||||
[what-is-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/whatisnavigation
|
||||
[nav-links-dests-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/navigation
|
||||
[sheets-popovers-covers-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/sheetspopoverscovers
|
||||
[alerts-dialogs-article]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/alertsdialogs
|
||||
[bindings]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/bindings
|
||||
[docs]: https://pointfreeco.github.io/swiftui-navigation/main/documentation/swiftuinavigation/
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
#if compiler(>=5.5)
|
||||
extension View {
|
||||
/// Presents an alert from a binding to optional alert state.
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Presents an alert from a binding to an optional value.
|
||||
///
|
||||
/// 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
|
||||
|
@ -67,17 +68,19 @@
|
|||
)
|
||||
}
|
||||
|
||||
/// Presents an alert from a binding to an optional enum, and a case path to a specific case.
|
||||
/// Presents an alert from a binding to an optional enum, and a [case path][case-paths-gh] to a
|
||||
/// specific case.
|
||||
///
|
||||
/// A version of `alert(unwrapping:)` that works with enum state.
|
||||
///
|
||||
/// [case-paths-gh]: http://github.com/pointfreeco/swift-case-paths
|
||||
///
|
||||
/// - 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
|
||||
/// 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.
|
||||
|
@ -98,5 +101,116 @@
|
|||
message: message
|
||||
)
|
||||
}
|
||||
|
||||
#if swift(>=5.7)
|
||||
/// Presents an alert from a binding to optional ``AlertState``.
|
||||
///
|
||||
/// See <doc:AlertsDialogs> for more information on how to use this API.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - 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 used 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, and the action is fed to the `action` closure.
|
||||
/// - action: A closure that is called with an action from a particular alert button when
|
||||
/// tapped.
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func alert<Value>(
|
||||
unwrapping value: Binding<AlertState<Value>?>,
|
||||
action: @escaping (Value) -> Void = { (_: Never) in fatalError() }
|
||||
) -> some View {
|
||||
self.alert(
|
||||
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
|
||||
isPresented: value.isPresent(),
|
||||
presenting: value.wrappedValue,
|
||||
actions: {
|
||||
ForEach($0.buttons) {
|
||||
Button($0, action: action)
|
||||
}
|
||||
#endif
|
||||
},
|
||||
message: { $0.message.map { Text($0) } }
|
||||
)
|
||||
}
|
||||
|
||||
/// Presents an alert from a binding to an optional enum, and a [case path][case-paths-gh] to a
|
||||
/// specific case of ``AlertState``.
|
||||
///
|
||||
/// A version of `alert(unwrapping:)` that works with enum state. See <doc:AlertsDialogs> for
|
||||
/// more information on how to use this API.
|
||||
///
|
||||
/// [case-paths-gh]: http://github.com/pointfreeco/swift-case-paths
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - 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 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, and the action is fed to the `action` closure.
|
||||
/// - casePath: A case path that identifies a particular case that holds alert state.
|
||||
/// - action: A closure that is called with an action from a particular alert button when
|
||||
/// tapped.
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func alert<Enum, Value>(
|
||||
unwrapping `enum`: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, AlertState<Value>>,
|
||||
action: @escaping (Value) -> Void = { (_: Never) in fatalError() }
|
||||
) -> some View {
|
||||
self.alert(unwrapping: `enum`.case(casePath), action: action)
|
||||
}
|
||||
#else
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func alert<Value>(
|
||||
unwrapping value: Binding<AlertState<Value>?>,
|
||||
action: @escaping (Value) -> Void
|
||||
) -> some View {
|
||||
self.alert(
|
||||
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
|
||||
isPresented: value.isPresent(),
|
||||
presenting: value.wrappedValue,
|
||||
actions: {
|
||||
ForEach($0.buttons) {
|
||||
Button($0, action: action)
|
||||
}
|
||||
},
|
||||
message: { $0.message.map { Text($0) } }
|
||||
)
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func alert(
|
||||
unwrapping value: Binding<AlertState<Never>?>
|
||||
) -> some View {
|
||||
self.alert(
|
||||
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
|
||||
isPresented: value.isPresent(),
|
||||
presenting: value.wrappedValue,
|
||||
actions: {
|
||||
ForEach($0.buttons) {
|
||||
Button($0, action: { (_: Never) in fatalError() })
|
||||
}
|
||||
},
|
||||
message: { $0.message.map { Text($0) } }
|
||||
)
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func alert<Enum, Value>(
|
||||
unwrapping `enum`: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, AlertState<Value>>,
|
||||
action: @escaping (Value) -> Void
|
||||
) -> some View {
|
||||
self.alert(unwrapping: `enum`.case(casePath), action: action)
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func alert<Enum>(
|
||||
unwrapping `enum`: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, AlertState<Never>>
|
||||
) -> some View {
|
||||
self.alert(unwrapping: `enum`.case(casePath), action: { (_: Never) in fatalError() })
|
||||
}
|
||||
#endif
|
||||
|
||||
// TODO: support iOS <15?
|
||||
}
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Synchronizes model state to view state via two-way bindings.
|
||||
///
|
||||
/// SwiftUI comes with many property wrappers that can be used in views to drive view state, like
|
||||
/// field focus. Unfortunately, these property wrappers _must_ be used in views. It's not possible
|
||||
/// to extract this logic to an observable object and integrate it with the rest of the model's
|
||||
/// business logic, and be in a better position to test this state.
|
||||
///
|
||||
/// We can work around these limitations by introducing a published field to your observable
|
||||
/// object and synchronizing it to view state with this view modifier.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - modelValue: A binding from model state. _E.g._, a binding derived from a published field
|
||||
/// on an observable object.
|
||||
/// - viewValue: A binding from view state. _E.g._, a focus binding.
|
||||
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
|
||||
public func bind<ModelValue: _Bindable, ViewValue: _Bindable>(
|
||||
_ modelValue: ModelValue, to viewValue: ViewValue
|
||||
) -> some View
|
||||
where ModelValue.Value == ViewValue.Value, ModelValue.Value: Equatable {
|
||||
self.modifier(_Bind(modelValue: modelValue, viewValue: viewValue))
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
|
||||
private struct _Bind<ModelValue: _Bindable, ViewValue: _Bindable>: ViewModifier
|
||||
where ModelValue.Value == ViewValue.Value, ModelValue.Value: Equatable {
|
||||
let modelValue: ModelValue
|
||||
let viewValue: ViewValue
|
||||
|
||||
@State var hasAppeared = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear {
|
||||
guard !self.hasAppeared else { return }
|
||||
self.hasAppeared = true
|
||||
guard self.viewValue.wrappedValue != self.modelValue.wrappedValue else { return }
|
||||
self.viewValue.wrappedValue = self.modelValue.wrappedValue
|
||||
}
|
||||
.onChange(of: self.modelValue.wrappedValue) {
|
||||
guard self.viewValue.wrappedValue != $0
|
||||
else { return }
|
||||
self.viewValue.wrappedValue = $0
|
||||
}
|
||||
.onChange(of: self.viewValue.wrappedValue) {
|
||||
guard self.modelValue.wrappedValue != $0
|
||||
else { return }
|
||||
self.modelValue.wrappedValue = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol _Bindable: DynamicProperty {
|
||||
associatedtype Value
|
||||
var wrappedValue: Value { get nonmutating set }
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension AccessibilityFocusState: _Bindable {}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension AccessibilityFocusState.Binding: _Bindable {}
|
||||
|
||||
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
|
||||
extension AppStorage: _Bindable {}
|
||||
|
||||
extension Binding: _Bindable {}
|
||||
|
||||
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
|
||||
extension FocusedBinding: _Bindable {}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension FocusState: _Bindable {}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension FocusState.Binding: _Bindable {}
|
||||
|
||||
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
|
||||
extension SceneStorage: _Bindable {}
|
||||
|
||||
extension State: _Bindable {}
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
extension Binding {
|
||||
/// Creates a binding by projecting the base value to an unwrapped value.
|
||||
///
|
||||
|
@ -37,6 +39,7 @@ extension Binding {
|
|||
return `case`
|
||||
},
|
||||
set: {
|
||||
guard casePath.extract(from: `enum`.wrappedValue) != nil else { return }
|
||||
`case` = $0
|
||||
`enum`.transaction($1).wrappedValue = casePath.embed($0)
|
||||
}
|
||||
|
@ -56,6 +59,7 @@ extension Binding {
|
|||
.init(
|
||||
get: { self.wrappedValue.flatMap(casePath.extract(from:)) },
|
||||
set: { newValue, transaction in
|
||||
guard self.wrappedValue != nil else { return }
|
||||
self.transaction(transaction).wrappedValue = newValue.map(casePath.embed)
|
||||
}
|
||||
)
|
||||
|
@ -88,35 +92,35 @@ extension Binding {
|
|||
/// 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.
|
||||
/// For example, a view may model all of its presentations in a single destination 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 destination enum into a boolean binding using
|
||||
/// `isPresent`, so that it can be passed to various presentation APIs.
|
||||
///
|
||||
/// ```swift
|
||||
/// enum Route {
|
||||
/// enum Destination {
|
||||
/// case deleteAlert
|
||||
/// ...
|
||||
/// }
|
||||
///
|
||||
/// struct ProductView: View {
|
||||
/// @State var route: Route?
|
||||
/// @State var destination: Destination?
|
||||
/// @State var product: Product
|
||||
///
|
||||
/// var body: some View {
|
||||
/// Button("Delete") {
|
||||
/// self.viewModel.route = .deleteAlert
|
||||
/// self.model.destination = .deleteAlert
|
||||
/// }
|
||||
/// // SwiftUI's vanilla alert modifier
|
||||
/// .alert(
|
||||
/// self.product.name
|
||||
/// isPresented: self.$viewModel.route.isPresent(/Route.deleteAlert),
|
||||
/// isPresented: self.$model.destination.isPresent(/Destination.deleteAlert),
|
||||
/// actions: {
|
||||
/// Button("Delete", role: .destructive) {
|
||||
/// self.viewModel.deleteConfirmationButtonTapped()
|
||||
/// self.model.deleteConfirmationButtonTapped()
|
||||
/// }
|
||||
/// },
|
||||
/// message: {
|
||||
|
@ -168,3 +172,19 @@ extension Binding where Value: Equatable {
|
|||
self.removeDuplicates(by: ==)
|
||||
}
|
||||
}
|
||||
|
||||
extension Binding {
|
||||
public func _printChanges(_ prefix: String = "") -> Self {
|
||||
Self(
|
||||
get: { self.wrappedValue },
|
||||
set: { newValue, transaction in
|
||||
var oldDescription = ""
|
||||
debugPrint(self.wrappedValue, terminator: "", to: &oldDescription)
|
||||
var newDescription = ""
|
||||
debugPrint(newValue, terminator: "", to: &newDescription)
|
||||
print("\(prefix.isEmpty ? "\(Self.self)" : prefix):", oldDescription, "=", newDescription)
|
||||
self.transaction(transaction).wrappedValue = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
#if compiler(>=5.5)
|
||||
extension View {
|
||||
/// Presents a confirmation dialog from a binding to optional dialog state.
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Presents a confirmation dialog from a binding to an optional value.
|
||||
///
|
||||
/// 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
|
||||
|
@ -42,6 +43,8 @@
|
|||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// See <doc:AlertsDialogs> for more information on how to use this API.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: A closure returning the dialog's title given the current dialog state.
|
||||
/// - titleVisibility: The visibility of the dialog's title.
|
||||
|
@ -74,7 +77,8 @@
|
|||
/// 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.
|
||||
/// A version of `confirmationDialog(unwrapping:)` that works with enum state. See
|
||||
/// <doc:AlertsDialogs> for more information on how to use this API.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: A closure returning the dialog's title given the current dialog case.
|
||||
|
@ -105,5 +109,115 @@
|
|||
message: message
|
||||
)
|
||||
}
|
||||
|
||||
#if swift(>=5.7)
|
||||
/// Presents a confirmation dialog from a binding to optional ``ConfirmationDialogState``.
|
||||
///
|
||||
/// See <doc:AlertsDialogs> for more information on how to use this API.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: A binding to an optional value that determines whether a confirmation dialog should
|
||||
/// be presented. When the binding is updated with non-`nil` value, it is unwrapped and used
|
||||
/// 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, and the action is fed to the `action` closure.
|
||||
/// - action: A closure that is called with an action from a particular dialog button when
|
||||
/// tapped.
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func confirmationDialog<Value>(
|
||||
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
|
||||
action: @escaping (Value) -> Void = { (_: Never) in fatalError() }
|
||||
) -> some View {
|
||||
self.confirmationDialog(
|
||||
value.wrappedValue.flatMap { Text($0.title) } ?? Text(""),
|
||||
isPresented: value.isPresent(),
|
||||
titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic,
|
||||
presenting: value.wrappedValue,
|
||||
actions: {
|
||||
ForEach($0.buttons) {
|
||||
Button($0, action: action)
|
||||
}
|
||||
#endif
|
||||
},
|
||||
message: { $0.message.map { Text($0) } }
|
||||
)
|
||||
}
|
||||
|
||||
/// Presents a confirmation dialog from a binding to an optional enum, and a case path to a
|
||||
/// specific case of ``ConfirmationDialogState``.
|
||||
///
|
||||
/// A version of `confirmationDialog(unwrapping:)` that works with enum state. See
|
||||
/// <doc:AlertsDialogs> for more information on how to use this API.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - 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 use it to populate the fields of an 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, and the action is fed to the `action` closure.
|
||||
/// - casePath: A case path that identifies a particular case that holds dialog state.
|
||||
/// - action: A closure that is called with an action from a particular dialog button when
|
||||
/// tapped.
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func confirmationDialog<Enum, Value>(
|
||||
unwrapping `enum`: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
|
||||
action: @escaping (Value) -> Void = { (_: Never) in fatalError() }
|
||||
) -> some View {
|
||||
self.confirmationDialog(
|
||||
unwrapping: `enum`.case(casePath),
|
||||
action: action
|
||||
)
|
||||
}
|
||||
#else
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func confirmationDialog<Value>(
|
||||
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
|
||||
action: @escaping (Value) -> Void
|
||||
) -> some View {
|
||||
self.confirmationDialog(
|
||||
value.wrappedValue.flatMap { Text($0.title) } ?? Text(""),
|
||||
isPresented: value.isPresent(),
|
||||
titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic,
|
||||
presenting: value.wrappedValue,
|
||||
actions: {
|
||||
ForEach($0.buttons) {
|
||||
Button($0, action: action)
|
||||
}
|
||||
},
|
||||
message: { $0.message.map { Text($0) } }
|
||||
)
|
||||
}
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func confirmationDialog(
|
||||
unwrapping value: Binding<ConfirmationDialogState<Never>?>
|
||||
) -> some View {
|
||||
self.confirmationDialog(
|
||||
unwrapping: value,
|
||||
action: { (_: Never) in fatalError() }
|
||||
)
|
||||
}
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func confirmationDialog<Enum, Value>(
|
||||
unwrapping `enum`: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
|
||||
action: @escaping (Value) -> Void
|
||||
) -> some View {
|
||||
self.confirmationDialog(
|
||||
unwrapping: `enum`.case(casePath),
|
||||
action: action
|
||||
)
|
||||
}
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func confirmationDialog<Enum>(
|
||||
unwrapping `enum`: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, ConfirmationDialogState<Never>>
|
||||
) -> some View {
|
||||
self.confirmationDialog(
|
||||
unwrapping: `enum`.case(casePath),
|
||||
action: { (_: Never) in fatalError() }
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// TODO: support iOS <15?
|
||||
}
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
# Alerts and dialogs
|
||||
|
||||
Learn how to present alerts and confirmation dialogs in a concise and testable manner.
|
||||
|
||||
## Overview
|
||||
|
||||
The library comes with new tools for driving alerts and confirmation dialogs from optional and enum
|
||||
state, and makes them more testable.
|
||||
|
||||
### Alerts
|
||||
|
||||
Suppose you have a feature for deleting something in your application and you want to show an alert
|
||||
for the user to confirm the deletion. You can do this by holding onto an optional `AlertState` in
|
||||
your model, as well as an enum that describes every action that can happen in the alert:
|
||||
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
var alert: AlertState<AlertAction>?
|
||||
enum AlertAction {
|
||||
case deletionConfirmed
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Then, when you need to show an alert you can hydate the alert state with a title, message and
|
||||
buttons:
|
||||
|
||||
```swift
|
||||
func deleteButtonTapped() {
|
||||
self.alert = AlertState {
|
||||
TextState("Are you sure?")
|
||||
} actions: {
|
||||
ButtonState("Delete", action: .send(.delete))
|
||||
ButtonState("Nevermind", role: .cancel)
|
||||
} message: {
|
||||
TextState("Deleting this item cannot be undone.")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The type `TextState` is closely related to `Text` from SwiftUI, but plays more nicely with
|
||||
equatability. This makes it possible to write tests against these values.
|
||||
|
||||
> Tip: The `actions` closure is a result builder, which allows you to insert small bits of logic:
|
||||
> ```swift
|
||||
> } actions: {
|
||||
> if item.isLocked {
|
||||
> ButtonState("Unlock and delete", action: .send(.unlockAndDelete))
|
||||
> } else {
|
||||
> ButtonState("Delete", action: .send(.delete))
|
||||
> }
|
||||
> ButtonState("Nevermind", role: .cancel)
|
||||
> }
|
||||
> ```
|
||||
|
||||
Next you can provide an endpoint that will be called when the alert is interacted with:
|
||||
|
||||
```swift
|
||||
func alertButtonTapped(_ action: AlertAction) {
|
||||
switch action {
|
||||
case .deletionConfirmed:
|
||||
// NB: Perform deletion logic here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Finally, you can use a new, overloaded `.alert` view modifier for showing the alert when this state
|
||||
becomes non-`nil`:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@ObservedObject var model: FeatureModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.alert(
|
||||
unwrapping: self.$model.alert,
|
||||
action: self.alertButtonTapped
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By having all of the alert's state in your feature's model, you instantly unlock the ability to test
|
||||
it:
|
||||
|
||||
```swift
|
||||
func testDelete() {
|
||||
let model = FeatureModel(…)
|
||||
|
||||
model.deleteButtonTapped()
|
||||
XCTAssertEqual(model.alert?.title, TextState("Are you sure?"))
|
||||
|
||||
model.alertButtonTapped(.deletionConfirmation)
|
||||
// NB: Assert that deletion actually occurred.
|
||||
}
|
||||
```
|
||||
|
||||
This works because all of the types for describing an alert are `Equatable`, including `AlertState`,
|
||||
`TextState`, and even the buttons.
|
||||
|
||||
Sometimes it is not optimal to model the alert as an optional. In particular, if a feature can
|
||||
navigate to multiple, mutually exclusive screens, then an enum is more appropriate.
|
||||
|
||||
In such a case
|
||||
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
var destination: Destination?
|
||||
enum Destination {
|
||||
case alert(AlertState<AlertAction>)
|
||||
// NB: Other destinations
|
||||
}
|
||||
enum AlertAction {
|
||||
case deletionConfirmed
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
With this kind of set up you can use an alternative `alert` view modifier that takes an additional
|
||||
argument for specifying which case of the enum drives the presentation of the alert:
|
||||
|
||||
```swift
|
||||
.alert(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /Destination.alert,
|
||||
action: self.alertButtonTapped
|
||||
)
|
||||
```
|
||||
|
||||
Note that the `case` argument is specified via a concept known as "case paths", which are like
|
||||
key paths except tuned specifically for enums and cases rather than structs and properties. See
|
||||
<doc:WhatIsNavigation> for more information.
|
||||
|
||||
### Confirmation dialogs
|
||||
|
||||
The APIs for driving confirmation dialogs from optional and enum state look nearly identical to that
|
||||
of alerts.
|
||||
|
||||
For example, the model for a delete confirmation could look like this:
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
var dialog: ConfirmationDialogState<DialogAction>?
|
||||
enum DialogAction {
|
||||
case deletionConfirmed
|
||||
}
|
||||
|
||||
func deleteButtonTapped() {
|
||||
self.dialog = ConfirmationDialogState(
|
||||
title: TextState("Are you sure?"),
|
||||
titleVisibility: .visible,
|
||||
message: TextState("Deleting this item cannot be undone."),
|
||||
buttons: [
|
||||
.destructive(TextState("Delete"), action: .send(.delete)),
|
||||
.cancel(TextState("Nevermind")),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func dialogButtonTapped(_ action: DialogAction) {
|
||||
switch action {
|
||||
case .deletionConfirmed:
|
||||
// NB: Perform deletion logic here
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And then the view would look like this:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@ObservedObject var model: FeatureModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.confirmationDialog(
|
||||
unwrapping: self.$model.dialog,
|
||||
action: self.dialogButtonTapped
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,63 @@
|
|||
# Bindings
|
||||
|
||||
Learn how to manage certain view state, such as `@FocusState` directly in your observable object.
|
||||
|
||||
## Overview
|
||||
|
||||
SwiftUI comes with many property wrappers that can be used in views to drive view state, such as
|
||||
`@FocusState`. Unfortunately, these property wrappers _must_ be used in views. It's not possible
|
||||
to extract this logic to an observable object and integrate it with the rest of the model's
|
||||
business logic, and be in a better position to test this state.
|
||||
|
||||
We can work around these limitations by introducing a published field to your observable
|
||||
object and synchronizing it to view state with the `bind` view modifier that ships with this
|
||||
library.
|
||||
|
||||
For example, suppose you have a sign in flow where if the API request to sign in fails, you want
|
||||
to refocus the email field. The model can be implement like so:
|
||||
|
||||
```swift
|
||||
class SignInModel: ObservableObject {
|
||||
@Published var email: String
|
||||
@Published var password: String
|
||||
@Published var focus: Field?
|
||||
enum Field { case email, password }
|
||||
|
||||
func signInButtonTapped() async {
|
||||
do {
|
||||
try await self.apiClient.signIn(self.email, self.password)
|
||||
} catch {
|
||||
self.focus = .email
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice that we store the focus as a `@Published` property in the model rather than `@FocusState`.
|
||||
This is because `@FocusState` only works when installed directly in a view. It cannot be used in
|
||||
an observable object.
|
||||
|
||||
You can implement the view as you would normally, except you must also us `@FocusState` for the
|
||||
focus _and_ use the `bind` helper to make sure that changes to the model's focus are replayed to
|
||||
the view, and vice versa.
|
||||
|
||||
```swift
|
||||
struct SignInView: View {
|
||||
@FocusState var focus: SignInModel.Field?
|
||||
@ObservedObject var model: SignInModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Email", text: self.$model.email)
|
||||
TextField("Password", text: self.$model.password)
|
||||
Button("Sign in") {
|
||||
Task {
|
||||
await self.model.signInButtonTapped()
|
||||
}
|
||||
}
|
||||
}
|
||||
// ⬇️ Replays changes of `model.focus` to `focus` and vice-versa.
|
||||
.bind(self.$model.focus, to: self.$focus)
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,164 @@
|
|||
# Destructuring views
|
||||
|
||||
Learn how to use ``IfLet``, ``IfCaseLet`` and ``Switch`` views in order to destructure bindings into
|
||||
smaller parts.
|
||||
|
||||
## Overview
|
||||
|
||||
Often our views can hold bindings of optional and enum state, and we will want to derive a binding
|
||||
to its underlying wrapped value or a particular case. SwiftUI does not come with tools to do this,
|
||||
but this library has a few views for accomplishing this.
|
||||
|
||||
### IfLet
|
||||
|
||||
The ``IfLet`` view allows one to derive a binding of an honest value from a binding of an optional
|
||||
value. For example, suppose you had an interface that could editing a single piece of text in the
|
||||
UI, and further those changes can be either saved or discarded.
|
||||
|
||||
Using ``IfLet`` you can model the state of being in editing mode as an optional string:
|
||||
|
||||
```swift
|
||||
struct EditView: View {
|
||||
@State var string: String = ""
|
||||
@State var editableString: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
IfLet(self.$editableString) { $string in
|
||||
TextField("Edit string", text: $string)
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
self.editableString = nil
|
||||
}
|
||||
Button("Save") {
|
||||
self.string = string
|
||||
self.editableString = nil
|
||||
}
|
||||
}
|
||||
} else: {
|
||||
Text("\(self.string)")
|
||||
Button("Edit") {
|
||||
self.editableString = self.string
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the most optimal way to model this domain. Without the ability to deriving a
|
||||
`Binding<String>` from a `Binding<String?>` we would have had to hold onto extra state to represent
|
||||
whether or not we are in editing mode:
|
||||
|
||||
```swift
|
||||
struct EditView: View {
|
||||
@State var string: String = ""
|
||||
@State var editableString: String
|
||||
@State var isEditing = false
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This is non-optimal because we have to make sure to clean up `editableString` before or after
|
||||
showing the editable `TextField`. If we forget to do that we can introduce bugs into our
|
||||
application, such as showing the _previous_ editing string when entering edit mode.
|
||||
|
||||
### IfCaseLet
|
||||
|
||||
The ``IfCaseLet`` view is similar to ``IfLet`` (see [above](#IfLet)), except it can derive a binding
|
||||
to a particular case of an enum.
|
||||
|
||||
For example, using the sample code from [above](#IfLet), what if you didn't want to use an optional
|
||||
string for `editableState`, but instead use a custom enum so that you can describe the two states
|
||||
more clearly:
|
||||
|
||||
```swift
|
||||
enum EditableString {
|
||||
case active(String)
|
||||
case inactive
|
||||
}
|
||||
```
|
||||
|
||||
You cannot use ``IfLet`` with this because it's an enum, but you can use ``IfCaseLet``:
|
||||
|
||||
```swift
|
||||
struct EditView: View {
|
||||
@State var string: String = ""
|
||||
@State var editableString: EditableString = .inactive
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
IfCaseLet(self.$editableString, pattern: /EditableString.active) { $string in
|
||||
TextField("Edit string", text: $string)
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
self.editableString = nil
|
||||
}
|
||||
Button("Save") {
|
||||
self.string = string
|
||||
self.editableString = nil
|
||||
}
|
||||
}
|
||||
} else: {
|
||||
Text("\(self.string)")
|
||||
Button("Edit") {
|
||||
self.editableString = self.string
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The "pattern" for the ``IfCaseLet`` is expressed by what is known as a "[case path][case-paths-gh]".
|
||||
A case path is like a key path, except it is specifically tuned for abstracting over the
|
||||
shape of enums rather than structs. A key path abstractly bundles up the functionality of getting
|
||||
and setting a property on a struct, whereas a case path bundles up the functionality of "extracting"
|
||||
a value from an enum and "embedding" a value into an enum. They are an indispensible tool for
|
||||
transforming bindings.
|
||||
|
||||
### Switch and CaseLet
|
||||
|
||||
The ``Switch`` and ``CaseLet`` generalize the ``IfLet`` and ``IfCaseLet`` views, allowing you to
|
||||
destructure a binding of an enum into bindings of each case, and provides some runtime exhaustivity
|
||||
checking.
|
||||
|
||||
For example, a warehousing application may model the status of an inventory item using an enum
|
||||
with cases that distinguish in-stock and out-of-stock statuses. ``Switch`` and ``CaseLet`` 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 {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In debug builds, exhaustivity is handled at runtime: if the `Switch` encounters an
|
||||
unhandled case, and no ``Default`` view is present, a runtime warning is issued and a warning
|
||||
view is presented.
|
||||
|
||||
[case-paths-gh]: http://github.com/pointfreeco/swift-case-paths
|
|
@ -0,0 +1,115 @@
|
|||
# Navigation links and destinations
|
||||
|
||||
Learn how to drive navigation in `NavigationView` and `NavigationStack` in a concise and testable
|
||||
manner.
|
||||
|
||||
## Overview
|
||||
|
||||
The library comes with new tools for driving drill-down navigation with optional and enum state.
|
||||
This includes new initializers on `NavigationLink` and new overloads of the `navigationDestination`
|
||||
view modifier.
|
||||
|
||||
Suppose your view or model holds a piece of optional state that represents whether or not a
|
||||
drill-down should occur:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var destination: Int?
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Further suppose that the screen being navigated to wants a binding to the integer when it is
|
||||
non-`nil`. You can construct a `NavigationLink` that will activate when that state becomes
|
||||
non-`nil`, and will deactivate when the state becomes `nil`:
|
||||
|
||||
```swift
|
||||
NavigationLink(unwrapping: self.$destination) { isActive in
|
||||
self.destination = isActive ? 42 : nil
|
||||
} destination: { $number in
|
||||
CounterView(number: $number)
|
||||
} label: {
|
||||
Text("Go to counter")
|
||||
}
|
||||
```
|
||||
|
||||
The first trailing closure is the "action" of the navigation link. It is invoked with `true` when
|
||||
the user taps on the link, and it is invoked with `false` when the user taps the back button or
|
||||
swipes on the left edge of the screen. It is your job to hydrate the state in the action closure.
|
||||
|
||||
The second trailing closure, labeled `destination`, takes an argument that is the binding of the
|
||||
unwrapped state. This binding can be handed to the child view, and any changes made by the parent
|
||||
will be reflected in the child, and vice-versa.
|
||||
|
||||
For iOS 16+ you can use the `navigationDestination` overload:
|
||||
|
||||
```swift
|
||||
Button {
|
||||
self.destination = 42
|
||||
} label: {
|
||||
Text("Go to counter")
|
||||
}
|
||||
.navigationDestination(
|
||||
unwrapping: self.$model.destination
|
||||
) { $item in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
```
|
||||
|
||||
Sometimes it is not optimal to model navigation destinations as optionals. In particular, if a
|
||||
feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate.
|
||||
|
||||
Suppose that in addition to be able to drill down to a counter view that one can also open a
|
||||
sheet with some text. We can model those destinations as an enum:
|
||||
|
||||
```swift
|
||||
enum Destination {
|
||||
case counter(Int)
|
||||
case text(String)
|
||||
}
|
||||
```
|
||||
|
||||
And we can hold an optional destination in state to represent whether or not we are navigated to
|
||||
one of these destinations:
|
||||
|
||||
```swift
|
||||
@State var destination: Destination?
|
||||
```
|
||||
|
||||
With this set up you can make use of the `init(unwrapping:case:)` initializer on `NavigationLink`
|
||||
in order to specify a binding to the optional destination, and further specify which case of the
|
||||
enum you want driving navigation:
|
||||
|
||||
```swift
|
||||
NavigationLink(
|
||||
unwrapping: self.$destination,
|
||||
case: /Destination.counter
|
||||
) { isActive in
|
||||
self.destination = isActive ? .counter(42) : nil
|
||||
} destination: { $number in
|
||||
CounterView(number: $number)
|
||||
} label: {
|
||||
Text("Go to counter")
|
||||
}
|
||||
```
|
||||
|
||||
And similarly for `navigationDestination`:
|
||||
|
||||
```swift
|
||||
Button {
|
||||
self.destination = .counter(42)
|
||||
} label: {
|
||||
Text("Go to counter")
|
||||
}
|
||||
.navigationDestination(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /Destination.counter
|
||||
) { $item in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
```
|
||||
|
||||
Note that the `case` argument is specified via a concept known as "case paths", which are like
|
||||
key paths except tuned specifically for enums and cases rather than structs and properties. See
|
||||
<doc:WhatIsNavigation> for more information.
|
|
@ -0,0 +1,162 @@
|
|||
# Sheets, popovers, and covers
|
||||
|
||||
Learn how to present sheets, popovers and covers in a concise and testable manner.
|
||||
|
||||
## Overview
|
||||
|
||||
The library comes with new tools for driving sheets, popovers and covers from optional and enum
|
||||
state.
|
||||
|
||||
* [Sheets](#Sheets)
|
||||
* [Popovers](#Popovers)
|
||||
* [Covers](#Covers)
|
||||
|
||||
### Sheets
|
||||
|
||||
Suppose your view or model holds a piece of optional state that represents whether or not a modal
|
||||
sheet is presented:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var destination: Int?
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Further suppose that the screen being presented wants a binding to the integer when it is non-`nil`.
|
||||
You can use the `sheet(unwrapping:)` view modifier that comes with the library:
|
||||
|
||||
```swift
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.sheet(unwrapping: self.$destination) { $number in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the trailing closure is handed a binding to the unwrapped state. This binding can be
|
||||
handed to the child view, and any changes made by the parent will be reflected in the child, and
|
||||
vice-versa.
|
||||
|
||||
Sometimes it is not optimal to model presentation destinations as optionals. In particular, if a
|
||||
feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate.
|
||||
|
||||
There is an additional overload of the `sheet` for this situation. If you model your destinations
|
||||
as an enum:
|
||||
|
||||
```swift
|
||||
@State var destination: Destination?
|
||||
|
||||
enum Destination {
|
||||
var counter(Int)
|
||||
// More destinations
|
||||
}
|
||||
```
|
||||
|
||||
Then you can show a sheet from the `counter` case with the following:
|
||||
|
||||
```swift
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.sheet(
|
||||
unwrapping: self.$destination,
|
||||
case: /Destination.counter
|
||||
) { $number in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Popovers
|
||||
|
||||
Popovers work similarly to covers. If the popover's state is represented as an optional you can do
|
||||
the following:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var destination: Int?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.popover(unwrapping: self.$destination) { $number in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And if the popover state is represented as an enum, then you can do the following:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var destination: Destination?
|
||||
enum Destination {
|
||||
case counter(Int)
|
||||
// More destinations
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.popover(
|
||||
unwrapping: self.$destination,
|
||||
case: /Destination.counter
|
||||
) { $number in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Covers
|
||||
|
||||
Full screen covers work similarly to covers and sheets. If the cover's state is represented as an
|
||||
optional you can do the following:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var destination: Int?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.fullscreenCover(unwrapping: self.$destination) { $number in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And if the cover's' state is represented as an enum, then you can do the following:
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State var destination: Destination?
|
||||
enum Destination {
|
||||
case counter(Int)
|
||||
// More destinations
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// ...
|
||||
}
|
||||
.fullscreenCover(
|
||||
unwrapping: self.$destination,
|
||||
case: /Destination.counter
|
||||
) { $number in
|
||||
CounterView(number: $number)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,300 @@
|
|||
# What is navigation?
|
||||
|
||||
Learn how one can think of navigation as a domain modeling problem, and how that leads to the
|
||||
creation of concise and testable APIs for navigation.
|
||||
|
||||
## Overview
|
||||
|
||||
We will define navigation as a "mode" change in an application. The most prototypical example of
|
||||
this in SwiftUI are navigation stacks and links. A user taps a button, and a right-to-left
|
||||
animation transitions you from the current screen to the next screen.
|
||||
|
||||
But there are more examples of navigation beyond that one example. Modal sheets can be thought of
|
||||
as navigation too. They slide from bottom-to-top and transition you from the current screen to a
|
||||
new screen. Full screen covers and popovers are also an example of navigation, as they are very
|
||||
similar to sheets except they either take over the full screen (i.e. covers) or only partially
|
||||
take over the screen (i.e. popovers).
|
||||
|
||||
Even alerts and confirmation dialogs can be thought of navigation as they take full control over
|
||||
the interface and force you to make a selection. It's also possible for you to define your own
|
||||
notions of navigation, such as bottom sheets, toasts, and more.
|
||||
|
||||
## State-driven navigation
|
||||
|
||||
All of these seemingly disparate examples of navigation can be unified under a single API. The
|
||||
presentation and dismissal of a screen can be described with an optional piece of state. When the
|
||||
state changes from `nil` to non-`nil` the screen will be presented, whether that be via a
|
||||
drill-down, modal, popover, etc. And when the state changes from non-`nil` to `nil` the screen will
|
||||
be dismissed.
|
||||
|
||||
Driving navigation from state like this can be incredibly powerful:
|
||||
|
||||
* It guarantees that your model will always be in sync with the visual representation of the UI.
|
||||
It shouldn't be possible for a piece of state to be non-`nil` and not have the corresponding view
|
||||
present.
|
||||
* It easily enables deep linking capabilities. If all forms of navigation in your application are
|
||||
driven off of state, then you can instantly open your application into any state imaginable by
|
||||
simply constructing a piece of state, handing it to SwiftUI, and letting it do its thing.
|
||||
* It also allows you to write unit tests for navigation logic without resorting to UI tests, which
|
||||
can be slow, flakey and introduce instability into your test suite. If you write a unit test that
|
||||
shows when a user performs an action that a piece of state went from `nil` to non-`nil`, then you
|
||||
can be assured that the user would be navigated to the next screen.
|
||||
|
||||
So, this is why state-driven navigation is so great. So, what tools does SwiftUI gives us to embrace
|
||||
this pattern?
|
||||
|
||||
## SwiftUI's tools for navigation
|
||||
|
||||
Many of SwiftUI's navigation tools are driven off of optional state, but sadly not all.
|
||||
|
||||
The simplest example is modal sheets. A simple API is provided that takes a binding of an optional
|
||||
item, and when that item flips to a non-`nil` value it is handed to a content closure to produce
|
||||
a view, and that view is what is animated from bottom-to-top:
|
||||
|
||||
```swift
|
||||
func sheet<Item: Identifiable, Content: View>(
|
||||
item: Binding<Item?>,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
content: @escaping (Item) -> Content
|
||||
) -> some View
|
||||
```
|
||||
|
||||
When SwiftUI detects the binding flips back to `nil`, the sheet will automatically be dismissed.
|
||||
|
||||
For example, suppose you have a list of items, and when one is tapped you want to bring up a modal
|
||||
sheet for editing the item:
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
@Published var editingItem: Item?
|
||||
func tapped(item: Item) {
|
||||
self.editingItem = item
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
struct FeatureView: View {
|
||||
@ObservedObject var model: FeatureModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(self.model.items) { item in
|
||||
Button(item.name) {
|
||||
self.model.tapped(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: self.$model.editingItem) { item in
|
||||
EditItemView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This works really great. When the button is tapped, the `tapped(item:)` method is called on the
|
||||
model causing the `editingItem` state to be hydrated, and then SwiftUI sees that value is no longer
|
||||
`nil` and so causes the sheet to be presented.
|
||||
|
||||
A lot of SwiftUI's navigation APIs follow this pattern. For example, here's the signatures for
|
||||
showing popovers and full screen covers:
|
||||
|
||||
```swift
|
||||
func popover<Item, Content>(
|
||||
item: Binding<Item?>,
|
||||
attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds),
|
||||
arrowEdge: Edge = .top,
|
||||
content: @escaping (Item) -> Content
|
||||
) -> some View where Item : Identifiable, Content : View
|
||||
|
||||
func fullScreenCover<Item, Content>(
|
||||
item: Binding<Item?>,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
content: @escaping (Item) -> Content
|
||||
) -> some View where Item : Identifiable, Content : View
|
||||
```
|
||||
|
||||
Both take a binding of an optional and a content closure for transforming the non-`nil` state into
|
||||
a view that is presented in the popover or cover.
|
||||
|
||||
There are, however, two potential problems with these APIs.
|
||||
|
||||
First, the argument passed to the `content` closure is the plain, non-`nil` value. This means the
|
||||
sheet view presented is handed a plain, inert value, and if that view wants to make mutations it
|
||||
will need to find a way to communicate that back to the parent. However, two-way communication
|
||||
is already a solved problem in SwiftUI with bindings.
|
||||
|
||||
So, it might be better if the `sheet(item:content:)` API handed a binding to the unwrapped item so
|
||||
that any mutations in the sheet would be instantly observable by the parent:
|
||||
|
||||
```swift
|
||||
.sheet(item: self.$model.editingItem) { $item in
|
||||
EditItemView(item: $item)
|
||||
}
|
||||
```
|
||||
|
||||
However, this is not the API exposed to us from SwiftUI.
|
||||
|
||||
The second problem is that while optional state is a great way to drive navigation, it doesn't
|
||||
scale to multiple navigation destinations.
|
||||
|
||||
For example, suppose that in addition to being able to edit an item, the feature can also add an
|
||||
item and duplicate an item, and you can navigate to a help screen. That can technically be
|
||||
represented as four optionals:
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
@Published var addItem: Item?
|
||||
@Published var duplicateItem: Item?
|
||||
@Published var editingItem: Item?
|
||||
@Published var help: Help?
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
But this is not the most concise way to model this domain. Four optional values means there are
|
||||
`2⁴=16` different states this feature can be in, but only 5 of those states are valid. Either all
|
||||
can be `nil`, representing we are not navigated anywhere, or at most one can be non-`nil`,
|
||||
representing navigation to a single screen.
|
||||
|
||||
But it is not valid to have 2, 3 or 4 non-`nil` values. That would represent multiple screens
|
||||
being simultaneously navigated to, such as two sheets being presented, which is invalid in SwiftUI
|
||||
and can even cause crashes.
|
||||
|
||||
This is showing that four optional values is not the best way to represent 4 navigation
|
||||
destinations. Instead, it is more concise to model the 4 destinations as an enum with a case for
|
||||
each destination, and then hold onto a single optional value to represent which destination
|
||||
is currently active:
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
@Published var destination: Destination?
|
||||
// ...
|
||||
|
||||
enum Destination {
|
||||
case add(Item)
|
||||
case duplicate(Item)
|
||||
case edit(Item)
|
||||
case help(Help)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to prove that at most one destination can be active at a time. It is impossible
|
||||
to have both an "add" and "duplicate" screen presented at the same time.
|
||||
|
||||
But sadly SwiftUI does not come with the tools necessary to drive navigation off of an optional
|
||||
enum. This is what motivated the creation of this library. It should be possible to represent
|
||||
all of the screens a feature can navigate to as an enum, and then drive sheets, popovers, covers
|
||||
and more from a particular case of that enum.
|
||||
|
||||
## SwiftUINavigation's tools
|
||||
|
||||
The tools that ship with this library aim to solve the problems discussed above, and more. There are
|
||||
new APIs for sheets, popovers, covers, alerts, confirmation dialogs _and_ navigation links that
|
||||
allow you to model destinations as an enum and drive navigation by a particular case of the enum.
|
||||
|
||||
All of the APIs for these seemingly disparate forms of navigation are unified by a single pattern.
|
||||
You first specify a binding to the optional enum driving navigation, and then you specify the case
|
||||
of the enum that you want to isolate.
|
||||
|
||||
For example, the new sheet API now takes a binding to an optional enum, and something known as a
|
||||
[`CasePath`][case-paths-gh]:
|
||||
|
||||
```swift
|
||||
func sheet<Enum, Case, Content>(
|
||||
unwrapping: Binding<Enum?>,
|
||||
case: CasePath<Enum, Case>,
|
||||
content: @escaping (Binding<Case>) -> Content
|
||||
) -> some View where Content : View
|
||||
```
|
||||
|
||||
This allows you to drive the presentation and dismiss of a sheet from a particular case of an enum.
|
||||
|
||||
In order to isolate a specific case of an enum we must make use of our [CasePaths][case-paths-gh]
|
||||
library. A case path is like a key path, except it is specifically tuned for abstracting over the
|
||||
shape of enums rather than structs. A key path abstractly bundles up the functionality of getting
|
||||
and setting a property on a struct, whereas a case path bundles up the functionality of "extracting"
|
||||
a value from an enum and "embedding" a value into an enum. They are an indispensible tool for
|
||||
transforming bindings.
|
||||
|
||||
Similar APIs are defined for popovers, covers, and more.
|
||||
|
||||
For example, consider a feature model that has 3 different destinations that can be navigated to:
|
||||
|
||||
```swift
|
||||
class FeatureModel: ObservableObject {
|
||||
@Published var destination: Destination?
|
||||
// ...
|
||||
|
||||
enum Destination {
|
||||
case add(Item)
|
||||
case duplicate(Item)
|
||||
case edit(Item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Suppose we want the `add` destination to be shown in a sheet, the `duplicate` destination to be
|
||||
shown in a popover, and the `edit` destination in a drill-down. We can do so easily using the APIs
|
||||
that ship with this library:
|
||||
|
||||
```swift
|
||||
.popover(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /FeatureModel.Destination.duplicate
|
||||
) { $item in
|
||||
DuplicateItemView(item: $item)
|
||||
}
|
||||
.sheet(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /FeatureModel.Destination.add
|
||||
) { $item in
|
||||
AddItemView(item: $item)
|
||||
}
|
||||
.navigationDestination(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /FeatureModel.Destination.edit
|
||||
) { $item in
|
||||
EditItemView(item: $item)
|
||||
}
|
||||
```
|
||||
|
||||
Even though all 3 forms of navigation are visually quite different, describing how to present them
|
||||
is very consistent. You simply provide the binding to the optional enum held in the model, and then
|
||||
you construct a case path for a particular case, which can be done by prefixing the case with a
|
||||
forward slash.
|
||||
|
||||
The above code uses the `navigationDestination` view modifier, which is only available in iOS 16.
|
||||
If you must support iOS 15 and earlier, you can use the following initializer on `NavigationLink`,
|
||||
which also has a very similar API to the above:
|
||||
|
||||
```swift
|
||||
NavigationLink(
|
||||
unwrapping: self.$model.destination,
|
||||
case: /FeatureModel.Destination.edit
|
||||
) { isActive in
|
||||
self.model.setEditIsActive(isActive)
|
||||
} destination: { $item in
|
||||
EditItemView(item: $item)
|
||||
} label: {
|
||||
Text("\(item.name)")
|
||||
}
|
||||
```
|
||||
|
||||
That is the basics of using this library's APIs for driving navigation off of state. Learn more
|
||||
by reading the articles below.
|
||||
|
||||
## Topics
|
||||
|
||||
### Tools
|
||||
|
||||
Read the following articles to learn more about the tools that ship with this library for presenting
|
||||
alerts, dialogs, sheets, popovers, covers, and navigation links all from bindings of enum state.
|
||||
|
||||
- <doc:Navigation>
|
||||
- <doc:SheetsPopoversCovers>
|
||||
- <doc:AlertsDialogs>
|
||||
- <doc:DestructuringViews>
|
||||
- <doc:Bindings>
|
||||
|
||||
[case-paths-gh]: http://github.com/pointfreeco/swift-case-paths
|
|
@ -0,0 +1,60 @@
|
|||
# ``SwiftUINavigation``
|
||||
|
||||
Tools for making SwiftUI navigation simpler, more ergonomic and more precise.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [GitHub Repo](https://github.com/pointfreeco/swiftui-navigation)
|
||||
- [Discussions](https://github.com/pointfreeco/swiftui-navigation/discussions)
|
||||
- [Point-Free Videos](https://www.pointfree.co/collections/swiftui/navigation)
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
|
||||
* "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. To wield it correctly you must be able to model your domain as concisely as possible,
|
||||
and this usually means using enums.
|
||||
|
||||
Unfortunately, SwiftUI does not ship with all of the tools necessary to model our domains with
|
||||
enums and make use of navigation APIs. This library bridges that gap by providing APIs that allow
|
||||
you to model your navigation destinations as an enum, and then drive navigation by a binding
|
||||
to that enum.
|
||||
|
||||
## Topics
|
||||
|
||||
### Essentials
|
||||
|
||||
- <doc:WhatIsNavigation>
|
||||
|
||||
### Tools
|
||||
|
||||
- <doc:Navigation>
|
||||
- <doc:SheetsPopoversCovers>
|
||||
- <doc:AlertsDialogs>
|
||||
- <doc:DestructuringViews>
|
||||
- <doc:Bindings>
|
||||
|
||||
## See Also
|
||||
|
||||
The collection of videos from [Point-Free](https://www.pointfree.co) that dive deep into the
|
||||
development of the library.
|
||||
|
||||
* [Point-Free Videos](https://www.pointfree.co/collections/swiftui/navigation)
|
||||
|
||||
[NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s
|
||||
[TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:)
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Presents a full-screen cover using a binding as a data source for the sheet's content.
|
||||
///
|
||||
|
@ -35,7 +37,7 @@ extension View {
|
|||
/// - 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
|
||||
/// sheet's binding will be reflected back in the source of 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.
|
||||
|
@ -48,7 +50,10 @@ extension View {
|
|||
@ViewBuilder content: @escaping (Binding<Value>) -> Content
|
||||
) -> some View
|
||||
where Content: View {
|
||||
self.fullScreenCover(isPresented: value.isPresent(), onDismiss: onDismiss) {
|
||||
self.fullScreenCover(
|
||||
isPresented: value.isPresent(),
|
||||
onDismiss: onDismiss
|
||||
) {
|
||||
Binding(unwrapping: value).map(content)
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +68,7 @@ extension View {
|
|||
/// 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`
|
||||
/// sheet's binding will be reflected back in the source of truth. Likewise, changes 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
|
||||
|
|
|
@ -60,8 +60,8 @@ where IfContent: View, ElseContent: View {
|
|||
public init(
|
||||
_ `enum`: Binding<Enum>,
|
||||
pattern casePath: CasePath<Enum, Case>,
|
||||
@ViewBuilder ifContent: @escaping (Binding<Case>) -> IfContent,
|
||||
@ViewBuilder elseContent: () -> ElseContent
|
||||
@ViewBuilder then ifContent: @escaping (Binding<Case>) -> IfContent,
|
||||
@ViewBuilder else elseContent: () -> ElseContent
|
||||
) {
|
||||
self.casePath = casePath
|
||||
self.elseContent = elseContent()
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
/// A view that computes content by unwrapping a binding to an optional and passing a non-optional
|
||||
/// binding to its content closure.
|
||||
///
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
extension Binding {
|
||||
func didSet(_ perform: @escaping (Value) -> Void) -> Self {
|
||||
.init(
|
||||
|
|
|
@ -1,3 +1,19 @@
|
|||
import SwiftUI
|
||||
|
||||
// NB: Deprecated after 0.3.0
|
||||
|
||||
@available(*, deprecated, renamed: "init(_:pattern:then:else:)")
|
||||
extension IfCaseLet {
|
||||
public init(
|
||||
_ `enum`: Binding<Enum>,
|
||||
pattern casePath: CasePath<Enum, Case>,
|
||||
@ViewBuilder ifContent: @escaping (Binding<Case>) -> IfContent,
|
||||
@ViewBuilder elseContent: () -> ElseContent
|
||||
) {
|
||||
self.init(`enum`, pattern: casePath, then: ifContent, else: elseContent)
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Deprecated after 0.2.0
|
||||
|
||||
extension NavigationLink {
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
@_exported import CasePaths
|
||||
@_exported import SwiftUI
|
||||
@_exported import _SwiftUINavigationState
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
#if swift(>=5.7)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
extension View {
|
||||
/// Pushes a view onto a `NavigationStack` using a binding as a data source for the destination's
|
||||
/// content.
|
||||
///
|
||||
/// This is a version of SwiftUI's `navigationDestination(isPresented:)` modifier, but powered by
|
||||
/// a binding to optional state instead of a binding to a boolean. When state becomes non-`nil`,
|
||||
/// a _binding_ to the unwrapped value is passed to the destination closure.
|
||||
///
|
||||
/// ```swift
|
||||
/// struct TimelineView: View {
|
||||
/// @State var detail: Post?
|
||||
///
|
||||
/// var body: Body {
|
||||
/// Button("Compose") {
|
||||
/// self.draft = Post()
|
||||
/// }
|
||||
/// .navigationDestination(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 destination. When `value` is
|
||||
/// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. You
|
||||
/// use this binding to produce content that the system pushes to the user in a navigation
|
||||
/// stack. Changes made to the destination's binding will be reflected back in the source of
|
||||
/// truth. Likewise, changes to `value` are instantly reflected in the destination. If `value`
|
||||
/// becomes `nil`, the destination is popped.
|
||||
/// - destination: A closure returning the content of the destination.
|
||||
public func navigationDestination<Value, Destination: View>(
|
||||
unwrapping value: Binding<Value?>,
|
||||
@ViewBuilder destination: (Binding<Value>) -> Destination
|
||||
) -> some View {
|
||||
self.modifier(
|
||||
_NavigationDestination(
|
||||
isPresented: value.isPresent(),
|
||||
destination: Binding(unwrapping: value).map(destination)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Pushes a view onto a `NavigationStack` using a binding and case path as a data source for the
|
||||
/// destination's content.
|
||||
///
|
||||
/// A version of `View.navigationDestination(unwrapping:)` that works with enum state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - enum: A binding to an optional enum that holds the source of truth for the destination 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 pushes to the user in a navigation stack.
|
||||
/// Changes made to the destination's binding will be reflected back in the source of truth.
|
||||
/// Likewise, changes to `enum` at the given case are instantly reflected in the destination.
|
||||
/// If `enum` becomes `nil`, or becomes a case other than the one identified by `casePath`,
|
||||
/// the destination is popped.
|
||||
/// - casePath: A case path that identifies a case of `enum` that holds a source of truth for
|
||||
/// the destination.
|
||||
/// - destination: A closure returning the content of the destination.
|
||||
public func navigationDestination<Enum, Case, Destination: View>(
|
||||
unwrapping enum: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, Case>,
|
||||
@ViewBuilder destination: (Binding<Case>) -> Destination
|
||||
) -> some View {
|
||||
self.navigationDestination(unwrapping: `enum`.case(casePath), destination: destination)
|
||||
}
|
||||
}
|
||||
|
||||
// NB: This view modifier works around a bug in SwiftUI's built-in modifier:
|
||||
// https://gist.github.com/mbrandonw/f8b94957031160336cac6898a919cbb7#file-fb11056434-md
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
private struct _NavigationDestination<Destination: View>: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
let destination: Destination
|
||||
|
||||
@State private var isPresentedState = false
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationDestination(isPresented: self.$isPresentedState) { self.destination }
|
||||
.bind(self.$isPresented, to: self.$isPresentedState)
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
extension NavigationLink {
|
||||
/// Creates a navigation link that presents the destination view when a bound value is non-`nil`.
|
||||
///
|
||||
|
@ -69,18 +71,18 @@ extension NavigationLink {
|
|||
///
|
||||
/// ```swift
|
||||
/// struct ContentView: View {
|
||||
/// @State var route: Route?
|
||||
/// @State var destination: Destination?
|
||||
/// @State var posts: [Post]
|
||||
///
|
||||
/// enum Route {
|
||||
/// enum Destination {
|
||||
/// case edit(Post)
|
||||
/// /* other routes */
|
||||
/// /* other destinations */
|
||||
/// }
|
||||
///
|
||||
/// var body: some View {
|
||||
/// ForEach(self.posts) { post in
|
||||
/// NavigationLink(unwrapping: self.$route, case: /Route.edit) { isActive in
|
||||
/// self.route = isActive ? .edit(post) : nil
|
||||
/// NavigationLink(unwrapping: self.$destination, case: /Destination.edit) { isActive in
|
||||
/// self.destination = isActive ? .edit(post) : nil
|
||||
/// } destination: { $draft in
|
||||
/// EditPostView(post: $draft)
|
||||
/// } label: {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Presents a popover using a binding as a data source for the popover's content.
|
||||
///
|
||||
|
@ -35,7 +37,7 @@ extension View {
|
|||
/// - 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,
|
||||
/// made to the popover's binding will be reflected back in the source of 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.
|
||||
|
@ -51,7 +53,9 @@ extension View {
|
|||
@ViewBuilder content: @escaping (Binding<Value>) -> Content
|
||||
) -> some View where Content: View {
|
||||
self.popover(
|
||||
isPresented: value.isPresent(), attachmentAnchor: attachmentAnchor, arrowEdge: arrowEdge
|
||||
isPresented: value.isPresent(),
|
||||
attachmentAnchor: attachmentAnchor,
|
||||
arrowEdge: arrowEdge
|
||||
) {
|
||||
Binding(unwrapping: value).map(content)
|
||||
}
|
||||
|
@ -66,7 +70,7 @@ extension View {
|
|||
/// 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
|
||||
/// popover's binding will be reflected back in the source of truth. Likewise, changes 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
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
extension View {
|
||||
/// Presents a sheet using a binding as a data source for the sheet's content.
|
||||
///
|
||||
|
@ -35,11 +43,12 @@ extension View {
|
|||
/// - 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 the sheet's binding will be reflected back in the source of 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.
|
||||
@MainActor
|
||||
public func sheet<Value, Content>(
|
||||
unwrapping value: Binding<Value?>,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
|
@ -60,13 +69,14 @@ extension View {
|
|||
/// 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`
|
||||
/// sheet's binding will be reflected back in the source of truth. Likewise, changes 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.
|
||||
@MainActor
|
||||
public func sheet<Enum, Case, Content>(
|
||||
unwrapping enum: Binding<Enum?>,
|
||||
case casePath: CasePath<Enum, Case>,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
/// A view that can switch over a binding of enum state and exhaustively handle each case.
|
||||
///
|
||||
/// Useful for computing a view from enum state where every case should be handled (using a
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import SwiftUI
|
||||
|
||||
/// A container view that provides a binding to another view.
|
||||
///
|
||||
/// This view is most helpful for creating Xcode previews of views that require bindings.
|
||||
///
|
||||
/// For example, if you wanted to create a preview for a text field, you cannot simply introduce
|
||||
/// some `@State` to the preview since `previews` is static:
|
||||
///
|
||||
/// ```swift
|
||||
/// struct TextField_Previews: PreviewProvider {
|
||||
/// @State static var text = "" // ⚠️ @State static does not work.
|
||||
///
|
||||
/// static var previews: some View {
|
||||
/// TextField("Test", text: self.$text)
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// So, instead you can use ``WithState``:
|
||||
///
|
||||
///
|
||||
/// ```swift
|
||||
/// struct TextField_Previews: PreviewProvider {
|
||||
/// static var previews: some View {
|
||||
/// WithState(initialValue: "") { $text in
|
||||
/// TextField("Test", text: $text)
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
public struct WithState<Value, Content: View>: View {
|
||||
@State var value: Value
|
||||
@ViewBuilder let content: (Binding<Value>) -> Content
|
||||
|
||||
public init(
|
||||
initialValue value: Value,
|
||||
@ViewBuilder content: @escaping (Binding<Value>) -> Content
|
||||
) {
|
||||
self._value = State(wrappedValue: value)
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
self.content(self.$value)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,311 @@
|
|||
import CustomDump
|
||||
import SwiftUI
|
||||
|
||||
/// A data type that describes the state of an alert that can be shown to the user. The `Action`
|
||||
/// generic is the type of actions that can be sent from tapping on a button in the alert.
|
||||
///
|
||||
/// This type can be used in your application's state in order to control the presentation and
|
||||
/// actions of alerts. This API can be used to push the logic of alert presentation and actions into
|
||||
/// your model, making it easier to test, and simplifying your view layer.
|
||||
///
|
||||
/// To use this API, you first describe all of the actions that can take place in all of your
|
||||
/// alerts as an enum:
|
||||
///
|
||||
/// ```swift
|
||||
/// class HomeScreenModel: ObservableObject {
|
||||
/// enum AlertAction {
|
||||
/// case delete
|
||||
/// case removeFromHomeScreen
|
||||
/// }
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Then you hold onto optional `AlertState` as a `@Published` field in your model, which can
|
||||
/// start off as `nil`:
|
||||
///
|
||||
/// ```swift
|
||||
/// class HomeScreenModel: ObservableObject {
|
||||
/// @Published var alert: AlertState<AlertAction>?
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// And you define an endpoint for handling each alert action:
|
||||
///
|
||||
/// ```swift
|
||||
/// class HomeScreenModel: ObservableObject {
|
||||
/// // ...
|
||||
/// func alertButtonTapped(_ action: AlertAction) {
|
||||
/// switch action {
|
||||
/// case .delete:
|
||||
/// // ...
|
||||
/// case .removeFromHomeScreen:
|
||||
/// // ...
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Then, whenever you need to show an alert you can simply construct an ``AlertState`` value to
|
||||
/// represent the alert:
|
||||
///
|
||||
/// ```swift
|
||||
/// class HomeScreenModel: ObservableObject {
|
||||
/// // ...
|
||||
/// func deleteAppButtonTapped() {
|
||||
/// self.alert = AlertState {
|
||||
/// TextState(#"Remove "Twitter"?"#)
|
||||
/// } actions: {
|
||||
/// ButtonState(role: .destructive, action: .send(.delete)) {
|
||||
/// TextState("Delete App")
|
||||
/// }
|
||||
/// ButtonState(action: .send(.removeFromHomeScreen)) {
|
||||
/// TextState("Remove from Home Screen")
|
||||
/// }
|
||||
/// } message: {
|
||||
/// TextState(
|
||||
/// "Removing from Home Screen will keep the app in your App Library."
|
||||
/// )
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// And in your view you can use the `.alert(unwrapping:action:)` view modifier to present the
|
||||
/// alert:
|
||||
///
|
||||
/// ```swift
|
||||
/// struct FeatureView: View {
|
||||
/// @ObservedObject var model: HomeScreenModel
|
||||
///
|
||||
/// var body: some View {
|
||||
/// VStack {
|
||||
/// Button("Delete") {
|
||||
/// self.model.deleteAppButtonTapped()
|
||||
/// }
|
||||
/// }
|
||||
/// .alert(unwrapping: self.$model.alert) { action in
|
||||
/// self.model.alertButtonTapped(action)
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This makes your model in complete control of when the alert is shown or dismissed, and makes it
|
||||
/// so that any choice made in the alert is automatically fed back into the model so that you can
|
||||
/// handle its logic.
|
||||
///
|
||||
/// Even better, because `AlertState` is equatable (when `Action` is equatable), you can instantly
|
||||
/// write tests that your alert behavior works as expected:
|
||||
///
|
||||
/// ```swift
|
||||
/// let model = HomeScreenModel()
|
||||
///
|
||||
/// model.deleteAppButtonTapped()
|
||||
/// XCTAssertEqual(
|
||||
/// model.alert,
|
||||
/// AlertState {
|
||||
/// TextState(#"Remove "Twitter"?"#)
|
||||
/// } actions: {
|
||||
/// ButtonState(role: .destructive, action: .deleteButtonTapped) {
|
||||
/// TextState("Delete App"),
|
||||
/// },
|
||||
/// ButtonState(action: .removeFromHomeScreenButtonTapped) {
|
||||
/// TextState("Remove from Home Screen"),
|
||||
/// }
|
||||
/// } message: {
|
||||
/// TextState(
|
||||
/// "Removing from Home Screen will keep the app in your App Library."
|
||||
/// )
|
||||
/// }
|
||||
/// )
|
||||
///
|
||||
/// model.alertButtonTapped(.delete) {
|
||||
/// // Also verify that delete logic executed correctly
|
||||
/// }
|
||||
/// model.alert = nil
|
||||
/// ```
|
||||
public struct AlertState<Action>: Identifiable {
|
||||
public let id = UUID()
|
||||
public var buttons: [ButtonState<Action>]
|
||||
public var message: TextState?
|
||||
public var title: TextState
|
||||
|
||||
/// Creates alert state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: The title of the alert.
|
||||
/// - actions: A ``ButtonStateBuilder`` returning the alert's actions.
|
||||
/// - message: The message for the alert.
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public init(
|
||||
title: () -> TextState,
|
||||
@ButtonStateBuilder<Action> actions: () -> [ButtonState<Action>] = { [] },
|
||||
message: (() -> TextState)? = nil
|
||||
) {
|
||||
self.title = title()
|
||||
self.message = message?()
|
||||
self.buttons = actions()
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertState: CustomDumpReflectable {
|
||||
public var customDumpMirror: Mirror {
|
||||
var children: [(label: String?, value: Any)] = [
|
||||
("title", self.title)
|
||||
]
|
||||
if !self.buttons.isEmpty {
|
||||
children.append(("actions", self.buttons))
|
||||
}
|
||||
if let message = self.message {
|
||||
children.append(("message", message))
|
||||
}
|
||||
return Mirror(
|
||||
self,
|
||||
children: children,
|
||||
displayStyle: .struct
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertState: Equatable where Action: Equatable {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.title == rhs.title
|
||||
&& lhs.message == rhs.message
|
||||
&& lhs.buttons == rhs.buttons
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertState: Hashable where Action: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(self.title)
|
||||
hasher.combine(self.message)
|
||||
hasher.combine(self.buttons)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI bridging
|
||||
|
||||
extension Alert {
|
||||
/// Creates an alert from alert state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - state: Alert state used to populate the alert.
|
||||
/// - action: An action handler, called when a button with an action is tapped, by passing the
|
||||
/// action to the closure.
|
||||
public init<Action>(_ state: AlertState<Action>, action: @escaping (Action) -> Void) {
|
||||
if state.buttons.count == 2 {
|
||||
self.init(
|
||||
title: Text(state.title),
|
||||
message: state.message.map { Text($0) },
|
||||
primaryButton: .init(state.buttons[0], action: action),
|
||||
secondaryButton: .init(state.buttons[1], action: action)
|
||||
)
|
||||
} else {
|
||||
self.init(
|
||||
title: Text(state.title),
|
||||
message: state.message.map { Text($0) },
|
||||
dismissButton: state.buttons.first.map { .init($0, action: action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deprecations
|
||||
|
||||
extension AlertState {
|
||||
@available(*, deprecated, message: "Use 'ButtonState<Action>' instead.")
|
||||
public typealias Button = ButtonState<Action>
|
||||
|
||||
@available(*, deprecated, message: "Use 'ButtonState<Action>.ButtonAction' instead.")
|
||||
public typealias ButtonAction = ButtonState<Action>.ButtonAction
|
||||
|
||||
@available(*, deprecated, message: "Use 'ButtonState<Action>.Role' instead.")
|
||||
public typealias ButtonRole = ButtonState<Action>.Role
|
||||
|
||||
@available(
|
||||
iOS, introduced: 15, deprecated: 100000, message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS,
|
||||
introduced: 12,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
tvOS, introduced: 15, deprecated: 100000, message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 8,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
public init(
|
||||
title: TextState,
|
||||
message: TextState? = nil,
|
||||
buttons: [ButtonState<Action>]
|
||||
) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.buttons = buttons
|
||||
}
|
||||
|
||||
@available(
|
||||
iOS, introduced: 13, deprecated: 100000, message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS,
|
||||
introduced: 10.15,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
tvOS, introduced: 13, deprecated: 100000, message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 6,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
public init(
|
||||
title: TextState,
|
||||
message: TextState? = nil,
|
||||
dismissButton: ButtonState<Action>? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.buttons = dismissButton.map { [$0] } ?? []
|
||||
}
|
||||
|
||||
@available(
|
||||
iOS, introduced: 13, deprecated: 100000, message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS,
|
||||
introduced: 10.15,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
tvOS, introduced: 13, deprecated: 100000, message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 6,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
public init(
|
||||
title: TextState,
|
||||
message: TextState? = nil,
|
||||
primaryButton: ButtonState<Action>,
|
||||
secondaryButton: ButtonState<Action>
|
||||
) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.buttons = [primaryButton, secondaryButton]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,253 @@
|
|||
import CustomDump
|
||||
import SwiftUI
|
||||
|
||||
public struct ButtonState<Action>: Identifiable {
|
||||
/// A type that wraps an action with additional context, _e.g._ for animation.
|
||||
public struct Handler {
|
||||
public let type: _ActionType
|
||||
|
||||
public static func send(_ action: Action) -> Self {
|
||||
.init(type: .send(action))
|
||||
}
|
||||
|
||||
public static func send(_ action: Action, animation: Animation?) -> Self {
|
||||
.init(type: .animatedSend(action, animation: animation))
|
||||
}
|
||||
|
||||
public enum _ActionType {
|
||||
case send(Action)
|
||||
case animatedSend(Action, animation: Animation?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A value that describes the purpose of a button.
|
||||
///
|
||||
/// See `SwiftUI.ButtonRole` for more information.
|
||||
public enum Role {
|
||||
/// A role that indicates a cancel button.
|
||||
///
|
||||
/// See `SwiftUI.ButtonRole.cancel` for more information.
|
||||
case cancel
|
||||
|
||||
/// A role that indicates a destructive button.
|
||||
///
|
||||
/// See `SwiftUI.ButtonRole.destructive` for more information.
|
||||
case destructive
|
||||
}
|
||||
|
||||
public let id = UUID()
|
||||
public let action: Handler?
|
||||
public let label: TextState
|
||||
public let role: Role?
|
||||
|
||||
/// Creates button state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - role: An optional semantic role that describes the button. A value of `nil` means that the
|
||||
/// button doesn't have an assigned role.
|
||||
/// - action: The action to send when the user interacts with the button.
|
||||
/// - label: A view that describes the purpose of the button's `action`.
|
||||
public init(
|
||||
role: Role? = nil,
|
||||
action: Handler? = nil,
|
||||
label: () -> TextState
|
||||
) {
|
||||
self.role = role
|
||||
self.action = action
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
/// Creates button state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - role: An optional semantic role that describes the button. A value of `nil` means that the
|
||||
/// button doesn't have an assigned role.
|
||||
/// - action: The action to send when the user interacts with the button.
|
||||
/// - label: A view that describes the purpose of the button's `action`.
|
||||
public init(
|
||||
role: Role? = nil,
|
||||
action: Action? = nil,
|
||||
label: () -> TextState
|
||||
) {
|
||||
self.role = role
|
||||
self.action = action.map(Handler.send)
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
/// Handle the button's action in a closure.
|
||||
///
|
||||
/// - Parameter perform: Unwraps and passes a button's action to a closure to be performed. If the
|
||||
/// action has an associated animation, the context will be wrapped using SwiftUI's
|
||||
/// `withAnimation`.
|
||||
public func withAction(_ perform: (Action) -> Void) {
|
||||
switch self.action?.type {
|
||||
case let .send(action):
|
||||
perform(action)
|
||||
case let .animatedSend(action, animation: animation):
|
||||
withAnimation(animation) {
|
||||
perform(action)
|
||||
}
|
||||
case .none:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonState: CustomDumpReflectable {
|
||||
public var customDumpMirror: Mirror {
|
||||
var children: [(label: String?, value: Any)] = []
|
||||
if let role = self.role {
|
||||
children.append(("role", role))
|
||||
}
|
||||
if let action = self.action {
|
||||
children.append(("action", action))
|
||||
}
|
||||
children.append(("label", self.label))
|
||||
return Mirror(
|
||||
self,
|
||||
children: children,
|
||||
displayStyle: .struct
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonState.Handler: CustomDumpReflectable {
|
||||
public var customDumpMirror: Mirror {
|
||||
switch self.type {
|
||||
case let .send(action):
|
||||
return Mirror(
|
||||
self,
|
||||
children: [
|
||||
"send": action
|
||||
],
|
||||
displayStyle: .enum
|
||||
)
|
||||
case let .animatedSend(action, animation):
|
||||
return Mirror(
|
||||
self,
|
||||
children: [
|
||||
"send": (action, animation: animation)
|
||||
],
|
||||
displayStyle: .enum
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonState.Handler: Equatable where Action: Equatable {}
|
||||
extension ButtonState.Handler._ActionType: Equatable where Action: Equatable {}
|
||||
extension ButtonState.Role: Equatable {}
|
||||
extension ButtonState: Equatable where Action: Equatable {}
|
||||
|
||||
extension ButtonState.Handler: Hashable where Action: Hashable {}
|
||||
extension ButtonState.Handler._ActionType: Hashable where Action: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .send(action), let .animatedSend(action, animation: _):
|
||||
hasher.combine(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
extension ButtonState.Role: Hashable {}
|
||||
extension ButtonState: Hashable where Action: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(self.action)
|
||||
hasher.combine(self.label)
|
||||
hasher.combine(self.role)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI bridging
|
||||
|
||||
extension Alert.Button {
|
||||
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
|
||||
let action = button.action.map { _ in { button.withAction(action) } }
|
||||
switch button.role {
|
||||
case .cancel:
|
||||
self = .cancel(Text(button.label), action: action)
|
||||
case .destructive:
|
||||
self = .destructive(Text(button.label), action: action)
|
||||
case .none:
|
||||
self = .default(Text(button.label), action: action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension ButtonRole {
|
||||
public init<Action>(_ role: ButtonState<Action>.Role) {
|
||||
switch role {
|
||||
case .cancel:
|
||||
self = .cancel
|
||||
case .destructive:
|
||||
self = .destructive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Button where Label == Text {
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
|
||||
self.init(
|
||||
role: button.role.map(ButtonRole.init),
|
||||
action: { button.withAction(action) }
|
||||
) {
|
||||
Text(button.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deprecations
|
||||
|
||||
extension ButtonState {
|
||||
@available(*, deprecated, renamed: "Handler")
|
||||
public typealias ButtonAction = Handler
|
||||
}
|
||||
|
||||
extension ButtonState.Handler {
|
||||
@available(*, deprecated, message: "Use 'ButtonState.withAction' instead.")
|
||||
public typealias ActionType = _ActionType
|
||||
}
|
||||
|
||||
@available(
|
||||
iOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message: "Use 'ButtonState.init(role:action:label:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS, introduced: 10.15,
|
||||
deprecated: 100000,
|
||||
message: "Use 'ButtonState.init(role:action:label:)' instead."
|
||||
)
|
||||
@available(
|
||||
tvOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message: "Use 'ButtonState.init(role:action:label:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 6,
|
||||
deprecated: 100000,
|
||||
message: "Use 'ButtonState.init(role:action:label:)' instead."
|
||||
)
|
||||
extension ButtonState {
|
||||
public static func cancel(_ label: TextState, action: Handler? = nil) -> Self {
|
||||
Self(role: .cancel, action: action) {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
public static func `default`(_ label: TextState, action: Handler? = nil) -> Self {
|
||||
Self(action: action) {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
public static func destructive(_ label: TextState, action: Handler? = nil) -> Self {
|
||||
Self(role: .destructive, action: action) {
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
@resultBuilder
|
||||
public enum ButtonStateBuilder<Action> {
|
||||
public static func buildArray(_ components: [[ButtonState<Action>]]) -> [ButtonState<Action>] {
|
||||
components.flatMap { $0 }
|
||||
}
|
||||
|
||||
public static func buildBlock(_ components: [ButtonState<Action>]...) -> [ButtonState<Action>] {
|
||||
components.flatMap { $0 }
|
||||
}
|
||||
|
||||
public static func buildLimitedAvailability(
|
||||
_ component: [ButtonState<Action>]
|
||||
) -> [ButtonState<Action>] {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildEither(first component: [ButtonState<Action>]) -> [ButtonState<Action>] {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildEither(second component: [ButtonState<Action>]) -> [ButtonState<Action>] {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildExpression(_ expression: ButtonState<Action>) -> [ButtonState<Action>] {
|
||||
[expression]
|
||||
}
|
||||
|
||||
public static func buildOptional(_ component: [ButtonState<Action>]?) -> [ButtonState<Action>] {
|
||||
component ?? []
|
||||
}
|
||||
}
|
|
@ -0,0 +1,361 @@
|
|||
import CustomDump
|
||||
import SwiftUI
|
||||
|
||||
/// A data type that describes the state of a confirmation dialog that can be shown to the user. The
|
||||
/// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet.
|
||||
///
|
||||
/// This type can be used in your application's state in order to control the presentation and
|
||||
/// actions of dialogs. This API can be used to push the logic of alert presentation and action into
|
||||
/// your model, making it easier to test, and simplifying your view layer.
|
||||
///
|
||||
/// To use this API, you describe all of a dialog's actions as cases in an enum:
|
||||
///
|
||||
/// ```swift
|
||||
/// class FeatureModel: ObservableObject {
|
||||
/// enum ConfirmationDialogAction {
|
||||
/// case delete
|
||||
/// case favorite
|
||||
/// }
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// You model the state for showing the alert in as a published field, which can start off `nil`:
|
||||
///
|
||||
/// ```swift
|
||||
/// class FeatureModel: ObservableObject {
|
||||
/// // ...
|
||||
/// @Published var dialog: ConfirmationDialogState<ConfirmationDialogAction>?
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// And you define an endpoint for handling each alert action:
|
||||
///
|
||||
/// ```swift
|
||||
/// class FeatureModel: ObservableObject {
|
||||
/// // ...
|
||||
/// func dialogButtonTapped(_ action: ConfirmationDialogAction) {
|
||||
/// switch action {
|
||||
/// case .delete:
|
||||
/// // ...
|
||||
/// case .favorite:
|
||||
/// // ...
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Then, in an endpoint that should display an alert, you can construct a
|
||||
/// ``ConfirmationDialogState`` value to represent it:
|
||||
///
|
||||
/// ```swift
|
||||
/// class FeatureModel: ObservableObject {
|
||||
/// // ...
|
||||
/// func infoButtonTapped() {
|
||||
/// self.dialog = ConfirmationDialogState(
|
||||
/// title: "What would you like to do?",
|
||||
/// buttons: [
|
||||
/// .default(TextState("Favorite"), action: .send(.favorite)),
|
||||
/// .destructive(TextState("Delete"), action: .send(.delete)),
|
||||
/// .cancel(TextState("Cancel")),
|
||||
/// ]
|
||||
/// )
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// And in your view you can use the `.confirmationDialog(unwrapping:action:)` view modifier to
|
||||
/// present the dialog:
|
||||
///
|
||||
/// ```swift
|
||||
/// struct ItemView: View {
|
||||
/// @ObservedObject var model: FeatureModel
|
||||
///
|
||||
/// var body: some View {
|
||||
/// VStack {
|
||||
/// Button("Info") {
|
||||
/// self.model.infoButtonTapped()
|
||||
/// }
|
||||
/// }
|
||||
/// .confirmationDialog(unwrapping: self.$model.dialog) { action in
|
||||
/// self.model.dialogButtonTapped(action)
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This makes your model in complete control of when the alert is shown or dismissed, and makes it
|
||||
/// so that any choice made in the alert is automatically fed back into the model so that you can
|
||||
/// handle its logic.
|
||||
///
|
||||
/// Even better, you can instantly write tests that your alert behavior works as expected:
|
||||
///
|
||||
/// ```swift
|
||||
/// let model = FeatureModel()
|
||||
///
|
||||
/// model.infoButtonTapped()
|
||||
/// XCTAssertEqual(
|
||||
/// model.dialog,
|
||||
/// ConfirmationDialogState(
|
||||
/// title: "What would you like to do?",
|
||||
/// buttons: [
|
||||
/// .default(TextState("Favorite"), action: .send(.favorite)),
|
||||
/// .destructive(TextState("Delete"), action: .send(.delete)),
|
||||
/// .cancel(TextState("Cancel")),
|
||||
/// ]
|
||||
/// )
|
||||
/// )
|
||||
///
|
||||
/// model.dialogButtonTapped(.favorite)
|
||||
/// // Verify that favorite logic executed correctly
|
||||
/// model.dialog = nil
|
||||
/// ```
|
||||
@available(iOS 13, *)
|
||||
@available(macOS 12, *)
|
||||
@available(tvOS 13, *)
|
||||
@available(watchOS 6, *)
|
||||
public struct ConfirmationDialogState<Action>: Identifiable {
|
||||
public let id = UUID()
|
||||
public var buttons: [ButtonState<Action>]
|
||||
public var message: TextState?
|
||||
public var title: TextState
|
||||
public var titleVisibility: Visibility
|
||||
|
||||
/// Creates confirmation dialog state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - titleVisibility: The visibility of the dialog's title.
|
||||
/// - title: The title of the dialog.
|
||||
/// - actions: A ``ButtonStateBuilder`` returning the dialog's actions.
|
||||
/// - message: The message for the dialog.
|
||||
@available(iOS 15, *)
|
||||
@available(macOS 12, *)
|
||||
@available(tvOS 15, *)
|
||||
@available(watchOS 8, *)
|
||||
public init(
|
||||
titleVisibility: Visibility,
|
||||
title: () -> TextState,
|
||||
@ButtonStateBuilder<Action> actions: () -> [ButtonState<Action>],
|
||||
message: (() -> TextState)? = nil
|
||||
) {
|
||||
self.buttons = actions()
|
||||
self.message = message?()
|
||||
self.title = title()
|
||||
self.titleVisibility = titleVisibility
|
||||
}
|
||||
|
||||
/// Creates confirmation dialog state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: The title of the dialog.
|
||||
/// - actions: A ``ButtonStateBuilder`` returning the dialog's actions.
|
||||
/// - message: The message for the dialog.
|
||||
public init(
|
||||
title: () -> TextState,
|
||||
@ButtonStateBuilder<Action> actions: () -> [ButtonState<Action>],
|
||||
message: (() -> TextState)? = nil
|
||||
) {
|
||||
self.buttons = actions()
|
||||
self.message = message?()
|
||||
self.title = title()
|
||||
self.titleVisibility = .automatic
|
||||
}
|
||||
|
||||
public enum Visibility {
|
||||
case automatic
|
||||
case hidden
|
||||
case visible
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
@available(macOS 12, *)
|
||||
@available(tvOS 13, *)
|
||||
@available(watchOS 6, *)
|
||||
extension ConfirmationDialogState: CustomDumpReflectable {
|
||||
public var customDumpMirror: Mirror {
|
||||
var children: [(label: String?, value: Any)] = []
|
||||
if self.titleVisibility != .automatic {
|
||||
children.append(("titleVisibility", self.titleVisibility))
|
||||
}
|
||||
children.append(("title", self.title))
|
||||
if !self.buttons.isEmpty {
|
||||
children.append(("actions", self.buttons))
|
||||
}
|
||||
if let message = self.message {
|
||||
children.append(("message", message))
|
||||
}
|
||||
return Mirror(
|
||||
self,
|
||||
children: children,
|
||||
displayStyle: .struct
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
@available(macOS 12, *)
|
||||
@available(tvOS 13, *)
|
||||
@available(watchOS 6, *)
|
||||
extension ConfirmationDialogState: Equatable where Action: Equatable {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.title == rhs.title
|
||||
&& lhs.message == rhs.message
|
||||
&& lhs.buttons == rhs.buttons
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
@available(macOS 12, *)
|
||||
@available(tvOS 13, *)
|
||||
@available(watchOS 6, *)
|
||||
extension ConfirmationDialogState: Hashable where Action: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(self.title)
|
||||
hasher.combine(self.message)
|
||||
hasher.combine(self.buttons)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI bridging
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension Visibility {
|
||||
public init<Action>(_ visibility: ConfirmationDialogState<Action>.Visibility) {
|
||||
switch visibility {
|
||||
case .automatic:
|
||||
self = .automatic
|
||||
case .hidden:
|
||||
self = .hidden
|
||||
case .visible:
|
||||
self = .visible
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deprecations
|
||||
|
||||
@available(iOS 13, *)
|
||||
@available(macOS 12, *)
|
||||
@available(tvOS 13, *)
|
||||
@available(watchOS 6, *)
|
||||
extension ConfirmationDialogState {
|
||||
@available(*, deprecated, message: "Use 'ButtonState<Action>' instead.")
|
||||
public typealias Button = ButtonState<Action>
|
||||
|
||||
@available(
|
||||
iOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(titleVisibility:title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS,
|
||||
introduced: 12,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(titleVisibility:title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
tvOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(titleVisibility:title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 6,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(titleVisibility:title:actions:message:)' instead."
|
||||
)
|
||||
public init(
|
||||
title: TextState,
|
||||
titleVisibility: Visibility,
|
||||
message: TextState? = nil,
|
||||
buttons: [ButtonState<Action>] = []
|
||||
) {
|
||||
self.buttons = buttons
|
||||
self.message = message
|
||||
self.title = title
|
||||
self.titleVisibility = titleVisibility
|
||||
}
|
||||
|
||||
@available(
|
||||
iOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS,
|
||||
introduced: 12,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
tvOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 6,
|
||||
deprecated: 100000,
|
||||
message: "Use 'init(title:actions:message:)' instead."
|
||||
)
|
||||
public init(
|
||||
title: TextState,
|
||||
message: TextState? = nil,
|
||||
buttons: [ButtonState<Action>] = []
|
||||
) {
|
||||
self.buttons = buttons
|
||||
self.message = message
|
||||
self.title = title
|
||||
self.titleVisibility = .automatic
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS, introduced: 13, deprecated: 100000, renamed: "ConfirmationDialogState")
|
||||
@available(macOS, unavailable)
|
||||
@available(tvOS, introduced: 13, deprecated: 100000, renamed: "ConfirmationDialogState")
|
||||
@available(watchOS, introduced: 6, deprecated: 100000, renamed: "ConfirmationDialogState")
|
||||
public typealias ActionSheetState<Action> = ConfirmationDialogState<Action>
|
||||
|
||||
@available(
|
||||
iOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message:
|
||||
"use 'View.confirmationDialog(title:isPresented:titleVisibility:presenting::actions:)' instead."
|
||||
)
|
||||
@available(
|
||||
macOS,
|
||||
unavailable
|
||||
)
|
||||
@available(
|
||||
tvOS,
|
||||
introduced: 13,
|
||||
deprecated: 100000,
|
||||
message:
|
||||
"use 'View.confirmationDialog(title:isPresented:titleVisibility:presenting::actions:)' instead."
|
||||
)
|
||||
@available(
|
||||
watchOS,
|
||||
introduced: 6,
|
||||
deprecated: 100000,
|
||||
message:
|
||||
"use 'View.confirmationDialog(title:isPresented:titleVisibility:presenting::actions:)' instead."
|
||||
)
|
||||
extension ActionSheet {
|
||||
public init<Action>(
|
||||
_ state: ConfirmationDialogState<Action>,
|
||||
action: @escaping (Action) -> Void
|
||||
) {
|
||||
self.init(
|
||||
title: Text(state.title),
|
||||
message: state.message.map { Text($0) },
|
||||
buttons: state.buttons.map { .init($0, action: action) }
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,741 @@
|
|||
import CustomDump
|
||||
import SwiftUI
|
||||
|
||||
/// An equatable description of SwiftUI `Text`. Useful for storing rich text in feature models
|
||||
/// that can still be tested for equality.
|
||||
///
|
||||
/// Although `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` are value types that conform to
|
||||
/// `Equatable`, their `==` do not return `true` when used with seemingly equal values. If we were
|
||||
/// to naively store these values in state, our tests may begin to fail.
|
||||
///
|
||||
/// ``TextState`` solves this problem by providing an interface similar to `SwiftUI.Text` that can
|
||||
/// be held in state and asserted against.
|
||||
///
|
||||
/// Let's say you wanted to hold some dynamic, styled text content in your app state. You could use
|
||||
/// ``TextState``:
|
||||
///
|
||||
/// ```swift
|
||||
/// class Model: Equatable {
|
||||
/// @Published var label = TextState("")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Your model can then assign a value to this state using an API similar to that of `SwiftUI.Text`.
|
||||
///
|
||||
/// ```swift
|
||||
/// self.label = TextState("Hello, ") + TextState(name).bold() + TextState("!")
|
||||
/// ```
|
||||
///
|
||||
/// And your view can render it by passing it to a `SwiftUI.Text` initializer:
|
||||
///
|
||||
/// ```swift
|
||||
/// var body: some View {
|
||||
/// Text(self.model.label)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// SwiftUI Navigation comes with a few convenience APIs for alerts and dialogs that wrap
|
||||
/// ``TextState`` under the hood. See ``AlertState`` and ``ConfirmationDialogState`` accordingly.
|
||||
///
|
||||
/// In the future, should `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` reliably conform to
|
||||
/// `Equatable`, ``TextState`` may be deprecated.
|
||||
///
|
||||
/// - Note: ``TextState`` does not support _all_ `LocalizedStringKey` permutations at this time
|
||||
/// (interpolated `SwiftUI.Image`s, for example). ``TextState`` also uses reflection to determine
|
||||
/// `LocalizedStringKey` equatability, so be mindful of edge cases.
|
||||
public struct TextState: Equatable, Hashable {
|
||||
fileprivate var modifiers: [Modifier] = []
|
||||
fileprivate let storage: Storage
|
||||
|
||||
fileprivate enum Modifier: Equatable, Hashable {
|
||||
case accessibilityHeading(AccessibilityHeadingLevel)
|
||||
case accessibilityLabel(TextState)
|
||||
case accessibilityTextContentType(AccessibilityTextContentType)
|
||||
case baselineOffset(CGFloat)
|
||||
case bold(isActive: Bool)
|
||||
case font(Font?)
|
||||
case fontDesign(Font.Design?)
|
||||
case fontWeight(Font.Weight?)
|
||||
case fontWidth(FontWidth?)
|
||||
case foregroundColor(Color?)
|
||||
case italic(isActive: Bool)
|
||||
case kerning(CGFloat)
|
||||
case monospacedDigit
|
||||
case speechAdjustedPitch(Double)
|
||||
case speechAlwaysIncludesPunctuation(Bool)
|
||||
case speechAnnouncementsQueued(Bool)
|
||||
case speechSpellsOutCharacters(Bool)
|
||||
case strikethrough(isActive: Bool, pattern: LineStylePattern?, color: Color?)
|
||||
case tracking(CGFloat)
|
||||
case underline(isActive: Bool, pattern: LineStylePattern?, color: Color?)
|
||||
}
|
||||
|
||||
public enum FontWidth: String, Equatable, Hashable {
|
||||
case compressed
|
||||
case condensed
|
||||
case expanded
|
||||
case standard
|
||||
|
||||
#if swift(>=5.7.1)
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
var toSwiftUI: SwiftUI.Font.Width {
|
||||
switch self {
|
||||
case .compressed: return .compressed
|
||||
case .condensed: return .condensed
|
||||
case .expanded: return .expanded
|
||||
case .standard: return .standard
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public enum LineStylePattern: String, Equatable, Hashable {
|
||||
case dash
|
||||
case dashDot
|
||||
case dashDotDot
|
||||
case dot
|
||||
case solid
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
var toSwiftUI: SwiftUI.Text.LineStyle.Pattern {
|
||||
switch self {
|
||||
case .dash: return .dash
|
||||
case .dashDot: return .dashDot
|
||||
case .dashDotDot: return .dashDotDot
|
||||
case .dot: return .dot
|
||||
case .solid: return .solid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate enum Storage: Equatable, Hashable {
|
||||
indirect case concatenated(TextState, TextState)
|
||||
case localized(LocalizedStringKey, tableName: String?, bundle: Bundle?, comment: StaticString?)
|
||||
case verbatim(String)
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.concatenated(l1, l2), .concatenated(r1, r2)):
|
||||
return l1 == r1 && l2 == r2
|
||||
|
||||
case let (.localized(lk, lt, lb, lc), .localized(rk, rt, rb, rc)):
|
||||
return lk.formatted(tableName: lt, bundle: lb, comment: lc)
|
||||
== rk.formatted(tableName: rt, bundle: rb, comment: rc)
|
||||
|
||||
case let (.verbatim(lhs), .verbatim(rhs)):
|
||||
return lhs == rhs
|
||||
|
||||
case let (.localized(key, tableName, bundle, comment), .verbatim(string)),
|
||||
let (.verbatim(string), .localized(key, tableName, bundle, comment)):
|
||||
return key.formatted(tableName: tableName, bundle: bundle, comment: comment) == string
|
||||
|
||||
// NB: We do not attempt to equate concatenated cases.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
enum Key {
|
||||
case concatenated
|
||||
case localized
|
||||
case verbatim
|
||||
}
|
||||
|
||||
switch self {
|
||||
case let (.concatenated(first, second)):
|
||||
hasher.combine(Key.concatenated)
|
||||
hasher.combine(first)
|
||||
hasher.combine(second)
|
||||
|
||||
case let .localized(key, tableName, bundle, comment):
|
||||
hasher.combine(Key.localized)
|
||||
hasher.combine(key.formatted(tableName: tableName, bundle: bundle, comment: comment))
|
||||
|
||||
case let .verbatim(string):
|
||||
hasher.combine(Key.verbatim)
|
||||
hasher.combine(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
extension TextState {
|
||||
public init(verbatim content: String) {
|
||||
self.storage = .verbatim(content)
|
||||
}
|
||||
|
||||
@_disfavoredOverload
|
||||
public init<S: StringProtocol>(_ content: S) {
|
||||
self.init(verbatim: String(content))
|
||||
}
|
||||
|
||||
public init(
|
||||
_ key: LocalizedStringKey,
|
||||
tableName: String? = nil,
|
||||
bundle: Bundle? = nil,
|
||||
comment: StaticString? = nil
|
||||
) {
|
||||
self.storage = .localized(key, tableName: tableName, bundle: bundle, comment: comment)
|
||||
}
|
||||
|
||||
public static func + (lhs: Self, rhs: Self) -> Self {
|
||||
.init(storage: .concatenated(lhs, rhs))
|
||||
}
|
||||
|
||||
public func baselineOffset(_ baselineOffset: CGFloat) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.baselineOffset(baselineOffset))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func bold() -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.bold(isActive: true))
|
||||
return `self`
|
||||
}
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
public func bold(isActive: Bool) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.bold(isActive: isActive))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func font(_ font: Font?) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.font(font))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func fontDesign(_ design: Font.Design?) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.fontDesign(design))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func fontWeight(_ weight: Font.Weight?) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.fontWeight(weight))
|
||||
return `self`
|
||||
}
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
public func fontWidth(_ width: FontWidth?) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.fontWidth(width))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func foregroundColor(_ color: Color?) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.foregroundColor(color))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func italic() -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.italic(isActive: true))
|
||||
return `self`
|
||||
}
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
public func italic(isActive: Bool) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.italic(isActive: isActive))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func kerning(_ kerning: CGFloat) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.kerning(kerning))
|
||||
return `self`
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
public func monospacedDigit() -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.monospacedDigit)
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func strikethrough(_ isActive: Bool = true, color: Color? = nil) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.strikethrough(isActive: isActive, pattern: .solid, color: color))
|
||||
return `self`
|
||||
}
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
public func strikethrough(
|
||||
_ isActive: Bool = true,
|
||||
pattern: LineStylePattern,
|
||||
color: Color? = nil
|
||||
) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.strikethrough(isActive: isActive, pattern: pattern, color: color))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func tracking(_ tracking: CGFloat) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.tracking(tracking))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func underline(_ isActive: Bool = true, color: Color? = nil) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.underline(isActive: isActive, pattern: .solid, color: color))
|
||||
return `self`
|
||||
}
|
||||
|
||||
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
public func underline(
|
||||
_ isActive: Bool = true,
|
||||
pattern: LineStylePattern,
|
||||
color: Color? = nil
|
||||
) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.underline(isActive: isActive, pattern: pattern, color: color))
|
||||
return `self`
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
extension TextState {
|
||||
public enum AccessibilityTextContentType: String, Equatable, Hashable {
|
||||
case console, fileSystem, messaging, narrative, plain, sourceCode, spreadsheet, wordProcessing
|
||||
|
||||
#if compiler(>=5.5.1)
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
var toSwiftUI: SwiftUI.AccessibilityTextContentType {
|
||||
switch self {
|
||||
case .console: return .console
|
||||
case .fileSystem: return .fileSystem
|
||||
case .messaging: return .messaging
|
||||
case .narrative: return .narrative
|
||||
case .plain: return .plain
|
||||
case .sourceCode: return .sourceCode
|
||||
case .spreadsheet: return .spreadsheet
|
||||
case .wordProcessing: return .wordProcessing
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public enum AccessibilityHeadingLevel: String, Equatable, Hashable {
|
||||
case h1, h2, h3, h4, h5, h6, unspecified
|
||||
|
||||
#if compiler(>=5.5.1)
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
var toSwiftUI: SwiftUI.AccessibilityHeadingLevel {
|
||||
switch self {
|
||||
case .h1: return .h1
|
||||
case .h2: return .h2
|
||||
case .h3: return .h3
|
||||
case .h4: return .h4
|
||||
case .h5: return .h5
|
||||
case .h6: return .h6
|
||||
case .unspecified: return .unspecified
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||
extension TextState {
|
||||
public func accessibilityHeading(_ headingLevel: AccessibilityHeadingLevel) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.accessibilityHeading(headingLevel))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func accessibilityLabel(_ label: Self) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.accessibilityLabel(label))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func accessibilityLabel(_ string: String) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.accessibilityLabel(.init(string)))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func accessibilityLabel<S: StringProtocol>(_ string: S) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.accessibilityLabel(.init(string)))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func accessibilityLabel(
|
||||
_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil,
|
||||
comment: StaticString? = nil
|
||||
) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(
|
||||
.accessibilityLabel(.init(key, tableName: tableName, bundle: bundle, comment: comment)))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func accessibilityTextContentType(_ type: AccessibilityTextContentType) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.accessibilityTextContentType(type))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func speechAdjustedPitch(_ value: Double) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.speechAdjustedPitch(value))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func speechAlwaysIncludesPunctuation(_ value: Bool = true) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.speechAlwaysIncludesPunctuation(value))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func speechAnnouncementsQueued(_ value: Bool = true) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.speechAnnouncementsQueued(value))
|
||||
return `self`
|
||||
}
|
||||
|
||||
public func speechSpellsOutCharacters(_ value: Bool = true) -> Self {
|
||||
var `self` = self
|
||||
`self`.modifiers.append(.speechSpellsOutCharacters(value))
|
||||
return `self`
|
||||
}
|
||||
}
|
||||
|
||||
extension Text {
|
||||
public init(_ state: TextState) {
|
||||
let text: Text
|
||||
switch state.storage {
|
||||
case let .concatenated(first, second):
|
||||
text = Text(first) + Text(second)
|
||||
case let .localized(content, tableName, bundle, comment):
|
||||
text = .init(content, tableName: tableName, bundle: bundle, comment: comment)
|
||||
case let .verbatim(content):
|
||||
text = .init(verbatim: content)
|
||||
}
|
||||
self = state.modifiers.reduce(text) { text, modifier in
|
||||
switch modifier {
|
||||
#if compiler(>=5.5.1)
|
||||
case let .accessibilityHeading(level):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.accessibilityHeading(level.toSwiftUI)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .accessibilityLabel(value):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
switch value.storage {
|
||||
case let .verbatim(string):
|
||||
return text.accessibilityLabel(string)
|
||||
case let .localized(key, tableName, bundle, comment):
|
||||
return text.accessibilityLabel(
|
||||
Text(key, tableName: tableName, bundle: bundle, comment: comment))
|
||||
case .concatenated(_, _):
|
||||
assertionFailure("`.accessibilityLabel` does not support contcatenated `TextState`")
|
||||
return text
|
||||
}
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .accessibilityTextContentType(type):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.accessibilityTextContentType(type.toSwiftUI)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
#else
|
||||
case .accessibilityHeading,
|
||||
.accessibilityLabel,
|
||||
.accessibilityTextContentType:
|
||||
return text
|
||||
#endif
|
||||
case let .baselineOffset(baselineOffset):
|
||||
return text.baselineOffset(baselineOffset)
|
||||
case let .bold(isActive):
|
||||
#if swift(>=5.7.1)
|
||||
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
|
||||
return text.bold(isActive)
|
||||
} else {
|
||||
return text.bold()
|
||||
}
|
||||
#else
|
||||
_ = isActive
|
||||
return text.bold()
|
||||
#endif
|
||||
case let .font(font):
|
||||
return text.font(font)
|
||||
case let .fontDesign(design):
|
||||
#if swift(>=5.7.1)
|
||||
if #available(iOS 16.1, macOS 13, tvOS 16.1, watchOS 9.1, *) {
|
||||
return text.fontDesign(design)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
#else
|
||||
_ = design
|
||||
return text
|
||||
#endif
|
||||
case let .fontWeight(weight):
|
||||
return text.fontWeight(weight)
|
||||
case let .fontWidth(width):
|
||||
#if swift(>=5.7.1)
|
||||
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
|
||||
return text.fontWidth(width?.toSwiftUI)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
#else
|
||||
_ = width
|
||||
return text
|
||||
#endif
|
||||
case let .foregroundColor(color):
|
||||
return text.foregroundColor(color)
|
||||
case let .italic(isActive):
|
||||
#if swift(>=5.7.1)
|
||||
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
|
||||
return text.italic(isActive)
|
||||
} else {
|
||||
return text.italic()
|
||||
}
|
||||
#else
|
||||
_ = isActive
|
||||
return text.italic()
|
||||
#endif
|
||||
case let .kerning(kerning):
|
||||
return text.kerning(kerning)
|
||||
case .monospacedDigit:
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.monospacedDigit()
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .speechAdjustedPitch(value):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.speechAdjustedPitch(value)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .speechAlwaysIncludesPunctuation(value):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.speechAlwaysIncludesPunctuation(value)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .speechAnnouncementsQueued(value):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.speechAnnouncementsQueued(value)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .speechSpellsOutCharacters(value):
|
||||
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
|
||||
return text.speechSpellsOutCharacters(value)
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .strikethrough(isActive, pattern, color):
|
||||
#if swift(>=5.7.1)
|
||||
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern {
|
||||
return text.strikethrough(isActive, pattern: pattern.toSwiftUI, color: color)
|
||||
} else {
|
||||
return text.strikethrough(isActive, color: color)
|
||||
}
|
||||
#else
|
||||
_ = pattern
|
||||
return text.strikethrough(isActive, color: color)
|
||||
#endif
|
||||
case let .tracking(tracking):
|
||||
return text.tracking(tracking)
|
||||
case let .underline(isActive, pattern, color):
|
||||
#if swift(>=5.7.1)
|
||||
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern {
|
||||
return text.underline(isActive, pattern: pattern.toSwiftUI, color: color)
|
||||
} else {
|
||||
return text.underline(isActive, color: color)
|
||||
}
|
||||
#else
|
||||
_ = pattern
|
||||
return text.strikethrough(isActive, color: color)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
public init(state: TextState, locale: Locale? = nil) {
|
||||
switch state.storage {
|
||||
case let .concatenated(lhs, rhs):
|
||||
self = String(state: lhs, locale: locale) + String(state: rhs, locale: locale)
|
||||
|
||||
case let .localized(key, tableName, bundle, comment):
|
||||
self = key.formatted(
|
||||
locale: locale,
|
||||
tableName: tableName,
|
||||
bundle: bundle,
|
||||
comment: comment
|
||||
)
|
||||
|
||||
case let .verbatim(string):
|
||||
self = string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedStringKey {
|
||||
// NB: `LocalizedStringKey` conforms to `Equatable` but returns false for equivalent format
|
||||
// strings. To account for this we reflect on it to extract and string-format its storage.
|
||||
fileprivate func formatted(
|
||||
locale: Locale? = nil,
|
||||
tableName: String? = nil,
|
||||
bundle: Bundle? = nil,
|
||||
comment: StaticString? = nil
|
||||
) -> String {
|
||||
let children = Array(Mirror(reflecting: self).children)
|
||||
let key = children[0].value as! String
|
||||
let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children)
|
||||
.compactMap {
|
||||
let children = Array(Mirror(reflecting: $0.value).children)
|
||||
let value: Any
|
||||
let formatter: Formatter?
|
||||
// `LocalizedStringKey.FormatArgument` differs depending on OS/platform.
|
||||
if children[0].label == "storage" {
|
||||
(value, formatter) =
|
||||
Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?)
|
||||
} else {
|
||||
value = children[0].value
|
||||
formatter = children[1].value as? Formatter
|
||||
}
|
||||
return formatter?.string(for: value) ?? value as! CVarArg
|
||||
}
|
||||
|
||||
let format = NSLocalizedString(
|
||||
key,
|
||||
tableName: tableName,
|
||||
bundle: bundle ?? .main,
|
||||
value: "",
|
||||
comment: comment.map(String.init) ?? ""
|
||||
)
|
||||
return String(format: format, locale: locale, arguments: arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomDumpRepresentable
|
||||
|
||||
extension TextState: CustomDumpRepresentable {
|
||||
public var customDumpValue: Any {
|
||||
func dumpHelp(_ textState: Self) -> String {
|
||||
var output: String
|
||||
switch textState.storage {
|
||||
case let .concatenated(lhs, rhs):
|
||||
output = dumpHelp(lhs) + dumpHelp(rhs)
|
||||
case let .localized(key, tableName, bundle, comment):
|
||||
output = key.formatted(tableName: tableName, bundle: bundle, comment: comment)
|
||||
case let .verbatim(string):
|
||||
output = string
|
||||
}
|
||||
func tag(_ name: String, attribute: String? = nil, _ value: String? = nil) {
|
||||
output = """
|
||||
<\(name)\(attribute.map { " \($0)" } ?? "")\(value.map { "=\($0)" } ?? "")>\
|
||||
\(output)\
|
||||
</\(name)>
|
||||
"""
|
||||
}
|
||||
for modifier in textState.modifiers {
|
||||
switch modifier {
|
||||
case let .accessibilityHeading(headingLevel):
|
||||
tag("accessibility-heading-level", headingLevel.rawValue)
|
||||
case let .accessibilityLabel(value):
|
||||
tag("accessibility-label", dumpHelp(value))
|
||||
case let .accessibilityTextContentType(type):
|
||||
tag("accessibility-text-content-type", type.rawValue)
|
||||
case let .baselineOffset(baselineOffset):
|
||||
tag("baseline-offset", "\(baselineOffset)")
|
||||
case .bold(isActive: true), .fontWeight(.some(.bold)):
|
||||
output = "**\(output)**"
|
||||
case .font(.some):
|
||||
break // TODO: capture Font description using DSL similar to TextState and print here
|
||||
case let .fontDesign(.some(design)):
|
||||
func describe(design: Font.Design) -> String {
|
||||
switch design {
|
||||
case .default: return "default"
|
||||
case .serif: return "serif"
|
||||
case .rounded: return "rounded"
|
||||
case .monospaced: return "monospaced"
|
||||
@unknown default: return "\(design)"
|
||||
}
|
||||
}
|
||||
tag("font-design", describe(design: design))
|
||||
case let .fontWeight(.some(weight)):
|
||||
func describe(weight: Font.Weight) -> String {
|
||||
switch weight {
|
||||
case .black: return "black"
|
||||
case .bold: return "bold"
|
||||
case .heavy: return "heavy"
|
||||
case .light: return "light"
|
||||
case .medium: return "medium"
|
||||
case .regular: return "regular"
|
||||
case .semibold: return "semibold"
|
||||
case .thin: return "thin"
|
||||
default: return "\(weight)"
|
||||
}
|
||||
}
|
||||
tag("font-weight", describe(weight: weight))
|
||||
case let .fontWidth(.some(width)):
|
||||
tag("font-width", width.rawValue)
|
||||
case let .foregroundColor(.some(color)):
|
||||
tag("foreground-color", "\(color)")
|
||||
case .italic(isActive: true):
|
||||
output = "_\(output)_"
|
||||
case let .kerning(kerning):
|
||||
tag("kerning", "\(kerning)")
|
||||
case let .speechAdjustedPitch(value):
|
||||
tag("speech-adjusted-pitch", "\(value)")
|
||||
case .speechAlwaysIncludesPunctuation(true):
|
||||
tag("speech-always-includes-punctuation")
|
||||
case .speechAnnouncementsQueued(true):
|
||||
tag("speech-announcements-queued")
|
||||
case .speechSpellsOutCharacters(true):
|
||||
tag("speech-spells-out-characters")
|
||||
case let .strikethrough(isActive: true, pattern: _, color: .some(color)):
|
||||
tag("s", attribute: "color", "\(color)")
|
||||
case .strikethrough(isActive: true, pattern: _, color: .none):
|
||||
output = "~~\(output)~~"
|
||||
case let .tracking(tracking):
|
||||
tag("tracking", "\(tracking)")
|
||||
case let .underline(isActive: true, pattern: _, .some(color)):
|
||||
tag("u", attribute: "color", "\(color)")
|
||||
case .underline(isActive: true, pattern: _, color: .none):
|
||||
tag("u")
|
||||
case .bold(isActive: false),
|
||||
.font(.none),
|
||||
.fontDesign(.none),
|
||||
.fontWeight(.none),
|
||||
.fontWidth(.none),
|
||||
.foregroundColor(.none),
|
||||
.italic(isActive: false),
|
||||
.monospacedDigit,
|
||||
.speechAlwaysIncludesPunctuation(false),
|
||||
.speechAnnouncementsQueued(false),
|
||||
.speechSpellsOutCharacters(false),
|
||||
.strikethrough(isActive: false, pattern: _, color: _),
|
||||
.underline(isActive: false, pattern: _, color: _):
|
||||
break
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return dumpHelp(self)
|
||||
}
|
||||
}
|
|
@ -19,6 +19,24 @@
|
|||
"version": "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-custom-dump",
|
||||
"repositoryURL": "https://github.com/pointfreeco/swift-custom-dump",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "ead7d30cc224c3642c150b546f4f1080d1c411a8",
|
||||
"version": "0.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftDocCPlugin",
|
||||
"repositoryURL": "https://github.com/apple/swift-docc-plugin",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-identified-collections",
|
||||
"repositoryURL": "https://github.com/pointfreeco/swift-identified-collections.git",
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
import CustomDump
|
||||
import SwiftUINavigation
|
||||
import XCTest
|
||||
|
||||
final class AlertTests: XCTestCase {
|
||||
func testAlertState() {
|
||||
var dump = ""
|
||||
customDump(
|
||||
AlertState(
|
||||
title: .init("Alert!"),
|
||||
message: .init("Something went wrong..."),
|
||||
primaryButton: .destructive(.init("Destroy"), action: .send(true, animation: .default)),
|
||||
secondaryButton: .cancel(.init("Cancel"), action: .send(false))
|
||||
),
|
||||
to: &dump
|
||||
)
|
||||
XCTAssertNoDifference(
|
||||
dump,
|
||||
"""
|
||||
AlertState(
|
||||
title: "Alert!",
|
||||
actions: [
|
||||
[0]: ButtonState(
|
||||
role: ButtonState.Role.destructive,
|
||||
action: ButtonState.Handler.send(
|
||||
true,
|
||||
animation: Animation.easeInOut
|
||||
),
|
||||
label: "Destroy"
|
||||
),
|
||||
[1]: ButtonState(
|
||||
role: ButtonState.Role.cancel,
|
||||
action: ButtonState.Handler.send(false),
|
||||
label: "Cancel"
|
||||
)
|
||||
],
|
||||
message: "Something went wrong..."
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
if #available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) {
|
||||
dump = ""
|
||||
customDump(
|
||||
ConfirmationDialogState(
|
||||
title: .init("Alert!"),
|
||||
message: .init("Something went wrong..."),
|
||||
buttons: [
|
||||
.destructive(.init("Destroy"), action: .send(true, animation: .default)),
|
||||
.cancel(.init("Cancel"), action: .send(false)),
|
||||
]
|
||||
),
|
||||
to: &dump
|
||||
)
|
||||
XCTAssertNoDifference(
|
||||
dump,
|
||||
"""
|
||||
ConfirmationDialogState(
|
||||
title: "Alert!",
|
||||
actions: [
|
||||
[0]: ButtonState(
|
||||
role: ButtonState.Role.destructive,
|
||||
action: ButtonState.Handler.send(
|
||||
true,
|
||||
animation: Animation.easeInOut
|
||||
),
|
||||
label: "Destroy"
|
||||
),
|
||||
[1]: ButtonState(
|
||||
role: ButtonState.Role.cancel,
|
||||
action: ButtonState.Handler.send(false),
|
||||
label: "Cancel"
|
||||
)
|
||||
],
|
||||
message: "Something went wrong..."
|
||||
)
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
@testable import SwiftUINavigation
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import CustomDump
|
||||
import SwiftUINavigation
|
||||
import XCTest
|
||||
|
||||
final class TextStateTests: XCTestCase {
|
||||
func testTextState() {
|
||||
var dump = ""
|
||||
customDump(TextState("Hello, world!"), to: &dump)
|
||||
XCTAssertEqual(
|
||||
dump,
|
||||
"""
|
||||
"Hello, world!"
|
||||
"""
|
||||
)
|
||||
|
||||
dump = ""
|
||||
customDump(
|
||||
TextState("Hello, ")
|
||||
+ TextState("world").bold().italic()
|
||||
+ TextState("!"),
|
||||
to: &dump
|
||||
)
|
||||
XCTAssertEqual(
|
||||
dump,
|
||||
"""
|
||||
"Hello, _**world**_!"
|
||||
"""
|
||||
)
|
||||
|
||||
dump = ""
|
||||
customDump(
|
||||
TextState("Offset by 10.5").baselineOffset(10.5)
|
||||
+ TextState("\n") + TextState("Headline").font(.headline)
|
||||
+ TextState("\n") + TextState("No font").font(nil)
|
||||
+ TextState("\n") + TextState("Light font weight").fontWeight(.light)
|
||||
+ TextState("\n") + TextState("No font weight").fontWeight(nil)
|
||||
+ TextState("\n") + TextState("Red").foregroundColor(.red)
|
||||
+ TextState("\n") + TextState("No color").foregroundColor(nil)
|
||||
+ TextState("\n") + TextState("Italic").italic()
|
||||
+ TextState("\n") + TextState("Kerning of 2.5").kerning(2.5)
|
||||
+ TextState("\n") + TextState("Stricken").strikethrough()
|
||||
+ TextState("\n") + TextState("Stricken green").strikethrough(color: .green)
|
||||
+ TextState("\n") + TextState("Not stricken blue").strikethrough(false, color: .blue)
|
||||
+ TextState("\n") + TextState("Tracking of 5.5").tracking(5.5)
|
||||
+ TextState("\n") + TextState("Underlined").underline()
|
||||
+ TextState("\n") + TextState("Underlined pink").underline(color: .pink)
|
||||
+ TextState("\n") + TextState("Not underlined purple").underline(false, color: .pink),
|
||||
to: &dump
|
||||
)
|
||||
XCTAssertNoDifference(
|
||||
dump,
|
||||
#"""
|
||||
"""
|
||||
<baseline-offset=10.5>Offset by 10.5</baseline-offset>
|
||||
Headline
|
||||
No font
|
||||
<font-weight=light>Light font weight</font-weight>
|
||||
No font weight
|
||||
<foreground-color=red>Red</foreground-color>
|
||||
No color
|
||||
_Italic_
|
||||
<kerning=2.5>Kerning of 2.5</kerning>
|
||||
~~Stricken~~
|
||||
<s color=green>Stricken green</s>
|
||||
Not stricken blue
|
||||
<tracking=5.5>Tracking of 5.5</tracking>
|
||||
<u>Underlined</u>
|
||||
<u color=pink>Underlined pink</u>
|
||||
Not underlined purple
|
||||
"""
|
||||
"""#
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue