Zenn
😬

[SwiftUI] HStackを自作してみた

2025/01/27に公開

要約

SwiftUIのHStackViewを、Layout Protocolを使って自作してみた。
完全とはいかないが、多くの場合HStackと同じように振る舞う実装ができた。
似たような記事が発見できなかったので、実装を共有する。

Layout Protocolとは

iOS 16から導入された、複数の子Viewのレイアウトを実装できるプロトコル。
HStackVStackなどでは実現できない、複雑なレイアウトを作成するために使う。
たとえば

  • 子Viewを円形に配置する。
  • WaterfallLayoutを作成する。

などといった場合に使う。

このプロトコルの準拠には、以下の2つのメソッドを実装する必要がある。

  • sizeThatFits(proposal:subviews:cache:) -> レイアウトコンテナのサイズ
  • placeSubviews(in:proposal:subviews:cache:) -> それぞれの子ViewのFrameを決定

これに準拠したものは、HStackVStackと同じように使うことができる。

myLayout {
    Text("Hello")
    Text("World")
}

MyHStackの実装

Layout Protocolの準拠

あとで説明するframe(for: Subviews, in: ProposedViewSize)が子ViewのFrameを計算する。

sizeThatFitsでは、レイアウトコンテナのサイズを計算する。

  • Widthは、子ViewのWidthの合計とそのSpacingの和となる。
  • Heightは、子ViewのHeightの最大値となる。

placeSubviewsでは、子Viewの配置を決定する。
基本的には、frame(::)で計算した場所にそのまま配置する。alignmentの設定によって、Y軸の位置を調整する。

struct MyHStack: Layout {
    var alignment: VerticalAlignment = .center
    var spacing: CGFloat = 10
    
    // レイアウトコンテナのサイズを計算する
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let viewFrames = frames(for: subviews, in: proposal)
        let maxHeight = viewFrames.max { $0.height < $1.height }?.height ?? .zero
        let totalWidth = viewFrames.reduce(0) { $0 + $1.width } + CGFloat(viewFrames.count - 1) * spacing
        return CGSize(width: totalWidth, height: maxHeight)
    }
    
    // 子ViewのFrameを決定する
    func placeSubviews(in bounds: CGRect, proposal proposedViewSize: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let viewFrames = frames(for: subviews, in: proposedViewSize)
        
        for index in subviews.indices {
            let frame = viewFrames[index]
            // Adjust Y-position for `alignment`.
            let y = switch alignment {
                case .bottom:
                    bounds.midY + bounds.height/2 - frame.height/2
                case .center:
                    bounds.midY
                case .top:
                    bounds.midY - bounds.height/2 + frame.height/2
                default:
                    bounds.midY
            }
            
            let position = CGPoint(x: bounds.midX + frame.minX , y: y)
            subviews[index].place(at: position, anchor: .leading, proposal: ProposedViewSize(frame.size))
        }
    }
}

SubViewsのFrameの計算

おそらく、originalのHStackは以下に▼各ViewのFrameを計算していると思われる。

frame(for: Subviews, in: ProposedViewSize)の中身について説明する。
親ViewのWidthが固定の場合と、固定でない場合で、計算方法が異なる。

func frames(for subviews: Subviews, in proposedViewSize: ProposedViewSize) -> [CGRect] {

    //親ViewのWidthで場合分け
    let sizes: [CGSize] = if proposedViewSize.width == nil {
        // 親ViewのWidthが固定でない場合
        getSizeForFlexWidthParentView(with: subviews, in: proposedViewSize)
    } else {
        // 親ViewのWidthが固定の場合
        getSizeForFixedWidthParentView(with: subviews, in: proposedViewSize)
    }

    // 最初のViewから順番に配置していく。 (View1, spacing, View2, spacing, ...)
    var x: CGFloat = .zero
    let totalSpacing = spacing * CGFloat(sizes.count - 1)
    let totalWidth: CGFloat = sizes.map { $0.width }.reduce(0.0) { $0 + $1 } + totalSpacing

    return sizes.reduce(into: []) { list, size in
        let origin = CGPoint(x: -totalWidth/2 + x, y: .zero)
            let frame =  CGRect(origin: origin, size: size)
            x += size.width + spacing
            list.append(frame)
    }
}

親Viewが固定のWidthを持たない場合

たとえば、HStackがScrollViewの中にあり、好きなだけWidthを広げることができる場合。

ScrollView {
    HStack {
        ...
    }
}

