How to subclass UIScrollView and make the delegate property private

19,292

Solution 1

There is a problem with making MySubclass its own delegate. Presumably you don't want to run custom code for all of the UIScrollViewDelegate methods, but you have to forward the messages to the user-provided delegate whether you have your own implementation or not. So you could try to implement all of the delegate methods, with most of them just forwarding like this:

- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
    [self.myOwnDelegate scrollViewDidZoom:scrollView];
}

The problem here is that sometimes new versions of iOS add new delegate methods. For example, iOS 5.0 added scrollViewWillEndDragging:withVelocity:targetContentOffset:. So your scrollview subclass won't be future-proof.

The best way to handle this is to create a separate, private object that just acts as your scrollview's delegate, and handles forwarding. This dedicated-delegate object can forward every message it receives to the user-provided delegate, because it only receives delegate messages.

Here's what you do. In your header file, you only need to declare the interface for your scrollview subclass. You don't need to expose any new methods or properties, so it just looks like this:

MyScrollView.h

@interface MyScrollView : UIScrollView
@end

All the real work is done in the .m file. First, we define the interface for the private delegate class. Its job is to call back into MyScrollView for some of the delegate methods, and to forward all messages to the user's delegate. So we only want to give it methods that are part of UIScrollViewDelegate. We don't want it to have extra methods for managing a reference to the user's delegate, so we'll just keep that reference as an instance variable:

MyScrollView.m

@interface MyScrollViewPrivateDelegate : NSObject <UIScrollViewDelegate> {
@public
    id<UIScrollViewDelegate> _userDelegate;
}
@end

Next we'll implement MyScrollView. It needs to create an instance of MyScrollViewPrivateDelegate, which it needs to own. Since a UIScrollView doesn't own its delegate, we need an extra, strong reference to this object.

@implementation MyScrollView {
    MyScrollViewPrivateDelegate *_myDelegate;
}

- (void)initDelegate {
    _myDelegate = [[MyScrollViewPrivateDelegate alloc] init];
    [_myDelegate retain]; // remove if using ARC
    [super setDelegate:_myDelegate];
}

- (id)initWithFrame:(CGRect)frame {
    if (!(self = [super initWithFrame:frame]))
        return nil;
    [self initDelegate];
    return self;
}

- (id)initWithCoder:(NSCoder *)aDecoder {
    if (!(self = [super initWithCoder:aDecoder]))
        return nil;
    [self initDelegate];
    return self;
}

- (void)dealloc {
    // Omit this if using ARC
    [_myDelegate release];
    [super dealloc];
}

We need to override setDelegate: and delegate: to store and return a reference to the user's delegate:

- (void)setDelegate:(id<UIScrollViewDelegate>)delegate {
    _myDelegate->_userDelegate = delegate;
    // Scroll view delegate caches whether the delegate responds to some of the delegate
    // methods, so we need to force it to re-evaluate if the delegate responds to them
    super.delegate = nil;
    super.delegate = (id)_myDelegate;
}

- (id<UIScrollViewDelegate>)delegate {
    return _myDelegate->_userDelegate;
}

We also need to define any extra methods that our private delegate might need to use:

- (void)myScrollViewDidEndDecelerating {
    // do whatever you want here
}

@end

Now we can finally define the implementation of MyScrollViewPrivateDelegate. We need to explicitly define each method that should contain our private custom code. The method needs to execute our custom code, and forward the message to the user's delegate, if the user's delegate responds to the message:

@implementation MyScrollViewPrivateDelegate

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    [(MyScrollView *)scrollView myScrollViewDidEndDecelerating];
    if ([_userDelegate respondsToSelector:_cmd]) {
        [_userDelegate scrollViewDidEndDecelerating:scrollView];
    }
}

And we need to handle all of the other UIScrollViewDelegate methods that we don't have custom code for, and all of those messages that will be added in future versions of iOS. We have to implement two methods to make that happen:

- (BOOL)respondsToSelector:(SEL)selector {
    return [_userDelegate respondsToSelector:selector] || [super respondsToSelector:selector];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    // This should only ever be called from `UIScrollView`, after it has verified
    // that `_userDelegate` responds to the selector by sending me
    // `respondsToSelector:`.  So I don't need to check again here.
    [invocation invokeWithTarget:_userDelegate];
}

@end

Solution 2

Thanks @robmayoff I encapsulated to be a more generic delegate interceptor: Having original MessageInterceptor class:

