I have been enjoying round with the superior UIViewPropertyAnimator, however are observing one thing throughout scrubbing I do not fairly perceive.
Linearly scrubbing a UIViewPropertyAnimator configured with “quick” timing parameters and/or low animation length, causes the animated properties to snap between (bigger) values at the beginning of scrubbing.
This may be made seen with a easy backside sheet instance (recorded on iPhone 14 Professional with iOS 16.4.1):
The UIViewPropertyAnimator is instantiated as follows (the difficulty additionally noticed with different initializers):
// length set to 0.25 provides the undesirable snapping at the beginning of scrubbing:
UIViewPropertyAnimator(length: 0.25, dampingRatio: 1)
// length set to 1.25 provides the anticipated clean scrubbing.
UIViewPropertyAnimator(length: 1.25, dampingRatio: 1)
Any concepts about what is going on on right here? I might count on the scrubbing to be clean impartial of what parameters are given? It is particularly odd that the snapping solely occurs at the beginning of the scrubbing…
Additionally added the complete instance code:
//
// BottomSheetAnimationViewController.swift
//
import UIKit
// MARK: BottomSheetView
last class BottomSheetView: UIView {
personal let handleViewHeight: CGFloat = 8
personal lazy var handleView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .tertiarySystemBackground
view.layer.cornerRadius = handleViewHeight / 2
view.clipsToBounds = true
return view
}()
init() {
tremendous.init(body: .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 applied")
}
}
// MARK: BottomSheetAnimationViewController
class BottomSheetAnimationViewController: UIViewController {
personal lazy var bottomSheetView: UIView = {
let bottomSheetView = BottomSheetView()
bottomSheetView.translatesAutoresizingMaskIntoConstraints = false
return bottomSheetView
}()
personal lazy var panGestureRecognizer = UIPanGestureRecognizer(
goal: self, motion: #selector(onPan)
)
personal 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() {
tremendous.viewDidLoad()
bottomSheetView.addGestureRecognizer(panGestureRecognizer)
}
@objc
personal func onPan(_ panGestureRecognizer: UIPanGestureRecognizer) {
guard let view = panGestureRecognizer.view else {
return
}
let translation = panGestureRecognizer.translation(in: view.superview)
swap panGestureRecognizer.state {
case .attainable:
break
case .failed:
break
case .started:
animator.begin(animating: view)
case .modified:
animator.transfer(view, basedOn: translation)
default: // .ended, .cancelled, @unknown
let velocity = panGestureRecognizer.velocity(in: view.superview)
animator.cease(animating: view, with: velocity)
}
}
}
// MARK: BottomSheetAnimator
class BottomSheetAnimator {
var bottomConstraint: NSLayoutConstraint?
// Seize the sheet's preliminary top.
personal var initialSheetHeight: CGFloat = .zero
personal var offsetAnimator: UIViewPropertyAnimator?
personal func makeOffsetAnimator(
animating view: UIView,
to offset: CGFloat
) -> UIViewPropertyAnimator {
let propertyAnimator = UIViewPropertyAnimator(
length: 0.25, // Low values makes the scrubbing snap between values at the beginning.
dampingRatio: 1
)
propertyAnimator.addAnimations {
self.bottomConstraint?.fixed = offset
view.superview?.layoutIfNeeded()
}
propertyAnimator.addCompletion { place in
self.bottomConstraint?.fixed = place == .finish ? offset : 0
}
return propertyAnimator
}
}
extension BottomSheetAnimator {
func begin(animating view: UIView) {
initialSheetHeight = view.body.top
offsetAnimator = makeOffsetAnimator(
animating: view, to: initialSheetHeight
)
}
func transfer(_ view: UIView, basedOn translation: CGPoint) {
let fractionComplete = min(max(translation.y, 0) / initialSheetHeight, 1)
offsetAnimator?.fractionComplete = fractionComplete
}
func cease(animating view: UIView, with velocity: CGPoint) {
let fractionComplete = offsetAnimator?.fractionComplete ?? 0
offsetAnimator?.isReversed = fractionComplete < 0.5
offsetAnimator?.continueAnimation(
withTimingParameters: nil,
durationFactor: 1
)
}
}