I've been playing around with the awesome UIViewPropertyAnimator, but are observing something during scrubbing I don't quite understand.

Linearly scrubbing a UIViewPropertyAnimator configured with "fast" timing parameters and/or low animation duration, causes the animated properties to snap between (larger) values at the start of scrubbing.

This can be made visible with a simple bottom sheet example (recorded on iPhone 14 Pro with iOS 16.4.1):

Small/"fast" timing parameters: Large/"slow" timing parameters
enter image description here

The UIViewPropertyAnimator is instantiated as follows (the issue also observed with other initializers):

// duration set to 0.25 gives the unwanted snapping at the start of scrubbing:
UIViewPropertyAnimator(duration: 0.25, dampingRatio: 1)

// duration set to 1.25 gives the expected smooth scrubbing.
UIViewPropertyAnimator(duration: 1.25, dampingRatio: 1)

Any ideas about what's going on here? I would expect the scrubbing to be smooth independent of what parameters are given? It's especially odd that the snapping only happens at the start of the scrubbing...

Also added the full example code:

//
//  BottomSheetAnimationViewController.swift
//

import UIKit

// MARK: BottomSheetView

final class BottomSheetView: UIView {
    private let handleViewHeight: CGFloat = 8

    private lazy var handleView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .tertiarySystemBackground
        view.layer.cornerRadius = handleViewHeight / 2
        view.clipsToBounds = true
        return view
    }()

    init() {
        super.init(frame: .zero)

        addSubview(handleView)

        NSLayoutConstraint.activate([
            handleView.centerXAnchor.constraint(equalTo: centerXAnchor),
            handleView.centerYAnchor.constraint(equalTo: topAnchor, constant: handleViewHeight),
            handleView.heightAnchor.constraint(equalToConstant: handleViewHeight),
            handleView.widthAnchor.constraint(equalToConstant: handleViewHeight * 10)
        ])

        backgroundColor = .secondarySystemBackground
        layer.cornerRadius = 16
        layer.maskedCorners = [
            .layerMinXMinYCorner,
            .layerMaxXMinYCorner
        ]
        clipsToBounds = true
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: BottomSheetAnimationViewController

class BottomSheetAnimationViewController: UIViewController {
    private lazy var bottomSheetView: UIView = {
        let bottomSheetView = BottomSheetView()
        bottomSheetView.translatesAutoresizingMaskIntoConstraints = false
        return bottomSheetView
    }()

    private lazy var panGestureRecognizer = UIPanGestureRecognizer(
        target: self, action: #selector(onPan)
    )

    private let animator: BottomSheetAnimator = BottomSheetAnimator()

    override func loadView() {
        let view = UIView()

        view.addSubview(bottomSheetView)

        let bottomConstraint = bottomSheetView.bottomAnchor.constraint(
            equalTo: view.bottomAnchor
        )

        animator.bottomConstraint = bottomConstraint

        NSLayoutConstraint.activate([
            bottomSheetView.heightAnchor.constraint(
                equalTo: view.heightAnchor, multiplier: 0.66
            ),
            bottomSheetView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            bottomSheetView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            bottomConstraint,
        ])

        view.backgroundColor = .systemBackground

        self.view = view
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        bottomSheetView.addGestureRecognizer(panGestureRecognizer)
    }

    @objc
    private func onPan(_ panGestureRecognizer: UIPanGestureRecognizer) {
        guard let view = panGestureRecognizer.view else {
            return
        }

        let translation = panGestureRecognizer.translation(in: view.superview)

        switch panGestureRecognizer.state {
            case .possible:
                break
            case .failed:
                break
            case .began:
                animator.start(animating: view)
            case .changed:
                animator.move(view, basedOn: translation)
            default: // .ended, .cancelled, @unknown
                let velocity = panGestureRecognizer.velocity(in: view.superview)
                animator.stop(animating: view, with: velocity)
        }
    }
}

// MARK: BottomSheetAnimator

class BottomSheetAnimator {
    var bottomConstraint: NSLayoutConstraint?

    // Capture the sheet's initial height.
    private var initialSheetHeight: CGFloat = .zero

    private var offsetAnimator: UIViewPropertyAnimator?

    private func makeOffsetAnimator(
        animating view: UIView,
        to offset: CGFloat
    ) -> UIViewPropertyAnimator {
        let propertyAnimator = UIViewPropertyAnimator(
            duration: 0.25, // Low values makes the scrubbing snap between values at the start.
            dampingRatio: 1
        )

        propertyAnimator.addAnimations {
            self.bottomConstraint?.constant = offset

            view.superview?.layoutIfNeeded()
        }

        propertyAnimator.addCompletion { position in
            self.bottomConstraint?.constant = position == .end ? offset : 0
        }

        return propertyAnimator
    }
}

extension BottomSheetAnimator {
    func start(animating view: UIView) {
        initialSheetHeight = view.frame.height

        offsetAnimator = makeOffsetAnimator(
            animating: view, to: initialSheetHeight
        )
    }

    func move(_ view: UIView, basedOn translation: CGPoint) {
        let fractionComplete = min(max(translation.y, 0) / initialSheetHeight, 1)

        offsetAnimator?.fractionComplete = fractionComplete
    }

    func stop(animating view: UIView, with velocity: CGPoint) {
        let fractionComplete = offsetAnimator?.fractionComplete ?? 0

        offsetAnimator?.isReversed = fractionComplete < 0.5

        offsetAnimator?.continueAnimation(
            withTimingParameters: nil,
            durationFactor: 1
        )
    }
}
0

There are 0 best solutions below