Bidirectional, Manual Binding in custom control

  • I modified a "Star Rating" control to be bindings compatible by adding this…

    + (void)initialize {
      [self exposeBinding:@"rating"] ;
    }

    and then binding to it in the window controller's -awakeFromNib like this…

    // beForgiving is to handle NSNoSelectionMarker & friends…
    NSDictionary* beForgiving ;
    NSNumber* no = [NSNumber numberWithBool:NO] ;
    NSString* key = NSRaisesForNotApplicableKeysBindingOption
    beForgiving = [NSDictionary dictionaryWithObject:no
                                              forKey:key] ;

    [starRatingView bind:@"rating"
                toObject:fooController
            withKeyPath:@"selection.rating"
                options:beForgiving] ;

    fooController is an NSObjectController.  (This is an Inspector window.)

    This resulted in a one-way binding; the star rating field lights up according to the 'rating' of the Foo object being inspected in the data model.

    In order to make the reverse binding work, that is, in order for the user's clicks on stars update the data model, I needed to add stuff to setRating: in my star rating control like this…

    -(void)setRating:(float)rating
    {
        // Stuff to make reverse binding work…
        NSDictionary* bindingsInfo = [self infoForBinding:@"rating"] ;
        id object = [bindingsInfo objectForKey:NSObservedObjectKey] ;
        NSString* bindingsPath = [bindingsInfo objectForKey:NSObservedKeyPathKey] ;
        [object setValue:[NSNumber numberWithFloat:rating]
              forKeyPath:bindingsPath] ;

        // Set ivar, needsDisplay
        …
    }

    This all makes sense, and it works.  But it seems awkward.  Is there a better design pattern?
  • On May 21, 2012, at 20:44 , Jerry Krinock wrote:

    > and then binding to it in the window controller's -awakeFromNib like this…

    > [starRatingView bind:@"rating"
    > toObject:fooController
    > withKeyPath:@"selection.rating"
    > options:beForgiving] ;

    > -(void)setRating:(float)rating
    > {
    > // Stuff to make reverse binding work…
    > NSDictionary* bindingsInfo = [self infoForBinding:@"rating"] ;
    > id object = [bindingsInfo objectForKey:NSObservedObjectKey] ;
    > NSString* bindingsPath = [bindingsInfo objectForKey:NSObservedKeyPathKey] ;
    > [object setValue:[NSNumber numberWithFloat:rating]
    > forKeyPath:bindingsPath] ;
    >
    > // Set ivar, needsDisplay
    > …
    > }
    >
    > This all makes sense, and it works.  But it seems awkward.  Is there a better design pattern?

    This seems more or less the correct approach to defining a custom binding. "More or less" because you may have committed some minor technical violation of custom binding implementations, described unintelligibly in this document:

    https://developer.apple.com/library/mac/#documentation/Cocoa/Conceptual/Coc
    oaBindings/Concepts/HowDoBindingsWork.html


    However, it's not particularly clear what benefit it brings you to implement a custom binding, since there's no practical way any more (AFAIK) to use custom bindings in IB.

    With a code-based approach, it may be easier and clearer just to implement the two halves of the behavior directly:

    (a) Have your window controller KVO-observe fooController.selection.rating, using an observation method like this:

    - (void) observeValueForKeyPath: (NSString*) keyPath ofObject: (id) object change: (NSDictionary*) change context: (void*) context {
      if (context != myContext) …
      else if (object == fooController && [keyPath isEqualToString: @"selection.rating"]) {
      NSNumber* rating = [fooController valueForKeyPath: @"selection.rating"];
      if ([rating isKindOfClass: [NSNumber class]])
        starRatingView.objectValue = rating;
      else
        … // disable the control or whatever you want to do for not-applicable markers
      }
      else …
    }

    (b) Connect the Star Rating control to an action method that's implemented in the window controller:

    (IBAction) changeRating: (sender) {
      fooController.selection.rating = sender.floatValue;
    }

    (You could even do this entirely within the control itself, provided that the window controller configured it with an object and key path to observe, or it had some other way of finding what to observe.)

    It's not so much a question of number of lines of code. It's rather that doing this without bindings seems much more obvious.
  • On 2012 May 21, at 21:52, Quincey Morris wrote:

    > This seems more or less the correct approach to defining a custom binding. "More or less" because you may have committed some minor technical violation of custom binding implementations, described unintelligibly in this document:
    >
    > https://developer.apple.com/library/mac/#documentation/Cocoa/Conceptual/Coc
    oaBindings/Concepts/HowDoBindingsWork.html

    >
    > However, it's not particularly clear what benefit it brings you to implement a custom binding, since there's no practical way any more (AFAIK) to use custom bindings in IB.

    Thank you, Quincey.  Yes, I know about that – arghhh – IB limitation.  That's why I -bind:::: in the window controller's -awakeFromNib.

    > With a code-based approach, it may be easier and clearer just to implement the two halves of the behavior directly:

    Yes, I see what you mean.  This may be one of those cases where Cocoa Bindings makes life more difficult instead of easier.

    In this case, I kind of like the binding though, because all of the other fields in this Inspector window use bindings, and I'd rather not break the pattern.  I'll read "How Do Bindings Work" and see if I can find any "technical violations".

    Jerry

    (For sake of list archives, for anyone else who wants to try this, there's an important detail I left out of my original post.  Remember to -unbind: in the window controller's -windowWillClose or -dealloc, or you'll get exceptions and/or crashes.  I do it in both methods, calling a method which also removes other observers.  I use an instance variable, m_isObserving, to keep track of whether or not my bindings and observers are active.)
  • On 2012 May 21, at 21:52, Quincey Morris wrote:

    > On May 21, 2012, at 20:44 , Jerry Krinock wrote:

    >> -(void)setRating:(float)rating
    >> {
    >> // Stuff to make reverse binding work…
    >> NSDictionary* bindingsInfo = [self infoForBinding:@"rating"] ;
    >> id object = [bindingsInfo objectForKey:NSObservedObjectKey] ;
    >> NSString* bindingsPath = [bindingsInfo objectForKey:NSObservedKeyPathKey] ;
    >> [object setValue:[NSNumber numberWithFloat:rating]
    >> forKeyPath:bindingsPath] ;
    >>
    >> // Set ivar, needsDisplay
    >> …
    >> }
    >
    > This seems more or less the correct approach to defining a custom binding. "More or less" because you may have committed some minor technical violation of custom binding implementations, described unintelligibly in this document:
    >
    > https://developer.apple.com/library/mac/#documentation/Cocoa/Conceptual/Coc
    oaBindings/Concepts/HowDoBindingsWork.html


    So, five weeks later, I learn that I probably was committing a minor technical violation.

    Pushing a value into the data model whenever a value is set in the view is not a good idea.  Such a push will occur, for example, whenever the selection changes, which is of course not necessary.  In fact, it can do some damage…

    Tonight I modified my Inspector to support editing values in a multiple selection, for example:

    • Select 10 model objects.
    • Inspector indicates "Multiple Selection".
    • Click the 3rd star in the Inspector's Star Rating View.
    • I might put a warning sheet here, "Are you sure you want to change 10 items, blah?"
    • All 10 objects get a 3-star rating.

    But after I bound instead to a "multiFoo" class to make that work, I found that the extra setting of the data model was causing model values to be copied from one model object to the next when the I simply selected one model object and then changed the selection to a different model object.

    A better place for pushing the value to the data model is in the input-handling method of the view, -mouseDown: in the case of my star rating control.  Moving the above code to -mouseDown: fixed the problem.
  • On Jun 27, 2012, at 22:12 , Jerry Krinock wrote:

    >> On May 21, 2012, at 20:44 , Jerry Krinock wrote:
    >
    >>> -(void)setRating:(float)rating
    >>> {
    >>> // Stuff to make reverse binding work…
    >>> NSDictionary* bindingsInfo = [self infoForBinding:@"rating"] ;
    >>> id object = [bindingsInfo objectForKey:NSObservedObjectKey] ;
    >>> NSString* bindingsPath = [bindingsInfo objectForKey:NSObservedKeyPathKey] ;
    >>> [object setValue:[NSNumber numberWithFloat:rating]
    >>> forKeyPath:bindingsPath] ;
    >>>
    >>> // Set ivar, needsDisplay
    >>> …
    >>> }
    >
    > But after I bound instead to a "multiFoo" class to make that work, I found that the extra setting of the data model was causing model values to be copied from one model object to the next when the I simply selected one model object and then changed the selection to a different model object.

    Interesting stuff. :)

    Looking back at this code, it seems that you probably always needed to separate the two "directions" of value-changing. Since the model->view updates are coming via KVO, the setter should only contain the second part ("Set ivar, needsDisplay").

    It seems to me this should prevent selection changes from rebounding onto the data model.

    > A better place for pushing the value to the data model is in the input-handling method of the view, -mouseDown: in the case of my star rating control.  Moving the above code to -mouseDown: fixed the problem.

    Using mouseDown kinda feels wrong to me. Wouldn't it make more sense to do the first part of the existing code ("Stuff to make reverse binding work") in an action method?

    > - (IBAction) changeRating: (sender)
    > {
    > // Stuff to make reverse binding work…
    > NSDictionary* bindingsInfo = [self infoForBinding:@"rating"] ;
    > id object = [bindingsInfo objectForKey:NSObservedObjectKey] ;
    > NSString* bindingsPath = [bindingsInfo objectForKey:NSObservedKeyPathKey] ;
    > [object setValue:[sender objectValue] // since the ivar doesn't have the value yet
    > forKeyPath:bindingsPath] ;
    > // this value should now bounce back to the control via the setter, thus
    > }

    That way you're not dependent on *how* the control gets a new value.
  • On 2012 Jun 27, at 22:42, Quincey Morris wrote:

    > the setter should only contain the second part ("Set ivar, needsDisplay").
    >
    > It seems to me this should prevent selection changes from rebounding onto the data model.

    Yes.

    > Using mouseDown kinda feels wrong to me. Wouldn't it make more sense to do the first part of the existing code ("Stuff to make reverse binding work") in an action method?
    >
    >> - (IBAction) changeRating: (sender) { …
    >
    > That way you're not dependent on *how* the control gets a new value.

    OK, but effectively this means that I simply factor this -changeRating: action method out of my -mouseDown: method.  Only -mouseDown: will invoke -changeRating: at this time.

    I don't think I've ever seen a control class implementing an action method, but I suppose that factoring as you suggest is a good practice to avoid bugs being introduced during future changes.
  • On Jun 28, 2012, at 10:49 , Jerry Krinock wrote:

    > OK, but effectively this means that I simply factor this -changeRating: action method out of my -mouseDown: method.  Only -mouseDown: will invoke -changeRating: at this time.
    >
    > I don't think I've ever seen a control class implementing an action method, but I suppose that factoring as you suggest is a good practice to avoid bugs being introduced during future changes.

    You're right, it makes no sense to use an action method. I was mistakenly eliding the distinction between the control and the view/controller containing/managing the control. I'd keep the factoring, though, for the reason you state.

    OTOH, I'm not sure now why your control needs a 'rating' ivar. Doesn't this just duplicate the value of the control's objectValue, and create extra housekeeping?
  • On 2012 Jun 28, at 11:04, Quincey Morris wrote:

    > I'm not sure now why your control needs a 'rating' ivar.  Doesn't this just duplicate the value of the control's objectValue, and create extra housekeeping?

    In this situation, probably yes, but it is quite normal for Cocoa controls to have an instance variable for their control value.  NSControl has properties objectValue, stringValue, doubleValue, etc.  Controls should be useable without bindings.
previous month may 2012 next month
MTWTFSS
  1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31      
Go to today