Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using ReactiveCocoa to track UI updates with a remote object

I'm making an iOS app which lets you remotely control music in an app playing on your desktop.

One of the hardest problems is being able to update the position of the "tracker" (which shows the time position and duration of the currently playing song) correctly. There are several sources of input here:

  • At launch, the remote sends a network request to get the initial position and duration of the currently playing song.
  • When the user adjusts the position of the tracker using the remote, it sends a network request to the music app to change the position of the song.
  • If the user uses the app on the desktop to change the position of the tracker, the app sends a network request to the remote with the new position of the tracker.
  • If the song is currently playing, the position of the tracker is updated every 0.5 seconds or so.

At the moment, the tracker is a UISlider which is backed by a "Player" model. Whenever the user changes the position on the slider, it updates the model and sends a network request, like so:

In NowPlayingViewController.m

[[slider rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(UISlider *x) {
    [playerModel seekToPosition:x.value];
}];

[RACObserve(playerModel, position) subscribeNext:^(id x) {
    slider.value = player.position;
}];

In PlayerModel.m:

@property (nonatomic) NSTimeInterval position;

- (void)seekToPosition:(NSTimeInterval)position
{
    self.position = position;
    [self.client newRequestWithMethod:@"seekTo" params:@[positionArg] callback:NULL];
}

- (void)receivedPlayerUpdate:(NSDictionary *)json
{
    self.position = [json objectForKey:@"position"]
}

The problem is when a user "fiddles" with the slider, and queues up a number of network requests which all come back at different times. The user could be have moved the slider again when a response is received, moving the slider back to a previous value.

My question: How do I use ReactiveCocoa correctly in this example, ensuring that updates from the network are dealt with, but only if the user hasn't moved the slider since?

like image 388
oliland Avatar asked Dec 13 '13 16:12

oliland


1 Answers

In your GitHub thread about this you say that you want to consider the remote's updates as canonical. That's good, because (as Josh Abernathy suggested there), RAC or not, you need to pick one of the two sources to take priority (or you need timestamps, but then you need a reference clock...).

Given your code and disregarding RAC, the solution is just setting a flag in seekToPosition: and unsetting it using a timer. Check the flag in recievedPlayerUpdate:, ignoring the update if it's set.

By the way, you should use the RAC() macro to bind your slider's value, rather than the subscribeNext: that you've got:

RAC(slider, value) = RACObserve(playerModel, position);

You can definitely construct a signal chain to do what you want, though. You've got four signals you need to combine.

For the last item, the periodic update, you can use interval:onScheduler::

[[RACSignal interval:kPositionFetchSeconds
         onScheduler:[RACScheduler scheduler]] map:^(id _){
                          return /* Request position over network */;
}];

The map: just ignores the date that the interval:... signal produces, and fetches the position. Since your requests and messages from the desktop have equal priority, merge: those together:

[RACSignal merge:@[desktopPositionSignal, timedRequestSignal]];

You decided that you don't want either of those signals going through if the user has touched the slider, though. This can be accomplished in one of two ways. Using the flag I suggested, you could filter: that merged signal:

[mergedSignal filter:^BOOL (id _){ return userFiddlingWithSlider; }];

Better than that -- avoiding extra state -- would be to build an operation out of a combination of throttle: and sample: that passes a value from a signal at a certain interval after another signal has not sent anything:

[mergedSignal sample:
          [sliderSignal throttle:kUserFiddlingWithSliderInterval]];

(And you might, of course, want to throttle/sample the interval:onScheduler: signal in the same way -- before the merge -- in order to avoid unncessary network requests.)

You can put this all together in PlayerModel, binding it to position. You'll just need to give the PlayerModel the slider's rac_signalForControlEvents:, and then merge in the slider value. Since you're using the same signal multiple places in one chain, I believe that you want to "multicast" it.

Finally, use startWith: to get your first item above, the inital position from the desktop app, into the stream.

RAC(self, position) = 
    [[RACSignal merge:@[sampledSignal,
                        [sliderSignal map:^id(UISlider * slider){
                                                    return [slider value];
                        }]]
] startWith:/* Request position over network */];

The decision to break each signal out into its own variable or string them all together Lisp-style I'll leave to you.

Incidentally, I've found it helpful to actually draw out the signal chains when working on problems like this. I made a quick diagram for your scenario. It helps with thinking of the signals as entities in their own right, as opposed to worrying about the values that they carry.

like image 134
jscs Avatar answered Oct 12 '22 01:10

jscs