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

SwiftUI2021

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

環境&対象

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

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

前回まで

SwiftUI2021[SwfitUI] Layout を理解する

前回の記事では、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"
        }
    }
}

コメントを残す

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