├── Assets
├── tba@2x.png
├── project@2x.png
└── navigation-graph@2x.png
├── LICENSE
├── .gitignore
└── README.md
/Assets/tba@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeclarativeHub/TheBinderArchitecture/HEAD/Assets/tba@2x.png
--------------------------------------------------------------------------------
/Assets/project@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeclarativeHub/TheBinderArchitecture/HEAD/Assets/project@2x.png
--------------------------------------------------------------------------------
/Assets/navigation-graph@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeclarativeHub/TheBinderArchitecture/HEAD/Assets/navigation-graph@2x.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Declarative Hub
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Build generated
6 | build/
7 | DerivedData/
8 |
9 | ## Various settings
10 | *.pbxuser
11 | !default.pbxuser
12 | *.mode1v3
13 | !default.mode1v3
14 | *.mode2v3
15 | !default.mode2v3
16 | *.perspectivev3
17 | !default.perspectivev3
18 | xcuserdata/
19 |
20 | ## Other
21 | *.moved-aside
22 | *.xccheckout
23 | *.xcscmblueprint
24 | .DS_Store
25 |
26 | ## Obj-C/Swift specific
27 | *.hmap
28 | *.ipa
29 | *.dSYM.zip
30 | *.dSYM
31 |
32 | ## Playgrounds
33 | timeline.xctimeline
34 | playground.xcworkspace
35 |
36 | # Swift Package Manager
37 | #
38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
39 | # Packages/
40 | # Package.pins
41 | .build/
42 |
43 | # CocoaPods
44 | #
45 | # We recommend against adding the Pods directory to your .gitignore. However
46 | # you should judge for yourself, the pros and cons are mentioned at:
47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
48 | #
49 | # Pods/
50 |
51 | # Carthage
52 | #
53 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
54 | # Carthage/Checkouts
55 |
56 | Carthage/Build
57 |
58 | # fastlane
59 | #
60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
61 | # screenshots whenever they are needed.
62 | # For more information about the recommended setup visit:
63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
64 |
65 | fastlane/report.xml
66 | fastlane/Preview.html
67 | fastlane/screenshots
68 | fastlane/test_output
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # The Binder Architecture
2 |
3 | The Binder Architecture is a declarative architecture for iOS development inspired by MVVM and VIPER. It is an attempt to take the best ideas of MVVM and VIPER and implement them without the boilerplate code that the two architectures, especially the latter, suffer from.
4 |
5 | The central idea of the Binder architecture is implementing application logic as a function, as opposed to an object. In place of the ViewModel or Presenter, The Binder Architecture defines a function called *the binder*. Such approach enforces declarative application logic that provides all the benefits of declarative paradigm and results in a significant reduction of the boilerplate code that can be found in MVVM and VIPER.
6 |
7 | ## Overview
8 |
9 |
10 |
11 | The architecture defines three main layers:
12 |
13 | 1. **Business logic** layer represents all the code that defines *what the app can do and how to do it*. It can be comprised out of many first or third party components. It exposes all of its functionality to other layers of the architecture through a number of components called *Services*.
14 | 2. **Application logic** layer represents a bridge between the business logic and the view. It knows when and how to presents the UI, loads the data using the business logic services, formats the data for the display, displays the data using the view and handles users actions by notifying appropriate Services. The central component of this layer is the *Binder* function.
15 | 3. **View** layer defines UI. It is a declarative layer comprised of *UIView* and *UIViewController* subclasses.
16 |
17 | ## Business Logic
18 |
19 | Every good app provides some kind of a value to the user. Sometimes it does that by exposing a web service, sometimes by solving problems locally on the device and often by a combination of the two.
20 |
21 | An app can communicate with web APIs, different business services, databases or other data persistence solutions, system services or device sensors and various other things. All of those will have their entities, managers and other kinds of types and objects. A code that interacts with them and builds on top of them is often referred to as the *business logic*. That is the bottom-most layer of our architecture.
22 |
23 | One could write a book on how to properly implement the business logic layer and even that might not be enough. Thankfully there is a simple rule of thumb that we can use to define boundaries of the layer: imagine that we are making a cross-platform app (i.e. an app that can run on iOS, Android, TV, watch and/or desktop) and ask ourselves: “What is the code that can be shared across all of the platforms?” The answer is the code of the business logic layer.
24 |
25 | The business logic layer exposes its functionality through a number of Services. Each Service is responsible for its own concern. For example, a webshop app could have `ProductService`, `ProductSearchService`, `CartService`, `CheckoutService`, etc. A `CartService` would represent a cart and be able to manage it by providing methods like `addProduct`, `removeProduct`, `empty`, etc.
26 | The Services would be accompanied by the Entities they work on like `Product`, `Cart`, `User`, etc. It is recommended to model those as value types like structs and enums when possible.
27 |
28 | Here is a simple example of an entity and a service.
29 |
30 | ```swift
31 | public struct User {
32 | public let name: String
33 | public let imageUrl: URL
34 | ...
35 | }
36 |
37 | public class UserService {
38 |
39 | public let user: User
40 | public let friends: LoadingSignal<[User], ApplicationError>
41 | ...
42 |
43 | public func logOut()
44 | public func addFriend(_ user: User) -> LoadingSignal
45 | ...
46 | }
47 | ```
48 |
49 | We are showing only the public interface, not the implementation. It probably communicates with some web API and it might use CoreData for the persistance, but we don’t really care about its implementation. We only need to know that it represents our business logic on top of which we are building our app.
50 | Now, you have probably noticed the *Signal* type there. We will be using [ReactiveKit](https://github.com/DeclarativeHub/ReactiveKit) and [Bond](https://github.com/DeclarativeHub/Bond) to demonstrate functional reactive aspects of the architecture, but other solutions will work well too.
51 |
52 | ## View
53 |
54 | View displays content and UI to the user. It is responsible for layout, style and dynamic aspects of UI like rotations, animations, etc. The View is mostly built declaratively by leveraging Storyboards, writing declarative Auto Layout code, using declarative layout and styling frameworks or a combination of those.
55 |
56 | On iOS, we usually build View layer out of `UIView` family of classes which we structure within `UIViewController` subclasses. One can think of `UIViewController` as the top-most view of the view hierarchy. It can present that hierarchy in a modal, tab, navigational or some other context. Our `UIViewController` subclasses, thus, belong to the View layer.
57 |
58 | Here is an example of a View Controller. We need not go into the details of implementing one in order to define our architecture. All we care for now is its public interface.
59 |
60 | ```swift
61 | public class ProfileViewController: UIViewController {
62 | public let nameLabel: UILabel
63 | public let imageView: UIImageView
64 | public let friendsView: UICollectionView
65 | public let logoutButton: UIButton
66 | }
67 | ```
68 |
69 | Note that there are no dependencies to other layers of the architecture, nor does it implement actions like `didTapLogoutButton`. We are not going to add those later!
70 |
71 | We shall treat View Controller as another View, but properly this time. Of course we will be subclassing `UIViewController` to define its view structure and other View-related aspects like in the example, but we will not be taking any dependencies, loading data or even handing actions from the subclass. We will do that from the “outside”.
72 |
73 | ## Application Logic
74 |
75 | I am sure you could think of a number of ways to connect our Service and our View. You could go with MVC, you could introduce `ProfileViewModel` or go full in with VIPER. All those solutions would, however, require us to modify the View Controller. We would need to add `viewModel` or `interactor` property and handle `didTapLogoutButton` in the `ProfileViewContoller`. We, of course, are not going to do that because we need to respect the `ProfileViewController` and treat it as a View layer. We are not going to touch its implementation. Instead, we will say hello to the Binder!
76 |
77 | ### Binder
78 |
79 | How does one then connect the business logic (Service) and the View (or View Controller) without touching their implementations at all? It took me more than a year to figure that out. The irony is that we were all already doing it. We were doing it with `UIButton`, with `UILabel` and with other views.
80 |
81 | ```swift
82 | extension ProfileViewController {
83 |
84 | func makeLogoutButton() -> UIButton {
85 | let button = UIButton(type: .system)
86 | button.setTitle("Log out", for: .normal)
87 | return button
88 | }
89 | }
90 | ```
91 |
92 | Sometimes we did it in a method like above, sometimes as a lazy property, but the pattern is always the same: instantiate the view, configure it and return it. Why have we never done the same for `XYZViewController` that we said we are going to treat like any other View. Why have we failed to see the pattern.
93 |
94 | Let us, however, try doing that as an experiment. We will start by defining a function that instantiates the View Controller and returns it.
95 |
96 | ```swift
97 | extension ??? {
98 |
99 | func makeViewController() -> ProfileViewController {
100 | let viewController = ProfileViewController()
101 | // ...configure viewController...
102 | return viewController
103 | }
104 | }
105 | ```
106 |
107 | Just as in our button example, we have a function that now creates the view controller, configures it and returns it. Before we proceed to the implementation, let us think for a while what type should that extension be defined on.
108 |
109 | Our example function that makes the button is defined on the view controller (or in its extension) because the view controller is the one that instantiates the button. Following the same principle, the function that makes the view controller could be defined on a type which will be instantiating the view controller. However, since a view controller can be instantiated from many places, it would be wasteful to have each such place implement the same *make* function. A better way would be to implement it at only one place — on the view controller itself, now of course as a static function.
110 |
111 | ```swift
112 | extension ProfileViewController {
113 |
114 | static func makeViewController() -> ProfileViewController {
115 | let viewController = ProfileViewController()
116 | // ...configure viewController...
117 | return viewController
118 | }
119 | }
120 | ```
121 |
122 | One can do the same for views, too. If there is a button that repeats throughout the app and which does not require a subclass, we can implement it as a static *make* function on the `UIButton` itself.
123 |
124 | Let us continue with the experiment. Now that we have a function that creates the view controller, how do we actually make it do the stuff it was meant to do. We want it to display the user data represented by the user service. We can try passing in the service and see what can we do with it.
125 |
126 | ```swift
127 | extension ProfileViewController {
128 |
129 | static func makeViewController(_ userService: UserService) -> ProfileViewController {
130 | let viewController = ProfileViewController()
131 |
132 | viewController.nameLabel.text = userService.user.name
133 | viewController.imageView.imageUrl = userService.user.imageUrl // Assuming using an image caching libary
134 |
135 | userService.friends
136 | .consumeLoadingState(by: viewController.friendsView) // Show the loading indicator on friendsView
137 | .bind(to: viewController.friendsView) { cell, friend in
138 | cell.nameLabel.text = friend.name
139 | }
140 |
141 | // ...
142 |
143 | return viewController
144 | }
145 | }
146 | ```
147 |
148 | What the heck — did we just fill the data in. It is like our button example, now configuring the View Controller. Awesome!
149 |
150 | There are two cases regarding the data. The data that is available *at the binding time* can be just **assigned** to the View Controller, while the data that is available *asynchronously* should be **bound** to the View Controller (or its subviews).
151 |
152 | We are making functional reactive programming a first-class citizen here. No longer it serves the function of communication channel between the View and the View Model. Now we are directly binding the reactive business logic data of the business logic layer to the View while at the same time we keep those layers completely oblivious of each other. The only piece of code that is aware of both layers is this one function.
153 |
154 | Business logic layer data and events flow from the Service to the View Controller, while user actions and user input flow from the View Controller to the Service. We have solved the problem of Service data flow by assignments and bindings, but how do we handle user action and user input? How do we make tapping the *Log Out* button call `logOut` function on the `UserService`? Well, since we are leveraging functional reactive programming and making it a first-class citizen, there is really no problem to solve there. Just make function of a service an observer of the user action or user input.
155 |
156 | ```swift
157 | extension ProfileViewController {
158 |
159 | static func makeViewController(_ userService: UserService) -> ProfileViewController {
160 | let viewController = ProfileViewController()
161 |
162 | viewController.nameLabel.text = userService.user.name
163 | viewController.imageView.imageUrl = userService.user.imageUrl // Assuming using an image caching libary
164 |
165 | userService.friends
166 | .consumeLoadingState(by: viewController.friendsView) // Show the loading indicator on friendsView
167 | .bind(to: viewController.friendsView) { cell, friend in
168 | cell.nameLabel.text = friend.name
169 | }
170 |
171 | viewController.logoutButton.reactive.tap
172 | .observeNext(with: userService.logOut)
173 | .dispose(in: viewController.bag)
174 |
175 | return viewController
176 | }
177 | }
178 | ```
179 |
180 | Is that it? Yes — that is our View Model destroyer! Who would have thought it would be just a function, but as we learned from the experiment it turns out that we do not really need another type between the business logic layer (the Model layer if you will) and the View.
181 |
182 | I like to call this function **the Binder** because it binds two architectural layers together. The example we built is a template that can scale no matter how the Service or the View Controller are complex as long as you follow the three rules we empirically deduced:
183 |
184 | 1. **Assign** Service data that is available at *the binding time* to the View Controller
185 | 2. **Bind** Service data or events that are available *asynchronously* (as Signals/Observables) to the View Controller
186 | 3. **Observe** user actions or user input from the View Controller with the *instance methods* of the Service
187 |
188 | With those three rules you can implement any Binder, i.e. connect any Service to any View Controller. Through those three rules you express your applications logic — what loads when, what displays where, what formats how, etc. It really is as simple as that.
189 |
190 | ## Navigation (Routing)
191 |
192 | When thinking about app navigation, we must ask ourselves two questions:
193 |
194 | 1. Who initiates the navigation?
195 | 2. Who performs the navigation?
196 |
197 | Navigation is most commonly initiated by the user tapping a button or performing some other action. Alternatively, navigation can be triggered by an event from the business logic. A code that handles both user actions and business logic events is the application logic code - in our case the binder function. That answers the first question.
198 |
199 | The answer to the second questions should already be known to those familiar with UIKit. It is view controllers that perform navigation. View controllers present other view controllers. They present them in a navigation stack in case of UINavigationController, in a tab container in case of UITabBarController, modally in case of calling `present` method or in some other built-in or custom way.
200 |
201 | Binders both implement the application logic and create view controllers. Does that mean that they should also implement the navigation? The answer is yes, however, there is a couple of ways to do it.
202 |
203 | Let us say that our `ProfileViewController` has a button that should open a view controller where the user can edit their profile. We can implement such navigation in the following way:
204 |
205 | ```swift
206 | extension ProfileViewController {
207 |
208 | static func makeViewController(_ userService: UserService) -> ProfileViewController {
209 | ...
210 | viewController.editProfileButton.reactive.tap
211 | .bind(to: viewController) { viewController in
212 | let editProfileVC = EditProfileViewController.makeViewController(userService)
213 | viewController.present(editProfileVC, animated: true)
214 | }
215 | ...
216 | }
217 | }
218 | ```
219 |
220 | We observe button tap evens with a binding and present the new view controller when the event occurs. Simple.
221 |
222 | #### Dependency Pyramid Problem
223 |
224 | `EditProfileViewController` has the same dependency as `ProfileViewController` - `UserService`. However, what if that was not the case? What if the view controller that we are about to present has some other dependency unknown to `ProfileViewController`? Let us consider something like the navigation to a friend list that depends on an arbitrary `FriendsService`. Following the same approach
225 |
226 | ```swift
227 | extension ProfileViewController {
228 |
229 | static func makeViewController(_ userService: UserService) -> ProfileViewController {
230 | ...
231 | viewController.friendsButton.reactive.tap
232 | .bind(to: viewController) { viewController in
233 | let friendService = ???
234 | let friendsViewController = FriendsViewController.makeViewController(friendService)
235 | viewController.navigationController?.pushViewController(friendsViewController, animated: true)
236 | }
237 | ...
238 | }
239 | }
240 | ```
241 |
242 | we would end up in a trouble. Where do we get `friendService` from? Passing it to binder (to the `makeViewController`) is a bad idea. `ProfileViewController` does not use it so it would be redundant. Could we just make `FriendsService` a singleton? No, singletons are out of the question!
243 |
244 | This is a classic dependency injection problem. A dependency injection framework could help, but that would not be fun. We can actually solve this ourselves.
245 |
246 | A basic solution would be to make services create another services. For example, our `UserService` could have a method `makeFriendsService()` that creates and returns a `FriendService`. This would be a fine solution for a simple app or for a related services (for example if they are in a parent-child relationship), but in the more complex projects we would end with many services being able to create many other unrelated services.
247 |
248 | A general solution is to have a dependency provider and pass that to the binders. A dependency provider is just a top level service (the peek of our pyramid) like a session object or another object that owns or makes dependencies. Here is an example:
249 |
250 | ```swift
251 | class Session {
252 |
253 | // Session usually owns the client as it know about authentication
254 | let client: APIClient
255 |
256 | // Session can own services that are alive as long as the app is alive
257 | let currentUserService: UserService
258 | ...
259 |
260 | // Session can create other services when needed
261 | func makeFriendsService(for user: User) -> FriendsService
262 | ...
263 | }
264 | ```
265 |
266 | We can then refactor our example to use the `Session` object.
267 |
268 | ```swift
269 | extension ProfileViewController {
270 |
271 | static func makeViewController(_ session: Session) -> ProfileViewController {
272 | let userService = session.currentUserService
273 |
274 | ...
275 |
276 | viewController.friendsButton.reactive.tap
277 | .bind(to: viewController) { viewController in
278 | let friendsViewController = FriendsViewController.makeViewController(user: userService.user, session: session)
279 | viewController.navigationController?.pushViewController(friendsViewController, animated: true)
280 | }
281 | ...
282 | }
283 | }
284 | ```
285 |
286 | `FriendsViewController` binder would then call session's `makeFriendsService` method with the given user to get the service it requires.
287 |
288 | Make sure to check out the [demo app](https://github.com/DeclarativeHub/AbsurdGitter) for a working example.
289 |
290 | ## Discussion
291 |
292 | ### Ownership
293 |
294 | Who owns the Services and other business logic layer objects? Turns out that nobody has to own them explicitly. There are three cases to consider:
295 |
296 | 1. *A service provides only static data*. In this case, after we assign the data at the binding time, we no longer need the service so it can be released and destroyed as far as we are concerned.
297 |
298 | 2. *A service provides asynchronous data or events*. We are assuming that this kind of data will be provided by Signals/Observables. Since those will be bound to the View Controller and bindings retain the Signals while the binding target (View Controller) is alive, we again need not care about the ownership. Relevant objects will be retained as long as the View Controller lives.
299 |
300 | 3. *A service handles user actions or accepts user data*. We said earlier that we will be observing that kind of data with the Services’ instance methods. Signals/Observables retain their observers which are in our case the Service instance methods so that will in turn retain the Service itself. We will always put the observation disposable in the View Controller’s dispose bag to ensure that the observation is disposed when the View Controller is deallocated.
301 |
302 | You might ask yourself why don’t we bind user actions and user input to the Service. The reason is that bindings, as opposed to the observations, do not retain their targets. We need the Service alive when action happens - case #3.
303 |
304 | ### Massive Binders
305 |
306 | When the View Controller presents a lot of data and handles a lot of user input and actions, the Binder function will naturaly grow. This might be scary, but it is actually not a problem. Here is why.
307 |
308 | 1. The binder is a declarative piece of code. There is no state in it so the complexity does not increase with a new line of code. Each line, i.e. an assignment, a binding or an observation is independent with the respect to others. You should be able to completely shuffle their order and the Binder behaviour should remain unchanged.
309 | 2. Like any function, you can split it into more smaller functions. Every observation or binding closure that is longer than a couple of lines of code can be extracted into a separate function.
310 |
311 | ### Structuring Xcode Project
312 |
313 |
314 |
315 | The Binder function and related helper functions should live in their own file! I would actually recommend developing every layer of the architecture as a separate framework (target).
316 |
317 | Here is an example of simple structure that makes use of frameworks for each layer. The bottom-most layer, the business logic, is implemented by franeworks like API and Services.
318 |
319 | The app also needs the View, or the user interface. We can put that in separate framework called Views. `UIView` subclasses, as well as `UIViewController` subclasses, would live there.
320 |
321 | On top of the business logic layers and View layer there will be our Binder layer. That is where all the Binder functions will live. You would put each binder into a separate file. You will usually have one Binder file for each View Controller.
322 |
323 | On top of everything is the Application itself. It will contain the App Delegate and nothing else. The app delegate would import Binders and use the application’s root view controller Binder to instantiate the root view controller.
324 |
325 | ### Reactive Libraries
326 |
327 | This architecture should be a great fit for all major functional reactive libraries.
328 |
329 | With [RxSwift](https://github.com/ReactiveX/RxSwift) one could leverage Observables and Drivers in their Services and Binders. RxCocoa provides a great collection of bindings and reactive extensions to handle user actions and user input.
330 |
331 | [ReactiveSwift](https://github.com/ReactiveCocoa/ReactiveSwift/) with [ReactiveCocoa](https://github.com/ReactiveCocoa/ReactiveCocoa/#readme) is another great solution. Although it provides hot and cold signal distinction that this architecture does not require, it would be interesting to see Binders built on top of it.
332 |
333 | The reason I am blatantly promoting [ReactiveKit](https://github.com/DeclarativeHub/ReactiveKit) and [Bond](https://github.com/DeclarativeHub/Bond) in this article is that I am the author of the two. They are by no means a requirement for this architecture, but they do offer some perks like safe signals, bindings that automatically handle threading, [inline bindings](https://github.com/DeclarativeHub/ReactiveKit#bindings), [loading signals](https://github.com/DeclarativeHub/ReactiveKit#loading-signals) that enable simple lodading state side effects and [observable collection](https://github.com/DeclarativeHub/Bond#observablearray--mutableobservablearray) with out of the box diffing. As I have been evolving the Binder architecture over time, so were the two libraries shaped to make them perfect fit for the architecture.
334 |
335 | ### Is This a New Architecture?
336 |
337 | Yes and no. Although it required some thinking and a lot of experimentation before I came up with it, the architecture seems to be a variation of [Model-View-Adapter](https://en.wikipedia.org/wiki/Model–view–adapter) where the Binder takes place of the Adapter. The Binder, conceptually, is the Adapter because it solves the same problem with the same constraints, however, our Binder is a function — not a traditional object like the Adapter.
338 |
339 | Since the Binder binds two layers of the architecture together and because it does that by leveraging reactive bindings, I think it is given the right name.
340 |
341 | ### Is There a Demo Project?
342 |
343 | Yes! Check out [AbsurdGitter](https://github.com/DeclarativeHub/AbsurdGitter).
344 |
345 | ## Conclusion
346 |
347 | Thank you for reading this. I would love to hear you feedback, no matter if you love it or if you have some concerns. Feel free to open an issue or make a pull request.
348 |
--------------------------------------------------------------------------------