スポンサードリンク

アプリ編#15 プロパティラッパーでViewを自動更新【SwiftUI】

スマートカーテン

やぎ星人です。どうもこんにちは。
突然ですが皆様はラーニングピラミッドというものをご存じでしょうか。
アメリカ国立訓練研究所が発表した研究結果によると、学習方法とその定着率を↓のようなピラミッド図で表すことができるそうです。


上に行くほど受動的、下に行くほど能動的な学習方法になります。
能動的に学習したほうが成果が出るのは当然なのですが、こうやって数値化して比較すると改めてその重要性を感じますね。大学、会社の研修で講義を聴いたくらいじゃダメですし、参考書をパラパラ眺めていてもそれほど定着しないとのこと。
よくありがちなのが、「分かったつもりになっている」ですね。私も何度も痛い目に遭いました。

その点、今取り込んでいる活動って、実は物凄く合理的に学習を行えているのではないかと思っています。
悪戦苦闘が続いてますが実際にモノをつくりながら「体験」を積み、ブログ記事にOUTPUTすることで「他者にレクチャー」に相当することが(;・∀・)実践できるので、学習定着という観点では良い取り組みかもしれないですね。レクチャーというにはおこがましい限りですが(;・∀・)

ラーニングピラミッド、皆様の参考になれば幸いです。


それでは本題です。
前回の記事でアニメーションを動作させましたが、例えばある特定の条件が成立した場合にアニメーションを実行するような場合、どのように実現すれば良いのでしょうか。
今回は、SwiftUIのビュー表示を動的に切り替える方法を調査したいと思います。

プロパティラッパー

参考書をパラパラとめくるとすぐに解決の糸口が見つかりました。
結果として「プロパティラッパー」という仕組み利用することでビューを動的に変更することができました。
プロパティラッパーについてご紹介させていただきます。

概要

プロパティラッパーとは構造体やクラスにて定義されるプロパティのルール(初期値の決め方や変更の仕方など)を予め定義しておき、必要な場面でそれを適用することができるという仕組みらしいです。

使用例

あまり良い例えが浮かびませんが..
例えば、3回まで変更可能なプロパティを定義するためのプロパティラッパー、名付けて「仏の顔」を考えてみます。

プロパティラッパーの定義

プロパティラッパーを定義するためには、@proprtyWrapperという属性の記述が必要とのこと。
また、計算型プロパティwrappedValueの定義も必須らしいです。
下記の実装ではイニシャライザを定義しておりますが、こちらは任意でかまいません。

@propertyWrapper
struct Hotokenokao <Value> {
    private var value: Value
    private var updateCounter = 1

    init(wrappedValue inValue: Value) {
        value = inValue
    }

    var wrappedValue: Value {
        get { value }
        set {
            if ( updateCounter < 3 ) {
                value = newValue
                updateCounter = updateCounter + 1
            }
            else {
                // 前回値を保持
            }
        }
    }
}
プロパティラッパーの使用

定義したプロパティラッパーを特定のプロパティに適用するためには、”@”+プロパティラッパー名を属性として付与します。
下記の実装例では「仏の顔」プロパティラッパーを適用したプロパティ値の更新が3回までしか実施できないことを示しています。

@Hotokenokao var userName = "ponimaruA"

userName = "ponimaruB"
userName = "ponimaruC"
userName = "ponimaruD"
userName = "ponimaruE"

// 結果としてuserNameには"ponimaruC"が設定されるはず

プロパティ毎に毎回こういったルールを実装するのは大変ですよね。プロパティラッパーに感謝です。

SwiftUIフレームワークより提供されるプロパティラッパー

SwiftUIより提供されるプロパティラッパーでは、プロパティ値の変化によりビューを再構築するような仕組みが実装されているようです。
この仕組みを利用すれば、プロパティ値に応じてビューを動的に変更することができそうです。
SwiftUIより提供されるプロパティラッパー について、いくつかご紹介させていただきます。

@State

SwiftUIではViewプロトコルに準拠した構造体定義し、bodyプロパティを実装することで、容易に画面の描画処理を行うことができるのですが、bodyプロパティ内に実装したUIイメージを変更することができません。
ここで@State属性を付与したプロパティを構造体に追加することで、そのプロパティに更新が発生した場合にbodyプロパティの内容が再描画される仕組みをSwiftUI側が提供してくれます。
body内に@State属性プロパティ値に応じて描画内容を切り替えるような処理を実装しておけば、
@State属性プロパティ値の変更
⇒ Viewの再描画
⇒ 再描画時に@State属性プロパティ値に応じて描画内容を決定
という流れで描画内容を動的に変更することができます。

注意点として@State属性プロパティはVeiwに紐づいてSwiftUI側で管理されるため、Viewの外側では変更してはならないそうです。
プロパティの変更が許されるのは、自Viewかその配下におかれるViewのみとのこと。

@ObservedObject

