Skip to content

Commit a8c516b

Browse files
committed
Initial Commit
1 parent 670b0bd commit a8c516b

File tree

17 files changed

+993
-0
lines changed

17 files changed

+993
-0
lines changed

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>IDEDidComputeMac32BitWarning</key>
6+
<true/>
7+
</dict>
8+
</plist>

Package.resolved

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// swift-tools-version: 5.10
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
import CompilerPluginSupport
6+
7+
let package = Package(
8+
name: "ComposableArchitecturePattern",
9+
platforms: [
10+
.iOS(.v17),
11+
.macOS(.v14),
12+
.tvOS(.v17),
13+
.watchOS(.v10),
14+
.macCatalyst(.v13)
15+
],
16+
products: [
17+
.library(
18+
name: "ComposableArchitecturePattern",
19+
targets: ["ComposableArchitecturePattern"]),
20+
.library(
21+
name: "Composable",
22+
targets: ["Composable"]
23+
),
24+
.executable(
25+
name: "ComposableClient",
26+
targets: ["ComposableClient"]
27+
),
28+
],
29+
dependencies: [
30+
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2"),
31+
],
32+
targets: [
33+
.macro(
34+
name: "ComposableMacros",
35+
dependencies: [
36+
.product(
37+
name: "SwiftSyntaxMacros",
38+
package: "swift-syntax"
39+
),
40+
.product(
41+
name: "SwiftCompilerPlugin",
42+
package: "swift-syntax"
43+
)
44+
]
45+
),
46+
47+
.target(
48+
name: "Composable",
49+
dependencies: ["ComposableMacros"]
50+
),
51+
52+
.executableTarget(
53+
name: "ComposableClient",
54+
dependencies: ["Composable"]
55+
),
56+
57+
.target(
58+
name: "ComposableArchitecturePattern",
59+
dependencies: ["Composable"]
60+
),
61+
62+
.testTarget(
63+
name: "ComposableArchitecturePatternTests",
64+
dependencies: [
65+
"ComposableMacros",
66+
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
67+
]
68+
),
69+
]
70+
)

