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
相关产品推荐
相关产品推荐

