UIKit TableView嵌套SwiftUI视图:动态Cell高度动画异常问题
问题描述
点击按钮调整SwiftUI View高度时,动画不符合预期,希望动画从顶部向下展开,而非从View或Cell中心展开,尝试过相关方案但未解决,附上演示代码:
class ViewController: UIViewController { private let viewModel = ViewModel() private let tableView = UITableView() override func viewDidLoad() { super.viewDidLoad() setupTableView() viewModel.fetchData { self.tableView.reloadData() } } private func setupTableView() { tableView.backgroundColor = UIColor.systemGray3 tableView.register(HostingCell.self, forCellReuseIdentifier: "\(HostingCell.self)") tableView.delegate = self tableView.dataSource = self tableView.separatorStyle = .none view.addSubview(tableView) tableView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } } extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return 100 } } extension ViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewModel.hostVMs.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(HostingCell.self)", for: indexPath) as? HostingCell else { return UITableViewCell() } cell.onHeightChanged = { DispatchQueue.main.async { self.tableView.performBatchUpdates(nil) } } let hostVM = viewModel.hostVMs[indexPath.row] cell.configure(with: hostVM) return cell } } class ViewModel { var hostVMs: [CellViewModel] = [] func fetchData(completion: (() -> Void)?) { let cellData: [CellData] = [ CellData(systemImageName: "star", title: "Star"), CellData(systemImageName: "heart", title: "Heart"), ] for data in cellData { self.hostVMs.append(CellViewModel(SwiftUIVM(data))) } completion?() } } class HostingCell: UITableViewCell { private var hostVM: CellViewModel? var onHeightChanged: (() -> Void)? var heightConstraint: NSLayoutConstraint? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none let heightConstraint = contentView.heightAnchor.constraint(equalToConstant: 1) heightConstraint.priority = .defaultHigh heightConstraint.isActive = true self.heightConstraint = heightConstraint } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() hostVM = nil heightConstraint = nil onHeightChanged = nil } func configure(with hostVM: CellViewModel) { self.hostVM = hostVM guard let hostVM = self.hostVM else { return } let swiftUIVM = hostVM.swiftUIVM let swiftUIView = SwivtUIView(vm: swiftUIVM, height: Binding(get: { 1 }, set: { newHeight in if let newHeight = newHeight, newHeight > 0, hostVM.height != newHeight { hostVM.height = newHeight self.heightConstraint?.constant = newHeight self.onHeightChanged?() } })) let host = UIHostingController(rootView: swiftUIView) host.view.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(host.view) hostVM.height = host.view.bounds.height NSLayoutConstraint.activate([ host.view.topAnchor.constraint(equalTo: contentView.topAnchor), host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), host.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } } class CellViewModel { let swiftUIVM: SwiftUIVM var height: CGFloat = 0 init(_ swiftUIVM: SwiftUIVM) { self.swiftUIVM = swiftUIVM } } struct CellData: Hashable { let systemImageName: String let title: String } class SwiftUIVM: ObservableObject { let cellData: CellData @Published var showText: Bool = false init(_ cellData: CellData) { self.cellData = cellData } } struct SwivtUIView: View { @ObservedObject var vm: SwiftUIVM @Binding var height: CGFloat? var body: some View { SizeReportingView(height: $height) { VStack(spacing: 0) { VStack { Image(systemName: vm.cellData.systemImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 50) if vm.showText, vm.cellData.title.isEmpty == false { Text(vm.cellData.title) .frame(height: 50) } } .background(Color.yellow) .cornerRadius(8) HStack(spacing: 16) { Button(action: { vm.showText.toggle() }, label: { Text("Tap Me") }) .cornerRadius(8) } .frame(height: 50) } .frame(maxWidth: .infinity) .background(Color(UIColor.systemGray6)) .animation(.easeInOut(duration: 0.3), value: vm.showText) } } } struct SizeReportingView<Content: View>: View { @Binding var height: CGFloat? let content: () -> Content var body: some View { content() .background( GeometryReader { geometry in if height != nil { DispatchQueue.main.async { self.height = geometry.size.height } } return Color.clear } ) } }
解决方案
问题核心是当前动画为Cell整体尺寸变化导致的中心展开效果,要实现顶部向下展开,需让展开内容执行局部动画,并同步SwiftUI与UIKit的动画逻辑。
修改要点
1. 调整SwiftUI视图的动画逻辑
给展开的Text添加顶部滑入的过渡动画,用withAnimation包裹状态切换,取消全局动画避免冲突:
struct SwivtUIView: View { @ObservedObject var vm: SwiftUIVM @Binding var height: CGFloat? var body: some View { SizeReportingView(height: $height) { VStack(spacing: 0) { VStack(spacing: 0) { Image(systemName: vm.cellData.systemImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 50) if vm.showText, !vm.cellData.title.isEmpty { Text(vm.cellData.title) .frame(height: 50) // 添加顶部滑入+透明度过渡动画 .transition(.move(edge: .top).combined(with: .opacity)) } } .background(Color.yellow) .cornerRadius(8) HStack(spacing: 16) { Button(action: { // 用withAnimation触发局部动画 withAnimation(.easeInOut(duration: 0.3)) { vm.showText.toggle() } }, label: { Text("Tap Me") }) .cornerRadius(8) } .frame(height: 50) } .frame(maxWidth: .infinity) .background(Color(UIColor.systemGray6)) // 移除全局动画,避免与UIKit动画冲突 } } }
2. 优化HostingCell的高度更新逻辑
移除手动设置的heightConstraint,让UITableView自动处理高度变化,同时同步动画时长:
class HostingCell: UITableViewCell { private var hostVM: CellViewModel? var onHeightChanged: (() -> Void)? private weak var hostController: UIHostingController<AnyView>? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() hostVM = nil hostController?.view.removeFromSuperview() hostController = nil onHeightChanged = nil } func configure(with hostVM: CellViewModel) { self.hostVM = hostVM let swiftUIView = SwivtUIView(vm: hostVM.swiftUIVM, height: Binding(get: { hostVM.height }, set: { newHeight in guard let newHeight = newHeight, newHeight != hostVM.height else { return } hostVM.height = newHeight DispatchQueue.main.async { // 同步动画时长,匹配SwiftUI动画节奏 UIView.animate(withDuration: 0.3) { self.onHeightChanged?() } } })) let host = UIHostingController(rootView: AnyView(swiftUIView)) hostController = host host.view.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(host.view) NSLayoutConstraint.activate([ host.view.topAnchor.constraint(equalTo: contentView.topAnchor), host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), host.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } }
3. 调整ViewController中的回调逻辑
cell.onHeightChanged = { self.tableView.performBatchUpdates(nil, completion: nil) }
原理说明
- 通过
transition(.move(edge: .top))让Text从顶部滑入,配合透明度动画让过渡更自然; withAnimation确保SwiftUI仅对展开内容执行动画,而非整个视图的尺寸变化;- UITableView通过
performBatchUpdates自动处理Cell高度变化,同步动画时长避免SwiftUI与UIKit动画冲突,最终实现从顶部向下展开的效果。
内容的提问来源于stack exchange,提问作者Jason
相关产品推荐
相关产品推荐

