11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

SwiftUIのAnchorの使い方を学ぶ

Last updated at Posted at 2019-10-11

モチベーション

Inspecting the View Tree – Part 2: AnchorPreferences を元に、
PreferenceKey と PreferenceKey + Anchor の違いを整理する目的で記事を作成した。

オリジナルの記事が簡潔に正確に書かれているので、本記事を読んだ方は是非そちらも訪れてみてください。

また、本記事は GeometryReader および Preference を知っている前提に説明を端折っているため、理解されていない方はリンクを貼っておきますので以下の記事も参照ください。

Let's start

anchor2.gif

上、下で PreferenceAnchorPreference を並びて比較していく。

PreferenceData

// Preference
private struct MyTextPreferenceData: Equatable {
    let viewIdx: Int
    let rect: CGRect
}
// AnchorPreference
struct MyTextPreferenceDataAnchor {
    let viewIdx: Int
    let bounds: Anchor<CGRect>
}

この Anchor<T>T には CGRectCGPoint を受け取ることが可能である。

タップされるView ( January, February ...)

BackgroundにViewを追加 + 追加されるViewの中でGeometryReaderを使うことで、対象のViewの座標(CGRect)をPreferenceによって親から参照できるようにしている。CGRectは、カスタム座標(ここでは、myZstack)を指定する。
そして、座標取得用のView自体は見える必要がないので、Color.clear 指定で透明にしている。←このテクニックは頻繁に使うので覚えておくと良い。

// Preference
private struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int
    
    var body: some View {
        Text(label)
            .padding(10)
            .background(MyPreferenceViewSetter(idx: idx))
            .onTapGesture { self.activeMonth = self.idx } // @Bindingを通じて親Viewの@Stateの値を書き換えて更新を走らせている。
    }
}

private struct MyPreferenceViewSetter: View {
    let idx: Int
    
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.clear)
                .preference(key: MyTextPreferenceKey.self,
                            value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
        }
    }
}

続いて、 AnchorPreference を見ていく。こちらはすごくスッキリしている。

Backgroundの中にViewを追加する必要も、 GeometryReader 処理も、 カスタム座標(ここでは、myZstack)の設定も行っていない。

これらがない代わりに、 anchorPreference が追加されている。
この1行を書くことで、CGRectとして親Viewから座標・サイズの取得が可能になる。第2引数を value: .boundsと指定することでCGRectの値が取得できる。

// AnchorPreference
private struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int
    
    var body: some View {
        Text(label)
            .padding(10)
            .anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceDataAnchor(viewIdx: self.idx, bounds: $0)] })            
            .onTapGesture { self.activeMonth = self.idx }
    }
}

枠表示

緑の枠を表示している処理を見ていく。

Preferenceで記述する場合は以下のようにして表示している。

  • Body直下にZStackを全体に囲んでいて、その中で、緑枠 と MonthlyViewを配置する
  • レイアウトに変化があった場合に呼ばれるこのメソッド(onPreferenceChange)内で、子Viewの座標を rectsプロパティに登録する
  • ZStackに coordinateSpace(name: "myZstack") を指定しておりこの範囲を独自の座標系として設定する
  • 緑の枠のViewはoffset、frameで上記でrectsに指定した値を元に描画する
// Preference
    var body: some View {
        ZStack(alignment: .topLeading) {
            // 緑の枠
            RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
                .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
                .offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
                .animation(.easeInOut(duration: 1.0))
            
            VStack {
              // MonthView表示エリア
              ....
            }.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
                // 画面初期表示時、画面回転時、画面のレイアウトが変わった時に呼ばれる
                // ここでrectsにMonthView郡の座標・大きさをrectsに登録
                for p in preferences {
                    self.rects[p.viewIdx] = p.rect
                }
            }
        }.coordinateSpace(name: "myZstack")
    }

ZStackは 下に書いたものが優先されるので、
枠を優先させたい場合は、MonthView表示エリア の下に移動する。(この例では枠が図形の前後どちらにあっても大差ないと判断してZStackの並び順を意識していない気がしている。)

