34
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2(PreferenceKey編)

Last updated at Posted at 2019-07-23

はじめに

前回のチュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその1(GeometryReader編)から引き続きチュートリアルから一歩踏み出したSwiftUIのCustom Viewを作ってみたいと思います。

今回も右記の素晴らしいブログを参考にしています。GeometryReader to the Rescue

今回のCustom View

円をクリックすると円型の枠がオーバーレイのアニメーションをしながらクリックした側に移動します。
ezgif.com-resize-2.gif

大雑把なしくみ

まずはViewの構造をみてみましょう。
Screen Shot 2019-07-23 at 1.45.02 pm.png
緑と赤の円はHStack下にあって、それと円の周りを囲むアニメーションで移動するView(以下、円周View)がZStack下で重なっています。円周Viewはクリックされた円の場所にオフセットを利用して移動します。
BGViewは円Viewのframe(サイズおよび位置)取得のための透明なViewでそれぞれの円Viewのbackgroundに設定されます

そのためには、二つの円Viewの位置、サイズ情報(frame)を取得し、円周Viewを移動させるために、その位置情報を親Viewからアクセス出来なければなりません。またその位置情報はデバイスが回転した場合には変化するので、自動で変化時には位置情報の書き換えをする必要があります。

前回の記事を読んでいただいたはお分かりだと思いますが、frameの取得にはGeometryReaderを使います。その情報の共有と更新には今回のメイントピックPreferenceKeyを使います。

ちなみに、@Bindingを使って親ViewとGeometryReaderで取得した位置、サイズ情報を共有すればシンプルなのでは?と思われるかもしれませんが、**Modifying state during view update, this will cause undefined behavior.**というエラーが出て上手くいきません。

Binding.swift
DispatchQueue.main.async {
    self.rects[self.idx] = geometry.frame(in: .named("myCoordinate"))
}

のようにすれば一見うまくいくように見えますが、根本的な解決には至りません。

PreferenceKeyとは

ユーザーが作成したViewのデータ(Preference)を保持、親Viewからアクセスさせることを可能にするプロトコルです。下記を実装する必要があります。

  • associatedtype Value
     保持するPreferenceのタイプ
  • static var defaultValue: Self.Value
     そのPreferenceのデフォルト値
  • static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)
     親Viewからアクセスする時に、Preferenceを持つ複数の子ViewのPreferenceをどのようにまとめ上げるか。valueは今までの値、nextValueは次の値。
    例えば、Arrayでまとめ上げるとすると、valueは今までのPreferenceを要素として持つ配列、nextValueは次の値なので、このクロージャをvalue.append(contentsOf: nextValue())とすれば全てのこのPreferenceを配列の要素としてまとめ上げ、親Viewからアクセスできることになります。

実装

では、早速今回のユーザーデータを作ってみましょう。Preferenceのデータとして使うにはEquatableである必要があります。中身はどの円Viewをタップしたか知るための番号(idx)とframeを保持するためのCGRect型のrectです。

PreferenceData.swift
struct PreferenceData: Equatable {
    let idx: Int
    var rect: CGRect
}

次に独自のPreferenceKeyを実装します。上記で説明した3つを実装します。今回は複数の子ViewのPreference(上記で作成したPreferenceDataタイプ)を保持したいので、配列としています。

CirclePreferenceKey.swift
struct CirclePreferenceKey: PreferenceKey {
    typealias Value = [PreferenceData]

    static var defaultValue: [PreferenceData] = []
    
    static func reduce(value: inout [PreferenceData], nextValue: () -> [PreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

上記で作成したPreferenceに円Viewのframeを設定しています。このView円Viewのbackgroundで円Viewと同じframeを持つ透明なViewです。
GeometryReaderを使って取得したViewのframeと番号(idx)をpreference modifierによって先ほど作ったpreferenceDataとして設定しています。
[PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
frame(in: .named("myCoordination"))は独自の座標系を設定しています。(後述)
これでpreferenceの値の設定が終わりました。

BGView.swift
struct BGView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: CirclePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}

読出し部分ですがその前にそれぞれの円Viewのbackgroudに上記のBGViewを設定しています。
そして読み出した値を保持するための配列を作成しています。
@State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 2)
次に円View達の親ViewであるHStackにてonPreferenceChange modifierを使ってpreferenceのデータを上記で作成した配列に突っ込みます。onPreferenceChangeはpreferencenのデータが変わるたびにコールされるので、例えば端末が回転して Viewの構成が変わった場合、自動的に値の更新が行われます。

CirclePreferenceView.swift
@State private var activeIdx: Int = 0
@State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 2)

HStack {
    Circle()
        .fill(Color.green)
        .frame(width: 100, height: 100)
        .background(BGView(idx: 0))
        .tapAction {
            self.activeIdx = 0
        }
        .padding()
        Circle()
            .fill(Color.pink)
            .frame(width: 150, height: 150)
            .background(BGView(idx: 1))
            .tapAction {
                self.activeIdx = 1
            }
            .padding()
    }
    .onPreferenceChange(CirclePreferenceKey.self) {  preference in
        for p in preference {
            self.rects[p.idx] = p.rect
        }
    }

円Viewタップ時に、上記で取得した円Viewのframe情報を元に円周Viewの位置、大きさを変更しアニメーションさせます。

stroke.swift
Circle()
    .stroke(Color.blue, lineWidth: 10)
    .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
    .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)

全体のコード

詳しくは説明しませんが、座標系を統一するためにZStackに.coordinateSpace(name: "myCoordination")で独自の座標系を設定し、frame取得時に利用しています。

.swift
import SwiftUI

struct PreferenceData: Equatable {
    let idx: Int
    var rect: CGRect
}

struct CirclePreferenceKey: PreferenceKey {
    typealias Value = [PreferenceData]

    static var defaultValue: [PreferenceData] = []
    
    static func reduce(value: inout [PreferenceData], nextValue: () -> [PreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

struct CirclePreferenceView: View {
    
    @State private var activeIdx: Int = 0
    @State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 2)
    @State var isStarted:Bool = false
    
    var body: some View {

        ZStack(alignment: .topLeading) {
            HStack {
                Circle()
                    .fill(Color.green)
                    .frame(width: 100, height: 100)
                    .background(BGView(idx: 0))
                    .tapAction {
                        self.isStarted = true
                        self.activeIdx = 0
                    }
                    .padding()
                    Circle()
                        .fill(Color.pink)
                        .frame(width: 150, height: 150)
                        .background(BGView(idx: 1))
                        .tapAction {
                            self.isStarted = true
                            self.activeIdx = 1
                        }
                        .padding()
                }
                .onPreferenceChange(CirclePreferenceKey.self) {  preference in
                    for p in preference {
                        self.rects[p.idx] = p.rect
                    }
                }
            
Circle()
    .stroke(Color.blue, lineWidth: 10)
    .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
    .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
    .animation(.linear(duration: isStarted ? 0.5 : 0))
            
        }.coordinateSpace(name: "myCoordination")
    }
}

struct BGView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: CirclePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}


#if DEBUG
struct CirclePreferenceView_Previews: PreviewProvider {
    static var previews: some View {
        CirclePreferenceView()
    }
}
#endif

最後に

PreferenceKey、ちょっと理解しづらいところもありますが、色々使えそうです。
座標系のせいか何故かLiveViewではエラーが出て表示できません。シミュレーターか実機で確認してください。
View名がContnetViewではないので、SceneDelegateのwindow.rootViewControllerの書き換えを忘れないように。(CirclePreferenceView)

34
25
1

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
34
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?