Custom UISlider like the Music app

2019-06-17 19:25发布

问题:

I've built a custom slider to show the progress of a music track playing and to allow scrubbing within the track.

Both function fine, but there is a slight lag (and jumpy movement) once dragging has stopped and the slider is repositioned - The Apple Music app slider is seamless.

_scrubberSlider = [[ScrubberSlider alloc] initWithFrame:CGRectMake(0, 0, 300, 30)];
_scrubberSlider.continuous = YES;
_scrubberSlider.maximumValue = 1.0f;
_scrubberSlider.minimumValue = 0.0f;
 [_scrubberSlider addTarget:self action:@selector(handleSliderMove:) forControlEvents:UIControlEventValueChanged];

- (void) handleSliderMove:(UISlider*)sender
{
    CGFloat currentPlayback = sender.value * [[_theMusicPlayer.nowPlayingItem valueForKey:MPMediaItemPropertyPlaybackDuration] floatValue];
    _theMusicPlayer.currentPlaybackTime = currentPlayback;
}

-(void) handleTrackTime
{
    if (!trackTimer)
    {
    NSNumber *playBackTime = [NSNumber numberWithFloat: _musicPlayer.currentPlaybackTime];
    trackTimer = [NSTimer scheduledTimerWithTimeInterval:0.1f target:self selector:@selector(timeIntervalFinished:) userInfo:@{@"playbackTime" : playBackTime} repeats:YES];
    }
}

-(void) timeIntervalFinished:(NSTimer*)sender
{
     [self playbackTimeUpdated:_musicPlayer.currentPlaybackTime];
}

-(void) playbackTimeUpdated:(CGFloat)playbackTime 
{
    // Update time label
    [self updatePosition];
}

-(void) updatePosition
{
   if (!_scrubberSlider.isScrubbing)
   {
   CGFloat percent = _theMusicPlayer.currentPlaybackTime / [[_theMusicPlayer.nowPlayingItem valueForKey:MPMediaItemPropertyPlaybackDuration] floatValue];
   _scrubberSlider.value = percent;
   }
}

The custom Slider

@interface ScrubberSlider : UISlider

@property(nonatomic) BOOL isScrubbing;

@end

@implementation ScrubberSlider

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

-(BOOL) beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
{
    [super beginTrackingWithTouch:touch withEvent:event];

    _isScrubbing = YES;

    return YES;
}

- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
{
    [super endTrackingWithTouch:touch withEvent:event];

    _isScrubbing = NO;
}

@end

回答1:

Perhaps you're going about this the wrong way. Instead of trying to be exactly synced to the time of the track (which leads to some pretty nasty race conditions, and is precisely why your slider doesn't update properly when "thrown" rather than dragged, held, then released), try to use the timer you have as a "tick". You can decrease the rate of fire down to, say, once every half a second, then use basically the same logic you have now, but instead of trying to get an exact fractional value in -updatePosition, you "tick" the slider forward.

-(void) updatePosition
{
   if (!_scrubberSlider.isScrubbing)
   {
       _scrubberSlider.value += .5f;
   }
}

It's not perfect, but then again, it's far more efficient and seamless than trying to sync yourself to the timing of a music track with NSTimer (which can be disgustingly inaccurate for precise movements). This also requires that your slider's max value be equivalent to the track's max value, so an example method to start playback would include something like:

- (IBAction)startPlayback:(id)sender {
    //... Handle however you wish to play the track
    _scrubberSlider.maximumValue = [[_theMusicPlayer.nowPlayingItem valueForKey:MPMediaItemPropertyPlaybackDuration] floatValue];
    [self handleTrackTime];
}


回答2:

After alot of logging - I found that the UISlider component had a slight lag updating itself - between 1/10 and 3/10s of a second - which caused the jump.

I also changed the timer interval to 0.5 seconds and I also helped the update with adding a savedValue variable inside ScrubberSlider and a BOOL hasFinishedMoving, so the beginTrackingWithTouch endTrackingWithTouch methods inside ScrubberSlider looks like this:

-(BOOL) beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
{
    [super beginTrackingWithTouch:touch withEvent:event];

    _hasFinishedMoving = NO;

    return YES;
}

- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
{
    [super endTrackingWithTouch:touch withEvent:event];

    _savedValue = self.value;

   _hasFinishedMoving = YES;
}

and changed the updatePosition to include these vars and validations:

-(void) updatePosition
{
   if (!_scrubberSlider.tracking) // this is a UISlider BOOL
   {
       if (_scrubberSlider.hasFinishedMoving)
       {
           _scrubberSlider.value = _scrubberSlider.savedValue;
           _scrubberSlider.hasFinishedMoving = NO;
       }
       else
       {
           CGFloat percent = _theMusicPlayer.currentPlaybackTime / [[_theMusicPlayer.nowPlayingItem valueForKey:MPMediaItemPropertyPlaybackDuration] floatValue];

           _scrubberSlider.value = percent;
       }
   }
}


回答3:

The AVFoundation framework has a method called addPeriodicTimeObserverForInterval:queue:usingBlock: that works very well with the AVPlayer. There is an AVPlayer demo that has very smooth scrubbing. I was unable to find a similar method for AVAudioPlayer, but perhaps the AVPlayer demo will give you some ideas of how to achieve smooth scrubbing.

Part of the problem is finding the right timer interval, which I'm sure the AVPlayer demo can help you with. If you have to use a timer, it's probably better to use a CADisplayLink (instead of NSTimer) as that's more accurate when dealing with updating the display of something on the screen.

It looks like you're using the MPMusicPlayerController. If you're making your own controls, you're probably better off with AVAudioPlayer or AVPlayer.



回答4:

To update timer continuously on slider drag (without jumpy movement) we ought to minimize the value of scheduledTimerWithTimeInterval. So, If your timer value is something like this:-

timer=[NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(updateElapsedTime) userInfo:nil repeats:YES];

Try a small modification by minimising scheduledTimerWithTimeInterval value

timer=[NSTimer scheduledTimerWithTimeInterval:0.001 target:self selector:@selector(updateElapsedTime) userInfo:nil repeats:YES];



-(void)updateElapsedTime {

     //do your stuffs here
}