Skip to content

Commit

Permalink
Merge pull request #8 from thoughtbot/merge-bindings
Browse files Browse the repository at this point in the history
Merge thoughtbot/Bindings as a subtree of CombineViewModel
  • Loading branch information
sharplet committed Aug 24, 2020
2 parents 119a638 + 04b164b commit 19fd07a
Show file tree
Hide file tree
Showing 29 changed files with 580 additions and 30 deletions.
31 changes: 31 additions & 0 deletions CONTRIBUTING.md
@@ -0,0 +1,31 @@
# Contributing

We love contributions from everyone.
By participating in this project,
you agree to abide by the thoughtbot [code of conduct][].

[code of conduct]: https://thoughtbot.com/open-source-code-of-conduct

We expect everyone to follow the code of conduct
anywhere in thoughtbot's project codebases,
issue trackers, chatrooms, and mailing lists.

## Contributing Code

Reactive extensions are usually small and straightforward to implement,
making them a great way to share useful utilities for others to use.

We consider all kinds of changes. If you have an idea but you're not sure
whether it's likely to be accepted, feel free to [open an issue][] first to
discuss it.

[open an issue]: https://github.com/thoughtbot/CombineViewModel/issues

First make your change, being sure to test that it compiles on all supported
platforms.

Push to your fork. Write a [good commit message][commit]. Submit a pull request.

[commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html

Thank you for contributing!
Binary file added Documentation/Images/add-package-dependency.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 0 additions & 2 deletions Example/Sources/CounterViewController.swift
Expand Up @@ -8,8 +8,6 @@ final class CounterViewController: UITableViewController, ViewModelObserver {

@ViewModel private var counter: Counter

var subscriptions: Set<AnyCancellable> = []

required init?(coder: NSCoder) {
super.init(coder: coder)

Expand Down
2 changes: 0 additions & 2 deletions Example/Sources/TasksViewController.swift
Expand Up @@ -21,8 +21,6 @@ final class TasksViewController: UITableViewController, ViewModelObserver {
.appendingPathComponent("tasks.json", isDirectory: false)
@ViewModel private var taskList: TaskList

var subscriptions: Set<AnyCancellable> = []

required init?(coder: NSCoder) {
super.init(coder: coder)

Expand Down
19 changes: 19 additions & 0 deletions LICENSE
@@ -0,0 +1,19 @@
Copyright (c) 2019-20 thoughtbot, inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
11 changes: 9 additions & 2 deletions Package.swift
Expand Up @@ -7,13 +7,20 @@ let package = Package(
platforms: [
.iOS(.v13),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v6),
],
products: [
.library(name: "CombineViewModel", targets: ["CombineViewModel"]),
.library(name: "Bindings", targets: ["Bindings"]),
.library(name: "UIKitBindings", targets: ["UIKitBindings"]),
],
targets: [
.target(name: "CombineViewModel"),
.target(name: "ObjCTestSupport", path: "Tests/ObjCTestSupport"),
.target(name: "CombineViewModel", dependencies: ["Bindings"]),
.target(name: "Bindings"),
.target(name: "UIKitBindings", dependencies: ["Bindings"]),

.testTarget(name: "CombineViewModelTests", dependencies: ["CombineViewModel", "ObjCTestSupport"]),
.target(name: "ObjCTestSupport", path: "Tests/ObjCTestSupport"),
]
)
68 changes: 62 additions & 6 deletions README.md
Expand Up @@ -2,7 +2,13 @@

An implementation of the Model-View-ViewModel (MVVM) pattern using Combine.

## Example
- [Getting Started](#getting-started)
- [Installation](#installation)
- [Bindings](#bindings)
- [Contributing](#contributing)
- [About](#about)

## Getting Started

### Step 1: A view model is class that conforms to `ObservableObject`

Expand Down Expand Up @@ -41,25 +47,20 @@ import CombineViewModel
import UIKit

// (1) Conform your view controller to the ViewModelObserver protocol.
//
final class CounterViewController: UIViewController, ViewModelObserver {
@IBOutlet private var valueLabel: UILabel!

// (2) Declare your view model using the `@ViewModel` property wrapper.
//
@ViewModel private var counter: Counter
var subscriptions: Set<AnyCancellable> = []

// (3) Initialize your view model in init().
//
required init?(coder: NSCoder) {
super.init(coder: coder)
self.counter = Counter()
}

// (4) The `updateView()` method is automatically called on the main queue
// when the view model changes. It is always called after `viewDidLoad()`.
//
func updateView() {
valueLabel.text = counter.formattedValue
}
Expand All @@ -73,3 +74,58 @@ final class CounterViewController: UIViewController, ViewModelObserver {
}
}
```

## Installation

CombineViewModel is distributed via Swift Package Manager. To add it to your
Xcode project, navigate to File > Add Package Dependency…, paste in the
repository URL, and follow the prompts.

<img alt="Screen capture of Xcode on macOS Big Sur, with the Add Package Dependency menu item highlighted" width="945" src="/Documentation/Images/add-package-dependency.png">

## Bindings

CombineViewModel also provides the complementary [`Bindings`](/Sources/Bindings)
module. It provides two operators — `<~`, the **input binding operator**, and
`~>`, the **output binding operator** — along with various types and protocols
that support it. Note that the concept of a "binding" provided by the Bindings
module is different to [SwiftUI's `Binding` type][Binding].

[Binding]: https://developer.apple.com/documentation/swiftui/binding

Platform-specific binding helpers are also provided:

- [UIKitBindings](/Sources/UIKitBindings)

## Contributing

Have a useful reactive extension in your project?
Please consider contributing it back to the community!

For more details, see the [CONTRIBUTING][] document.
Thank you, [contributors][]!

[CONTRIBUTING]: CONTRIBUTING.md
[contributors]: https://github.com/thoughtbot/Bindings/graphs/contributors

## License

CombineViewModel is Copyright © 2019–20 thoughtbot, inc.
It is free software, and may be redistributed
under the terms specified in the [LICENSE][] file.

[LICENSE]: /LICENSE

## About

![thoughtbot](http://presskit.thoughtbot.com/images/thoughtbot-logo-for-readmes.svg)

Bindings is maintained and funded by thoughtbot, inc.
The names and logos for thoughtbot are trademarks of thoughtbot, inc.

We love open source software!
See [our other projects][community]
or [hire us][hire] to help build your product.

[community]: https://thoughtbot.com/community?utm_source=github
[hire]: https://thoughtbot.com/hire-us?utm_source=github
69 changes: 69 additions & 0 deletions Sources/Bindings/BindingOwner.swift
@@ -0,0 +1,69 @@
import Combine
import Foundation

private var bindingOwnerSubscriptionsKey: UInt8 = 0

public protocol BindingOwner: AnyObject, ReactiveExtensionProvider {
associatedtype Subscriptions: Collection & ExpressibleByArrayLiteral = Set<AnyCancellable>
where Subscriptions.Element == AnyCancellable

var subscriptions: Subscriptions { get set }
func store<C: Cancellable>(_ subcription: C)
}

extension NSObject: BindingOwner {}

extension BindingOwner where Subscriptions: RangeReplaceableCollection {
@inlinable
public var subscriptions: Subscriptions {
_read { yield _subscriptions }
set { _subscriptions = newValue }
_modify { yield &_subscriptions }
}

@inlinable
public func store<C: Cancellable>(_ subscription: C) {
subscription.store(in: &subscriptions)
}
}

extension BindingOwner where Subscriptions == Set<AnyCancellable> {
@inlinable
public var subscriptions: Subscriptions {
_read { yield _subscriptions }
set { _subscriptions = newValue }
_modify { yield &_subscriptions }
}

@inlinable
public func store<C: Cancellable>(_ subscription: C) {
subscription.store(in: &subscriptions)
}
}

extension BindingOwner {
@usableFromInline
var _subscriptions: Subscriptions {
_read {
yield _getBox().value
}
set {
_getBox().value = newValue
}
_modify {
yield &_getBox().value
}
}

@usableFromInline
func _getBox() -> Box<Subscriptions> {
let box: Box<Subscriptions>
if let object = objc_getAssociatedObject(self, &bindingOwnerSubscriptionsKey) {
box = object as! Box<Subscriptions>
} else {
box = Box([])
objc_setAssociatedObject(self, &bindingOwnerSubscriptionsKey, box, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
return box
}
}
77 changes: 77 additions & 0 deletions Sources/Bindings/BindingSink.swift
@@ -0,0 +1,77 @@
import Combine

public final class BindingSink<Owner: BindingOwner, Input> {
public typealias Failure = Never

private(set) weak var owner: Owner?
private let receiveCompletion: (Owner, Subscribers.Completion<Failure>) -> Void
private let receiveValue: (Owner, Input) -> Void
private var subscription: Subscription?

public init(owner: Owner, receiveCompletion: @escaping (Owner, Subscribers.Completion<Failure>) -> Void = { _, _ in }, receiveValue: @escaping (Owner, Input) -> Void) {
self.owner = owner
self.receiveCompletion = receiveCompletion
self.receiveValue = receiveValue
}

private func withOwner(_ body: (Owner) -> Void) {
if let owner = owner {
body(owner)
} else {
cancel()
}
}
}

extension BindingSink where Input == Void {
public convenience init(owner: Owner, receiveCompletion: @escaping (Owner, Subscribers.Completion<Failure>) -> Void = { _, _ in }, receiveValue: @escaping (Owner) -> Void) {
self.init(owner: owner, receiveCompletion: receiveCompletion, receiveValue: { owner, _ in receiveValue(owner) })
}
}

extension BindingSink where Input == Never {
public convenience init(owner: Owner, receiveCompletion: @escaping (Owner, Subscribers.Completion<Failure>) -> Void) {
self.init(owner: owner, receiveCompletion: receiveCompletion, receiveValue: { _, _ in })
}
}

extension BindingSink: Subscriber {
public func receive(subscription: Subscription) {
if owner != nil {
subscription.request(.unlimited)
self.subscription = subscription
} else {
subscription.cancel()
}
}

public func receive(_ input: Input) -> Subscribers.Demand {
withOwner { receiveValue($0, input) }
return .max(1)
}

public func receive(completion: Subscribers.Completion<Failure>) {
withOwner { receiveCompletion($0, completion) }
}
}

extension BindingSink: Cancellable {
public func cancel() {
subscription?.cancel()
subscription = nil
owner = nil
}
}

extension BindingSink: BindingSubscriber {
@discardableResult
public static func <~ <P: Publisher> (sink: BindingSink, publisher: P) -> AnyCancellable
where P.Output == Input, P.Failure == Failure
{
guard let owner = sink.owner else { return AnyCancellable({}) }
let cancellable = AnyCancellable(sink)
owner.store(cancellable)
publisher.subscribe(sink)
return cancellable
}
}
38 changes: 38 additions & 0 deletions Sources/Bindings/BindingSubscriber.swift
@@ -0,0 +1,38 @@
import Combine

infix operator <~: DefaultPrecedence

public protocol BindingSubscriber: Subscriber, Cancellable {
@discardableResult
static func <~ <P: Publisher> (subscriber: Self, source: P) -> AnyCancellable
where P.Output == Input, P.Failure == Failure
}

extension Publisher {
@discardableResult
public static func ~> <B: BindingSubscriber> (source: Self, subscriber: B) -> AnyCancellable
where Output == B.Input, Failure == B.Failure
{
subscriber <~ source
}
}

// MARK: Optional

extension BindingSubscriber {
@discardableResult
public static func <~ <P: Publisher> (subscriber: Self, source: P) -> AnyCancellable
where Input == P.Output?, Failure == P.Failure
{
subscriber <~ source.map(Optional.some)
}
}

extension Publisher {
@discardableResult
public static func ~> <B: BindingSubscriber> (source: Self, subscriber: B) -> AnyCancellable
where B.Input == Output?, B.Failure == Failure
{
subscriber <~ source
}
}
8 changes: 8 additions & 0 deletions Sources/Bindings/Box.swift
@@ -0,0 +1,8 @@
@usableFromInline
final class Box<T> {
var value: T

init(_ value: T) {
self.value = value
}
}

0 comments on commit 19fd07a

Please sign in to comment.