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

如何在SwiftUI自定义Layout中基于文本基线对齐子视图?

实现自定义EqualHStack的文本基线对齐

要解决自定义布局中的首行/末行文本基线对齐问题,核心是利用LayoutSubview提供的baselineOffset(from:)方法获取子视图的基线位置偏移,再通过计算统一调整每个子视图的Y轴位置。以下是修改后的完整实现:

import SwiftUI

struct EqualHStack: Layout {
    
    static var layoutProperties: LayoutProperties {
        var properties = LayoutProperties()
        properties.stackOrientation = .horizontal
        return properties
    }
        
    var alignment = VerticalAlignment.center
    var spacing: CGFloat?
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
        let spacingValues = spacing(subviews: subviews)
        let totalSpacing = spacingValues.reduce(0, +)
        let totalWidth = maxWidth * CGFloat(subviews.count) + totalSpacing
        
        // 根据对齐方式计算布局高度
        let totalHeight: CGFloat
        switch alignment {
        case .firstTextBaseline:
            // 首行基线对齐时,高度 = 最大的首行基线顶部偏移 + 最大的首行基线到底部延伸距离
            let firstBaselineOffsets = subviews.map { $0.baselineOffset(from: .top) }
            let maxTopOffset = firstBaselineOffsets.max() ?? 0
            let maxBottomExtension = subviews.enumerated().map { $0.element.sizeThatFits(.unspecified).height - firstBaselineOffsets[$0.offset] }.max() ?? 0
            totalHeight = maxTopOffset + maxBottomExtension
        case .lastTextBaseline:
            // 末行基线对齐时,高度 = 最大的末行基线顶部延伸距离 + 最大的末行基线底部偏移
            let lastBaselineOffsets = subviews.map { $0.baselineOffset(from: .bottom) }
            let maxTopExtension = subviews.enumerated().map { $0.element.sizeThatFits(.unspecified).height - lastBaselineOffsets[$0.offset] }.max() ?? 0
            let maxBottomOffset = lastBaselineOffsets.max() ?? 0
            totalHeight = maxTopExtension + maxBottomOffset
        default:
            // 其他对齐方式沿用原逻辑
            totalHeight = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
        }
        
        return CGSize(width: totalWidth, height: totalHeight)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let maxWidth = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
        let spacingValues = spacing(subviews: subviews)
        let subProposal = ProposedViewSize(width: maxWidth, height: nil) // 高度由子视图自身决定,避免强制拉伸
        var currentX = bounds.minX
        