MessageInterceptor.h

@interface MessageInterceptor : NSObject

@property (nonatomic, assign) id receiver;
@property (nonatomic, assign) id middleMan;

@end

MessageInterceptor.m

@implementation MessageInterceptor

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if ([self.middleMan respondsToSelector:aSelector]) { return    self.middleMan; }
    if ([self.receiver respondsToSelector:aSelector]) { return self.receiver; }
    return [super forwardingTargetForSelector:aSelector];
}

- (BOOL)respondsToSelector:(SEL)aSelector {
    if ([self.middleMan respondsToSelector:aSelector]) { return YES; }
    if ([self.receiver respondsToSelector:aSelector]) { return YES; }
    return [super respondsToSelector:aSelector];
}

@end

It is used in your generic delegate class:

GenericDelegate.h

@interface GenericDelegate : NSObject

@property (nonatomic, strong) MessageInterceptor * delegate_interceptor;

- (id)initWithDelegate:(id)delegate;

@end

GenericDelegate.m

@implementation GenericDelegate

- (id)initWithDelegate:(id)delegate {
    self = [super init];
    if (self) {
        self.delegate_interceptor = [[MessageInterceptor alloc] init];
        [self.delegate_interceptor setMiddleMan:self];
        [self.delegate_interceptor setReceiver:delegate];
    }
    return self;
}

// delegate methods I wanna override:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    // 1. your custom code goes here
    NSLog(@"Intercepting scrollViewDidScroll: %f %f", scrollView.contentOffset.x, scrollView.contentOffset.y);

    // 2. forward to the delegate as usual
    if ([self.delegate_interceptor.receiver respondsToSelector:@selector(scrollViewDidScroll:)]) {
        [self.delegate_interceptor.receiver scrollViewDidScroll:scrollView];
    }
 }
//other delegate functions you want to intercept
...

So I can intercept any delegate I need to on any UITableView, UICollectionView, UIScrollView... :

@property (strong, nonatomic) GenericDelegate *genericDelegate;
@property (nonatomic, strong) UICollectionView* collectionView;

//intercepting delegate in order to add separator line functionality on this scrollView
self.genericDelegate = [[GenericDelegate alloc]initWithDelegate:self];
self.collectionView.delegate = (id)self.genericDelegate.delegate_interceptor;

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    NSLog(@"Original scrollViewDidScroll: %f %f", scrollView.contentOffset.x, scrollView.contentOffset.y);
}

In this case UICollectionViewDelegate scrollViewDidScroll: function will be executed on our GenericDelegate (with any code we want to add) and on in the implementation of our own class

Thats my 5 cents, thanks to @robmayoff and @jhabbott previous answer

Solution 3

Another option is to subclass and use the power of abstract functions. For instance, you create

@interface EnhancedTableViewController : UITableViewController <UITableViewDelegate>

There you override some delegate method and define your abstract function

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    // code before interception
    [self bfTableView:(UITableView *)tableView didSelectRowAtIndexPath:indexPath];
    // code after interception
}

- (void)bfTableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {};

Now all you need to do is to subclass your EnhancedTableViewController and use your abstract functions instead of delegate ones. Like this:

@interface MyTableViewController : EnhancedTableViewController ...

- (void)bfTableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    //overriden implementation with pre/post actions
};

Let me know if there is anything wrong here.

Share:
19,292

Related videos on Youtube

Mexyn
Author by

Mexyn

Updated on June 05, 2022

