|
| 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] |
0 commit comments