如何实现UIScrollView滚动驱动的子视图多布局过渡动画?
Problem Statement
I have a
UIViewControllercontaining aUIScrollViewwith three distinct pages. The middle page is displayed on launch, containing several views arranged in two columns. These content views are subviews of the scroll view and can move freely between pages.
- When scrolling left to the left page: Some views (like green views in the mockup) should animate to this page, while new views (like the bottom green circle) appear. The animation progress must be controlled by the scroll position/progress.
- When scrolling back to the middle page: All views revert to their initial state.
- When scrolling right to the right page: Another set of views (red views) animate to this page, with new views appearing.
I know I can detect scroll changes in
scrollViewDidScroll:and update constraints to move views, but this is extremely tedious—view movements are non-linear, and relative positions change. Simply modifying constraint constants doesn't seem sufficient.My ideal solution is to define three sets of layouts/constraints for the initial (middle), left, and right pages, then let the iOS SDK handle the transition animations between layouts automatically. While I can switch constraint sets in a
UIViewanimation block, this only completes a full animation from "middle layout" to "left layout". The requirement is that the animation progress is controlled by scrolling: if the left page is scrolled to 50% of its final position, content views should be in the 50% transition state between the middle and left layouts. Is this feasible?Edit: The red boxes in the middle view of the last mockup should not exist—they have been moved to the right page.
Solution: Use UIViewPropertyAnimator to Bind Layout Transitions to Scroll Progress
Great question—this is exactly the kind of interactive, scroll-driven animation that UIViewPropertyAnimator was built for. It lets you predefine full layout transitions and then control their progress in real-time using your scroll view's offset. Here's a step-by-step implementation:
1. Predefine Your Three Layout States
First, set up all three constraint sets (middle, left, right pages) for your content views. You'll want to keep references to each set so you can toggle them in your animators:
- Start with the middle page constraints active (since that's your initial state).
- Store constraints in dictionaries or arrays grouped by their state (e.g.,
middleConstraints,leftConstraints,rightConstraints) to avoid conflicts when switching.
For example, if you have a green view that moves from the middle column to the left page, you'd define:
- A constraint pinning it to the middle page's left column
- A separate constraint pinning it to the left page's top corner
- Keep the middle constraint active initially.
2. Create Property Animators for Each Transition
Build two UIViewPropertyAnimator instances—one for the middle-to-left transition, and another for middle-to-right. These animators will encapsulate all the layout changes and visibility animations you need, and we'll keep them paused until the scroll view drives their progress.
private var leftTransitionAnimator: UIViewPropertyAnimator? private var rightTransitionAnimator: UIViewPropertyAnimator? override func viewDidLoad() { super.viewDidLoad() setupAllConstraints() // Your method to define and store constraint sets setupTransitionAnimators() } private func setupTransitionAnimators() { // Left page transition: move green views, show the green circle leftTransitionAnimator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { NSLayoutConstraint.deactivate(self.middleConstraints) NSLayoutConstraint.activate(self.leftConstraints) // Animate new views into place self.greenCircleView.alpha = 1.0 self.greenCircleView.transform = .identity // Reset from hidden scale/position } leftTransitionAnimator?.pauseAnimation() // Hold until scroll drives it // Right page transition: move red views, show right-page elements rightTransitionAnimator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { NSLayoutConstraint.deactivate(self.middleConstraints) NSLayoutConstraint.activate(self.rightConstraints) self.redBoxViews.forEach { $0.alpha = 1.0 } self.rightPageNewView.transform = .identity } rightTransitionAnimator?.pauseAnimation() }
3. Sync Animator Progress with Scroll Position
In scrollViewDidScroll:, calculate how far the user has scrolled away from the middle page, then update the corresponding animator's fractionComplete property (a value between 0 and 1 that represents how much of the animation has completed).
First, define your page width (matches the scroll view's bounds width):
private var pageWidth: CGFloat { scrollView.bounds.width }
Then handle the scroll updates:
func scrollViewDidScroll(_ scrollView: UIScrollView) { let currentOffsetX = scrollView.contentOffset.x let middlePageOffset = pageWidth // Middle page is at index 1, so offset = 1 * pageWidth // Scrolling left from middle to left page if currentOffsetX < middlePageOffset { let leftProgress = (middlePageOffset - currentOffsetX) / pageWidth leftTransitionAnimator?.fractionComplete = leftProgress // Reset right animator if we're moving back from the right rightTransitionAnimator?.fractionComplete = 0.0 } // Scrolling right from middle to right page else if currentOffsetX > middlePageOffset { let rightProgress = (currentOffsetX - middlePageOffset) / pageWidth rightTransitionAnimator?.fractionComplete = rightProgress // Reset left animator if we're moving back from the left leftTransitionAnimator?.fractionComplete = 0.0 } // Back to exact middle page else { leftTransitionAnimator?.fractionComplete = 0.0 rightTransitionAnimator?.fractionComplete = 0.0 } }
4. Handle Scroll End Snap Behavior
When the user stops scrolling, you'll want to snap to the nearest page and ensure the animators finish or reset fully:
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { snapToNearestPage() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { snapToNearestPage() } } private func snapToNearestPage() { let currentOffset = scrollView.contentOffset.x let pageIndex = round(currentOffset / pageWidth) let targetOffset = pageIndex * pageWidth // Animate scroll view to target page UIView.animate(withDuration: 0.2) { self.scrollView.contentOffset = CGPoint(x: targetOffset, y: 0) } // Update animators to match final page state switch pageIndex { case 0: // Left page leftTransitionAnimator?.fractionComplete = 1.0 case 1: // Middle page leftTransitionAnimator?.fractionComplete = 0.0 rightTransitionAnimator?.fractionComplete = 0.0 case 2: // Right page rightTransitionAnimator?.fractionComplete = 1.0 default: break } }
5. Add Non-Linear Animations (If Needed)
If you need views to move along curved paths, scale, or rotate during transitions, you can add those transforms directly to the animator's closure. For example:
// Inside left transition animator closure self.greenView.transform = CGAffineTransform(translationX: -self.pageWidth/2, y: 50) .scaledBy(x: 0.8, y: 0.8)
For custom easing curves, use a cubic timing parameter instead of the default curve:
let customTiming = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.2, y: 0.8), controlPoint2: CGPoint(x: 0.8, y: 0.2)) leftTransitionAnimator = UIViewPropertyAnimator(duration: 0.3, timingParameters: customTiming) { // ... layout changes }
Key Tips
- Avoid Constraint Conflicts: Always deactivate the previous state's constraints before activating the new ones. Using grouped arrays/dictionaries makes this easy.
- Performance:
UIViewPropertyAnimatoris optimized for interactive animations, so you won't hit frame drops even with multiple views animating. - Smooth Visibility Changes: Animate
alphaortransform(e.g., scale from 0 to 1) instead of usingisHiddendirectly for views that appear/disappear during transitions.
内容的提问来源于stack exchange,提问作者Andrei Herford