次に、Anchorを見ていくことにする。

// AnchorPreference
    var body: some View {
        VStack {
            ...
        }.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
            return GeometryReader { geometry in
                ZStack(alignment: .topLeading) {
                    self.createBorder(geometry, preferences)
                    
                    HStack { Spacer() } // makes the ZStack to expand horizontally
                    VStack { Spacer() } // makes the ZStack to expand vertically
                }.frame(alignment: .topLeading)
            }
        }
    }

Body直下にZStackは存在しておらず、 preference の代わりに、 backgroundPreferenceValue を記述する。
この backgroundPreferenceValue はMonthViewをパラメータとして受け取るIFとなっている。
Preferenceは、自身の座標を取得するのに Backgroundの中で、 GeometryReader を指定して取得していたが、ここでは、 backgroundPreferenceValue であり、 この 「background」がキーとなると記憶すると良さそうだ。

ちょっと横道に反れるが

HStack { Spacer() } // makes the ZStack to expand horizontally
VStack { Spacer() } // makes the ZStack to expand vertically

上記のように書くことで、はZStackを画面全体(NavigationAreaは含まない)に広げることが出来る。

    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceDataAnchor]) -> some View {
        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
        let bounds = p != nil ? geometry[p!.bounds] : .zero
        return RoundedRectangle(cornerRadius: 15)
                .stroke(lineWidth: 3.0)
                .foregroundColor(Color.green)
                .frame(width: bounds.size.width, height: bounds.size.height)
                .fixedSize()
                .offset(x: bounds.minX, y: bounds.minY)
                .animation(.easeInOut(duration: 1.0))
    }

ここでは、backgroundPreferenceValue は すべての MonthView が引数として渡ってくるので、その中で、選択した図形のindexと一致しないものをフィルターして、一致したものを緑枠を表示するようにしている。

let p = preferences.first(where: { $0.viewIdx == self.activeIdx })

ソース全体(AnchorReference)

struct ContentView : View {
    
    @State private var activeIdx: Int = 0
    
    var body: some View {
        VStack {
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "January", idx: 0)
                MonthView(activeMonth: $activeIdx, label: "February", idx: 1)
                MonthView(activeMonth: $activeIdx, label: "March", idx: 2)
                MonthView(activeMonth: $activeIdx, label: "April", idx: 3)
            }
            
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "May", idx: 4)
                MonthView(activeMonth: $activeIdx, label: "June", idx: 5)
                MonthView(activeMonth: $activeIdx, label: "July", idx: 6)
                MonthView(activeMonth: $activeIdx, label: "August", idx: 7)
            }
            
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "September", idx: 8)
                MonthView(activeMonth: $activeIdx, label: "October", idx: 9)
                MonthView(activeMonth: $activeIdx, label: "November", idx: 10)
                MonthView(activeMonth: $activeIdx, label: "December", idx: 11)
            }
            
            Spacer()
        }.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
            return GeometryReader { geometry in
                ZStack(alignment: .topLeading) {
                    self.createBorder(geometry, preferences)
                    
                    HStack { Spacer() } // makes the ZStack to expand horizontally
                    VStack { Spacer() } // makes the ZStack to expand vertically
                }.frame(alignment: .topLeading)
            }
        }
    }
    
    //  CGRect
    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceDataAnchor]) -> some View {
        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
        let bounds = p != nil ? geometry[p!.bounds] : .zero
        return RoundedRectangle(cornerRadius: 15)
                .stroke(lineWidth: 3.0)
                .foregroundColor(Color.green)
                .frame(width: bounds.size.width, height: bounds.size.height)
                .fixedSize()
                .offset(x: bounds.minX, y: bounds.minY)
                .animation(.easeInOut(duration: 1.0))
    }
    
}

// CGRect
struct MyTextPreferenceDataAnchor {
    let viewIdx: Int
    let bounds: Anchor<CGRect>
}