@ObservedObjectとは@Stateの概念をインスタンスとして扱えるようにしたものというイメージです。
使用方法としては以下の通りです。

  1. ObservedObjectプロトコルを継承したクラスを定義する
  2. クラス内のプロパティの内、Viewと関連付けしたいものについては@Published属性を付与しておく
  3. Viewの中で@ObservedObject属性を付与したプロパティ(”1″で定義したクラスのインスタンス)を定義

これにより、@ObservedObject属性を付与したプロパティの更新が発生した際にViewを更新することができます。
取り扱うプロパティの数が多い場合や特定の意味を持ったプロパティ群を纏める際に、こうしてクラス化すると便利ですね。

@EnvironmentObject

@EnvironmentObjectはさきほどの@ObservedObject属性のインスタンスをアプリ内で共通化する仕組みです。
使用方法としては以下の通りです。

  1. ObservedObjectプロトコルを継承したクラスを定義する
  2. クラス内のプロパティの内、Viewと関連付けしたいものについては@Published属性を付与しておく
  3. “1”で定義したクラスのインスタンスを生成し、任意のViewのenvironment関数をコールし、生成したインスタンスを引数情報としてセットする。
  4. Viewの中で@EnvironmentObject属性を付与したプロパティ(”3″で生成したインスタンスへの参照)を定義

基本的に”2″までは先ほどの@ObservedObject使用時と同手順です。

“3”については実際にインスタンスを生成し、environmentObject関数を用いて任意のViewにセットします。(インスタンスを共有化する)
これにより、そのViewの配下にいるViewは後述する”4″の手順により、共有化されたインスタンスを参照することができるようになります。
※任意のViewのenvironmentObject関数をコールすると説明しましたが、アプリ内全体でインスタンスを共有するためには、すべてのViewの先祖にあたるViewのenvironmentObject関数をコールすればOKです。

// SceneDelegate.swift

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        // Create the SwiftUI view that provides the window contents.

        // XXXXは任意のインスタンス名、YYYYはObservedObjectプロトコルを継承したクラス名
        let XXXX = YYYY()
        let contentView = ContentView().environmentObject(XXXX)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

”4”については下記の記述により、共有化されたインスタンスへのアクセスが可能となります。

// XXXXは任意のインスタンス名、YYYYはObservedObjectプロトコルを継承したクラス名
// EnvironmentObject(共有インスタンス)の参照
@EnvironmentObject var XXXX: YYYY

アプリ内共通のプロパティとして扱えるので、例えばあるView中のボタンが押下された際に共通プロパティを更新することで、他のViewの表示を切り替えたり、BLE送信を行ったりすることができそうです。

お試し実装して動かしてみた

お試しでアプリ画面上の各UIボタンを押下した際にアニメーションが切り替わるように実装してみました。

OPENボタンを押下 ⇒ カーテンが開動作するアニメーション
CLOSEボタンを押下 ⇒ カーテンが開動作するアニメーション
STOPボタンを押下 ⇒ アニメーション停止

↓動作確認結果です。

参考になるかわかりませんが、↓ソースコードです。

//
//  SystemState.swift
//  SmartCurtain
//
//  Created by yagi seijin on 2020/08/12.
//  Copyright © 2020 Loose Life Hack. All rights reserved.
//

import Foundation

enum CurtaionState {
    case stopping
    case openingOperation
    case closingOperation
}


class SystemState: ObservableObject{
    @Published var curtainState : CurtaionState = .stopping
}

					
//
//  SceneDelegate.swift
//  SmartCurtain
//
//  Created by yagi seijin on 2020/08/14.
//  Copyright © 2020 Loose Life Hack. All rights reserved.
//

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let systemState = SystemState()
        let contentView = ContentView().environmentObject(systemState)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

    func sceneDidDisconnect(_ scene: UIScene) {
        // Called as the scene is being released by the system.
        // This occurs shortly after the scene enters the background, or when its session is discarded.
        // Release any resources associated with this scene that can be re-created the next time the scene connects.
        // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
    }

    func sceneDidBecomeActive(_ scene: UIScene) {
        // Called when the scene has moved from an inactive state to an active state.
        // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
    }

    func sceneWillResignActive(_ scene: UIScene) {
        // Called when the scene will move from an active state to an inactive state.
        // This may occur due to temporary interruptions (ex. an incoming phone call).
    }

    func sceneWillEnterForeground(_ scene: UIScene) {
        // Called as the scene transitions from the background to the foreground.
        // Use this method to undo the changes made on entering the background.
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        // Called as the scene transitions from the foreground to the background.
        // Use this method to save data, release shared resources, and store enough scene-specific state information
        // to restore the scene back to its current state.
    }


}


					
//
//  ControlView.swift
//  SmartCurtain
//
//  Created by yagi seijin on 2020/08/24.
//  Copyright © 2020 Loose Life Hack. All rights reserved.
//

import SwiftUI

struct ControlView: View {
    
    @EnvironmentObject var systemState :SystemState
    
