[SwiftUI] HStackを自作してみた
要約
SwiftUIのHStackView
を、Layout Protocol
を使って自作してみた。
完全とはいかないが、多くの場合HStack
と同じように振る舞う実装ができた。
似たような記事が発見できなかったので、実装を共有する。
Layout Protocolとは
iOS 16から導入された、複数の子Viewのレイアウトを実装できるプロトコル。
HStack
やVStack
などでは実現できない、複雑なレイアウトを作成するために使う。
たとえば
- 子Viewを円形に配置する。
- WaterfallLayoutを作成する。
などといった場合に使う。
このプロトコルの準拠には、以下の2つのメソッドを実装する必要がある。
-
sizeThatFits(proposal:subviews:cache:)
-> レイアウトコンテナのサイズ -
placeSubviews(in:proposal:subviews:cache:)
-> それぞれの子ViewのFrameを決定
これに準拠したものは、HStack
やVStack
と同じように使うことができる。
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
の挙動が再現できていない。
コード全体
最後に
完全には再現できなかったが、ある程度は同じ振る舞いをするMyHStack
を実装できた。
minWidth
、maxWidth
,layoutPriority
、Spacing
が絡んでいて、場合分けが複雑になっている。
本物のHStack
の挙動から、その実装も同程度に複雑だと思われるが、中身を見てみたい。
改善できそうな点を教えていただけるなら、コメントやPRなどで教えていただけると嬉しいです。
Discussion