        switch alignment {
        case .top:
            for (index, subview) in subviews.enumerated() {
                let subviewSize = subview.sizeThatFits(subProposal)
                subview.place(at: CGPoint(x: currentX + maxWidth/2, y: bounds.minY + subviewSize.height/2), anchor: .center, proposal: subProposal)
                currentX += maxWidth + spacingValues[index]
            }
        case .center:
            for (index, subview) in subviews.enumerated() {
                subview.place(at: CGPoint(x: currentX + maxWidth/2, y: bounds.midY), anchor: .center, proposal: subProposal)
                currentX += maxWidth + spacingValues[index]
            }
        case .bottom:
            for (index, subview) in subviews.enumerated() {
                let subviewSize = subview.sizeThatFits(subProposal)
                subview.place(at: CGPoint(x: currentX + maxWidth/2, y: bounds.maxY - subviewSize.height/2), anchor: .center, proposal: subProposal)
                currentX += maxWidth + spacingValues[index]
            }
        case .firstTextBaseline:
            // 计算统一的首行基线Y坐标
            let firstBaselineOffsets = subviews.map { $0.baselineOffset(from: .top) }
            let maxTopOffset = firstBaselineOffsets.max() ?? 0
            let baselineY = bounds.minY + maxTopOffset
            
            for (index, subview) in subviews.enumerated() {
                let subviewSize = subview.sizeThatFits(subProposal)
                // 子视图顶部位置 = 基线Y - 自身首行基线到顶部的偏移
                let subviewTopY = baselineY - firstBaselineOffsets[index]
                subview.place(at: CGPoint(x: currentX + maxWidth/2, y: subviewTopY + subviewSize.height/2), anchor: .center, proposal: subProposal)
                currentX += maxWidth + spacingValues[index]
            }
        case .lastTextBaseline:
            // 计算统一的末行基线Y坐标
            let lastBaselineOffsets = subviews.map { $0.baselineOffset(from: .bottom) }
            let maxBottomOffset = lastBaselineOffsets.max() ?? 0
            let baselineY = bounds.maxY - maxBottomOffset
            
            for (index, subview) in subviews.enumerated() {
                let subviewSize = subview.sizeThatFits(subProposal)
                // 子视图底部位置 = 基线Y + 自身末行基线到底部的偏移
                let subviewBottomY = baselineY + lastBaselineOffsets[index]
                subview.place(at: CGPoint(x: currentX + maxWidth/2, y: subviewBottomY - subviewSize.height/2), anchor: .center, proposal: subProposal)
                currentX += maxWidth + spacingValues[index]
            }
        default:
            //  fallback到居中对齐
            for (index, subview) in subviews.enumerated() {
                subview.place(at: CGPoint(x: currentX + maxWidth/2, y: bounds.midY), anchor: .center, proposal: subProposal)
                currentX += maxWidth + spacingValues[index]
            }
        }
    }
        
    private func spacing(subviews: Subviews) -> [CGFloat] {
        subviews.indices.map { index in
            guard index < subviews.count - 1 else { return 0 }
            if let spacing {
                return spacing
            }
            return subviews[index].spacing.distance(to: subviews[index + 1].spacing, along: .horizontal)
        }
    }
}

#Preview {
    VStack(spacing: 20) {
        EqualHStack {
            Text("Item 1\nSecond Line")
            Text("Item twoooo")
            Text("I3")
        }
        .border(.red)
        
        EqualHStack(alignment: .top, spacing: 20) {
            Text("Item 1\nSecond Line")
            Text("Item twoooo")
            Text("I3")
        }
        .border(.red)
        
        EqualHStack(alignment: .bottom) {
            Text("Item 1\nSecond Line")
            Text("Item twoooo")
            Text("I3")
        }
        .border(.red)
        
        EqualHStack(alignment: .firstTextBaseline) {
            Text("Item 1\nSecond Line")
            Text("Item twoooo")
            Text("I3")
        }
        .border(.red)
        
        EqualHStack(alignment: .lastTextBaseline) {
            Text("Item 1\nSecond Line")
            Text("Item twoooo")
            Text("I3")
        }
        .border(.red)
    }
    .padding()
}

关键修改说明:

  1. 基线偏移获取:

    • 通过subview.baselineOffset(from: .top)获取子视图首行基线到自身顶部的距离
    • 通过subview.baselineOffset(from: .bottom)获取子视图末行基线到自身底部的距离
  2. 布局高度计算:

    • 首行基线对齐时,取最大的首行基线顶部偏移量,加上最大的首行基线到底部的延伸距离,确保所有子视图完整显示
    • 末行基线对齐时,取最大的末行基线顶部延伸距离,加上最大的末行基线底部偏移量,保证布局高度足够容纳所有子视图
  3. 子视图放置逻辑:

    • 首行基线对齐:计算统一的基线Y坐标,每个子视图的顶部位置为基线Y减去自身首行基线偏移,确保所有首行基线在同一水平线
    • 末行基线对齐:计算统一的基线Y坐标,每个子视图的底部位置为基线Y加上自身末行基线偏移,确保所有末行基线在同一水平线
    • 宽度方向保持原逻辑,强制所有子视图使用最大宽度,保证等宽布局效果

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.08 00:24:57