Repository: airbnb/lottie-ios
File: Sources/Public/Animation/AnimationView.swift (Swift)

Code before:

    // TODO: Improve this integration point with the experimental rendering engine
    // (e.g. when changing the `loopMode`, the new animation should begin at
    // `currentFrame` instead of starting from the beginning.)
    if let experimentalAnimationLayer = animationlayer as? ExperimentalAnimationLayer {
      experimentalAnimationLayer.setupAnimation(
        context: animationContext,
        timingConfiguration: .init(
          autoreverses: loopMode.caAnimationConfiguration.autoreverses,
          repeatCount: loopMode.caAnimationConfiguration.repeatCount,
          speed: Float(animationSpeed)))
      return
    }

    /// At this point there is no animation on animationLayer and its state is set.

    let framerate = animation.framerate

    let playFrom = animationContext.playFrom.clamp(animation.startFrame, animation.endFrame)
    let playTo = animationContext.playTo.clamp(animation.startFrame, animation.endFrame)

    let duration = ((max(playFrom, playTo) - min(playFrom, playTo)) / CGFloat(framerate))

    let playingForward: Bool =
      (
        (animationSpeed > 0 && playFrom < playTo) ||
          (animationSpeed < 0 && playTo < playFrom))

    var startFrame = currentFrame.clamp(min(playFrom, playTo), max(playFrom, playTo))
    if startFrame == playTo {
      startFrame = playFrom
    }

    let timeOffset: TimeInterval = playingForward ?
      Double(startFrame - min(playFrom, playTo)) / framerate :
      Double(max(playFrom, playTo) - startFrame) / framerate

    let layerAnimation = CABasicAnimation(keyPath: "currentFrame")
    layerAnimation.fromValue = playFrom
    layerAnimation.toValue = playTo
    layerAnimation.speed = Float(animationSpeed)
    layerAnimation.duration = TimeInterval(duration)
    layerAnimation.fillMode = CAMediaTimingFillMode.both
    layerAnimation.repeatCount = loopMode.caAnimationConfiguration.repeatCount
    layerAnimation.autoreverses = loopMode.caAnimationConfiguration.autoreverses

    layerAnimation.isRemovedOnCompletion = false
    if timeOffset != 0 {
      let currentLayerTime = viewLayer?.convertTime(CACurrentMediaTime(), from: nil) ?? 0
      layerAnimation.beginTime = currentLayerTime - (timeOffset * 1 / Double(abs(animationSpeed)))
    }

Review comment (suggestion):
A silly issue I was running in to at first: this `timeOffset` codepath works perfectly for any animation mode that loops, but doesn't work for `.playOnce`. If you had a `playOnce` from 25% to 75% where `currentTime` was 50%, it would play from 50% to 75% and then wrap around to play from 25% to 50% (since it was playing for the "full duration" specified by `endTime - startTime`).

Code after:

    if let experimentalAnimationLayer = animationlayer as? ExperimentalAnimationLayer {
      var animationContext = animationContext
      var timingConfiguration = ExperimentalAnimationLayer.CAMediaTimingConfiguration(
        autoreverses: loopMode.caAnimationConfiguration.autoreverses,
        repeatCount: loopMode.caAnimationConfiguration.repeatCount,
        speed: Float(animationSpeed))

      // The animation should start playing from the `currentFrame`,
      // if `currentFrame` is included in the time range being played.
      // We have to configure this differently depending on the loop mode:
      if currentFrame.clamp(animationContext.playFrom, animationContext.playTo) == currentFrame {
        switch loopMode {
        // When playing exactly once (and not looping), we can just set the
        // `playFrom` time to be the `currentFrame`. Since the animation duration
        // is based on `playFrom` and `playTo`, this automatically truncates the
        // duration (so the animation stops playing at `playFrom`).
        case .playOnce:
          animationContext.playFrom = currentFrame

        // When looping, we specifically _don't_ want to affect the duration of the animation,
        // since that would affect the duration of all subsequent loops. We just want to adjust
        // the duration of the _first_ loop. Instead of setting `playFrom`, we just add a `timeOffset`
        // so the first loop begins at `currentTime` but all subsequent loops are the standard duration.
        default:
          timingConfiguration.timeOffset = currentTime - animation.time(forFrame: animationContext.playFrom)
        }
      }

      experimentalAnimationLayer.setupAnimation(
        context: animationContext,
        timingConfiguration: timingConfiguration)

      return
    }

    /// At this point there is no animation on animationLayer and its state is set.

    let framerate = animation.framerate

    let playFrom = animationContext.playFrom.clamp(animation.startFrame, animation.endFrame)
    let playTo = animationContext.playTo.clamp(animation.startFrame, animation.endFrame)

    let duration = ((max(playFrom, playTo) - min(playFrom, playTo)) / CGFloat(framerate))

    let playingForward: Bool =
      (
        (animationSpeed > 0 && playFrom < playTo) ||
          (animationSpeed < 0 && playTo < playFrom))

    var startFrame = currentFrame.clamp(min(playFrom, playTo), max(playFrom, playTo))