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

UIHostingController自定义转场布局异常:SwiftUI视图未随动画缩放

问题:SwiftUI视图在UIKit转场动画中无法随视图缩放,直接显示完整尺寸
import UIKit
import SwiftUI

class ViewController: UIViewController {

    @IBOutlet weak var testButton: UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
    
    @IBOutlet weak var swiftuiButton: UIButton!
    
    @IBAction func didTap(_ sender: Any) {
        let storyboard = UIStoryboard(name: "DetailsVC", bundle: .main)
        let vc = storyboard.instantiateInitialViewController() as! DetailsVC
        vc.modalPresentationStyle = .custom
        vc.transitioningDelegate = self
        present(vc, animated: true)
    }
    
    @IBAction func swiftUITest(_ sender: Any) {
        let swiftUIView = SwiftUIView() {
            self.dismiss(animated: true)
        }
        let hostingController = UIHostingController.init(rootView: swiftUIView)
        hostingController.modalPresentationStyle = .custom
        hostingController.transitioningDelegate = self
        present(hostingController, animated: true)
    }
}

struct SwiftUIView: View {
    var didTapClose:()->()
    
    var body: some View {
        GeometryReader { geo in
            ZStack {
                Color.yellow.ignoresSafeArea()
                Text("SWIFTUI VIEW")
                    .foregroundColor(.black)
            }
            .transaction { transaction in
                transaction.animation = nil
            }
            .frame(width: geo.size.width, height: geo.size.height)
            .border(.red)
            .onTapGesture {
                didTapClose()
            }
        }
    }
}

extension ViewController:UIViewControllerTransitioningDelegate {
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return ToTileDetailsAnimator(fromFrame: testButton.frame, duration: 5)
    }
    
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return nil
    }
    
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return nil
    }
}

final class ToTileDetailsAnimator:NSObject, UIViewControllerAnimatedTransitioning {
    fileprivate(set) var fromFrame:CGRect
    fileprivate(set) var duration:Double
    
    init(fromFrame:CGRect, duration:Double) {
        self.fromFrame = fromFrame
        self.duration = duration
    }
    
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return duration
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromVC = transitionContext.viewController(forKey: .from) else { return }
        let finalFrame = fromVC.view.frame.insetBy(dx: 20, dy: 20)
        
        guard let toVC = transitionContext.viewController(forKey: .to)  else { return }
        
        let containerView = transitionContext.containerView
        containerView.addSubview(toVC.view)
        
        toVC.view.frame = fromFrame
        toVC.view.clipsToBounds = true
        toVC.view.layer.cornerRadius = 6
        toVC.view.layoutIfNeeded()
        
        fromVC.beginAppearanceTransition(false, animated: true)
        toVC.beginAppearanceTransition(true, animated: true)
        toVC.view.alpha = 0
        
        UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut, animations: {
            toVC.view.frame = finalFrame
            toVC.view.alpha = 1
            toVC.view.layoutIfNeeded()
        }) { (completed) in
            toVC.view.frame = finalFrame
            toVC.view.layoutIfNeeded()
            toVC.view.alpha = 1
            fromVC.endAppearanceTransition()
            toVC.endAppearanceTransition()
            let transitionSuccess = !transitionContext.transitionWasCancelled
            transitionContext.completeTransition(transitionSuccess)
        }
    }
}

展示UIViewController时转场动画表现正常,但展示UIHostingController时,SwiftUIView并未像UIKit视图那样随动画缩放,而是直接显示为完整尺寸。转场动画效果如下:
转场动画


原因及解决方案

核心原因

  1. SwiftUI布局系统特性:SwiftUI是声明式布局,会直接根据父容器的最终尺寸计算自身布局,不会实时响应UIKit中逐帧修改的frame动画。UIHostingController的view在动画过程中frame变化时,内部SwiftUI视图不会跟随调整,直接使用最终的容器尺寸渲染。
  2. GeometryReader的影响:你在SwiftUIView中使用了GeometryReader,它会直接获取宿主view的最终可用尺寸,而非动画过程中的临时frame,导致内容一开始就铺满整个视图。

解决方案

方案1:移除GeometryReader,让SwiftUI自适应宿主frame

修改SwiftUIView,去掉GeometryReader的强制尺寸约束,让视图自适应宿主view的frame变化:

struct SwiftUIView: View {
    var didTapClose:()->()
    
    var body: some View {
        ZStack {
            Color.yellow.ignoresSafeArea()
            Text("SWIFTUI VIEW")
                .foregroundColor(.black)
        }
        .border(.red)
        .onTapGesture {
            didTapClose()
        }
    }
}

同时在创建HostingController时,设置初始的preferredContentSize匹配按钮尺寸:

@IBAction func swiftUITest(_ sender: Any) {
    let swiftUIView = SwiftUIView() {
        self.dismiss(animated: true)
    }
    let hostingController = UIHostingController(rootView: swiftUIView)
    hostingController.modalPresentationStyle = .custom
    hostingController.transitioningDelegate = self
    // 初始尺寸匹配按钮frame
    hostingController.preferredContentSize = testButton.frame.size
    present(hostingController, animated: true)
}

方案2:改用缩放变换实现动画

修改转场动画逻辑,通过缩放变换替代直接修改frame,让整个HostingController视图(包括内部SwiftUI内容)跟随动画缩放:

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    guard let fromVC = transitionContext.viewController(forKey: .from) else { return }
    let finalFrame = fromVC.view.frame.insetBy(dx: 20, dy: 20)
    
    guard let toVC = transitionContext.viewController(forKey: .to)  else { return }
    
    let containerView = transitionContext.containerView
    containerView.addSubview(toVC.view)
    
    // 初始状态:设置位置和缩放为1
    toVC.view.frame = fromFrame
    toVC.view.transform = .identity
    toVC.view.clipsToBounds = true
    toVC.view.layer.cornerRadius = 6
    toVC.view.layoutIfNeeded()
    
    fromVC.beginAppearanceTransition(false, animated: true)
    toVC.beginAppearanceTransition(true, animated: true)
    toVC.view.alpha = 0
    
    // 计算缩放比例
    let scaleX = finalFrame.width / fromFrame.width
    let scaleY = finalFrame.height / fromFrame.height
    
    UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut, animations: {
        // 应用缩放变换并调整中心位置
        toVC.view.transform = CGAffineTransform(scaleX: scaleX, scaleY: scaleY)
        toVC.view.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)
        toVC.view.alpha = 1
        toVC.view.layoutIfNeeded()
    }) { (completed) in
        // 动画结束后恢复变换,设置最终frame
        toVC.view.transform = .identity
        toVC.view.frame = finalFrame
        toVC.view.layoutIfNeeded()
        toVC.view.alpha = 1
        fromVC.endAppearanceTransition()
        toVC.endAppearanceTransition()
        let transitionSuccess = !transitionContext.transitionWasCancelled
        transitionContext.completeTransition(transitionSuccess)
    }
}

内容的提问来源于stack exchange,提问作者zumzum

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.13 15:15:40