Creating a category for classes that implement a specific protocol in Objective-C?
Asked Answered
B

2

9

Short problem description

Can I extend UIView with a category, but have it only work on subclasses that implement a specific protocol (WritableView)?

I.e. can I do something like the following?

@interface UIView<WritableView> (foo) // SYNTAX ERROR
- (void)setTextToDomainOfUrl:(NSString *)text;
- (void)setTextToIntegerValue:(NSInteger)value;
- (void)setCapitalizedText:(NSString *)text;
@end
@implementation UIView<WritableView> (foo)
// implementation of 3 above methods would go here
@end

Detailed problem description

Imagine I want the following category function added to any instance of UILabel:

[label setTextToDomainOfUrl:@"http://google.com"];

Which simply sets a UILabel's text property to google.com.

Simlarly, I want to be able to call this function on several other classes:

[button setTextToDomainOfUrl:@"http://apple.com"]; // same as: [button setTitle:@"apple.com" forState:UIControlStateNormal];
[textField setTextToDomainOfUrl:@"http://example.com"]; // same as: textField.text = @"example.com"
[tableViewCell setTextToDomainOfUrl:@"http://stackoverflow.com"]; // same as: tableViewCell.textLabel.text = @"stackoverflow.com"

Let's say I'm really happy with my design so far, and I want to add 2 more methods to all 4 classes:

[label setTextToIntegerValue:5] // same as: label.text = [NSString stringWithFormat:@"%d", 5];
[textField setCapitalizedText:@"abc"] // same as: textField.text = [@"abc" capitalizedString]

So now we have 4 classes that have 3 methods each. If I wanted to actually make this work, I would need to write 12 functions (4*3). As I add more functions, I need to implement them on each of my subclasses, which can quickly become very hard to maintain.

Instead, I want to implement these methods only once, and simply expose a new category method on the supported components called writeText:. This way instead of having to implement 12 functions, I can cut the number down to 4 (one for each supported component) + 3 (one for each method available) for a total of 7 methods that need to be implemented.

Note: These are silly methods, used just for illustrative purposes. The important part is that there are many methods (in this case 3), which shouldn't have their code duplicated.

My first step at trying to implement this is noticing that the first common ancestor of these 4 classes is UIView. Therefore, the logical place to put the 3 methods seems to be in a category on UIView:

@interface UIView (foo)
- (void)setTextToDomainOfUrl:(NSString *)text;
- (void)setTextToIntegerValue:(NSInteger)value;
- (void)setCapitalizedText:(NSString *)text;
@end

@implementation UIView (foo)
- (void)setTextToDomainOfUrl:(NSString *)text {
    text = [text stringByReplacingOccurrencesOfString:@"http://" withString:@""]; // just an example, obviously this can be improved
    // ... implement more code to strip everything else out of the string
    NSAssert([self conformsToProtocol:@protocol(WritableView)], @"Must conform to protocol");
    [(id<WritableView>)self writeText:text];
}
- (void)setTextToIntegerValue:(NSInteger)value {
    NSAssert([self conformsToProtocol:@protocol(WritableView)], @"Must conform to protocol");
    [(id<WritableView>)self writeText:[NSString stringWithFormat:@"%d", value]];
}
- (void)setCapitalizedText:(NSString *)text {
    NSAssert([self conformsToProtocol:@protocol(WritableView)], @"Must conform to protocol");
    [(id<WritableView>)self writeText:[text capitalizedString]];
}
@end    

These 3 methods will work as long as the current instance of UIView conforms to the WritableView protocol. So I extend my 4 supported classes with the following code:

@protocol WritableView <NSObject>
- (void)writeText:(NSString *)text;
@end

@interface UILabel (foo)<WritableView>
@end

@implementation UILabel (foo)
- (void)writeText:(NSString *)text {
    self.text = text;
}
@end

@interface UIButton (foo)<WritableView>
@end

@implementation UIButton (foo)
- (void)writeText:(NSString *)text {
    [self setTitle:text forState:UIControlStateNormal];
}
@end

// similar code for UITextField and UITableViewCell omitted

And now when I call the following:

[label setTextToDomainOfUrl:@"http://apple.com"];
[tableViewCell setCapitalizedText:@"hello"];

It works! Hazzah! Everything works perfectly... until I try this:

[slider setTextToDomainOfUrl:@"http://apple.com"];

