Sponsor Link
環境&対象
- macOS Monterey 12.5 Beta
- Xcode 14.0 Beta3
- iOS 16.0 beta
前回の記事では、Layout に準拠する GuideBoardStack を作りました。
中身は、VStack と同じ振る舞いをするものでした。
この記事では、もうすこし(?)手を加えて 以下の案内板のような配置になるように、変更してみます。
最終的に、以下のような表示になる予定です。
GuideBoardStack
単純な VStack では、上から下に順番に配置していきますが、GuideBoardStack では、中心から 左側と右側に分かれるように配置していきます。
呼び出し側
呼び出し側では以下のように使用することを想定します。
Layout はあくまでレイアウトなので、真ん中の柱は、 ZStack で重ねる必要があります。
struct ContentView: View {
var body: some View {
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 2)
.fill(.gray).frame(width: 30, height: 300)
GuideBoardStack()
.callAsFunction({
GuideArrow(text: "London", direction: .up)
GuideArrow(text: "Tokyo", direction: .upright).font(.title)
GuideArrow(text: "Nagoya", direction: .left).font(.largeTitle)
GuideArrow(text: "Okinawa", direction: .downleft)
GuideArrow(text: "Australia", direction: .down).font(.largeTitle)
GuideArrow(text: "Yokohama Station", direction: .right)
})
}
}
}
まずは、以下のようなレイアウトを目指します。
GuideBoardStack (左右自動振り分け)
まずは、単純に上から左・右・左・右・左・・・・と配置してみます。
高さ方向の配置は、特に調整せず、VStack 相当で配置していた時と同じ位置計算にしています。
左右の配置を Bool 等で表現しても良いのですが、そのための enum を Side として定義しました。
enum Side {
case left
case right
mutating func toggle() -> Void {
switch self {
case .left:
self = .right
case .right:
self = .left
}
}
}
便利関数として toggle も定義しています。
最初の subview は、左側に配置し、以降 右側、左側、右側・・・・と順番に配置する時に、toggle していくことを想定しています。
Swift では enum をうまく使うと綺麗なコードが書けるので、以下の解説記事も書いてます。
[Swift] enum のすすめ
[Swift] enum のすすめ Vol.2 (associatedValue の扱い)
[Swift] enum のすすめ Vol.3 (アプリの状態復元と Codable)
layout を修正していく準備はできました。
layout でキーとなるのは、sizeThatFits と placeSubviews なので、実装を変更していきます。
sizeThatFits
この関数は、自分が必要とするサイズを返す関数でした。
幅は、右側・左側それぞれに配置する GuideArrow の最大幅を算出し、大きい側の値を2倍して 自分の幅とします。さらに、中央部分の隙間分として、固定値 10 を足しています。(左右それぞれに 5 を割り当てるイメージです。)
高さについては、単純に これまでのスペーシングを使うことにします。
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
// calc width
let maxWidth: CGFloat = subviewSizes.reduce(0.0, {partialResult, subviewSize in
max(partialResult, subviewSize.width)
})
// calc height
var totalHeight = subviewSizes.reduce(0.0, { partialResult, subviewSize in
partialResult + subviewSize.height
})
_ = subviews.indices.dropLast().map({
totalHeight += subviews[$0].spacing.distance(to: subviews[$0 + 1].spacing, along: .vertical)
})
return CGSize(width: maxWidth * 2 + 10, height: height)
}
placeSubviews
palceSubviews で 子ビューを配置していきます。
左右順番に配置していきます。
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard !subviews.isEmpty else { return }
let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
var y = bounds.minY
for index in subviews.indices {
for index in subviews.indices {
//let side = subviews[index][SideInfo.self]
let anchor: UnitPoint = (side == .left) ? .topTrailing : .topLeading
let x = (side == .left) ? bounds.midX - 5 : bounds.midX + 5
subviews[index].place(at: CGPoint(x: x, y: y),
anchor: anchor,
proposal: .unspecified)
y += subviewSizes[index].height
if subviews.index(after: index) subviews.endIndex {
let nextIndex = subviews.index(after: index)
y += subviews[index].spacing.distance(to: subviews[nextIndex].spacing, along: .vertical)
}
side.toggle()
}
}
上から順番に配置していき、右左と 交互に配置しています。上下方向の Spacing は、subview の持っている spacing から取得しています。(VStack 相当のレイアウトと同一)
完成図
このようにして作った Layout で配置すると以下のような配置になります。
GuideBoardStack (左右振り分け指定)
順番に左右に振り分けてもよいのですが、コードで指定できるように拡張していきます。
つまり、左右のどちらへ配置するかを指定できるようにしてみます。
ですが、外部から Layout へ 個別の View に関する情報を渡そうとすると(そのままでは)手段がないことに気づきます。
Layout に渡される subview 情報は、Proxy であり、View そのものではありません。
つまり、View が持っている情報に Layout からアクセスする方法がありませんので、通常(?)の方法では、Layout が情報を入手する手段がありません。
このようなケースに向けて用意されているのが、.layoutValue という ViewModifier と LayoutValueKey です。
以下のように使用することで、View に LayoutValue を紐づけることができ、この情報は、Layout から参照することができます。
MyView()
.layoutValue(key: MyLayoutKey.self, value: myValue)
今回は、左側へ配置するか、右側へ配置するかを指定することにしたいので、上で作った enum Side を使って、LayoutValueKey と その情報を設定する View extension を作りました。
private struct SideInfo: LayoutValueKey {
static let defaultValue: Side = .left
}
extension View {
func layoutSide(_ side: Side = .left) -> some View {
layoutValue(key: SideInfo.self, value: side)
}
}
このように、定義しておくことで、以下のように記述すると、GuideArrow(“Londong”) には、.left が紐付き、GuideArrow(“Tokyo”)には、.right が紐づけることができます。
GuideArrow(text: "London", direction: .upLeft)
.layoutSide(.left)
GuideArrow(text: "Tokyo", direction: .downRight)
.layoutSide(.right)
準備ができたので、Layout に必要な2つのメソッド (sizeThatFits, placeSubviews) を修正していきます。
sizeThatFits
この関数は、自分が必要とするサイズを返す関数です。
幅は、右側・左側それぞれに配置する GuideArrow の最大幅を算出し、大きい側の値を2倍して 自分の幅とします。
高さについては、右もしくは左側に連続して GuideArrow を配置するときは、5 のスペースを開けて配置し、
左右異なる側に配置するときは、1つ上の GuideArrow の中心に沿って、自分の GuideArrow の上端を配置することを想定して、高さを計算します。こうすることで、左右に配置される GuideArrow が高さ方向に少し詰めて配置されることになります。
# 言葉で書くと難しく感じますが、冒頭の写真にちかくなるよう それっぽい配置にしたいだけです・・・
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
// calc width
let maxWidth: CGFloat = subviewSizes.reduce(0.0, {partialResult, subviewSize in
max(partialResult, subviewSize.width)
})
// calc height
var height = 0.0
for index in subviews.indices {
let side = subviews[index][SideInfo.self] // - subview に設定された .layoutValue を取得
let nextIndex = subviews.index(after: index)
if !subviews.indices.contains(nextIndex) {
height += subviewSizes[index].height
} else {
let nextSide = subviews[nextIndex][SideInfo.self]
if nextSide != side {
height += subviewSizes[index].height / 2.0
} else {
height += subviewSizes[index].height + 5.0 // 5 is padding
}
}
}
return CGSize(width: maxWidth * 2 + 10, height: height)
}
palceSubviews
palceSubviews で 子ビューを配置していきます。
sizeTahtFits で必要としたサイズに収まるように 順番に配置していきます。
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard !subviews.isEmpty else { return }
let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
var y = bounds.minY
for index in subviews.indices {
let side = subviews[index][SideInfo.self] // - subview に設定された .layoutValue を取得
let anchor: UnitPoint = (side == .left) ? .topTrailing : .topLeading
let x = (side == .left) ? bounds.midX - 5 : bounds.midX + 5
subviews[index].place(at: CGPoint(x: x, y: y),
anchor: anchor,
proposal: .unspecified)
let nextIndex = subviews.index(after: index)
if !subviews.indices.contains(nextIndex) {
// last subview
} else {
let nextSide = subviews[nextIndex][SideInfo.self]
if nextSide != side {
y += subviewSizes[index].height / 2.0
} else {
y += subviewSizes[index].height + 5 // 5 is padding
}
}
}
}
上から順番に配置していき、次のビューの配置位置を 自分の配置位置と比較し、次のビューの配置座標を調整することを繰り返しています。
完成図
このようにして作った Layout で配置すると以下のようになります。
まとめ
Layout を使って、独自レイアウトを作る
- Layout 途中では、View 自体の情報にアクセスすることはできない
- LayoutValueKey/ .layoutValue を使用すると View に情報を付与して、Layout から参照できる
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
SwiftUI おすすめ本
SwiftUI を理解するには、以下の本がおすすめです。
# SwiftUI は毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。
コード全体
以下は、使用したコードの全体です。
//
// ContentView.swift
//
// Created by : Tomoaki Yagishita on 2022/07/13
// © 2022 SmallDeskSoftware
//
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 2)
.fill(.gray).frame(width: 30, height: 300)
GuideBoardStack()
.callAsFunction({
GuideArrow(text: "London", direction: .up)
.layoutSide(.left)
GuideArrow(text: "Tokyo", direction: .upright).font(.title)
.layoutSide(.right)
GuideArrow(text: "Nagoya", direction: .left).font(.largeTitle)
.layoutSide(.left)
GuideArrow(text: "Okinawa", direction: .downleft)
.layoutSide(.left)
GuideArrow(text: "Australia", direction: .down).font(.largeTitle)
.layoutSide(.right)
GuideArrow(text: "Yokohama Station", direction: .right)
.layoutSide(.right)
})
.border(.green)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct GuideArrow: View {
let text: String
let direction: GuideDirection
var body: some View {
HStack(spacing: 0) {
switch direction {
case .left, .up, .upleft, .downleft:
Image(systemName: direction.symbolName)
Text(text)
case .right, .down, .downright, .upright:
Text(text)
Image(systemName: direction.symbolName)
}
}
.foregroundColor(.white)
.padding(2)
.background{
RoundedRectangle(cornerRadius: 5).fill(Color.blue)
}
.padding(1)
.background{
RoundedRectangle(cornerRadius: 6).fill(Color.white)
}
.padding(1)
.background{
RoundedRectangle(cornerRadius: 7).fill(Color.blue)
}
}
}
private struct SideInfo: LayoutValueKey {
static let defaultValue: Side = .left
}
extension View {
func layoutSide(_ side: Side = .left) -> some View {
layoutValue(key: SideInfo.self, value: side)
}
}
struct GuideBoardStack: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
// calc width
let maxWidth: CGFloat = subviewSizes.reduce(0.0, {partialResult, subviewSize in
max(partialResult, subviewSize.width)
})
// calc height
var height = 0.0
for index in subviews.indices {
let side = subviews[index][SideInfo.self]
let nextIndex = subviews.index(after: index)
if !subviews.indices.contains(nextIndex) {
height += subviewSizes[index].height
} else {
let nextSide = subviews[nextIndex][SideInfo.self]
if nextSide != side {
height += subviewSizes[index].height / 2.0
} else {
height += subviewSizes[index].height + 5.0 // 5 is padding
}
}
}
return CGSize(width: maxWidth * 2 + 10, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard !subviews.isEmpty else { return }
let subviewSizes = subviews.map({$0.sizeThatFits(.unspecified)})
var y = bounds.minY
for index in subviews.indices {
let side = subviews[index][SideInfo.self]
let anchor: UnitPoint = (side == .left) ? .topTrailing : .topLeading
let x = (side == .left) ? bounds.midX - 5 : bounds.midX + 5
subviews[index].place(at: CGPoint(x: x, y: y),
anchor: anchor,
proposal: .unspecified)
let nextIndex = subviews.index(after: index)
if !subviews.indices.contains(nextIndex) {
// last subview
} else {
let nextSide = subviews[nextIndex][SideInfo.self]
if nextSide != side {
y += subviewSizes[index].height / 2.0
} else {
y += subviewSizes[index].height + 5 // 5 is padding
}
}
}
}
}
enum Side {
case left
case right
mutating func toggle() -> Void {
switch self {
case .left:
self = .right
case .right:
self = .left
}
}
}
enum GuideDirection: String, RawRepresentable {
case up, down, left, right, upleft, upright, downleft, downright
var symbolName:String {
switch self {
case .up:
return "arrow.up"
case .down:
return "arrow.down"
case .left:
return "arrow.left"
case .right:
return "arrow.right"
case .upleft:
return "arrow.up.left"
case .upright:
return "arrow.up.right"
case .downleft:
return "arrow.down.left"
case .downright:
return "arrow.down.right"
}
}
}
Sponsor Link