[SwiftUI] Layout を理解する/View のレイアウト情報取得

SwiftUI2021

     
⌛️ 4 min.
iOS16, macOS13 で新しく導入される Layout を使ってみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.5 Beta
  • Xcode 14.0 Beta3
  • iOS 16.0 beta

Layout 解説シリーズ記事

実際に Layout を作ってみることで、理解を深めます。全3回

前回の記事では、Layout に準拠する GuideBoardStack を作りました。

中身は、VStack と同じ振る舞いをするものでした。

この記事では、もうすこし(?)手を加えて 以下の案内板のような配置になるように、変更してみます。

RoadSign

最終的に、以下のような表示になる予定です。

GuideBoardStack

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)
                })
        }
    }
}

まずは、以下のようなレイアウトを目指します。

AlternateSide

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 で配置すると以下のような配置になります。

AlternateSide

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 で配置すると以下のようになります。

GuideBoardStackFin

まとめ

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"
        }
    }
}

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です