如何实现Apple Maps中Sheet叠加时的融合过渡动画?
复刻Apple Maps Sheet融合过渡动画的实现方案
我想复刻Apple Maps里的Sheet过渡动画:点击地点时,新Sheet会从当前Sheet上方弹出,并且和下方Sheet产生融合效果。我找了很多资料都没相关信息,想知道这个效果是基于UIViewController还是UIView实现的,是否涉及遮罩/混合模式,要不要用自定义Presentation Controller这类技术,另外需要一个入门示例。下面是我尝试的代码,但效果和Apple Maps里的差很多:
import UIKit class ViewControllerStack { static let shared = ViewControllerStack() var views: [UIViewController] = [] } class ViewController: UIViewController { override func loadView() { view = MKMapView() } override func viewDidAppear(_ animated: Bool) { let welcomeVC = WelcomeViewController() welcomeVC.isModalInPresentation = true if let sheet = welcomeVC.sheetPresentationController { sheet.detents = [.custom { context in return 200 }, .medium(), .large()] sheet.prefersGrabberVisible = true sheet.preferredCornerRadius = 15 sheet.largestUndimmedDetentIdentifier = .large } present(welcomeVC, animated: true) ViewControllerStack.shared.views.append(welcomeVC) } } class WelcomeViewController: UIViewController { lazy var blurEffectView: UIVisualEffectView = { let blurEffect = UIBlurEffect(style: .systemChromeMaterial) let blurEffectView = UIVisualEffectView(effect: blurEffect) blurEffectView.frame = view.bounds return blurEffectView }() override func viewDidLoad() { view.addSubview(blurEffectView) view.sendSubviewToBack(blurEffectView) let button = UIButton() button.setTitle("Go to View Controller 2", for: .normal) button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: view.centerXAnchor), button.centerYAnchor.constraint(equalTo: view.centerYAnchor), button.widthAnchor.constraint(equalToConstant: 200), button.heightAnchor.constraint(equalToConstant: 44) ]) } @objc func buttonTapped() { let vc2 = ViewController2() vc2.modalPresentationStyle = .currentContext vc2.modalTransitionStyle = .coverVertical present(vc2, animated: true) UIView.animate(withDuration: 0.75) { self.view.alpha = 0 self.view.layer.masksToBounds = true } ViewControllerStack.shared.views.append(vc2) } } class ViewController2: UIViewController { override func viewDidLoad() { let blurEffect = UIBlurEffect(style: .systemChromeMaterial) let blurEffectView = UIVisualEffectView(effect: blurEffect) blurEffectView.frame = view.bounds view.addSubview(blurEffectView) view.sendSubviewToBack(blurEffectView) let button = UIButton() button.setTitle("Go back to Welcome VC", for: .normal) button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: view.centerXAnchor), button.centerYAnchor.constraint(equalTo: view.centerYAnchor), button.widthAnchor.constraint(equalToConstant: 200), button.heightAnchor.constraint(equalToConstant: 44) ]) } @objc func buttonTapped() { ViewControllerStack.shared.views.removeLast() guard let lastVCInStack = ViewControllerStack.shared.views.last else { return } lastVCInStack.view.alpha = 1 lastVCInStack.dismiss(animated: true) } }
核心技术分析
- 实现基础:该效果基于
UIViewController的自定义转场 + 自定义Presentation Controller实现,同时需要结合图层混合模式和遮罩动画来打造融合视觉,系统原生Sheet转场无法实现这种效果。 - 关键技术点:
- 自定义
UIPresentationController:控制新Sheet的布局、背景样式及过渡过程中的视觉变化。 - 图层混合模式:通过
compositingFilter或CGBlendMode让两个Sheet的模糊背景产生融合感。 - 遮罩动画:过渡时给下方Sheet添加渐变遮罩,配合新Sheet的弹出动画,强化“融合”的视觉层次。
- 转场代理:通过
UIViewControllerTransitioningDelegate自定义转场的时长、动画曲线及具体过程。
- 自定义
入门示例代码
import UIKit // 自定义转场动画控制器 class SheetFusionAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.45 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let toVC = transitionContext.viewController(forKey: .to), let fromVC = transitionContext.viewController(forKey: .from) else { transitionContext.completeTransition(false) return } let container = transitionContext.containerView container.addSubview(toVC.view) // 初始化新Sheet的起始状态:从下方Sheet顶部开始,高度为0 let fromFrame = fromVC.view.frame toVC.view.frame = CGRect(x: 0, y: fromFrame.minY, width: fromFrame.width, height: 0) toVC.view.layer.cornerRadius = 16 toVC.view.clipsToBounds = true // 给下方Sheet添加渐变遮罩 let maskLayer = CAGradientLayer() maskLayer.frame = fromVC.view.bounds maskLayer.colors = [UIColor.clear.cgColor, UIColor.black.withAlphaComponent(0.2).cgColor] maskLayer.startPoint = CGPoint(x: 0.5, y: 0) maskLayer.endPoint = CGPoint(x: 0.5, y: 1) fromVC.view.layer.mask = maskLayer // 动画过程:新Sheet展开,下方Sheet遮罩反向渐变,同时启用混合模式 UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.6) { toVC.view.frame = CGRect(x: 0, y: UIScreen.main.bounds.height * 0.25, width: fromFrame.width, height: UIScreen.main.bounds.height * 0.75) maskLayer.colors = [UIColor.black.withAlphaComponent(0.4).cgColor, UIColor.clear.cgColor] fromVC.view.layer.compositingFilter = "multiplyBlendMode" } completion: { finished in fromVC.view.layer.mask = nil fromVC.view.layer.compositingFilter = nil transitionContext.completeTransition(finished && !transitionContext.transitionWasCancelled) } } } // 自定义Presentation Controller class SheetFusionPresentationController: UIPresentationController { override func presentationTransitionWillBegin() { super.presentationTransitionWillBegin() guard let presentedView = presentedView else { return } // 设置Sheet背景为模糊效果 let blurEffect = UIBlurEffect(style: .systemChromeMaterial) let blurView = UIVisualEffectView(effect: blurEffect) blurView.frame = presentedView.bounds blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] presentedView.insertSubview(blurView, at: 0) presentedView.backgroundColor = .systemBackground.withAlphaComponent(0.8) presentedView.layer.cornerRadius = 16 presentedView.clipsToBounds = true } } // 转场代理 class SheetTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate { func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { return SheetFusionPresentationController(presentedViewController: presented, presenting: presenting) } func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return SheetFusionAnimator() } } // 测试用地图页面 class MapVC: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBlue let tap = UITapGestureRecognizer(target: self, action: #selector(showFirstSheet)) view.addGestureRecognizer(tap) } @objc func showFirstSheet() { let sheetVC = FirstSheetVC() sheetVC.modalPresentationStyle = .custom sheetVC.transitioningDelegate = SheetTransitionDelegate() present(sheetVC, animated: true) } } // 第一层Sheet class FirstSheetVC: UIViewController { override func viewDidLoad() { super.viewDidLoad() let button = UIButton(type: .system) button.setTitle("打开第二层Sheet", for: .normal) button.addTarget(self, action: #selector(showSecondSheet), for: .touchUpInside) button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: view.centerXAnchor), button.centerYAnchor.constraint(equalTo: view.centerYAnchor), button.widthAnchor.constraint(equalToConstant: 220), button.heightAnchor.constraint(equalToConstant: 44) ]) } @objc func showSecondSheet() { let sheetVC = SecondSheetVC() sheetVC.modalPresentationStyle = .custom sheetVC.transitioningDelegate = SheetTransitionDelegate() present(sheetVC, animated: true) } } // 第二层Sheet class SecondSheetVC: UIViewController { override func viewDidLoad() { super.viewDidLoad() let button = UIButton(type: .system) button.setTitle("返回上一层", for: .normal) button.addTarget(self, action: #selector(dismissSelf), for: .touchUpInside) button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: view.centerXAnchor), button.centerYAnchor.constraint(equalTo: view.centerYAnchor), button.widthAnchor.constraint(equalToConstant: 220), button.heightAnchor.constraint(equalToConstant: 44) ]) } @objc func dismissSelf() { dismiss(animated: true) } }
原代码问题分析
原代码无法达到预期效果的核心原因:
- 使用
modalPresentationStyle = .currentContext会让新Sheet嵌入当前VC层级,破坏了Sheet独立弹出的视觉结构。 - 直接修改原Sheet的
alpha只是简单隐藏,没有融合过渡的视觉层次。 - 未利用自定义转场和Presentation Controller来精细控制Sheet的布局、动画曲线及视觉融合逻辑。
内容的提问来源于stack exchange,提问作者User95797654974
相关产品推荐
相关产品推荐

