見出し画像

[SwiftUI]Tinder風デザイン作ってみる: EnvironmentObjectを用いてViewModelを実装する

こんばんわ、中川(Twitter)です。
今回はViewModelを作成していきたいと思います。
@EnvironmentObjectを用いて、ファイル間で共通のプロパティを参照できるようにします。
参考動画はこちらです⬇︎


参考動画: yusukeさん

非常に丁寧に解説してくださっています。


それではやっていきましょう✊

■はじめに


こちらの記事は「Tinder風デザイン作ってみる」シリーズとして地続きで内容を進めていっています。前回の流れで出てくる記述が多くあるので、もしよければ前の記事から読んでいただけると内容が理解しやすいかと思います。よろしくお願いいたします🙇‍♂️🙇‍♂️

■EnvironmentObjectとは?


EnvironmentObjectはSwiftUIのデータバインディングの仕組みの一つです。
アプリ内全体でデータを共有したい場合に使用します。
使用する際、インスタンスの基となるクラスは"ObservableObject"プロトコルに準拠する必要があり、更新したデータを受け取るプロパティに"@Published"を付けます。

■前工程、リファクタリング


さて、まずやりたいこととして、Viewの切り分けをしたいです。
この工程にEnvironmentObjectはまだ出てきませんが、
後々この作業が絡んできます。
⬇︎の黄枠のZStackがありますね。このZStackのスコープ内部には前回作成したカードViewが格納されています。うちの愛猫が載ってたアレですね。(長い記述のためスコープ全体を折り畳んでいます)

こちらのカードViewがモディファイアも含めて記述が長くなっているので、
新しいViewを作って切り分けしていきたいと思います。
View名はCardDetailViewとします。
新しく作成した構造体CardDetailViewに、前回作成したカードView全体を引っ越しします。

struct CardDetailView: View {
    var body: some View {
        // カードView全体が束ねられている
        ZStack {
            ZStack {

                Image("neko1")
                    .resizable()
                    .scaledToFill()

                LinearGradient(gradient: Gradient(colors: [.clear, .black]), startPoint: .top, endPoint: .bottom)

            }
            // Imageとグラディエントをまとめてモディファイアでフレーム調整
            .frame(width: geometry.size.width - 20, height: geometry.size.height)
            .cornerRadius(10)
            .padding(.all, 10)
            .shadow(radius: 10)


            VStack {

                HStack {
                    Text("GOOD")
                        .font(.system(size: 40, weight: .heavy))
                        .foregroundColor(.green)
                        .padding(.all, 5)
                        .overlay(
                            RoundedRectangle(cornerRadius: 15)
                                .stroke(Color.green, lineWidth: 4)
                        )
                        .opacity(self.numbers.last == number ? goodOpacity : .zero)

                    Spacer()

                    Text("NOPE")
                        .font(.system(size: 40, weight: .heavy))
                        .foregroundColor(.red)
                        .padding(.all, 5)
                        .overlay(
                            RoundedRectangle(cornerRadius: 15)
                                .stroke(Color.red, lineWidth: 4)
                        )
                        .opacity(self.numbers.last == number ? nopeOpacity : .zero)
                }
                .padding(.all, 30)

                Spacer()

                HStack {
                    //(alignment: .leading)でテキストが左寄りに並ぶ
                    VStack(alignment: .leading) {
                        Text("Daikiti")
                            .foregroundColor(Color.white)
                        // system fontを使うことで数値でフォントデザインを指定可能
                            .font(.system(size: 40, weight: .heavy))

                        Text("大阪")
                            .foregroundColor(Color.white)
                            .font(.system(size: 20, weight: .medium))

                        Text("カツオ")
                            .foregroundColor(Color.white)
                            .font(.system(size: 25, weight: .medium))

                        Text("カツオが大好きです")
                            .foregroundColor(Color.white)
                            .font(.system(size: 25, weight: .medium))
                    }
                    // テキストの位置を変えたければpaddingを指定
                    .padding(.leading, 20)

                    // テキストとボタンの間を空ける
                    Spacer()

                    Button(action: {}, label: {
                        Image(systemName: "info.circle.fill")
                            .resizable()
                            .foregroundColor(.white)
                            .frame(width: 40, height: 40)
                    })
                    .padding(.trailing, 50)

                }
                .padding(.bottom, 40)
            }
        }
    }
}

はい、これで引っ越しが完了です。

さて、ここからが本題です✊


■ViewModelの作成


ViewModelを作成し、EnvironmentObjectを用いてViewModel内のデータをアプリ全体から参照できるようにしていきます。
今回全体で共有したい対象データはこちら⬇︎

struct CardView: View {

       // スワイプジェスチャー時の値の変化を監視
    @State var translation: CGSize = .zero    // ✅
        // 複数カード生成時のForEachが参照している配列
    @State var numbers = [0,1,2,3,4,5]      // ✅
        // 「GOOD」の透明度の変化を監視
    @State var goodOpacity: Double = 0     // ✅
    // 「NOPE」の透明度の変化を監視
    @State var nopeOpacity: Double = 0     // ✅