Comments

  • Mexyn
    Mexyn about 2 years

    Here is what I want to achieve:

    I want to subclass an UIScrollView to have additional functionality. This subclass should be able to react on scrolling, so i have to set the delegate property to self to receive events like:

    - (void) scrollViewDidEndDecelerating:(UIScrollView *)scrollView { ... }
    

    On the other hand, other classes should still be able to receive these events too, like they were using the base UIScrollView class.

    So I had different ideas how to solve that problem, but all of these are not entirely satisfying me :(

    My main approach is..using an own delegate property like this:

    @interface MySubclass : UIScrollView<UIScrollViewDelegate>
    @property (nonatomic, assign) id<UIScrollViewDelegate> myOwnDelegate;
    @end
    
    @implementation MySubclass
    @synthesize myOwnDelegate;
    
    - (id) initWithFrame:(CGRect)frame {
        self = [super initWithFrame:frame];
        if (self) {
            self.delegate = self;
        }
        return self;
    }
    
    // Example event
    - (void) scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
        // Do something custom here and after that pass the event to myDelegate
        ...
        [self.myOwnDelegate scrollViewDidEndDecelerating:(UIScrollView*)scrollView];
    }
    @end
    

    In that way my subclass can do something special when the inherited scrollview ends scrolling, but still informs the external delegate of the event. That works so far. But as I want to make this subclass available to other developers, I want to restrict access to the base class delegate property, as it should only be used by the subclass. I think it's most likely that other devs intuitively use the delegate property of the base class, even if I comment the problem in the header file. If someone alters the delegate property the subclass won't do what it's supposed to do and I can't do anything to prevent that right now. And that's the point where i don't have a clue how to solve it.

    What I tried is trying to override the delegate property to make it readonly like this:

    @interface MySubclass : UIScrollView<UIScrollViewDelegate>
    ...
    @property (nonatomic, assign, readonly) id<UIScrollViewDelegate>delegate;
    @end
    
    @implementation MySubclass
    @property (nonatomic, assign, readwrite) id<UIScrollViewDelegate>delegate;
    @end
    

    That will result in a warning

    "Attribute 'readonly' of property 'delegate' restricts attribute 'readwrite' of property inherited from 'UIScrollView'
    

    Ok bad idea, as i'm obviously violating liskovs substitution principle here.

    Next try --> Trying to override the delegate setter like this:

    ...
    - (void) setDelegate(id<UIScrollViewDelegate>)newDelegate {
        if (newDelegate != self) self.myOwnDelegate = newDelegate;
        else _delegate = newDelegate; // <--- This does not work!
    }
    ...
    

    As commented, this example does not compile as it seems that the _delegate ivar wasn't found?! So i looked up the header file of UIScrollView and found this:

    @package
        ...
        id           _delegate;
    ...
    

    The @package directive restricts the access of the _delegate ivar to be accessible only by the framework itself. So when i want to set the _delegate ivar I HAVE TO use the synthesized setter. I can't see a way to override it in any way :( But i can't believe that there isn't a way around this, maybe i can't see the wood for the trees.

    I appreciate for any hint on solving this problem.


    Solution:

    It works now with the solution of @rob mayoff . As i commented right below there was a problem with the scrollViewDidScroll: call. I finally did find out, what the problem is, even i don't understand why this is so :/

    Right in the moment when we set the super delegate:

    - (id) initWithFrame:(CGRect)frame {
        ...
        _myDelegate = [[[MyPrivateDelegate alloc] init] autorelease];
        [super setDelegate:_myDelegate]; <-- Callback is invoked here
    }
    

    there is a callback to _myDelegate. The debugger breaks at

    - (BOOL) respondsToSelector:(SEL)aSelector {
        return [self.userDelegate respondsToSelector:aSelector];
    }
    

    with the "scrollViewDidScroll:" selector as argument.

    The funny thing at this time self.userDelegate isnt set yet and points to nil, so the return value is NO! That seems to cause that the the scrollViewDidScroll: methods won't get fired afterwards. It looks like a precheck if the method is implemented and if it fails this method won't get fired at all, even if we set our userDelegate property afterwards. I don't know why this is so, as the most other delegate methods don't have this precheck.

    So my solution for this is, to invoke the [super setDelegate...] method in the PrivateDelegate setDelegate method, as this is the spot i'm pretty sure my userDelegate method is set.

    So I'll end up with this implementation snippet:

    MyScrollViewSubclass.m

    - (void) setDelegate:(id<UIScrollViewDelegate>)delegate {
        self.internalDelegate.userDelegate = delegate;  
        super.delegate = self.internalDelegate;
    }
    
    - (id) initWithFrame:(CGRect)frame {
        self = [super initWithFrame:frame];
        if (self) {
            self.internalDelegate = [[[MyScrollViewPrivateDelegate alloc] init] autorelease];
            // Don't set it here anymore
        }
        return self;
    }
    

    The rest of the code remains untouched. I'm still not really satisfied with this workaround, because it makes it necessary to call the setDelegate method at least once, but it works for my needs for the moment, although it feels very hacky :/

    If someone has ideas how to improve that, I'd appreciate that.

    Thanks @rob for your example!

    • jhabbott
      jhabbott almost 12 years
      Seems like the scroll view caches if the delegate responds to scrollViewDidScroll:, probably for performance reasons. To fix your issue that the user delegate must be set, set it in init as before and in the setDelegate you can do super.delegate = nil then super.delegate = self.internalDelegate to force it to re-check.
  • Mexyn
    Mexyn about 12 years
    Ok, i thought of this too, but the disadvantage of this approach is that you have to reimplement the whole uiscrollview api in case you want that the subclass acting like its base class. This can still be solved by forwarding all unknown method calls to another target with the use of forwardInvocation:(NSInvocation*)invocation. The problem here is, that i have a two targets to forward to depending on the message called. If its a delegate method (e.g. scrollViewDid...) i want to forward to the "outer" delegate and if its an instance method of the scrollview i want to forward to the scrollview.
  • deanWombourne
    deanWombourne about 12 years
    Ah, I didn't realise you wanted it to behave exactly as a scroll view would. My edit should let you behave mostly like a UIScrollView I think.
  • Mexyn
    Mexyn about 12 years
    That's really awesome!! Thanks for this code, it seems to work very well. But i have a new problem with this approach, that i don't quite understand, it's very strange.
  • Mexyn
    Mexyn about 12 years
    When i set the delegate to _myDelegate like u did in your example, my private delegate class doesn't get the viewDidScroll delegate method even it is properly implemented. The strange thing is, i get other calls like scrollViewWillBeginDragging: and scrollViewWillBeginDecelerating:. For testing purposes i set the delegate to self (my subclass) again and also implemented the viewdidscroll method there. And woot there the method is called. I don't get it at all why the viewDidScroll event is not recognized or even fired, when i scroll the scrollview....:(
  • rob mayoff
    rob mayoff about 12 years
    UIScrollViewDelegate does not declare a method named viewDidScroll. Perhaps you are overriding a private UIScrollView method. The delegate method is named scrollViewDidScroll:.
  • Mexyn
    Mexyn about 12 years
    You're right, my fault. But it's only a typo i actually meant the proper delegate method (scrollViewDidScroll:). I edited my post to fix this. But the strange behavior remains as described above :/
  • Ryder Mackay
    Ryder Mackay about 12 years
    So that’s how you do it. Thanks! One problem: MyScrollViewPrivateDelegate’s implementation of -respondsToSelector: is missing a call to super, which can prevent its own -scrollViewDidEndDecelerating: method from being called if _userDelegate doesn’t respond to that selector. Changing it to return [_userDelegate respondsToSelector:selector] || [super respondsToSelector:selector]; worked for me, should work for @Mexyn too.
  • rob mayoff
    rob mayoff about 12 years
    @RyderMackay Good catch. I have fixed my answer.
  • jhabbott
    jhabbott almost 12 years
    I had the same problem as @Mexyn - whether the delegate responds to scrollViewDidScroll: (and two others) is cached at the time the delegate is set. I'll edit my fix into this code.
  • William Rust
    William Rust over 11 years
    Thanks @robmayoff. This worked great for me. Just had to use __weak for _userDelegate using ARC. __weak id<UIScrollViewDelegate> _userDelegate;
  • Paul Young
    Paul Young about 11 years
    @robmayoff - I've implemented this on a subclass of UITextField rather than a UIScrollView and I'm getting an infinite loop inside of respondsToSelector:selector where selector is textFieldShouldBeginEditing. Can you offer any advice on how to make this work?
  • rob mayoff
    rob mayoff about 11 years
    Make sure you wrote [super respondsToSelector:...], not [self respondsToSelector:].
  • Patrick Pijnappel
    Patrick Pijnappel over 10 years
    Note that in cases you only need to respond to scrollViewDidScroll: you can also override UIScrollView and use layoutSubviews:. It's called whenever the bounds change which is how a scrollview scrolls. Just bumped into this in WWDC 2011 session 104.
  • Awesome-o
    Awesome-o almost 10 years
    "// Scroll view delegate caches whether the delegate responds to some of the delegate // methods, so we need to force it to re-evaluate if the delegate responds to them" I've never heard of anything like this before, could you clearify?
  • rob mayoff
    rob mayoff almost 10 years
    @Awesome-o When you call setDelegate: (or when the XIB loader calls it), the scroll view immediately sends respondsToSelector: to the new delegate for several of the selectors defined by the UIScrollViewDelegate protocol. The scroll view saves the returned booleans, so it doesn't have to ask again later. You can easily check this yourself by overriding respondsToSelector: in your scroll view delegate.
  • Awesome-o
    Awesome-o almost 10 years
    Very, very interesting... Thanks for this thoroughly researched solution!