You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 21:57:05