private struct MyTextPreferenceKey: PreferenceKey {
    typealias Value = [MyTextPreferenceDataAnchor]
    
    static var defaultValue: [MyTextPreferenceDataAnchor] = []
    
    static func reduce(value: inout [MyTextPreferenceDataAnchor], nextValue: () -> [MyTextPreferenceDataAnchor]) {
        value.append(contentsOf: nextValue())
    }
}

private struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int
    
    var body: some View {
        Text(label)
            .padding(10)
            .anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceDataAnchor(viewIdx: self.idx, bounds: $0)] })            
            .onTapGesture { self.activeMonth = self.idx }
    }
}

Anchor< CGPoint >の例を見ていく

上記ではAnchor< CGRect > の例であったので、筆者がより複雑な2つのAnchorを使った例を説明するために記載している。
ここでは、スマートなのは1つのAnchorで表現する方法だが見ていくことにする。

struct MyTextPreferenceData {
    let viewIdx: Int
    var topLeading: Anchor<CGPoint>? = nil
    var bottomTrailing: Anchor<CGPoint>? = nil
}

Optionalにしているのは、初期化時に、topLeadingとbottomTrailingを同時に行わないようにしているためである。

struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int
    
    var body: some View {
        Text(label)
            .padding(10)
            // topLeadingをセット
            .anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] })
            // bottomTrailingに値をセット
            .transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
                value[0].bottomTrailing = anchor
            })
            
            .onTapGesture { self.activeMonth = self.idx }
    }
}

topLeadingに値を設定

.anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] })

定義を見ると、

    @inlinable public func anchorPreference<A, K>(key _: K.Type = K.self, value: Anchor<A>.Source, transform: @escaping (Anchor<A>) -> K.Value) -> some View where K : PreferenceKey

無名引数とreturnを省略しなければ以下のように記述できる。

.anchorPreference(key: MyTextPreferenceKey.self,
      value: .topLeading,
      transform: { anchor in
                    return [MyTextPreferenceDataAnchor(viewIdx: self.idx, topLeading: anchor)]
})

続いて、bottomTrailingをセットしている以下を見るが、

ここでは、先の anchorPreference の処理で、 MyTextPreferenceDataAnchor で作成したstructに対して、 bottomTrailingを追加登録しているが

.transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
                value[0].bottomTrailing = anchor
            })

定義を見ると、以下のようになっており、 inout 値型の参照渡し により引数に渡したものを変更できるようにしており、
この例では、 [MyTextPreferenceData] に対してbottomTrailing に anchorの値を設定している。
補足しておくと、この例では、 value[0] としているのは、同時に選択されることはなく、単一のものであるため。

@inlinable public func transformAnchorPreference<A, K>(key _: K.Type = K.self, value: Anchor<A>.Source, transform: @escaping (inout K.Value, Anchor<A>) -> Void) -> some View where K : PreferenceKey

枠を表示している部分はCGRectからCGPointに変わったため多少変わっているがほとんど同じである。


    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceDataAnchor]) -> some View {
        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
        // 下記の4行が異なる
        let aTopLeading = p?.topLeading
        let aBottomTrailing = p?.bottomTrailing
        let topLeading = aTopLeading != nil ? geometry[aTopLeading!] : .zero
        let bottomTrailing = aBottomTrailing != nil ? geometry[aBottomTrailing!] : .zero


        return RoundedRectangle(cornerRadius: 15)
            .stroke(lineWidth: 3.0)
            .foregroundColor(Color.green)
            .frame(width: bottomTrailing.x - topLeading.x, height: bottomTrailing.y - topLeading.y) // ここが変わっている
            .fixedSize()
            .offset(x: topLeading.x, y: topLeading.y) // ここが変わっている
            .animation(.easeInOut(duration: 1.0))
    }

まとめ

Referenceスゴイ!!と思っていたが、Anchor Referenceに置き換えることでより簡潔にコードが書けるようになることを学んだ。
SwiftUI-Labさんはまだ数日分記事があるので引き続き読み込んでいきたい。

参考

11
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?