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):
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
)
}
}

