Jul 8, 2019

Adding scrubbing support to video playback

More and more apps these days embed video content into its user experience. Often they will play automatically when shown and have the ability to play and pause the video when tapping on it. It seems this is becoming a standard practice for videos. But limiting interaction to just play and pause always seems a bit too restrictive to the user. So lets see how we can enable the user to scrub a video backwards and forwards when panning across it.

We start by adding an AVPlayerLayer and a UIPanGestureRecognizer to our view.

// video layer that will stream the url
private let videoLayer = AVPlayerLayer()
  
// pan gesture used for scrubbing
private let panGesture = UIPanGestureRecognizer()

// setup view
override init(frame: CGRect) {
  super.init(frame: frame)
  // self
  backgroundColor = .black
  // setup videoLayer
  videoLayer.videoGravity = .resizeAspect
  layer.addSublayer(videoLayer)
  // setup panGesture
  panGesture.minimumNumberOfTouches = 1
  panGesture.maximumNumberOfTouches = 1
  panGesture.cancelsTouchesInView = true
  panGesture.addTarget(self, action: #selector(didScrub(recognizer:)))
  addGestureRecognizer(panGesture)
}

override func layoutSublayers(of layer: CALayer) {
  super.layoutSublayers(of: layer)
  // resize videoLayer frame to fit bounds
  videoLayer.frame = bounds
}

Since it is not possible to limit the UIPanGestureRecognizer to only take inputs when panning horizontally, we must override its delegate method gestureRecognizerShouldBegin() and prevent it from beginning when the user pans vertically.

override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
  if gestureRecognizer == panGesture {
  // only allow pan gesture to begin when panning horizontally
  let translation = panGesture.translation(in: self)
  return abs(translation.x) >= 0 && abs(translation.x) > abs(translation.y)
  }
  return super.gestureRecognizerShouldBegin(gestureRecognizer)
}

Now the important part is actually handling the pan gesture when receiving it. There are three important stages we need to consider to make scrubbing work as intended:

  1. When the scrubbing begins, we must pause the video and save the current CMTime value of the AVPlayerLayer. This is important because we want to start scrubbing the video from the current position of the playback.

  2. Whenever the translated position of the users finger on the AVPlayerLayer changes, the point to which it must seek should change accordingly. For this we use the previously saved scrubbingBeginTime to get the percentage of the videos completed playback and then calculate the percentage of the translated position in the AVPlayerLayer from left. Adding these two together and limiting the value to >= 0 & <= 1.0 lets us easily calculate the new CMTime value by using CMTimeMakeWithSeconds(, preferredTimescale:), which we than pass on to the seek(to:, toleranceBefore:, toleranceAfter:) method of the AVPlayerLayer. This method actually jumps the videos position the the new CMTime we just calculated.

  3. After the pan has ended or is cancelled, the video should resume playback. It will automatically do this from the position we seeked to in the previous step.

// time when scrubbing began
private var scrubbingBeginTime: CMTime?

@objc private func didScrub(recognizer: UIPanGestureRecognizer) {
  guard videoLayer.isReadyForDisplay == true, let player = videoLayer.player, let currentItem = player.currentItem else {
    return
  }
  switch recognizer.state {
  case .possible:
    // nothing to do here
    break
  case .began:
    // pause playback when user begins panning
    videoLayer.player?.pause()
    // set time scrubbing began
    scrubbingBeginTime = currentItem.currentTime()
  case .changed:
    guard let scrubbingBeginTime = scrubbingBeginTime else {
      return
    }
    let totalSeconds = currentItem.duration.seconds
    // translate point of pan in view
    let point = recognizer.translation(in: self)
    let scrubbingBeginPercent = Double(scrubbingBeginTime.seconds/totalSeconds)
    // calculate percentage of point in view
    var percent = Double(point.x/bounds.width)
    percent += scrubbingBeginPercent
    if percent < 0 {
      percent = 0
    } else if percent > 1.0 {
      percent = 1.0
    }
    // calculate time to seek to in video timeline
    let seconds = Float64(percent * totalSeconds)
    let time = CMTimeMakeWithSeconds(seconds, preferredTimescale: currentItem.duration.timescale)
    player.seek(to: time, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero)
  case .ended, .cancelled, .failed:
    // reset scrubbing begin time
    scrubbingBeginTime = nil
    // resume playback after user stops panning
    videoLayer.player?.play()
  @unknown default:
    break
  }
}

That’s it. As you can see, adding scrubbing to a video is fairly straight forward and made easy with the API the AVPlayerLayer exposes to us.

You can view the complete code here or experience how it works in my app Monocle.

Language: Swift 5.0 ยท Written on: iPad Pro