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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 10:00:54