diff --git a/Example/ExampleTests/UICollectionViewTests.swift b/Example/ExampleTests/UICollectionViewTests.swift index 06f13f7..0e6313f 100644 --- a/Example/ExampleTests/UICollectionViewTests.swift +++ b/Example/ExampleTests/UICollectionViewTests.swift @@ -130,4 +130,33 @@ class UICollectionViewTests: XCTestCase { XCTAssertEqual(didScroll, true) XCTAssertEqual(didSelect, true) } + + func test_setDelegate() { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + let delegate = StubCollectionViewDelegate() + + var resultIndexPath: IndexPath? = nil + + collectionView.didSelectItemPublisher + .sink(receiveValue: { resultIndexPath = $0 }) + .store(in: &subscriptions) + + collectionView.setDelegate(delegate) + .store(in: &subscriptions) + + let givenIndexPath = IndexPath(row: 1, section: 0) + collectionView.delegate!.collectionView!(collectionView, didSelectItemAt: givenIndexPath) + let offset = collectionView.delegate!.collectionView!(collectionView, targetContentOffsetForProposedContentOffset: CGPoint(x: 0, y: 0)) + + XCTAssertEqual(resultIndexPath, givenIndexPath) + XCTAssertEqual(offset, StubCollectionViewDelegate.offset) + } +} + +private class StubCollectionViewDelegate: NSObject, UICollectionViewDelegate { + static let offset = CGPoint(x: 1, y: 2) + + func collectionView( _ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint ) -> CGPoint { + Self.offset + } } diff --git a/Example/ExampleTests/UIScrollViewTests.swift b/Example/ExampleTests/UIScrollViewTests.swift index 9d54e25..8911951 100644 --- a/Example/ExampleTests/UIScrollViewTests.swift +++ b/Example/ExampleTests/UIScrollViewTests.swift @@ -193,4 +193,32 @@ class UIScrollViewTests: XCTestCase { XCTAssertEqual(resultView, givenView) XCTAssertEqual(resultScale, givenScale) } + + func test_setDelegate() { + let scrollView = UIScrollView() + let delegate = StubScrollViewDelegate() + + var didScroll = false + + scrollView.didScrollPublisher + .sink(receiveValue: { didScroll = true }) + .store(in: &subscriptions) + + scrollView.setDelegate(delegate) + .store(in: &subscriptions) + + scrollView.delegate!.scrollViewDidScroll!(scrollView) + let viewForZooming = scrollView.delegate!.viewForZooming!(in: scrollView) + + XCTAssertEqual(didScroll, true) + XCTAssertEqual(viewForZooming, StubScrollViewDelegate.view) + } +} + +private class StubScrollViewDelegate: NSObject, UIScrollViewDelegate { + static let view = UIView() + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + Self.view + } } diff --git a/Example/ExampleTests/UISearchBarTests.swift b/Example/ExampleTests/UISearchBarTests.swift index f9a769d..21230bb 100644 --- a/Example/ExampleTests/UISearchBarTests.swift +++ b/Example/ExampleTests/UISearchBarTests.swift @@ -55,4 +55,34 @@ class UISearchBarTests: XCTestCase { XCTAssertEqual(clicked, true) subscription.cancel() } + + func test_setDelegate() { + var subscriptions = Set() + let searchbar = UISearchBar() + let delegate = StubSearchBarDelegate() + + var resultSearchText = "" + + searchbar.textDidChangePublisher + .sink(receiveValue: { resultSearchText = $0 }) + .store(in: &subscriptions) + + searchbar.setDelegate(delegate) + .store(in: &subscriptions) + + let givenSearchText = "Hello world" + searchbar.delegate!.searchBar!(searchbar, textDidChange: givenSearchText) + let shouldBeginEditing = searchbar.delegate!.searchBarShouldBeginEditing!(searchbar) + + XCTAssertEqual(resultSearchText, givenSearchText) + XCTAssertEqual(shouldBeginEditing, StubSearchBarDelegate.shouldBeginEditing) + } +} + +private class StubSearchBarDelegate: NSObject, UISearchBarDelegate { + static let shouldBeginEditing = true + + func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { + Self.shouldBeginEditing + } } diff --git a/Example/ExampleTests/UITableViewTests.swift b/Example/ExampleTests/UITableViewTests.swift index 8b5a4ff..0a86f5a 100644 --- a/Example/ExampleTests/UITableViewTests.swift +++ b/Example/ExampleTests/UITableViewTests.swift @@ -124,4 +124,35 @@ class UITableViewTests: XCTestCase { XCTAssertEqual(firstResultIndexPaths, [givenIndexPath]) XCTAssertEqual(firstResultIndexPaths, secondResultIndexPaths) } + + func test_setDelegate() { + let tableView = UITableView() + let delegate = StubTableViewDelegate() + + var resultIndexPath: IndexPath? = nil + + tableView.didSelectRowPublisher + .sink(receiveValue: { resultIndexPath = $0 }) + .store(in: &subscriptions) + + tableView.setDelegate(delegate) + .store(in: &subscriptions) + + let givenIndexPath = IndexPath(row: 1, section: 0) + tableView.delegate!.tableView!(tableView, didSelectRowAt: givenIndexPath) + let selector = #selector(UITableViewDelegate.tableView(_:heightForRowAt:)) + let height = tableView.delegate!.tableView!(tableView, heightForRowAt: givenIndexPath) + + XCTAssertTrue(tableView.delegate!.responds(to: selector)) + XCTAssertEqual(resultIndexPath, givenIndexPath) + XCTAssertEqual(height, StubTableViewDelegate.height) + } +} + +private class StubTableViewDelegate: NSObject, UITableViewDelegate { + static let height = 10.0 + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + Self.height + } } diff --git a/Sources/CombineCocoa/Controls/NSTextStorage+Combine.swift b/Sources/CombineCocoa/Controls/NSTextStorage+Combine.swift index e85af9e..30b6c4a 100644 --- a/Sources/CombineCocoa/Controls/NSTextStorage+Combine.swift +++ b/Sources/CombineCocoa/Controls/NSTextStorage+Combine.swift @@ -36,6 +36,8 @@ public extension NSTextStorage { @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) private class NSTextStorageDelegateProxy: DelegateProxy, NSTextStorageDelegate, DelegateProxyType { + typealias Delegate = NSTextStorageDelegate + func setDelegate(to object: NSTextStorage) { object.delegate = self } diff --git a/Sources/CombineCocoa/Controls/UICollectionView+Combine.swift b/Sources/CombineCocoa/Controls/UICollectionView+Combine.swift index 920fafc..875fe3c 100644 --- a/Sources/CombineCocoa/Controls/UICollectionView+Combine.swift +++ b/Sources/CombineCocoa/Controls/UICollectionView+Combine.swift @@ -81,10 +81,16 @@ public extension UICollectionView { override var delegateProxy: DelegateProxy { CollectionViewDelegateProxy.createDelegateProxy(for: self) } + + func setDelegate(_ delegate: UICollectionViewDelegate) -> Cancellable { + CollectionViewDelegateProxy.installForwardDelegate(delegate, for: self) + } } @available(iOS 13.0, *) private class CollectionViewDelegateProxy: DelegateProxy, UICollectionViewDelegate, DelegateProxyType { + typealias Delegate = UICollectionViewDelegate + func setDelegate(to object: UICollectionView) { object.delegate = self } diff --git a/Sources/CombineCocoa/Controls/UIScrollView+Combine.swift b/Sources/CombineCocoa/Controls/UIScrollView+Combine.swift index 0c922a7..cf619f4 100644 --- a/Sources/CombineCocoa/Controls/UIScrollView+Combine.swift +++ b/Sources/CombineCocoa/Controls/UIScrollView+Combine.swift @@ -135,14 +135,19 @@ public extension UIScrollView { @objc var delegateProxy: DelegateProxy { ScrollViewDelegateProxy.createDelegateProxy(for: self) } + + func setDelegate(_ delegate: UIScrollViewDelegate) -> Cancellable { + ScrollViewDelegateProxy.installForwardDelegate(delegate, for: self) + } } @available(iOS 13.0, *) private class ScrollViewDelegateProxy: DelegateProxy, UIScrollViewDelegate, DelegateProxyType { + typealias Delegate = UIScrollViewDelegate + func setDelegate(to object: UIScrollView) { object.delegate = self } } #endif // swiftlint:enable force_cast - diff --git a/Sources/CombineCocoa/Controls/UISearchBar+Combine.swift b/Sources/CombineCocoa/Controls/UISearchBar+Combine.swift index 83587e5..5260c21 100644 --- a/Sources/CombineCocoa/Controls/UISearchBar+Combine.swift +++ b/Sources/CombineCocoa/Controls/UISearchBar+Combine.swift @@ -43,10 +43,16 @@ public extension UISearchBar { private var delegateProxy: UISearchBarDelegateProxy { .createDelegateProxy(for: self) } + + func setDelegate(_ delegate: UISearchBarDelegate) -> Cancellable { + UISearchBarDelegateProxy.installForwardDelegate(delegate, for: self) + } } @available(iOS 13.0, *) private class UISearchBarDelegateProxy: DelegateProxy, UISearchBarDelegate, DelegateProxyType { + typealias Delegate = UISearchBarDelegate + func setDelegate(to object: UISearchBar) { object.delegate = self } diff --git a/Sources/CombineCocoa/Controls/UITableView+Combine.swift b/Sources/CombineCocoa/Controls/UITableView+Combine.swift index 829fea0..952a246 100644 --- a/Sources/CombineCocoa/Controls/UITableView+Combine.swift +++ b/Sources/CombineCocoa/Controls/UITableView+Combine.swift @@ -121,10 +121,16 @@ public extension UITableView { override var delegateProxy: DelegateProxy { TableViewDelegateProxy.createDelegateProxy(for: self) } + + func setDelegate(_ delegate: UITableViewDelegate) -> Cancellable { + TableViewDelegateProxy.installForwardDelegate(delegate, for: self) + } } @available(iOS 13.0, *) private class TableViewDelegateProxy: DelegateProxy, UITableViewDelegate, DelegateProxyType { + typealias Delegate = UITableViewDelegate + func setDelegate(to object: UITableView) { object.delegate = self } diff --git a/Sources/CombineCocoa/DelegateProxy/DelegateProxyType.swift b/Sources/CombineCocoa/DelegateProxy/DelegateProxyType.swift index a847953..85f5ba9 100644 --- a/Sources/CombineCocoa/DelegateProxy/DelegateProxyType.swift +++ b/Sources/CombineCocoa/DelegateProxy/DelegateProxyType.swift @@ -8,11 +8,13 @@ #if !(os(iOS) && (arch(i386) || arch(arm))) import Foundation +import Combine private var associatedKey = "delegateProxy" public protocol DelegateProxyType { associatedtype Object + associatedtype Delegate func setDelegate(to object: Object) } @@ -20,6 +22,28 @@ public protocol DelegateProxyType { @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public extension DelegateProxyType where Self: DelegateProxy { static func createDelegateProxy(for object: Object) -> Self { + let delegateProxy = proxy(for: object) + + delegateProxy.setDelegate(to: object) + + return delegateProxy + } + + /// Sets forward delegate for `DelegateProxyType` associated with a specific object and return cancellable that can be used to unset the forward to delegate. + /// + /// - parameter forwardDelegate: Delegate object to set. + /// - parameter object: Object that has `delegate` property. + /// - returns: Cancellable object that can be used to clear forward delegate. + static func installForwardDelegate(_ forwardDelegate: Delegate, for object: Object) -> Cancellable { + let delegateProxy = proxy(for: object) + delegateProxy.setForwardToDelegate(forwardDelegate) + + return AnyCancellable { + delegateProxy.setForwardToDelegate(nil) + } + } + + private static func proxy(for object: Object) -> Self { objc_sync_enter(self) defer { objc_sync_exit(self) } @@ -32,9 +56,14 @@ public extension DelegateProxyType where Self: DelegateProxy { objc_setAssociatedObject(object, &associatedKey, delegateProxy, .OBJC_ASSOCIATION_RETAIN) } - delegateProxy.setDelegate(to: object) - return delegateProxy } + + /// Sets reference of normal delegate that receives all forwarded messages through `self`. + /// + /// - parameter delegate: Reference of delegate that receives all messages through `self`. + func setForwardToDelegate(_ delegate: Delegate?) { + self._setForwardToDelegate(delegate) + } } #endif diff --git a/Sources/Runtime/ObjcDelegateProxy.m b/Sources/Runtime/ObjcDelegateProxy.m index 9ae5624..69a6260 100644 --- a/Sources/Runtime/ObjcDelegateProxy.m +++ b/Sources/Runtime/ObjcDelegateProxy.m @@ -14,6 +14,11 @@ static NSMutableDictionary *> *allSelectors; +@interface ObjcDelegateProxy () { + id __weak forwardToDelegate; +} +@end + @implementation ObjcDelegateProxy - (NSSet *)selectors { @@ -41,16 +46,28 @@ - (BOOL)canRespondToSelector:(SEL _Nonnull)selector { return true; } } + + if (forwardToDelegate && [forwardToDelegate respondsToSelector:selector]) { + return true; + } return false; } - (void)interceptedSelector:(SEL _Nonnull)selector arguments:(NSArray * _Nonnull)arguments {} +-(void)_setForwardToDelegate:(id __nullable)forwardToDelegate { + self->forwardToDelegate = forwardToDelegate; +} + - (void)forwardInvocation:(NSInvocation *)anInvocation { NSArray * _Nonnull arguments = unpackInvocation(anInvocation); [self interceptedSelector:anInvocation.selector arguments:arguments]; + + if (forwardToDelegate && [forwardToDelegate respondsToSelector:anInvocation.selector]) { + [anInvocation invokeWithTarget:forwardToDelegate]; + } } NSArray * _Nonnull unpackInvocation(NSInvocation * _Nonnull invocation) { diff --git a/Sources/Runtime/include/ObjcDelegateProxy.h b/Sources/Runtime/include/ObjcDelegateProxy.h index a7d7d2f..9392786 100644 --- a/Sources/Runtime/include/ObjcDelegateProxy.h +++ b/Sources/Runtime/include/ObjcDelegateProxy.h @@ -15,5 +15,6 @@ - (void)interceptedSelector:(SEL _Nonnull)selector arguments:(NSArray * _Nonnull)arguments; - (BOOL)respondsToSelector:(SEL _Nonnull)aSelector; - (BOOL)canRespondToSelector:(SEL _Nonnull)selector; +- (void)_setForwardToDelegate:(id __nullable)forwardToDelegate NS_SWIFT_NAME(_setForwardToDelegate(_:)); @end