好きなだけWidthを広げることができるので、子Viewはそれぞれが望むSizeをもつことができる。

func getSizeForFlexWidthParentView(with subviews: Subviews, in proposedViewSize: ProposedViewSize) -> [CGSize] {
     subviews.map { $0.sizeThatFits(proposedViewSize) }
}

親Viewが固定のWidthをもつ場合

たとえば、

HStack {
    ...
}.frame(width: 100)

この場合は、子ViewのWidthは、親ViewのWidthに合わせる必要がある。
まず、それぞれの子Viewの最小のサイズに合わせる。

すべての子Viewを最小サイズにしたときの幅 > 親Viewの幅

最小のサイズにしても、親ViewのWidthを超える場合は、それを子Viewのサイズとする。
このときは、子Viewは親Viewを、はみ出すことになる。

//親Viewの幅
let widthLimit = proposedViewSize.width!

// 子Viewがとれる最小のサイズを計算する
let minSizes = subviews.map { $0.sizeThatFits(.zero) }
let minWidths = minSizes.map { $0.width }
let totalMinWidth = minSizes.reduce(0.0) { $0 + $1.width }

var remainingWidth = widthLimit - totalMinWidth - CGFloat(subviews.count - 1) * spacing
// すべての子Viewを最小サイズにしたときの幅 < 親Viewの幅
if remainingWidth < 0 {
    // 最小のサイズにしても、親ViewのWidthを超える場合は、それを子Viewのサイズとする。
    return zip(minWidths, subviews).map { width, subview in
        subview
    }
}
すべての子Viewを最小サイズにしたときの幅 < 親Viewの幅

すべての子Viewを最小サイズにしたときの幅が親Viewの幅より小さいなら、
余った幅を、子ViewのうちlayoutPriorityが高い順に割り当てていく。

// とりあえず、最小のサイズにしておく
var viewSizes = zip(minWidths, subviews).map { width, subview in
    subview.sizeThatFits(ProposedViewSize(width: width, height: heightLimit))
}

let sortedPriorities: [Double] = subviews
.map { $0.priority }
.reduce(into: Set<Double>.init()) { set, priority in
    set.insert(priority)
}.sorted { $0 > $1 }

// `layoutPriority`が高い順に広げていく
for targetPriority in sortedPriorities {
    // このループで幅を広げるViewのIndex
    var targetViewIndexs: Set<Int> = []
        for ((index, subview), minWidth) in zip(subviews.enumerated(), minWidths) {
            guard subview.priority == targetPriority else { continue }
            let canExpand = subview.sizeThatFits(.infinity).width > minWidth
                if canExpand {
                    targetViewIndexs.insert(index)
                }
        }

    // あまっているWidth(`remainingWidth`)を均等に分配する

    // 幅の調整が終るまで繰り返す。
    while !targetViewIndexs.isEmpty && remainingWidth > 0 {
        let distributeWidth = remainingWidth / CGFloat(targetViewIndexs.count)
            for index in targetViewIndexs {
                let subview = subviews[index]
                    let tempWidth = viewSizes[index].width
                    let newSize = subview.sizeThatFits(ProposedViewSize(width: tempWidth + distributeWidth, height: heightLimit))

                    // 幅がさらに広がる可能せいがあるので、次のループで再度調整する
                    if newSize.width > tempWidth {
                        viewSizes[index] = newSize
                            remainingWidth -= (newSize.width - tempWidth)
                    // これ以上は幅が広がらないので、次のループの対象から外す
                    } else {
                        targetViewIndexs.remove(index)
                    }
            }
    }
}
return viewSizes

Demo

うまくいっている例

うまくいっていない例

親Viewの幅が、子Viewの幅に対して十分でない場合、挙動を再現できてないときがある。特に、Textがある場合そのtruncateの挙動が再現できていない。

コード全体

https://github.com/Ueeek/SwiftUILayoutFromScratch/blob/main/SwiftUILayoutFromScratch/SwiftUILayoutFromScratch/MyHStack.swift

最後に

完全には再現できなかったが、ある程度は同じ振る舞いをするMyHStackを実装できた。
minWidthmaxWidth,layoutPrioritySpacingが絡んでいて、場合分けが複雑になっている。
本物のHStackの挙動から、その実装も同程度に複雑だと思われるが、中身を見てみたい。

改善できそうな点を教えていただけるなら、コメントやPRなどで教えていただけると嬉しいです。

Ref

Discussion

ログインするとコメントできます