The code compiles (since UISlider inherits from UIView), but fails at run time (since UISlider doesn't conform to the WritableView protocol).

What I would really like to do is make these 3 methods only available to those UIViews which have a writeText: method implemented (I.e. those UIViews which implement the WritableView protocol I set up). Ideally, I would define my category on UIView like the following:

@interface UIView<WritableView> (foo) // SYNTAX ERROR
- (void)setTextToDomainOfUrl:(NSString *)text;
- (void)setTextToIntegerValue:(NSInteger)value;
- (void)setCapitalizedText:(NSString *)text;
@end

The idea is that if this were valid syntax, it would make [slider setTextToDomainOfUrl:@"http://apple.com"] fail at compile time (since UISlider never implements the WritableView protocol), but it would make all my other examples succeed.

So my question is: is there any way to extend a class with a category, but limit it to only those subclasses that have implemented a specific protocol?


I realize I could change the assertion (which checks that it conforms to the protocol) to an if statement, but that would still allow the buggy UISlider line to compile. True, it won't cause an exception at runtime, but it won't cause anything to happen either, which is another kind of error I am also trying to avoid.

Similar questions that haven't been given satisfactory answers:

Brander answered 19/7, 2013 at 6:38 Comment(0)
S
8

It sounds like what you're after is a mixin: define a series of methods that form the behaviour that you want, and then add that behaviour to only the set of classes that need it.

Here is a strategy I've used to great success in my project EnumeratorKit, which adds Ruby-style block enumeration methods to built-in Cocoa collection classes (in particular EKEnumerable.h and EKEnumerable.m:

  1. Define a protocol that describes the behaviour you want. For method implementations you are going to provide, declare them as @optional.

    @protocol WritableView <NSObject>
    
    - (void)writeText:(NSString *)text;
    
    @optional
    - (void)setTextToDomainOfUrl:(NSString *)text;
    - (void)setTextToIntegerValue:(NSInteger)value;
    - (void)setCapitalizedText:(NSString *)text;
    
    @end
    
  2. Create a class that conforms to that protocol, and implements all the optional methods:

    @interface WritableView : NSObject <WritableView>
    
    @end
    
    @implementation WritableView
    
    - (void)writeText:(NSString *)text
    {
        NSAssert(@"expected -writeText: to be implemented by %@", [self class]);
    }
    
    - (void)setTextToDomainOfUrl:(NSString *)text
    {
        // implementation will call [self writeText:text]
    }
    
    - (void)setTextToIntegerValue:(NSInteger)value
    {
        // implementation will call [self writeText:text]
    }
    
    - (void)setCapitalizedText:(NSString *)text
    {
        // implementation will call [self writeText:text]
    }
    
    @end
    
  3. Create a category on NSObject that can add these methods to any other class at runtime (note that this code doesn't support class methods, only instance methods):

    #import <objc/runtime.h>
    
    @interface NSObject (IncludeWritableView)
    + (void)includeWritableView;
    @end
    
    @implementation
    
    + (void)includeWritableView
    {
        unsigned int methodCount;
        Method *methods = class_copyMethodList([WritableView class], &methodCount);
    
        for (int i = 0; i < methodCount; i++) {
            SEL name = method_getName(methods[i]);
            IMP imp = method_getImplementation(methods[i]);
            const char *types = method_getTypeEncoding(methods[i]);
    
            class_addMethod([self class], name, imp, types);
        }
    
        free(methods);
    }
    
    @end
    

Now in the class where you want to include this behaviour (for example, UILabel):

  1. Adopt the WritableView protocol
  2. Implement the required writeText: instance method
  3. Add this to the top of your implementation:

    @interface UILabel (WritableView) <WritableView>
    
    @end
    
    @implementation UILabel (WritableView)
    
    + (void)load
    {
        [self includeWritableView];
    }
    
    // implementation specific to UILabel
    - (void)writeText:(NSString *)text
    {
        self.text = text;
    }
    
    @end
    

Hope this helps. I've found it a really effective way to implement cross-cutting concerns without having to copy & paste code between multiple categories.

Scaffolding answered 19/7, 2013 at 7:40 Comment(5)
Awesome! Thanks for the detailed answer. I wish Apple made it as simple as @interface UIView<WritableView> (foo) though.Brander
@Brander Yeah not to mention that in RubyMotion you could just include WriteableView. Still, it's great that the runtime allows it at all!Scaffolding
I have a quick question: In your code you essentially make the methods available to the all instances of that class. Is it possible to "paste" behavior to a specific instance, that is, send the implementation of a method or a block to an instance and have it execute that on itself?Bono
@unmircea As far as I know there's no supported way of restricting a method implementation to a specific instance of a class. Sounds a bit like how with a mocking framework you can stub a method on an object -- is that the kind of thing you're talking about?Scaffolding
I was looking to do this: #23029797 and getting my IMPs from blocks like this: #1806078 The main reason to do this is to have the block IMPs take local context variable and pointer with them, but execute them on the objects themselves, this way they become aware of an entire context where they play a role. Does this make any sense?Bono
B
1

Swift 2.0 introduces Protocol Extensions which is exactly what I was looking for. Had I been just using Swift, I would have been able to achieve the desired results with the following code:

protocol WritableView {
    func writeText(text: String)
}

extension WritableView {
    func setTextToDomainOfUrl(text: String) {
        let t = text.stringByReplacingOccurrencesOfString("http://", withString:"") // just an example, obviously this can be improved
        writeText(t)
    }

    func setTextToIntegerValue(value: Int) {
        writeText("\(value)")
    }

    func setCapitalizedText(text: String) {
        writeText(text.capitalizedString)
    }
}

extension UILabel: WritableView {
    func writeText(text: String) {
        self.text = text
    }
}

extension UIButton: WritableView {
    fun writeText(text: String) {
        setTitle(text, forState:.Normal)
    }
}

Unfortunately, in my limited tests with Swift and Objective-C, it looks like you cannot use Swift protocol extensions in Objective-C (e.g. the moment I choose to extend the protocol WritableView in Swift, the protocol WritableView is no longer visible to Objective-C).

Brander answered 11/11, 2015 at 18:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.