    var body: some View {
        ZStack{
            Color("background")
            GeometryReader{ geometry in
                VStack(){
                    Spacer()
                        .frame(height: geometry.size.height * 0.05)
                    // 窓とカーテンのイメージ画像
                    ZStack(){
                        Image("blue_sky")
                            .resizable()
                            .scaledToFit()
                        if(self.systemState.curtainState == .openingOperation){
                            startCurtainAnimation(width: geometry.size.width * 0.9, height:  geometry.size.height * 0.45, curtainImages: openImages)
                        }
                        else if(self.systemState.curtainState == .closingOperation){
                            startCurtainAnimation(width: geometry.size.width * 0.9, height:  geometry.size.height * 0.45, curtainImages: closeImages)
                        }
                        else{
                            Image("role_curtain05")
                        }
                    }
                    .frame(width: geometry.size.width * 0.9, height: geometry.size.height * 0.45)
                    
                    Spacer()
                        .frame(height: geometry.size.height * 0.1)
                    
                    HStack(){
                        Spacer()
                        // カーテン開けるボタン
                        Button(action: {
                            self.systemState.curtainState = .openingOperation
                            
                        }) {
                            Image("openBtn")
                                .resizable()
                                .scaledToFit()
                                .frame(width: geometry.size.width * 0.4)

                        }
                        Spacer()
                        // カーテン閉めるボタン
                        Button(action: {
                            self.systemState.curtainState = .closingOperation
                            
                        }) {
                            Image("closeBtn")
                                .resizable()
                                .scaledToFit()
                                .frame(width: geometry.size.width * 0.4)
                        }
                        Spacer()
                    }
                    Spacer()
                        .frame(height: geometry.size.height * 0.05)
                    // 作動を止めるボタン
                    Button(action: {
                        self.systemState.curtainState = .stopping
                        
                    }) {
                        Image("stopBtn")
                            .resizable()
                            .scaledToFit()
                            .frame(width: geometry.size.width * 0.4)
                    }
                    Spacer()
                }
            }
        }
    }
}

struct ControlView_Previews: PreviewProvider {
    static var previews: some View {
        ControlView()
            .environmentObject(SystemState())
    }
}

let curtainImgName = [
    "role_curtain09",
    "role_curtain08",
    "role_curtain07",
    "role_curtain06",
    "role_curtain05",
    "role_curtain04",
    "role_curtain03",
    "role_curtain02",
    "role_curtain01"
]

let openImages : [UIImage]! = [UIImage(named: curtainImgName[0])!, UIImage(named: curtainImgName[1])!, UIImage(named: curtainImgName[2])!, UIImage(named: curtainImgName[3])!, UIImage(named: curtainImgName[4])!, UIImage(named: curtainImgName[5])!, UIImage(named: curtainImgName[6])!, UIImage(named: curtainImgName[7])!, UIImage(named: curtainImgName[8])!]

let closeImages : [UIImage]! = [UIImage(named: curtainImgName[8])!, UIImage(named: curtainImgName[7])!, UIImage(named: curtainImgName[6])!, UIImage(named: curtainImgName[5])!, UIImage(named: curtainImgName[4])!, UIImage(named: curtainImgName[3])!, UIImage(named: curtainImgName[2])!, UIImage(named: curtainImgName[1])!, UIImage(named: curtainImgName[0])!]

struct startCurtainAnimation: UIViewRepresentable {
    
    @State var width : CGFloat
    @State var height : CGFloat
    @State var curtainImages : [UIImage]
    
    func makeUIView(context: Self.Context) -> UIView {
        
        let curtainAnimation = UIImage.animatedImage(with: curtainImages, duration: 4)
        let ratio = height / curtainImages[0].size.height
        let x = ( width - curtainImages[0].size.width * ratio ) / 2

        let animationView = UIView(frame: CGRect(x: x, y: 0, width: curtainImages[0].size.width * ratio, height: height))
        let curtainImage = UIImageView(frame: CGRect(x: x,y:0, width: curtainImages[0].size.width * ratio, height: height))

        curtainImage.clipsToBounds = true
        curtainImage.autoresizesSubviews = true
        curtainImage.contentMode = UIView.ContentMode.scaleAspectFit
        curtainImage.image = curtainAnimation
                
        animationView.addSubview(curtainImage)

        return animationView

    }
    
    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) {
    }
}

					

おわりに

今回の記事ではSwiftUIにおけるビューの動的更新についてご紹介させていただきました。
これまでの記事でBLEの組み込みやアニメーション処理の実現方法など、スマートカーテンアプリ開発を行う上で必要となる技術要素を一つずつ学習することができました。
これらを踏まえ、次回の記事ではアプリの全体設計を行いたいと思います。
本来であれば実装開始前に全体設計を行いたかったのですが、SwiftUIによるアプリ開発は初めての経験でしたので、こういった技術要素を理解して検証しなければ設計のイメージができなかったのです。

あれこれ悩みながら設計するくらいなら、手を動かしていろいろ検証したほうが良いという考えです。
実際にやってみて、設計内容をイメージすることができたので良かったです。

未熟者ですが、次回の全体設計の記事も是非ご覧いただけると嬉しいです。

やぎ星人

それではまた次回の記事でお会いしましょう!

コメント