README.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Composable Architecture Pattern (CAP)
2+
3+
This package is designed to demonstrate how to build composable views and code so the views and code can be testable, scalable, and reusable. This also provides a library that's intended to very basic so you don't have to learn the library or use CAP as a framework but rather as a source you can dip into and use when you want or need to.
4+
5+
Composable means self-sustained (1), which means each view should be able to sustain itself. In order to do that the view should have an approach that allows actions in the view to be testable. This means giving the view what it needs so it can be testable, no more no less. This also means architecting our code so we can have a separation of concerns so we're not passing around large view models or objects into each view. There's several ways this can be done and will be discussed below.
6+
7+
You'll notice this is called a "pattern". This is because I believe software architecture always needs guidance but not always a library or framework. This approach allows you to make use of the architecture pattern and the library as you see fit. While being light and overall easy to use, writing good code takes time and effort and your goal should be to improve as a developer to architect safe code that hopefully is scalable and reusable.
8+
9+
## Get Started
10+
It would behoove you to read through [Core Principles](#core-principles) to fully understand the overall logic behind this architecture pattern.
11+
12+
## Core Principles
13+
1. Composable
14+
Each object and view should be composable, which means self-contained. So, we should avoid large complex views that are heavily dependent upon another view or on a specific object. There's several ways we can accomplish this:
15+
16+
a.) Protocols. This is a great way of isolating the view to whatever we define in the protocol so the view can be used anywhere that can conform and provide what the protocol entails.
17+
```swift
18+
protocol UserData {
19+
var imageURL: URL? { get }
20+
var name: String { get }
21+
var info: String? { get }
22+
}
23+
24+
struct UserCell<User: UserData>: View {
25+
let user: User
26+
27+
var body: some View {
28+
HStack {
29+
AsyncImage(url: user.imageURL)
30+
31+
VStack(alignment: .leading) {
32+
Text(user.name)
33+
34+
if let info = self.user.info {
35+
Text(info)
36+
.foregroundStyle(.secondary)
37+
}
38+
}
39+
}
40+
}
41+
}
42+
```
43+
44+
We could take this further by also applying actions to the view.
45+
```swift
46+
enum ImageAction {
47+
case change
48+
case remove
49+
}
50+
51+
protocol ImageData {
52+
var imageURL: URL? { get set }
53+
}
54+
55+
struct ImageViewer<Image: ImageData, Action: ImageAction>: View {
56+
typealias ActionHandler = (Action) async throws -> Void // This can also return a `Bool` or whatever you want.
57+
58+
let image: Image
59+
let handle: ActionHandler
60+
61+
var body: some View {
62+
AsyncImage(url: image.imageURL)
63+
.contextMenu {
64+
Button("Remove") {
65+
Task {
66+
// We don't do anything with any error but in production you definitely should.
67+
try? await handle(.remove)
68+
}
69+
}
70+
}
71+
}
72+
}
73+
```
74+
75+
b.) Predetermined values or models with actions. This is a similar approach to protocols but here we pass in an object or values that aren't specific to any protocol but are specific in what must be used.
76+
Here we will use specific values:
77+
```swift
78+
enum ImageAction {
79+
case change
80+
case remove
81+
}
82+
83+
struct ImageViewer: View {
84+
typealias ActionHandler = (ImageAction) async throws -> Void // This can also return a `Bool` or whatever you want.
85+
86+
let imageURL: URL?
87+
let handle: ActionHandler
88+
89+
var body: some View {
90+
AsyncImage(url: self.imageURL)
91+
.contextMenu {
92+
Button("Remove") {
93+
Task {
94+
// We don't do anything with any error but in production you definitely should.
95+
try? await handle(.remove)
96+
}
97+
}
98+
}
99+
}
100+
}
101+
102+
struct UserCell: View {
103+
...
104+
@State private var imageURL: URL?
105+
106+
var body: some View {
107+
ImageViewer(
108+
imageURL: self.imageURL,
109+
handle: { action in
110+
switch action {
111+
case .change:
112+
// Present view to change the image.
113+
...
114+
}
115+
}
116+
}
117+
}
118+
```
119+
120+
Here we will use an object
121+
```swift
122+
@Observable // Only available in Swift 5.9 -> iOS 17, macOS 14
123+
class ImageModel: ObservableObject {
124+
var imageURL: URL? // Will need to use @Published wrapper if not using @Observable macro.
125+
}
126+
127+
struct ImageViewer: View {
128+
typealias ActionHandler = (ImageAction) async throws -> Void // This can also return a `Bool` or whatever you want.
129+
130+
var model: ImageModel
131+
let handle: ActionHandler
132+
133+
var body: some View {
134+
AsyncImage(url: self.model.imageURL)
135+
.contextMenu {
136+
Button("Remove") {
137+
Task {
138+
// We don't do anything with any error but in production you definitely should.
139+
try? await handle(.remove)
140+
}
141+
}
142+
}
143+
}
144+
}
145+
146+
struct UserCell: View {
147+
...
148+
var imageModel: ImageModel // If not using @Observable macro, this will need to use @ObservedObject.
149+
150+
var body: some View {
151+
ImageViewer(
152+
imageURL: self.imageModel, // This could also be referenced from a user model like: `self.userModel.imageModel`.
153+
handle: { action in
154+
switch action {
155+
case .change:
156+
// Present view to change the image.
157+
...
158+
}
159+
}
160+
}
161+
}
162+
```
163+
164+
As you can see there's parts of this that could get repetitive, such as using class objects for each view.
165+
166+
## References
167+
1. (Composability - Wikipedia)[https://en.wikipedia.org/wiki/Composability]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import SwiftUI
2+
3+
/// A closure called asynchronously with the given output and throw any error.
4+
public typealias OutputHandler<Output> = (Output) async throws -> Void
5+
6+
public protocol Composable {
7+
/// The supported actions of a view.
8+
associatedtype Actions
9+
}
10+
11+
/// A protocol to provide a basis for making the implementation composable.
12+
public protocol ComposableView: View, Composable {
13+
/// A closure called to handle actions performed in the view.
14+
var perform: OutputHandler<Actions> { get }
15+
16+
/// Any layout designs to support a view.
17+
associatedtype Design
18+
}
19+
20+
public protocol ComposableObject: Composable {
21+
func handle(action: Actions) async throws
22+
}
23+
24+
@attached(member)
25+
@attached(member, names: named(perform), named(Actions), named(Design))
26+
public macro Composable() = #externalMacro(module: "ComposableMacros", type: "ComposableMacro")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// URLResponse+Extensions.swift
3+
// ComposableViewPattern
4+
//
5+
// Created by Jonathan Holland on 1/01/24.
6+
//
7+
8+
import Foundation
9+
10+
public extension URLResponse {
11+
func analyzeAsHTTPResponse() throws -> Bool {
12+
guard let httpResponse = self as? HTTPURLResponse else {
13+
throw ServerAPIError.unknown(description: "Unable to unwrap as `HTTPURLResponse`")
14+
}
15+
16+
switch httpResponse.statusCode {
17+
case 100...199:
18+
throw ServerAPIError.unknown(description: httpResponse.description)
19+
case 200...299:
20+
return true
21+
case 400...499:
22+
throw ServerAPIError.network(description: httpResponse.description)
23+
case 500...599:
24+
throw ServerAPIError.server(description: httpResponse.description, httpStatusCode: httpResponse.statusCode, jsonObject: nil)
25+
default:
26+
throw ServerAPIError.unknown(description: "Unknown HTTPURLResponse: \(httpResponse.description)")
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)