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

SwiftUI:可选择标签布局选型——解决自定义视图闪烁问题

SwiftUI 居中流式标签布局:解决手动计算行的闪烁问题

我在SwiftUI中实现了一个可选择标签视图,样式为所有标签居中对齐,和SwiftUI标准网格的外观不同。这个视图基于自定义的FlowLayoutView实现,但用户选中标签时,computeRows函数会被触发,导致布局出现闪烁(glitch)效果。我希望既能获得类似SwiftUI List或LazyVGrid的高效更新能力,避免手动计算行带来的闪烁问题,同时保持居中流式布局的外观。

原实现代码

struct FlowLayoutView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let data: Data
let spacing: CGFloat
let content: (Data.Element) -> Content

@State private var elementsSize: [Data.Element: CGSize] = [:]

var body: some View {
    VStack(alignment: .center, spacing: spacing) {
        let rows = computeRows()
        ForEach(rows, id: \.self) { row in
            HStack(spacing: spacing) {
                ForEach(row, id: \.self) { item in
                    content(item)
                        .fixedSize()
                        .readSize { size in
                            elementsSize[item] = size
                        }
                }
            }
        }
    }
}

private func computeRows() -> [[Data.Element]] {
    var rows: [[Data.Element]] = [[]]
    var currentRowWidth: CGFloat = 0
    let screenWidth = UIScreen.main.bounds.width - 40 // Adjust for padding

    for item in data {
        let itemSize = elementsSize[item, default: CGSize(width: 100, height: 1)]

        if currentRowWidth + itemSize.width > screenWidth {
            rows.append([item])
            currentRowWidth = itemSize.width
        } else {
            rows[rows.count - 1].append(item)
            currentRowWidth += itemSize.width + spacing
        }
    }

    return rows
}
}

问题根源

原实现的核心问题在于:手动计算行依赖elementsSize状态,当标签选中态改变(比如尺寸或样式变化)时,elementsSize更新会触发computeRows重新计算,进而导致整个VStack的行结构重建,引发布局闪烁。SwiftUI的状态更新会重绘整个依赖该状态的视图树,这种全量重排的方式效率低下。

解决方案

方案一:基于LazyVGrid的动态列实现

利用LazyVGrid的高效复用机制,通过动态计算每行的列数来模拟居中流式布局,避免手动构建行结构。

struct CenteredFlowGrid<Data: Collection, Content: View>: View where Data.Element: Hashable {
    let data: Data
    let spacing: CGFloat
    let content: (Data.Element) -> Content
    @State private var itemSizes: [Data.Element: CGSize] = [:]
    
    var body: some View {
        GeometryReader { geo in
            let availableWidth = geo.size.width - 40 // 预留左右padding
            LazyVGrid(columns: generateColumns(availableWidth: availableWidth), spacing: spacing) {
                ForEach(Array(data.enumerated()), id: \.element) { _, item in
                    content(item)
                        .fixedSize()
                        .readSize { size in
                            itemSizes[item] = size
                        }
                }
            }
            .padding(.horizontal, 20)
        }
    }
    
    private func generateColumns(availableWidth: CGFloat) -> [GridItem] {
        guard !data.isEmpty else { return [] }
        
        // 计算当前一行能容纳的最大标签数量
        var currentTotalWidth: CGFloat = 0
        var columnCount = 0
        
        for item in data {
            let itemWidth = itemSizes[item, default: CGSize(width: 100, height: 1)].width
            let requiredWidth = itemWidth + (columnCount > 0 ? spacing : 0)
            
            if currentTotalWidth + requiredWidth <= availableWidth {
                currentTotalWidth += requiredWidth
                columnCount += 1
            } else {
                break
            }
        }
        
        // 创建自适应列,让每行标签自动居中
        return Array(repeating: GridItem(.flexible(minimum: 0), spacing: spacing), count: columnCount)
    }
}

注意:首次加载时因itemSizes未缓存,列数可能基于默认值计算,可通过预加载标签尺寸或给默认值优化体验。

方案二:封装UICollectionView实现高效布局

利用UICollectionView的原生高效复用和自定义布局能力,实现居中流式布局,更新时仅刷新变化的单元格,彻底避免闪烁。

// 自定义居中流式布局
class CenteredFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let baseAttributes = super.layoutAttributesForElements(in: rect) else { return nil }
        
        var lineWidth: CGFloat = 0
        var currentLineAttributes: [UICollectionViewLayoutAttributes] = []
        
        // 遍历属性,统计每行的总宽度
        for attribute in baseAttributes {
            guard attribute.representedElementCategory == .cell else { continue }
            lineWidth += attribute.frame.width + minimumInteritemSpacing
            currentLineAttributes.append(attribute)
        }
        
        // 计算每行的左偏移量,实现居中
        let totalInset = (collectionView?.bounds.width ?? 0) - lineWidth + minimumInteritemSpacing
        var leftOffset = totalInset / 2
        
        for attribute in currentLineAttributes {
            attribute.frame.origin.x = leftOffset
            leftOffset += attribute.frame.width + minimumInteritemSpacing
        }
        
        return baseAttributes
    }
}

// SwiftUI封装视图
struct CenteredCollectionView<Data: Collection, Content: View>: UIViewRepresentable where Data.Element: Hashable {
    let data: Data
    let spacing: CGFloat
    let content: (Data.Element) -> Content
    
    func makeUIView(context: Context) -> UICollectionView {
        let layout = CenteredFlowLayout()
        layout.minimumInteritemSpacing = spacing
        layout.minimumLineSpacing = spacing
        layout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.backgroundColor = .clear
        collectionView.register(HostingCell<Content>.self, forCellWithReuseIdentifier: "TagCell")
        collectionView.dataSource = context.coordinator
        
        return collectionView
    }
    
    func updateUIView(_ uiView: UICollectionView, context: Context) {
        context.coordinator.data = data
        context.coordinator.content = content
        uiView.reloadData()
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(data: data, content: content)
    }
    
    // 数据源协调器
    class Coordinator: NSObject, UICollectionViewDataSource {
        var data: Data
        var content: (Data.Element) -> Content
        
        init(data: Data, content: @escaping (Data.Element) -> Content) {
            self.data = data
            self.content = content
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            data.count
        }
        
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TagCell", for: indexPath) as? HostingCell<Content> else {
                return UICollectionViewCell()
            }
            let item = data[indexPath.item]
            cell.host(content(item))
            return cell
        }
    }
}

// 承载SwiftUI视图的UICollectionViewCell
class HostingCell<Content: View>: UICollectionViewCell {
    private var hostingController: UIHostingController<Content>?
    
    func host(_ view: Content) {
        hostingController = UIHostingController(rootView: view)
        guard let controller = hostingController else { return }
        
        controller.view.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(controller.view)
        
        NSLayoutConstraint.activate([
            controller.view.topAnchor.constraint(equalTo: contentView.topAnchor),
            controller.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            controller.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            controller.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }
}

优势:UICollectionView的单元格复用机制确保仅更新变化的内容,自定义布局精准控制居中效果,完全避免布局闪烁,适合数据量较大或频繁交互的场景。

方案选择

  • 如果项目偏好纯SwiftUI实现,且数据量不大,优先选择方案一,代码更简洁易维护;
  • 如果需要极致的性能和稳定的交互体验,尤其是标签频繁切换选中态的场景,优先选择方案二。

内容的提问来源于stack exchange,提问作者aleksy.t

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 15:29:51