Search code examples
objective-ccocoamacosimagekitikimagebrowserview

Observing NSUserDefaultsController not working when bound properties are set


Here's the setup: I have a subclass of IKImageBrowserView whose zoomValue property is bound to a key VFBrowserZoomValue in the shared NSUserDefaultsController. I have an NSSlider whose value binding is bound to the same key.

This works perfectly to change the zoomValue of the browser from the slider.

I'm trying to implement -magnifyWithEvent: in my IKImageBrowserView subclass to allow zooming the browser with the pinch gesture on trackpads.

Here's my implementation:

-(void)magnifyWithEvent:(NSEvent *)event
{
  if ([event magnification] > 0) {
    if ([self zoomValue] < 1) {
      [self setZoomValue: [self zoomValue] + [event magnification]];
    }
  } 
  else if ([event magnification] < 0) {
    if ([self zoomValue] + [event magnification] > 0.1) {
      [self setZoomValue: [self zoomValue] + [event magnification]];
    }
    else {
      [self setZoomValue: 0.1];
    }
  }
}

This changes the browser's zoomValue correctly. The problem is that the NSUserDefaults is not updated with the new value.

Elsewhere in the app, I have an implementation of -observeValueForKeyPath:ofObject:change:context: that observes the browser's zoomValue. If I log the values of the browser's zoom, the slider's value and the key in defaults in that method, I see that the browser's zoomValue has not been pushed into NSUserDefaults and the slider hasn't updated.

I've tried surrounding the -magnifyWithEvent: method with calls to -{will,did}ChangeValueForKey to no effect.


Solution

  • The KVO flow for bindings isn't orthogonal; a binding isn't a property, it's a reference to a property. This is the shorthand to remember for how bindings work:

    • KVO is used to communicate changes from model to controller & view.
    • KVC is used to communicate changes from view to controller & model.

    Thus when a view with bindings handles events, it needs to propagate changes to the properties its bindings reference itself.

    Here's what your code might look like, with a utility method for doing the heavy lifting of propagating changes through bindings:

    - (void)magnifyWithEvent:(NSEvent *)event
    {
      if ([event magnification] > 0) {
        if ([self zoomValue] < 1) {
          [self setZoomValue: [self zoomValue] + [event magnification]];
        }
      } 
      else if ([event magnification] < 0) {
        if ([self zoomValue] + [event magnification] > 0.1) {
          [self setZoomValue: [self zoomValue] + [event magnification]];
        }
        else {
          [self setZoomValue: 0.1];
        }
      }
    
      // Update whatever is bound to our zoom value.
      [self updateValue:[NSNumber numberWithFloat:[self zoomValue]]
             forBinding:@"zoomValue"];
    }
    

    It's a little unfortunate that ImageKit requires the use of @"zoomValue" to reference the Zoom Value binding of an IKImageBrowserView, most bindings in AppKit have their own global string constant like NSContentBinding.

    And here's that generic utility method to propagate the change through the binding:

    - (void)updateValue:(id)value forBinding:(NSString *)binding
    {
      NSDictionary *bindingInfo = [self infoForBinding:binding];
    
      if (bindingInfo) {
        NSObject *object = [bindingInfo objectForKey:NSObservedObjectKey];
        NSString *keyPath = [bindingInfo objectForKey:NSObservedKeyPathKey];
        NSDictionary *options = [bindingInfo objectForKey:NSOptionsKey];
    
        // Use options to apply value transformer, placeholder, etc. to value
        id transformedValue = value; // exercise for the reader
    
        // Tell the model or controller object the new value
        [object setValue:transformedValue forKeyPath:keyPath];
      }
    }
    

    Actually applying placeholders, value transformers, and the like is left as an exercise for the reader.