├── Package.swift ├── LICENSE ├── README.md ├── .gitignore ├── Tests └── CancellorTests │ └── CancellorTests.swift └── Sources └── Cancellor └── Cancellor.swift /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Cancellor", 8 | platforms: [.iOS(.v13), .macOS(.v10_15)], 9 | products: [ 10 | .library( 11 | name: "Cancellor", 12 | targets: ["Cancellor"]), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "Cancellor", 17 | dependencies: []), 18 | .testTarget( 19 | name: "CancellorTests", 20 | dependencies: ["Cancellor"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marin Todorov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cancellor 2 | 3 | Bind multiple cancellables to the lifetime of another object like your view controller. 4 | 5 | ## Usage 6 | 7 | Import **Cancellor** and subscribe your publishers inside an `ownedCancellables` block. When your view controller is dismissed any active subscriptions will get cancelled automatically. 8 | 9 | ```swift 10 | import Cancellor 11 | 12 | class MyViewController: UIViewController { 13 | override func viewDidLoad() { 14 | ownedCancellables { 15 | myPublisher1.sink(...) 16 | myPublisher2.sink(...) 17 | myPublisher3.assign(to: ..., on: ...) 18 | } 19 | } 20 | } 21 | ``` 22 | 23 | To tie a single subscription to the lifetime of another object use: 24 | 25 | ```swift 26 | class ViewModel: NSObject { ... } 27 | let vm = ViewModel(...) 28 | 29 | ... 30 | 31 | myPublisher 32 | .sink(...) 33 | .owned(by: vm) 34 | ``` 35 | 36 | ## Import 37 | 38 | Add the following dependency to your **Package.swift** file: 39 | 40 | ```swift 41 | .package(url: "https://github.com/icanzilb/Cancellor", from: "0.2.0") 42 | ``` 43 | 44 | ## License 45 | 46 | Cancellor is available under the MIT license. See the LICENSE file for more info. 47 | 48 | ## Credits 49 | 50 | Created by Marin Todorov. 51 | 52 | 📚 You can support me by checking out our Combine book: [combinebook.com](http://combinebook.com). 53 | 54 | Inspired by Ash Furrow's [NSObject-rx](https://github.com/RxSwiftCommunity/NSObject-Rx). 55 | 56 | Name by https://github.com/manmal. 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Tests/CancellorTests/CancellorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import Cancellor 4 | 5 | #if os(iOS) 6 | import UIKit 7 | typealias ViewController = UIViewController 8 | #elseif os(macOS) 9 | import Cocoa 10 | typealias ViewController = NSViewController 11 | #endif 12 | 13 | fileprivate var testCancelEvents = [String]() 14 | fileprivate var testCancelOwnerEvents = [String]() 15 | 16 | final class CancellorTests: XCTestCase { 17 | 18 | class TestViewController: ViewController { 19 | let subject = CurrentValueSubject(1) 20 | 21 | override func viewDidLoad() { 22 | ownedCancellables { 23 | subject.handleEvents { _ in 24 | testCancelEvents.append("subscribe") 25 | } receiveOutput: { 26 | testCancelEvents.append("emit \($0)") 27 | } receiveCompletion: { _ in 28 | } receiveCancel: { 29 | testCancelEvents.append("cancel") 30 | } receiveRequest: { _ in 31 | } 32 | .sink(receiveValue: { _ in }) 33 | } 34 | } 35 | } 36 | 37 | func testCancel() { 38 | var vc: TestViewController? = TestViewController() 39 | vc?.viewDidLoad() 40 | XCTAssertEqual(testCancelEvents, ["subscribe", "emit 1"]) 41 | vc?.subject.send(2) 42 | XCTAssertEqual(testCancelEvents, ["subscribe", "emit 1", "emit 2"]) 43 | vc = nil 44 | XCTAssertEqual(testCancelEvents, ["subscribe", "emit 1", "emit 2", "cancel"]) 45 | } 46 | 47 | class TestViewModel: NSObject { } 48 | 49 | func testOwnerCancel() { 50 | var vm: TestViewModel? = TestViewModel() 51 | let subject = CurrentValueSubject(1) 52 | 53 | subject 54 | .handleEvents { _ in 55 | testCancelOwnerEvents.append("subscribe") 56 | } receiveOutput: { 57 | testCancelOwnerEvents.append("emit \($0)") 58 | } receiveCompletion: { _ in 59 | } receiveCancel: { 60 | testCancelOwnerEvents.append("cancel") 61 | } receiveRequest: { _ in 62 | } 63 | .sink(receiveValue: { _ in }) 64 | .owned(by: vm!) 65 | 66 | XCTAssertEqual(testCancelOwnerEvents, ["subscribe", "emit 1"]) 67 | subject.send(2) 68 | XCTAssertEqual(testCancelOwnerEvents, ["subscribe", "emit 1", "emit 2"]) 69 | vm = nil 70 | XCTAssertEqual(testCancelOwnerEvents, ["subscribe", "emit 1", "emit 2", "cancel"]) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Cancellor/Cancellor.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Marin Todorov 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | #if os(iOS) 22 | import UIKit 23 | fileprivate typealias ViewController = UIViewController 24 | #elseif os(macOS) 25 | import Cocoa 26 | fileprivate typealias ViewController = NSViewController 27 | #endif 28 | 29 | import Combine 30 | 31 | /// A builder that passes through a sequence of `AnyCancellable` values. 32 | @available(iOS 13.0, macOS 10.15, *) 33 | @resultBuilder 34 | public struct CancellablesBuilder { 35 | public static func buildBlock(_ parts: AnyCancellable...) -> [AnyCancellable] { 36 | return parts 37 | } 38 | } 39 | 40 | /// An extension that adds a function result builder to bind a list of cancellables 41 | /// to the lifetime of the current view controller. 42 | @available(iOS 13.0, macOS 10.15, *) 43 | extension NSObject { 44 | private static var cancellablesLock: os_unfair_lock_s = { os_unfair_lock_s() }() 45 | private static var cancellablesKeyRawValue: UInt8 = 0 46 | 47 | fileprivate func addCancellables(_ cancellables: [AnyCancellable]) { 48 | os_unfair_lock_lock(&Self.cancellablesLock) 49 | 50 | var storage = objc_getAssociatedObject(self, &Self.cancellablesKeyRawValue) as? [AnyCancellable] ?? [] 51 | storage.append(contentsOf: cancellables) 52 | objc_setAssociatedObject(self, &Self.cancellablesKeyRawValue, storage, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 53 | 54 | os_unfair_lock_unlock(&Self.cancellablesLock) 55 | } 56 | 57 | /// Binds the given cancellables to the lifetime of the current controller. 58 | /// - Parameter content: A list of `AnyCancellable` expressions. 59 | /// 60 | /// Use this function to subscribe multiple publishers in your `viewDidLoad()` or 61 | /// once in your `viewDidAppear(_:)` method: 62 | /// ``` 63 | /// ownedCancellables { 64 | /// myPublisher1.sink { ... } 65 | /// myPublisher2.sink { ... } 66 | /// myPublisher3.assign(to: ..., on: ...) 67 | /// } 68 | /// ``` 69 | public func ownedCancellables(@CancellablesBuilder content: () -> [AnyCancellable]) { 70 | addCancellables(content()) 71 | } 72 | } 73 | 74 | extension AnyCancellable { 75 | /// Bind the lifetime of this subscription to to the given object. 76 | /// - parameter by: An `NSObject` inheriting owner. 77 | /// 78 | /// In case this cancellable isn't cancelled when the owner object is deinitialized 79 | /// it will be cancelled and then released. 80 | /// ``` 81 | /// let viewModel = ViewModel(...) 82 | /// 83 | /// myPublisher 84 | /// .sink { ... } 85 | /// .owned(by: viewModel) 86 | /// ``` 87 | public func owned(by owner: NSObject) { 88 | owner.addCancellables([self]) 89 | } 90 | } 91 | --------------------------------------------------------------------------------