()
15 |
16 | override func tearDown() {
17 | subscriptions = .init()
18 | }
19 |
20 | func test_valuePublisher() {
21 | let tv = UITextView(frame: .zero)
22 | var values = [String?]()
23 |
24 | tv.valuePublisher
25 | .sink(receiveValue: { values.append($0) })
26 | .store(in: &subscriptions)
27 |
28 | tv.text = "hey"
29 | tv.text = "hey ho"
30 | tv.text = "test"
31 | tv.text = "shai"
32 | tv.text = ""
33 |
34 | XCTAssertEqual(values, ["", "hey", "hey ho", "test", "shai", ""])
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Example/Podfile:
--------------------------------------------------------------------------------
1 | platform :ios, '13.0'
2 | use_frameworks!
3 |
4 | target 'Example' do
5 | pod 'CombineCocoa', :path => '../'
6 |
7 | target 'ExampleTests' do
8 |
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/Example/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - CombineCocoa (0.4.1)
3 |
4 | DEPENDENCIES:
5 | - CombineCocoa (from `../`)
6 |
7 | EXTERNAL SOURCES:
8 | CombineCocoa:
9 | :path: "../"
10 |
11 | SPEC CHECKSUMS:
12 | CombineCocoa: 68a050228ce7c53c1c5ac2c57cccf92a7f5c5085
13 |
14 | PODFILE CHECKSUM: 19898029b2f3640d6c998827b076660d4767a5fd
15 |
16 | COCOAPODS: 1.10.2
17 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem 'cocoapods', '1.9.1'
4 | gem 'xcodeproj'
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.2)
5 | activesupport (4.2.11.3)
6 | i18n (~> 0.7)
7 | minitest (~> 5.1)
8 | thread_safe (~> 0.3, >= 0.3.4)
9 | tzinfo (~> 1.1)
10 | algoliasearch (1.27.4)
11 | httpclient (~> 2.8, >= 2.8.3)
12 | json (>= 1.5.1)
13 | atomos (0.1.3)
14 | claide (1.0.3)
15 | cocoapods (1.9.1)
16 | activesupport (>= 4.0.2, < 5)
17 | claide (>= 1.0.2, < 2.0)
18 | cocoapods-core (= 1.9.1)
19 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
20 | cocoapods-downloader (>= 1.2.2, < 2.0)
21 | cocoapods-plugins (>= 1.0.0, < 2.0)
22 | cocoapods-search (>= 1.0.0, < 2.0)
23 | cocoapods-stats (>= 1.0.0, < 2.0)
24 | cocoapods-trunk (>= 1.4.0, < 2.0)
25 | cocoapods-try (>= 1.1.0, < 2.0)
26 | colored2 (~> 3.1)
27 | escape (~> 0.0.4)
28 | fourflusher (>= 2.3.0, < 3.0)
29 | gh_inspector (~> 1.0)
30 | molinillo (~> 0.6.6)
31 | nap (~> 1.0)
32 | ruby-macho (~> 1.4)
33 | xcodeproj (>= 1.14.0, < 2.0)
34 | cocoapods-core (1.9.1)
35 | activesupport (>= 4.0.2, < 6)
36 | algoliasearch (~> 1.0)
37 | concurrent-ruby (~> 1.1)
38 | fuzzy_match (~> 2.0.4)
39 | nap (~> 1.0)
40 | netrc (~> 0.11)
41 | typhoeus (~> 1.0)
42 | cocoapods-deintegrate (1.0.4)
43 | cocoapods-downloader (1.4.0)
44 | cocoapods-plugins (1.0.0)
45 | nap
46 | cocoapods-search (1.0.0)
47 | cocoapods-stats (1.1.0)
48 | cocoapods-trunk (1.5.0)
49 | nap (>= 0.8, < 2.0)
50 | netrc (~> 0.11)
51 | cocoapods-try (1.2.0)
52 | colored2 (3.1.2)
53 | concurrent-ruby (1.1.7)
54 | escape (0.0.4)
55 | ethon (0.12.0)
56 | ffi (>= 1.3.0)
57 | ffi (1.13.1)
58 | fourflusher (2.3.1)
59 | fuzzy_match (2.0.4)
60 | gh_inspector (1.1.3)
61 | httpclient (2.8.3)
62 | i18n (0.9.5)
63 | concurrent-ruby (~> 1.0)
64 | json (2.3.1)
65 | minitest (5.14.2)
66 | molinillo (0.6.6)
67 | nanaimo (0.3.0)
68 | nap (1.1.0)
69 | netrc (0.11.0)
70 | ruby-macho (1.4.0)
71 | thread_safe (0.3.6)
72 | typhoeus (1.4.0)
73 | ethon (>= 0.9.0)
74 | tzinfo (1.2.7)
75 | thread_safe (~> 0.1)
76 | xcodeproj (1.18.0)
77 | CFPropertyList (>= 2.3.3, < 4.0)
78 | atomos (~> 0.1.3)
79 | claide (>= 1.0.2, < 2.0)
80 | colored2 (~> 3.1)
81 | nanaimo (~> 0.3.0)
82 |
83 | PLATFORMS
84 | ruby
85 |
86 | DEPENDENCIES
87 | cocoapods (= 1.9.1)
88 | xcodeproj
89 |
90 | BUNDLED WITH
91 | 2.1.4
92 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Shai Mishali
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 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | archive:
2 | scripts/carthage-archive.sh
3 | project:
4 | scripts/make_project.rb
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.1
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "CombineCocoa",
6 | platforms: [.iOS(.v10)],
7 | products: [
8 | .library(name: "CombineCocoa", targets: ["CombineCocoa"]),
9 | ],
10 | dependencies: [],
11 | targets: [
12 | .target(name: "CombineCocoa", dependencies: ["Runtime"]),
13 | .target(name: "Runtime", dependencies: [])
14 | ]
15 | )
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CombineCocoa
2 |
3 |
4 |
5 |
6 | 
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | CombineCocoa attempts to provide publishers for common UIKit controls so you can consume user interaction as Combine emissions and compose them into meaningful, logical publisher chains.
15 |
16 | **Note**: This is still a primal version of this, with much more to be desired. I gladly accept PRs, ideas, opinions, or improvements. Thank you ! :)
17 |
18 | ## Basic Examples
19 |
20 | Check out the [Example in the **Example** folder](https://github.com/freak4pc/CombineCocoa/blob/main/Example/Example/ControlsViewController.swift#L27). Open the project in Xcode 11 and Swift Package Manager should automatically resolve the required dependencies.
21 |
22 | 
23 |
24 | ## Usage
25 |
26 | tl;dr:
27 |
28 | ```swift
29 | import Combine
30 | import CombineCocoa
31 |
32 | textField.textPublisher // AnyPublisher
33 | segmented.selectedSegmentIndexPublisher // AnyPublisher
34 | slider.valuePublisher // AnyPublisher
35 | button.tapPublisher // AnyPublisher
36 | barButtonItem.tapPublisher // AnyPublisher
37 | switch.isOnPublisher // AnyPublisher
38 | stepper.valuePublisher // AnyPublisher
39 | datePicker.datePublisher // AnyPublisher
40 | refreshControl.isRefreshingPublisher // AnyPublisher
41 | pageControl.currentPagePublisher // AnyPublisher
42 | tapGesture.tapPublisher // AnyPublisher
43 | pinchGesture.pinchPublisher // AnyPublisher
44 | rotationGesture.rotationPublisher // AnyPublisher
45 | swipeGesture.swipePublisher // AnyPublisher
46 | panGesture.panPublisher // AnyPublisher
47 | screenEdgePanGesture.screenEdgePanPublisher // AnyPublisher
48 | longPressGesture.longPressPublisher // AnyPublisher
49 | scrollView.contentOffsetPublisher // AnyPublisher
50 | scrollView.reachedBottomPublisher(offset:) // AnyPublisher
51 | ```
52 |
53 | ## Installation
54 |
55 | ### CocoaPods
56 |
57 | Add the following line to your **Podfile**:
58 |
59 | ```rb
60 | pod 'CombineCocoa'
61 | ```
62 |
63 | ### Swift Package Manager
64 |
65 | Add the following dependency to your **Package.swift** file:
66 |
67 | ```swift
68 | .package(url: "https://github.com/CombineCommunity/CombineCocoa.git", from: "0.2.1")
69 | ```
70 |
71 | ### Carthage
72 |
73 | Add the following to your **Cartfile**:
74 |
75 | ```
76 | github "CombineCommunity/CombineCocoa"
77 | ```
78 |
79 | ## Future ideas
80 |
81 | * Support non `UIControl.Event`-based publishers (e.g. delegates).
82 | * ... your ideas? :)
83 |
84 | ## Acknowledgments
85 |
86 | * CombineCocoa is highly inspired by RxSwift's [RxCocoa](https://github.com/ReactiveX/RxSwift) in its essence, kudos to [Krunoslav Zaher](https://twitter.com/KrunoslavZaher) for all of his amazing work on this.
87 | * Thanks to [Antoine van der Lee](https://twitter.com/twannl) for his tutorial on [Creating Custom Publishers](https://www.avanderlee.com/swift/custom-combine-publisher/). The idea to set up a control target inside the publisher was inspired by it.
88 |
89 | ## License
90 |
91 | MIT, of course ;-) See the [LICENSE](LICENSE) file.
92 |
93 | The Apple logo and the Combine framework are property of Apple Inc.
94 |
--------------------------------------------------------------------------------
/Resources/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CombineCommunity/CombineCocoa/7300c75ff9e072aa7fd0fccefcc88f74aae9bf56/Resources/example.gif
--------------------------------------------------------------------------------
/Resources/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CombineCommunity/CombineCocoa/7300c75ff9e072aa7fd0fccefcc88f74aae9bf56/Resources/logo.png
--------------------------------------------------------------------------------
/Sources/CombineCocoa/AnimatedAssignSubscriber.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimatedAssignSubscriber.swift
3 | // CombineCocoa
4 | //
5 | // Created by Marin Todorov on 05/03/20.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | #if canImport(UIKit) && !(os(iOS) && (arch(i386) || arch(arm)))
12 | import Combine
13 | import UIKit
14 |
15 | /// A list of animations that can be used with `Publisher.assign(to:on:animation:)`
16 | @available(iOS 13.0, *)
17 | public enum AssignTransition {
18 | public enum Direction {
19 | case top, bottom, left, right
20 | }
21 |
22 | /// Flip from either bottom, top, left, or right.
23 | case flip(direction: Direction, duration: TimeInterval)
24 |
25 | /// Cross fade with previous value.
26 | case fade(duration: TimeInterval)
27 |
28 | /// A custom animation. Do not include your own code to update the target of the assign subscriber.
29 | case animation(duration: TimeInterval, options: UIView.AnimationOptions, animations: () -> Void, completion: ((Bool) -> Void)?)
30 | }
31 |
32 | @available(iOS 13.0, *)
33 | public extension Publisher where Self.Failure == Never {
34 | /// Behaves identically to `Publisher.assign(to:on:)` except that it allows the user to
35 | /// "wrap" emitting output in an animation transition.
36 | ///
37 | /// For example if you assign values to a `UILabel` on screen you
38 | /// can make it flip over when each new value is set:
39 | ///
40 | /// ```
41 | /// myPublisher
42 | /// .assign(to: \.text,
43 | /// on: myLabel,
44 | /// animation: .flip(direction: .bottom, duration: 0.33))
45 | /// ```
46 | ///
47 | /// You may also provide a custom animation block, as follows:
48 | ///
49 | /// ```
50 | /// myPublisher
51 | /// .assign(to: \.text, on: myLabel, animation: .animation(duration: 0.33, options: .curveEaseIn, animations: { _ in
52 | /// myLabel.center.x += 10.0
53 | /// }, completion: nil))
54 | /// ```
55 | func assign(to keyPath: ReferenceWritableKeyPath, on object: Root, animation: AssignTransition) -> AnyCancellable {
56 | var transition: UIView.AnimationOptions
57 | var duration: TimeInterval
58 |
59 | switch animation {
60 | case .fade(let interval):
61 | duration = interval
62 | transition = .transitionCrossDissolve
63 | case let .flip(dir, interval):
64 | duration = interval
65 | switch dir {
66 | case .bottom: transition = .transitionFlipFromBottom
67 | case .top: transition = .transitionFlipFromTop
68 | case .left: transition = .transitionFlipFromLeft
69 | case .right: transition = .transitionFlipFromRight
70 | }
71 | case let .animation(interval, options, animations, completion):
72 | // Use a custom animation.
73 | return handleEvents(
74 | receiveOutput: { value in
75 | UIView.animate(withDuration: interval,
76 | delay: 0,
77 | options: options,
78 | animations: {
79 | object[keyPath: keyPath] = value
80 | animations()
81 | },
82 | completion: completion)
83 | }
84 | )
85 | .sink { _ in }
86 | }
87 |
88 | // Use one of the built-in transitions like flip or crossfade.
89 | return self
90 | .handleEvents(receiveOutput: { value in
91 | UIView.transition(with: object,
92 | duration: duration,
93 | options: transition,
94 | animations: {
95 | object[keyPath: keyPath] = value
96 | },
97 | completion: nil)
98 | })
99 | .sink { _ in }
100 | }
101 | }
102 | #endif
103 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/CombineCocoa.h:
--------------------------------------------------------------------------------
1 | //
2 | // CombineCocoa.h
3 | // CombineCocoa
4 | //
5 | // Created by Joan Disho on 25/09/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #import
10 | #import
11 |
12 | FOUNDATION_EXPORT double CombineCocoaVersionNumber;
13 | FOUNDATION_EXPORT const unsigned char CombineCocoaVersionString[];
14 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/CombineControlEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CombineControlEvent.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 01/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import Foundation
12 | import UIKit.UIControl
13 |
14 | // MARK: - Publisher
15 | @available(iOS 13.0, *)
16 | public extension Combine.Publishers {
17 | /// A Control Event is a publisher that emits whenever the provided
18 | /// Control Events fire.
19 | struct ControlEvent: Publisher {
20 | public typealias Output = Void
21 | public typealias Failure = Never
22 |
23 | private let control: Control
24 | private let controlEvents: Control.Event
25 |
26 | /// Initialize a publisher that emits a Void
27 | /// whenever any of the provided Control Events trigger.
28 | ///
29 | /// - parameter control: UI Control.
30 | /// - parameter events: Control Events.
31 | public init(control: Control,
32 | events: Control.Event) {
33 | self.control = control
34 | self.controlEvents = events
35 | }
36 |
37 | public func receive(subscriber: S) where S.Failure == Failure, S.Input == Output {
38 | let subscription = Subscription(subscriber: subscriber,
39 | control: control,
40 | event: controlEvents)
41 |
42 | subscriber.receive(subscription: subscription)
43 | }
44 | }
45 | }
46 |
47 | // MARK: - Subscription
48 | @available(iOS 13.0, *)
49 | extension Combine.Publishers.ControlEvent {
50 | private final class Subscription: Combine.Subscription where S.Input == Void {
51 | private var subscriber: S?
52 | weak private var control: Control?
53 |
54 | init(subscriber: S, control: Control, event: Control.Event) {
55 | self.subscriber = subscriber
56 | self.control = control
57 | control.addTarget(self, action: #selector(processControlEvent), for: event)
58 | }
59 |
60 | func request(_ demand: Subscribers.Demand) {
61 | // We don't care about the demand at this point.
62 | // As far as we're concerned - UIControl events are endless until the control is deallocated.
63 | }
64 |
65 | func cancel() {
66 | subscriber = nil
67 | }
68 |
69 | @objc private func processControlEvent() {
70 | _ = subscriber?.receive()
71 | }
72 | }
73 | }
74 | #endif
75 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/CombineControlProperty.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CombineControlProperty.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 01/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import Foundation
12 | import UIKit.UIControl
13 |
14 | // MARK: - Publisher
15 | @available(iOS 13.0, *)
16 | public extension Combine.Publishers {
17 | /// A Control Property is a publisher that emits the value at the provided keypath
18 | /// whenever the specific control events are triggered. It also emits the keypath's
19 | /// initial value upon subscription.
20 | struct ControlProperty: Publisher {
21 | public typealias Output = Value
22 | public typealias Failure = Never
23 |
24 | private let control: Control
25 | private let controlEvents: Control.Event
26 | private let keyPath: KeyPath
27 |
28 | /// Initialize a publisher that emits the value at the specified keypath
29 | /// whenever any of the provided Control Events trigger.
30 | ///
31 | /// - parameter control: UI Control.
32 | /// - parameter events: Control Events.
33 | /// - parameter keyPath: A Key Path from the UI Control to the requested value.
34 | public init(control: Control,
35 | events: Control.Event,
36 | keyPath: KeyPath) {
37 | self.control = control
38 | self.controlEvents = events
39 | self.keyPath = keyPath
40 | }
41 |
42 | public func receive(subscriber: S) where S.Failure == Failure, S.Input == Output {
43 | let subscription = Subscription(subscriber: subscriber,
44 | control: control,
45 | event: controlEvents,
46 | keyPath: keyPath)
47 |
48 | subscriber.receive(subscription: subscription)
49 | }
50 | }
51 | }
52 |
53 | // MARK: - Subscription
54 | @available(iOS 13.0, *)
55 | extension Combine.Publishers.ControlProperty {
56 | private final class Subscription: Combine.Subscription where S.Input == Value {
57 | private var subscriber: S?
58 | weak private var control: Control?
59 | let keyPath: KeyPath
60 | private var didEmitInitial = false
61 | private let event: Control.Event
62 |
63 | init(subscriber: S, control: Control, event: Control.Event, keyPath: KeyPath) {
64 | self.subscriber = subscriber
65 | self.control = control
66 | self.keyPath = keyPath
67 | self.event = event
68 | control.addTarget(self, action: #selector(processControlEvent), for: event)
69 | }
70 |
71 | func request(_ demand: Subscribers.Demand) {
72 | // Emit initial value upon first demand request
73 | if !didEmitInitial,
74 | demand > .none,
75 | let control = control,
76 | let subscriber = subscriber {
77 | _ = subscriber.receive(control[keyPath: keyPath])
78 | didEmitInitial = true
79 | }
80 |
81 | // We don't care about the demand at this point.
82 | // As far as we're concerned - UIControl events are endless until the control is deallocated.
83 | }
84 |
85 | func cancel() {
86 | control?.removeTarget(self, action: #selector(processControlEvent), for: event)
87 | subscriber = nil
88 | }
89 |
90 | @objc private func processControlEvent() {
91 | guard let control = control else { return }
92 | _ = subscriber?.receive(control[keyPath: keyPath])
93 | }
94 | }
95 | }
96 |
97 | extension UIControl.Event {
98 | static var defaultValueEvents: UIControl.Event {
99 | return [.allEditingEvents, .valueChanged]
100 | }
101 | }
102 | #endif
103 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/CombineControlTarget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CombineControlTarget.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 12/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import Foundation
12 |
13 | // MARK: - Publisher
14 | @available(iOS 13.0, *)
15 | public extension Combine.Publishers {
16 | /// A publisher which wraps objects that use the Target & Action mechanism,
17 | /// for example - a UIBarButtonItem which isn't KVO-compliant and doesn't use UIControlEvent(s).
18 | ///
19 | /// Instead, you pass in a generic Control, and two functions:
20 | /// One to add a target action to the provided control, and a second one to
21 | /// remove a target action from a provided control.
22 | struct ControlTarget: Publisher {
23 | public typealias Output = Void
24 | public typealias Failure = Never
25 |
26 | private let control: Control
27 | private let addTargetAction: (Control, AnyObject, Selector) -> Void
28 | private let removeTargetAction: (Control?, AnyObject, Selector) -> Void
29 |
30 | /// Initialize a publisher that emits a Void whenever the
31 | /// provided control fires an action.
32 | ///
33 | /// - parameter control: UI Control.
34 | /// - parameter addTargetAction: A function which accepts the Control, a Target and a Selector and
35 | /// responsible to add the target action to the provided control.
36 | /// - parameter removeTargetAction: A function which accepts the Control, a Target and a Selector and it
37 | /// responsible to remove the target action from the provided control.
38 | public init(control: Control,
39 | addTargetAction: @escaping (Control, AnyObject, Selector) -> Void,
40 | removeTargetAction: @escaping (Control?, AnyObject, Selector) -> Void) {
41 | self.control = control
42 | self.addTargetAction = addTargetAction
43 | self.removeTargetAction = removeTargetAction
44 | }
45 |
46 | public func receive(subscriber: S) where S.Failure == Failure, S.Input == Output {
47 | let subscription = Subscription(subscriber: subscriber,
48 | control: control,
49 | addTargetAction: addTargetAction,
50 | removeTargetAction: removeTargetAction)
51 |
52 | subscriber.receive(subscription: subscription)
53 | }
54 | }
55 | }
56 |
57 | // MARK: - Subscription
58 | @available(iOS 13.0, *)
59 | private extension Combine.Publishers.ControlTarget {
60 | private final class Subscription: Combine.Subscription where S.Input == Void {
61 | private var subscriber: S?
62 | weak private var control: Control?
63 |
64 | private let removeTargetAction: (Control?, AnyObject, Selector) -> Void
65 | private let action = #selector(handleAction)
66 |
67 | init(subscriber: S,
68 | control: Control,
69 | addTargetAction: @escaping (Control, AnyObject, Selector) -> Void,
70 | removeTargetAction: @escaping (Control?, AnyObject, Selector) -> Void) {
71 | self.subscriber = subscriber
72 | self.control = control
73 | self.removeTargetAction = removeTargetAction
74 |
75 | addTargetAction(control, self, action)
76 | }
77 |
78 | func request(_ demand: Subscribers.Demand) {
79 | // We don't care about the demand at this point.
80 | // As far as we're concerned - The control's target events are endless until it is deallocated.
81 | }
82 |
83 | func cancel() {
84 | subscriber = nil
85 | removeTargetAction(control, self, action)
86 | }
87 |
88 | @objc private func handleAction() {
89 | _ = subscriber?.receive()
90 | }
91 | }
92 | }
93 | #endif
94 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/NSTextStorage+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSTextStorage+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 10/08/2020.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import UIKit
11 | import Combine
12 |
13 | @available(iOS 13.0, *)
14 | public extension NSTextStorage {
15 | /// Combine publisher for `NSTextStorageDelegate.textStorage(_:didProcessEditing:range:changeInLength:)`
16 | var didProcessEditingRangeChangeInLengthPublisher: AnyPublisher<(editedMask: NSTextStorage.EditActions, editedRange: NSRange, delta: Int), Never> {
17 | let selector = #selector(NSTextStorageDelegate.textStorage(_:didProcessEditing:range:changeInLength:))
18 |
19 | return delegateProxy
20 | .interceptSelectorPublisher(selector)
21 | .map { args -> (editedMask: NSTextStorage.EditActions, editedRange: NSRange, delta: Int) in
22 | // swiftlint:disable force_cast
23 | let editedMask = NSTextStorage.EditActions(rawValue: args[1] as! UInt)
24 | let editedRange = (args[2] as! NSValue).rangeValue
25 | let delta = args[3] as! Int
26 | return (editedMask, editedRange, delta)
27 | // swiftlint:enable force_cast
28 | }
29 | .eraseToAnyPublisher()
30 | }
31 |
32 | private var delegateProxy: NSTextStorageDelegateProxy {
33 | .createDelegateProxy(for: self)
34 | }
35 | }
36 |
37 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
38 | private class NSTextStorageDelegateProxy: DelegateProxy, NSTextStorageDelegate, DelegateProxyType {
39 | func setDelegate(to object: NSTextStorage) {
40 | object.delegate = self
41 | }
42 | }
43 | #endif
44 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIBarButtonItem+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIBarButtonItem+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 12/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIBarButtonItem {
15 | /// A publisher which emits whenever this UIBarButtonItem is tapped.
16 | var tapPublisher: AnyPublisher {
17 | Publishers.ControlTarget(control: self,
18 | addTargetAction: { control, target, action in
19 | control.target = target
20 | control.action = action
21 | },
22 | removeTargetAction: { control, _, _ in
23 | control?.target = nil
24 | control?.action = nil
25 | })
26 | .eraseToAnyPublisher()
27 | }
28 | }
29 | #endif
30 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIButton+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIButton+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIButton {
15 | /// A publisher emitting tap events from this button.
16 | var tapPublisher: AnyPublisher {
17 | controlEventPublisher(for: .touchUpInside)
18 | }
19 | }
20 | #endif
21 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UICollectionView+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UICollectionView+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Joan Disho on 05/04/20.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if canImport(UIKit) && !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Foundation
11 | import UIKit
12 | import Combine
13 |
14 | // swiftlint:disable force_cast
15 | @available(iOS 13.0, *)
16 | public extension UICollectionView {
17 | /// Combine wrapper for `collectionView(_:didSelectItemAt:)`
18 | var didSelectItemPublisher: AnyPublisher {
19 | let selector = #selector(UICollectionViewDelegate.collectionView(_:didSelectItemAt:))
20 | return delegateProxy.interceptSelectorPublisher(selector)
21 | .map { $0[1] as! IndexPath }
22 | .eraseToAnyPublisher()
23 | }
24 |
25 | /// Combine wrapper for `collectionView(_:didDeselectItemAt:)`
26 | var didDeselectItemPublisher: AnyPublisher {
27 | let selector = #selector(UICollectionViewDelegate.collectionView(_:didDeselectItemAt:))
28 | return delegateProxy.interceptSelectorPublisher(selector)
29 | .map { $0[1] as! IndexPath }
30 | .eraseToAnyPublisher()
31 | }
32 |
33 | /// Combine wrapper for `collectionView(_:didHighlightItemAt:)`
34 | var didHighlightItemPublisher: AnyPublisher {
35 | let selector = #selector(UICollectionViewDelegate.collectionView(_:didHighlightItemAt:))
36 | return delegateProxy.interceptSelectorPublisher(selector)
37 | .map { $0[1] as! IndexPath }
38 | .eraseToAnyPublisher()
39 | }
40 |
41 | /// Combine wrapper for `collectionView(_:didUnhighlightItemAt:)`
42 | var didUnhighlightRowPublisher: AnyPublisher {
43 | let selector = #selector(UICollectionViewDelegate.collectionView(_:didUnhighlightItemAt:))
44 | return delegateProxy.interceptSelectorPublisher(selector)
45 | .map { $0[1] as! IndexPath }
46 | .eraseToAnyPublisher()
47 | }
48 |
49 | /// Combine wrapper for `collectionView(_:willDisplay:forItemAt:)`
50 | var willDisplayCellPublisher: AnyPublisher<(cell: UICollectionViewCell, indexPath: IndexPath), Never> {
51 | let selector = #selector(UICollectionViewDelegate.collectionView(_:willDisplay:forItemAt:))
52 | return delegateProxy.interceptSelectorPublisher(selector)
53 | .map { ($0[1] as! UICollectionViewCell, $0[2] as! IndexPath) }
54 | .eraseToAnyPublisher()
55 | }
56 |
57 | /// Combine wrapper for `collectionView(_:willDisplaySupplementaryView:forElementKind:at:)`
58 | var willDisplaySupplementaryViewPublisher: AnyPublisher<(supplementaryView: UICollectionReusableView, elementKind: String, indexPath: IndexPath), Never> {
59 | let selector = #selector(UICollectionViewDelegate.collectionView(_:willDisplaySupplementaryView:forElementKind:at:))
60 | return delegateProxy.interceptSelectorPublisher(selector)
61 | .map { ($0[1] as! UICollectionReusableView, $0[2] as! String, $0[3] as! IndexPath) }
62 | .eraseToAnyPublisher()
63 | }
64 |
65 | /// Combine wrapper for `collectionView(_:didEndDisplaying:forItemAt:)`
66 | var didEndDisplayingCellPublisher: AnyPublisher<(cell: UICollectionViewCell, indexPath: IndexPath), Never> {
67 | let selector = #selector(UICollectionViewDelegate.collectionView(_:didEndDisplaying:forItemAt:))
68 | return delegateProxy.interceptSelectorPublisher(selector)
69 | .map { ($0[1] as! UICollectionViewCell, $0[2] as! IndexPath) }
70 | .eraseToAnyPublisher()
71 | }
72 |
73 | /// Combine wrapper for `collectionView(_:didEndDisplayingSupplementaryView:forElementKind:at:)`
74 | var didEndDisplaySupplementaryViewPublisher: AnyPublisher<(supplementaryView: UICollectionReusableView, elementKind: String, indexPath: IndexPath), Never> {
75 | let selector = #selector(UICollectionViewDelegate.collectionView(_:didEndDisplayingSupplementaryView:forElementOfKind:at:))
76 | return delegateProxy.interceptSelectorPublisher(selector)
77 | .map { ($0[1] as! UICollectionReusableView, $0[2] as! String, $0[3] as! IndexPath) }
78 | .eraseToAnyPublisher()
79 | }
80 |
81 | override var delegateProxy: DelegateProxy {
82 | CollectionViewDelegateProxy.createDelegateProxy(for: self)
83 | }
84 | }
85 |
86 | @available(iOS 13.0, *)
87 | private class CollectionViewDelegateProxy: DelegateProxy, UICollectionViewDelegate, DelegateProxyType {
88 | func setDelegate(to object: UICollectionView) {
89 | object.delegate = self
90 | }
91 | }
92 | #endif
93 | // swiftlint:enable force_cast
94 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIControl+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIControl+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Wes Wickwire on 9/23/20.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIControl {
15 | /// A publisher emitting events from this control.
16 | func controlEventPublisher(for events: UIControl.Event) -> AnyPublisher {
17 | Publishers.ControlEvent(control: self, events: events)
18 | .eraseToAnyPublisher()
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIDatePicker+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIDatePicker+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIDatePicker {
15 | /// A publisher emitting date changes from this date picker.
16 | var datePublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.date)
18 | .eraseToAnyPublisher()
19 | }
20 |
21 | /// A publisher emitting countdown duration changes from this date picker.
22 | var countDownDurationPublisher: AnyPublisher {
23 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.countDownDuration)
24 | .eraseToAnyPublisher()
25 | }
26 | }
27 | #endif
28 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIGestureRecognizer+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIGestureRecognizer+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 12/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | // MARK: - Gesture Publishers
14 | @available(iOS 13.0, *)
15 | public extension UITapGestureRecognizer {
16 | /// A publisher which emits when this Tap Gesture Recognizer is triggered
17 | var tapPublisher: AnyPublisher {
18 | gesturePublisher(for: self)
19 | }
20 | }
21 |
22 | @available(iOS 13.0, *)
23 | public extension UIPinchGestureRecognizer {
24 | /// A publisher which emits when this Pinch Gesture Recognizer is triggered
25 | var pinchPublisher: AnyPublisher {
26 | gesturePublisher(for: self)
27 | }
28 | }
29 |
30 | @available(iOS 13.0, *)
31 | public extension UIRotationGestureRecognizer {
32 | /// A publisher which emits when this Rotation Gesture Recognizer is triggered
33 | var rotationPublisher: AnyPublisher {
34 | gesturePublisher(for: self)
35 | }
36 | }
37 |
38 | @available(iOS 13.0, *)
39 | public extension UISwipeGestureRecognizer {
40 | /// A publisher which emits when this Swipe Gesture Recognizer is triggered
41 | var swipePublisher: AnyPublisher {
42 | gesturePublisher(for: self)
43 | }
44 | }
45 |
46 | @available(iOS 13.0, *)
47 | public extension UIPanGestureRecognizer {
48 | /// A publisher which emits when this Pan Gesture Recognizer is triggered
49 | var panPublisher: AnyPublisher {
50 | gesturePublisher(for: self)
51 | }
52 | }
53 |
54 | @available(iOS 13.0, *)
55 | public extension UIScreenEdgePanGestureRecognizer {
56 | /// A publisher which emits when this Screen Edge Gesture Recognizer is triggered
57 | var screenEdgePanPublisher: AnyPublisher {
58 | gesturePublisher(for: self)
59 | }
60 | }
61 |
62 | @available(iOS 13.0, *)
63 | public extension UILongPressGestureRecognizer {
64 | /// A publisher which emits when this Long Press Recognizer is triggered
65 | var longPressPublisher: AnyPublisher {
66 | gesturePublisher(for: self)
67 | }
68 | }
69 |
70 | // MARK: - Private Helpers
71 |
72 | // A private generic helper function which returns the provided
73 | // generic publisher whenever its specific event occurs.
74 | @available(iOS 13.0, *)
75 | private func gesturePublisher(for gesture: Gesture) -> AnyPublisher {
76 | Publishers.ControlTarget(control: gesture,
77 | addTargetAction: { gesture, target, action in
78 | gesture.addTarget(target, action: action)
79 | },
80 | removeTargetAction: { gesture, target, action in
81 | gesture?.removeTarget(target, action: action)
82 | })
83 | .subscribe(on: DispatchQueue.main)
84 | .map { gesture }
85 | .eraseToAnyPublisher()
86 | }
87 | #endif
88 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIPageControl+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIPageControl+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIPageControl {
15 | /// A publisher emitting current page changes for this page control.
16 | var currentPagePublisher: AnyPublisher {
17 | publisher(for: \.currentPage).eraseToAnyPublisher()
18 | }
19 | }
20 | #endif
21 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIRefreshControl+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIRefreshControl+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIRefreshControl {
15 | /// A publisher emitting refresh status changes from this refresh control.
16 | var isRefreshingPublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.isRefreshing)
18 | .eraseToAnyPublisher()
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIScrollView+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIScrollView+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Joan Disho on 09/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import UIKit
11 | import Combine
12 |
13 | // swiftlint:disable force_cast
14 | @available(iOS 13.0, *)
15 | public extension UIScrollView {
16 | /// A publisher emitting content offset changes from this UIScrollView.
17 | var contentOffsetPublisher: AnyPublisher {
18 | publisher(for: \.contentOffset)
19 | .eraseToAnyPublisher()
20 | }
21 |
22 | /// A publisher emitting if the bottom of the UIScrollView is reached.
23 | ///
24 | /// - parameter offset: A threshold indicating how close to the bottom of the UIScrollView this publisher should emit.
25 | /// Defaults to 0
26 | /// - returns: A publisher that emits when the bottom of the UIScrollView is reached within the provided threshold.
27 | func reachedBottomPublisher(offset: CGFloat = 0) -> AnyPublisher {
28 | contentOffsetPublisher
29 | .map { [weak self] contentOffset -> Bool in
30 | guard let self = self else { return false }
31 | let visibleHeight = self.frame.height - self.contentInset.top - self.contentInset.bottom
32 | let yDelta = contentOffset.y + self.contentInset.top
33 | let threshold = max(offset, self.contentSize.height - visibleHeight)
34 | return yDelta > threshold
35 | }
36 | .removeDuplicates()
37 | .filter { $0 }
38 | .map { _ in () }
39 | .eraseToAnyPublisher()
40 | }
41 |
42 | /// Combine wrapper for `scrollViewDidScroll(_:)`
43 | var didScrollPublisher: AnyPublisher {
44 | let selector = #selector(UIScrollViewDelegate.scrollViewDidScroll(_:))
45 | return delegateProxy.interceptSelectorPublisher(selector)
46 | .map { _ in () }
47 | .eraseToAnyPublisher()
48 | }
49 |
50 | /// Combine wrapper for `scrollViewWillBeginDecelerating(_:)`
51 | var willBeginDeceleratingPublisher: AnyPublisher {
52 | let selector = #selector(UIScrollViewDelegate.scrollViewWillBeginDecelerating(_:))
53 | return delegateProxy.interceptSelectorPublisher(selector)
54 | .map { _ in () }
55 | .eraseToAnyPublisher()
56 | }
57 |
58 | /// Combine wrapper for `scrollViewDidEndDecelerating(_:)`
59 | var didEndDeceleratingPublisher: AnyPublisher {
60 | let selector = #selector(UIScrollViewDelegate.scrollViewDidEndDecelerating(_:))
61 | return delegateProxy.interceptSelectorPublisher(selector)
62 | .map { _ in () }
63 | .eraseToAnyPublisher()
64 | }
65 |
66 | /// Combine wrapper for `scrollViewWillBeginDragging(_:)`
67 | var willBeginDraggingPublisher: AnyPublisher {
68 | let selector = #selector(UIScrollViewDelegate.scrollViewWillBeginDragging(_:))
69 | return delegateProxy.interceptSelectorPublisher(selector)
70 | .map { _ in () }
71 | .eraseToAnyPublisher()
72 | }
73 |
74 | /// Combine wrapper for `scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)`
75 | var willEndDraggingPublisher: AnyPublisher<(velocity: CGPoint, targetContentOffset: UnsafeMutablePointer), Never> {
76 | let selector = #selector(UIScrollViewDelegate.scrollViewWillEndDragging(_:withVelocity:targetContentOffset:))
77 | return delegateProxy.interceptSelectorPublisher(selector)
78 | .map { values in
79 | let targetContentOffsetValue = values[2] as! NSValue
80 | let rawPointer = targetContentOffsetValue.pointerValue!
81 |
82 | return (values[1] as! CGPoint, rawPointer.bindMemory(to: CGPoint.self, capacity: MemoryLayout.size))
83 | }
84 | .eraseToAnyPublisher()
85 | }
86 |
87 | /// Combine wrapper for `scrollViewDidEndDragging(_:willDecelerate:)`
88 | var didEndDraggingPublisher: AnyPublisher {
89 | let selector = #selector(UIScrollViewDelegate.scrollViewDidEndDragging(_:willDecelerate:))
90 | return delegateProxy.interceptSelectorPublisher(selector)
91 | .map { $0[1] as! Bool }
92 | .eraseToAnyPublisher()
93 | }
94 |
95 | /// Combine wrapper for `scrollViewDidZoom(_:)`
96 | var didZoomPublisher: AnyPublisher {
97 | let selector = #selector(UIScrollViewDelegate.scrollViewDidZoom(_:))
98 | return delegateProxy.interceptSelectorPublisher(selector)
99 | .map { _ in () }
100 | .eraseToAnyPublisher()
101 | }
102 |
103 | /// Combine wrapper for `scrollViewDidScrollToTop(_:)`
104 | var didScrollToTopPublisher: AnyPublisher {
105 | let selector = #selector(UIScrollViewDelegate.scrollViewDidScrollToTop(_:))
106 | return delegateProxy.interceptSelectorPublisher(selector)
107 | .map { _ in () }
108 | .eraseToAnyPublisher()
109 | }
110 |
111 | /// Combine wrapper for `scrollViewDidEndScrollingAnimation(_:)`
112 | var didEndScrollingAnimationPublisher: AnyPublisher {
113 | let selector = #selector(UIScrollViewDelegate.scrollViewDidEndScrollingAnimation(_:))
114 | return delegateProxy.interceptSelectorPublisher(selector)
115 | .map { _ in () }
116 | .eraseToAnyPublisher()
117 | }
118 |
119 | /// Combine wrapper for `scrollViewWillBeginZooming(_:with:)`
120 | var willBeginZoomingPublisher: AnyPublisher {
121 | let selector = #selector(UIScrollViewDelegate.scrollViewWillBeginZooming(_:with:))
122 | return delegateProxy.interceptSelectorPublisher(selector)
123 | .map { $0[1] as! UIView? }
124 | .eraseToAnyPublisher()
125 | }
126 |
127 | /// Combine wrapper for `scrollViewDidEndZooming(_:with:atScale:)`
128 | var didEndZooming: AnyPublisher<(view: UIView?, scale: CGFloat), Never> {
129 | let selector = #selector(UIScrollViewDelegate.scrollViewDidEndZooming(_:with:atScale:))
130 | return delegateProxy.interceptSelectorPublisher(selector)
131 | .map { ($0[1] as! UIView?, $0[2] as! CGFloat) }
132 | .eraseToAnyPublisher()
133 | }
134 |
135 | @objc var delegateProxy: DelegateProxy {
136 | ScrollViewDelegateProxy.createDelegateProxy(for: self)
137 | }
138 | }
139 |
140 | @available(iOS 13.0, *)
141 | private class ScrollViewDelegateProxy: DelegateProxy, UIScrollViewDelegate, DelegateProxyType {
142 | func setDelegate(to object: UIScrollView) {
143 | object.delegate = self
144 | }
145 | }
146 | #endif
147 | // swiftlint:enable force_cast
148 |
149 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UISearchBar+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UISearchBar+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Kevin Renskers on 01/10/2020.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import UIKit
11 | import Combine
12 |
13 | // swiftlint:disable force_cast
14 | @available(iOS 13.0, *)
15 | public extension UISearchBar {
16 | /// Combine wrapper for `UISearchBarDelegate.searchBar(_:textDidChange:)`
17 | var textDidChangePublisher: AnyPublisher {
18 | let selector = #selector(UISearchBarDelegate.searchBar(_:textDidChange:))
19 | return delegateProxy
20 | .interceptSelectorPublisher(selector)
21 | .map { $0[1] as! String }
22 | .eraseToAnyPublisher()
23 | }
24 |
25 | /// Combine wrapper for `UISearchBarDelegate.searchBarSearchButtonClicked(_:)`
26 | var searchButtonClickedPublisher: AnyPublisher {
27 | let selector = #selector(UISearchBarDelegate.searchBarSearchButtonClicked(_:))
28 | return delegateProxy
29 | .interceptSelectorPublisher(selector)
30 | .map { _ in () }
31 | .eraseToAnyPublisher()
32 | }
33 |
34 | /// Combine wrapper for `UISearchBarDelegate.searchBarCancelButtonClicked(_:)`
35 | var cancelButtonClickedPublisher: AnyPublisher {
36 | let selector = #selector(UISearchBarDelegate.searchBarCancelButtonClicked(_:))
37 | return delegateProxy
38 | .interceptSelectorPublisher(selector)
39 | .map { _ in () }
40 | .eraseToAnyPublisher()
41 | }
42 |
43 | private var delegateProxy: UISearchBarDelegateProxy {
44 | .createDelegateProxy(for: self)
45 | }
46 | }
47 |
48 | @available(iOS 13.0, *)
49 | private class UISearchBarDelegateProxy: DelegateProxy, UISearchBarDelegate, DelegateProxyType {
50 | func setDelegate(to object: UISearchBar) {
51 | object.delegate = self
52 | }
53 | }
54 | #endif
55 | // swiftlint:enable force_cast
56 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UISegmentedControl+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UISegmentedControl+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UISegmentedControl {
15 | /// A publisher emitting selected segment index changes for this segmented control.
16 | var selectedSegmentIndexPublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.selectedSegmentIndex)
18 | .eraseToAnyPublisher()
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UISlider+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UISlider+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UISlider {
15 | /// A publisher emitting value changes for this slider.
16 | var valuePublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.value)
18 | .eraseToAnyPublisher()
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UIStepper+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIStepper+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UIStepper {
15 | /// A publisher emitting value changes for this stepper.
16 | var valuePublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.value)
18 | .eraseToAnyPublisher()
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UISwitch+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UISwitch+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UISwitch {
15 | /// A publisher emitting on status changes for this switch.
16 | var isOnPublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.isOn)
18 | .eraseToAnyPublisher()
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UITableView+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITableView+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Joan Disho on 19/01/20.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if canImport(UIKit) && !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Foundation
11 | import UIKit
12 | import Combine
13 |
14 | // swiftlint:disable force_cast
15 | @available(iOS 13.0, *)
16 | public extension UITableView {
17 | /// Combine wrapper for `tableView(_:willDisplay:forRowAt:)`
18 | var willDisplayCellPublisher: AnyPublisher<(cell: UITableViewCell, indexPath: IndexPath), Never> {
19 | let selector = #selector(UITableViewDelegate.tableView(_:willDisplay:forRowAt:))
20 | return delegateProxy.interceptSelectorPublisher(selector)
21 | .map { ($0[1] as! UITableViewCell, $0[2] as! IndexPath) }
22 | .eraseToAnyPublisher()
23 | }
24 |
25 | /// Combine wrapper for `tableView(_:willDisplayHeaderView:forSection:)`
26 | var willDisplayHeaderViewPublisher: AnyPublisher<(headerView: UIView, section: Int), Never> {
27 | let selector = #selector(UITableViewDelegate.tableView(_:willDisplayHeaderView:forSection:))
28 | return delegateProxy.interceptSelectorPublisher(selector)
29 | .map { ($0[1] as! UIView, $0[2] as! Int) }
30 | .eraseToAnyPublisher()
31 | }
32 |
33 | /// Combine wrapper for `tableView(_:willDisplayFooterView:forSection:)`
34 | var willDisplayFooterViewPublisher: AnyPublisher<(footerView: UIView, section: Int), Never> {
35 | let selector = #selector(UITableViewDelegate.tableView(_:willDisplayFooterView:forSection:))
36 | return delegateProxy.interceptSelectorPublisher(selector)
37 | .map { ($0[1] as! UIView, $0[2] as! Int) }
38 | .eraseToAnyPublisher()
39 | }
40 |
41 | /// Combine wrapper for `tableView(_:didEndDisplaying:forRowAt:)`
42 | var didEndDisplayingCellPublisher: AnyPublisher<(cell: UITableViewCell, indexPath: IndexPath), Never> {
43 | let selector = #selector(UITableViewDelegate.tableView(_:didEndDisplaying:forRowAt:))
44 | return delegateProxy.interceptSelectorPublisher(selector)
45 | .map { ($0[1] as! UITableViewCell, $0[2] as! IndexPath) }
46 | .eraseToAnyPublisher()
47 | }
48 |
49 | /// Combine wrapper for `tableView(_:didEndDisplayingHeaderView:forSection:)`
50 | var didEndDisplayingHeaderViewPublisher: AnyPublisher<(headerView: UIView, section: Int), Never> {
51 | let selector = #selector(UITableViewDelegate.tableView(_:didEndDisplayingHeaderView:forSection:))
52 | return delegateProxy.interceptSelectorPublisher(selector)
53 | .map { ($0[1] as! UIView, $0[2] as! Int) }
54 | .eraseToAnyPublisher()
55 | }
56 |
57 | /// Combine wrapper for `tableView(_:didEndDisplayingFooterView:forSection:)`
58 | var didEndDisplayingFooterView: AnyPublisher<(headerView: UIView, section: Int), Never> {
59 | let selector = #selector(UITableViewDelegate.tableView(_:didEndDisplayingFooterView:forSection:))
60 | return delegateProxy.interceptSelectorPublisher(selector)
61 | .map { ($0[1] as! UIView, $0[2] as! Int) }
62 | .eraseToAnyPublisher()
63 | }
64 |
65 | /// Combine wrapper for `tableView(_:accessoryButtonTappedForRowWith:)`
66 | var itemAccessoryButtonTappedPublisher: AnyPublisher {
67 | let selector = #selector(UITableViewDelegate.tableView(_:accessoryButtonTappedForRowWith:))
68 | return delegateProxy.interceptSelectorPublisher(selector)
69 | .map { $0[1] as! IndexPath }
70 | .eraseToAnyPublisher()
71 | }
72 |
73 | /// Combine wrapper for `tableView(_:didHighlightRowAt:)`
74 | var didHighlightRowPublisher: AnyPublisher {
75 | let selector = #selector(UITableViewDelegate.tableView(_:didHighlightRowAt:))
76 | return delegateProxy.interceptSelectorPublisher(selector)
77 | .map { $0[1] as! IndexPath }
78 | .eraseToAnyPublisher()
79 | }
80 |
81 | /// Combine wrapper for `tableView(_:didUnHighlightRowAt:)`
82 | var didUnhighlightRowPublisher: AnyPublisher {
83 | let selector = #selector(UITableViewDelegate.tableView(_:didUnhighlightRowAt:))
84 | return delegateProxy.interceptSelectorPublisher(selector)
85 | .map { $0[1] as! IndexPath }
86 | .eraseToAnyPublisher()
87 | }
88 |
89 | /// Combine wrapper for `tableView(_:didSelectRowAt:)`
90 | var didSelectRowPublisher: AnyPublisher {
91 | let selector = #selector(UITableViewDelegate.tableView(_:didSelectRowAt:))
92 | return delegateProxy.interceptSelectorPublisher(selector)
93 | .map { $0[1] as! IndexPath }
94 | .eraseToAnyPublisher()
95 | }
96 |
97 | /// Combine wrapper for `tableView(_:didDeselectRowAt:)`
98 | var didDeselectRowPublisher: AnyPublisher {
99 | let selector = #selector(UITableViewDelegate.tableView(_:didDeselectRowAt:))
100 | return delegateProxy.interceptSelectorPublisher(selector)
101 | .map { $0[1] as! IndexPath }
102 | .eraseToAnyPublisher()
103 | }
104 |
105 | /// Combine wrapper for `tableView(_:willBeginEditingRowAt:)`
106 | var willBeginEditingRowPublisher: AnyPublisher {
107 | let selector = #selector(UITableViewDelegate.tableView(_:willBeginEditingRowAt:))
108 | return delegateProxy.interceptSelectorPublisher(selector)
109 | .map { $0[1] as! IndexPath }
110 | .eraseToAnyPublisher()
111 | }
112 |
113 | /// Combine wrapper for `tableView(_:didEndEditingRowAt:)`
114 | var didEndEditingRowPublisher: AnyPublisher {
115 | let selector = #selector(UITableViewDelegate.tableView(_:didEndEditingRowAt:))
116 | return delegateProxy.interceptSelectorPublisher(selector)
117 | .map { $0[1] as! IndexPath }
118 | .eraseToAnyPublisher()
119 | }
120 |
121 | override var delegateProxy: DelegateProxy {
122 | TableViewDelegateProxy.createDelegateProxy(for: self)
123 | }
124 | }
125 |
126 | @available(iOS 13.0, *)
127 | private class TableViewDelegateProxy: DelegateProxy, UITableViewDelegate, DelegateProxyType {
128 | func setDelegate(to object: UITableView) {
129 | object.delegate = self
130 | }
131 | }
132 | #endif
133 | // swiftlint:enable force_cast
134 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UITextField+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITextField+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 02/08/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Combine
11 | import UIKit
12 |
13 | @available(iOS 13.0, *)
14 | public extension UITextField {
15 | /// A publisher emitting any text changes to a this text field.
16 | var textPublisher: AnyPublisher {
17 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.text)
18 | .eraseToAnyPublisher()
19 | }
20 |
21 | /// A publisher emitting any attributed text changes to this text field.
22 | var attributedTextPublisher: AnyPublisher {
23 | Publishers.ControlProperty(control: self, events: .defaultValueEvents, keyPath: \.attributedText)
24 | .eraseToAnyPublisher()
25 | }
26 |
27 | /// A publisher that emits whenever the user taps the return button and ends the editing on the text field.
28 | var returnPublisher: AnyPublisher {
29 | controlEventPublisher(for: .editingDidEndOnExit)
30 | }
31 |
32 | /// A publisher that emits whenever the user taps the text fields and begin the editing.
33 | var didBeginEditingPublisher: AnyPublisher {
34 | controlEventPublisher(for: .editingDidBegin)
35 | }
36 | }
37 | #endif
38 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/Controls/UITextView+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITextView+Combine.swift
3 | // CombineCocoa
4 | //
5 | // Created by Shai Mishali on 10/08/2020.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import UIKit
11 | import Combine
12 |
13 | @available(iOS 13.0, *)
14 | public extension UITextView {
15 | /// A Combine publisher for the `UITextView's` value.
16 | ///
17 | /// - note: This uses the underlying `NSTextStorage` to make sure
18 | /// autocorrect changes are reflected as well.
19 | ///
20 | /// - seealso: https://git.io/JJM5Q
21 | var valuePublisher: AnyPublisher {
22 | Deferred { [weak textView = self] in
23 | textView?.textStorage
24 | .didProcessEditingRangeChangeInLengthPublisher
25 | .map { _ in textView?.text }
26 | .prepend(textView?.text)
27 | .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher()
28 | }
29 | .eraseToAnyPublisher()
30 | }
31 |
32 | var textPublisher: AnyPublisher { valuePublisher }
33 | }
34 | #endif
35 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/DelegateProxy/DelegateProxy.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DelegateProxy.swift
3 | // CombineCocoa
4 | //
5 | // Created by Joan Disho on 25/09/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Foundation
11 | import Combine
12 |
13 | #if canImport(Runtime)
14 | import Runtime
15 | #endif
16 |
17 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
18 | open class DelegateProxy: ObjcDelegateProxy {
19 | private var dict: [Selector: [([Any]) -> Void]] = [:]
20 | private var subscribers = [AnySubscriber<[Any], Never>?]()
21 |
22 | public required override init() {
23 | super.init()
24 | }
25 |
26 | public override func interceptedSelector(_ selector: Selector, arguments: [Any]) {
27 | dict[selector]?.forEach { handler in
28 | handler(arguments)
29 | }
30 | }
31 |
32 | public func intercept(_ selector: Selector, _ handler: @escaping ([Any]) -> Void) {
33 | if dict[selector] != nil {
34 | dict[selector]?.append(handler)
35 | } else {
36 | dict[selector] = [handler]
37 | }
38 | }
39 |
40 | public func interceptSelectorPublisher(_ selector: Selector) -> AnyPublisher<[Any], Never> {
41 | DelegateProxyPublisher<[Any]> { subscriber in
42 | self.subscribers.append(subscriber)
43 | return self.intercept(selector) { args in
44 | _ = subscriber.receive(args)
45 | }
46 | }.eraseToAnyPublisher()
47 | }
48 |
49 | deinit {
50 | subscribers.forEach { $0?.receive(completion: .finished) }
51 | subscribers = []
52 | }
53 | }
54 | #endif
55 |
--------------------------------------------------------------------------------
/Sources/CombineCocoa/DelegateProxy/DelegateProxyPublisher.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DelegateProxyPublisher.swift
3 | // CombineCocoa
4 | //
5 | // Created by Joan Disho on 25/09/2019.
6 | // Copyright © 2020 Combine Community. All rights reserved.
7 | //
8 |
9 | #if !(os(iOS) && (arch(i386) || arch(arm)))
10 | import Foundation
11 | import Combine
12 |
13 | @available(iOS 13.0, *)
14 | internal class DelegateProxyPublisher