    var body: some View {


        GeometryReader(content: { geometry in

            ForEach(numbers, id: \.self) { number in

これらのプロパティは元々、先ほどお引っ越ししたカードデザインが構築されていた構造体CardViewが保持していたプロパティたちです。
これらをアプリ全体からデータ共有できるようにしていきましょう。

ではまず、ViewModelを作っていきます。
下記のように新しく「ViewModel」グループを作成し、
その配下に新規FileをSwiftUIファイルを選択し、「CardViewModel」を作成します。

CardViewModelが作成できたら、内部にコードを書いていきます。

// CardViewModel

class CardViewModel: ObservableObject {

    init() {

    }
}

このようにクラスCardViewModelを定義します。
この時、クラスにObservableObjectプロトコルを準拠させます。
最初の説明にあったように、EnvironmentObjectを使用するには
参照の親元となるデータにObservableObjectを準拠させる必要があります。

では、このクラス内に先ほどの対象データたちを引っ越しさせます✊

// CardViewModelファイル

class CardViewModel: ObservableObject {

    @State var translation: CGSize = .zero
    @State var numbers = [0,1,2,3,4,5]
    @State var goodOpacity: Double = 0
    @State var nopeOpacity: Double = 0


    init() {

    }
}

これで引っ越しができましたね。
続いて、それぞれの@Stateを@Publishedに変更します。
ObservableObjectプロトコルを準拠したデータをバインディングするには@Publishedをつける必要があります。

class CardViewModel: ObservableObject {

    @Published var translation: CGSize = .zero
    @Published var numbers = [0,1,2,3,4,5]
    @Published var goodOpacity: Double = 0
    @Published var nopeOpacity: Double = 0

    init() {

    }
}

この時点で対象データを元々保持していたCardView側ではエラーが発生します。
Cannot find '◯◯' in scope
これは参照していたプロパティが無くなってしまったためですね。

さて、ここでやっとEnvironmentObjectが登場です。


■EnvironmentObjectを付与したプロパティの作成


CardViewCardDetailViewの方に戻って、新しくプロパティを作成します。

// ①ジェスチャー設定などを含めたカード全体のデザインView
struct CardView: View {


    @EnvironmentObject var vm: CardViewModel  // ✅


    var body: some View {
。
。
。

// ②カードのデザインだけを引っ越しさせたCardDetailView

struct CardDetailView: View {

    @EnvironmentObject var vm: CardViewModel  // ✅

    var body: some View {

これでViewModel「vm」からデータを貰う設定ができました。
このvmを引数として渡すことで、ViewModel側に置いてあるプロパティデータをアプリ全体から参照することができます。

※EnvironmentObjectを用いる際に必要な記述※

EnvironmentObjectを使う際に、SwiftUI Appファイルに追加で記述する必要があります。

import SwiftUI

@main
struct SampleTinderAppApp: App {
    var body: some Scene {
        WindowGroup {
             HomeView()
                .environmentObject(CardViewModel())  // ✅
        }
    }
}

このように、.environmentObject(<ViewModel名>())
付与しておきましょう。これがないとエラーを吐きます。


■カードViewのデータ参照元を全てViewModel「vm」へ変更


さあ、ここから突貫工事が始まります。
次に、CardDetailView(カードのデザイン)で参照していたプロパティデータ⬇️

        @State var translation: CGSize = .zero
    @State var numbers = [0,1,2,3,4,5]
    @State var goodOpacity: Double = 0
    @State var nopeOpacity: Double = 0

これらのプロパティを参照していたViewデザインの処理全ての参照元をViewModel「vm」に置換していきます。
例えば、translationプロパティの参照をvmに置換するなら

self.translation
// ⬇︎ 置換
vm.translation

といった感じですね。これをCardDetailView内全ての項目で置換します。
置換場所が多いですが、置換が必要な部分はちゃんとエラーが出てくれているのでわかりやすいかと思います。

ただ二点
number」と「geometry」に関してはvmの要素とは別に追加でプロパティを作ってあげる必要があるので、宣言しておきましょう。

// CardDetailView

var number: Int   // ✅
var geometry: GeometryProxy  // ✅
@EnvironmentObject var vm: CardViewModel


これでEnbironmentObJectを用いたViewModelからのデータ参照を実装することができました◎

Viewが問題なく表示されるかシュミレータで確認してみます。

ViewModelへの置換前と代わりなく、Viewの生成ができていますね◎


■まとめ


以上、ViewModelを作成し、EnvironmentObjectを用いてカードViewとのデータバインディングを実装しました。
実際に物を作りながらコードの意味もフォーカスしながらなので、どうしても記事が長くなってしまいますね🙇‍♂️
焦点を当てているコード記述の振る舞いを単一レベルで切り分けて細かく見ていく記事も、これから先で書いていけたらと思います。

ここまで読んでいただきありがとうございました🙏!
ではまた、進展があれば書きます。

以上


この記事が気に入ったらサポートをしてみませんか?