Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge thoughtbot/Bindings as a subtree of CombineViewModel #8

Merged
merged 28 commits into from
Aug 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
51716e0
Initial Commit
sharplet Aug 2, 2019
794fb57
Add ~> operator for output bindings
sharplet Aug 31, 2019
74b0ef7
Fix precedence for binding output operator
sharplet Aug 31, 2019
f8269bc
Add support for sets of AnyCancellable
sharplet Sep 1, 2019
888e1cd
Add reactive extension for UIApplication.didBecomeActiveNotification
sharplet Sep 1, 2019
96a758e
Add availability annotations for tvOS and watchOS
sharplet Sep 1, 2019
966692c
Add UIBarButtonItem.isEnabled extension
sharplet Sep 1, 2019
f7cc7c8
Add UIRefreshControl.endRefreshing extension
sharplet Sep 1, 2019
68008e4
Add UIViewController.toolbarItems extension
sharplet Sep 1, 2019
a0669a8
Remove BindingPrecedence and declare <~ with DefaultPrecedence
sharplet Sep 2, 2019
318de3c
Add UILabel.reactive.text binding
sharplet Sep 20, 2019
97a44dd
Add output and input binding operators for optional subscribers
sharplet Sep 20, 2019
3fe205e
Add UITextField placeholder bindings
sharplet Sep 20, 2019
8ea2598
Add UITextField text bindings
sharplet Sep 24, 2019
8428eee
Add UISwitch.isOn
sharplet Sep 25, 2019
d9cf483
Add UIControl.isEnabled
sharplet Sep 25, 2019
f39d201
Add binding for UIView.isHidden
sharplet Sep 27, 2019
24ef007
Use Set as the default Subscriptions collection
sharplet Aug 16, 2020
f4258fa
Use _read/set/_modify for associated-object-based subscriptions
sharplet Aug 16, 2020
948ae4d
Merge pull request #7 from thoughtbot/cosubscriptions
sharplet Aug 16, 2020
72a3705
Merge thoughtbot/Bindings 0.2.0 at commit '948ae4d1aabff0fbdb8d94e5b4…
sharplet Aug 16, 2020
62c9367
ViewModelObserver inherits from BindingOwner
sharplet Aug 16, 2020
6850122
Update conditional imports for iOS, tvOS and watchOS
sharplet Aug 18, 2020
82b63e7
BindingOwner.store(_:) should be generic over any Cancellable
sharplet Aug 18, 2020
4b3f880
Add LICENSE and CONTRIBUTING.md
sharplet Aug 24, 2020
66dbb54
Add table of contents to README
sharplet Aug 24, 2020
124a8a5
Merge Bindings README
sharplet Aug 24, 2020
04b164b
Add Installation section to README
sharplet Aug 24, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
Loading
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@usableFromInline
final class Box<T> {
var value: T

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