スポンサードリンク

アプリ編#14 パラパラ漫画風アニメーションを入れたい【SwiftUI】

スマートカーテン

やぎ星人です。どうもこんにちは。
新型コロナウィルスの影響で自身を取り巻く環境が一変しました。
ネガティブな影響も大きいのですが、世の中の変化を見ると様々な発見があります。
不謹慎かもしれませんが、こういった未曾有の事態がなければ生まれなかった発明や文化も多少なりともあるのではないでしょうか。
決してウィルスの流行を肯定しているわけではありません。物事には様々な側面があり、時には俯瞰的に世を見渡すことも大切だと感じました。


さて本題です。
今回はビューに配置したイメージ画像にアニメーション効果を設定したいと思います。

SwiftUIのビュー上でアニメーションを動作させる

ここでいうアニメーションとは、ビューに表示させる画像を複数パターン用意し、パラパラ漫画のように画像を切り替えるようなことをイメージしています。

ターゲット

アニメーション動作の対象とする画像は下記になります。

  • カーテン開閉イメージ画像
  • BLEの接続状態を示すアンテナ画像

アニメーションの狙いとしては、
アンテナ画像・・・スマートカーテンデバイスを検索中であることが直感的に分かるようにしたい
カーテン画像・・・カーテンが開または閉動作していることが視覚的に分かるようにしたい

【悲報】SwiftUIにはパラパラ漫画風に画像を切り替えるような仕組みがない

意気揚々とネットや書籍で実現手段を検索したのですが、SwiftUIには画像をパラパラ表示する仕組みがないとのこと。Σ( ̄ロ ̄lll)ガーン
(※2020年8月時点の情報です)

さらに調べてみると、SwiftUIでUIKitのコンポーネントを使用する仕組みが存在することがわかりました。UIKitであればアニメーションが可能なので、両者を組み合わせれば何とかなるかも!という希望の光が見えました。

UIViewRepresentableを用いてラップする

どうやらUIViewRepresentableというプロトコルを使用すれば実現可能とのこと。
原理は簡単で、UIViewをラップしてSwiftUIで使えるViewに変換すれば良いそうです。
つまり、UIViewRepresentableはUIViewをSwiftUIで使用するためのラッパーということになります。

イメージ

UIViewRepresentableの使用方法

Xcode上でUIViewRepresentableの定義部にジャンプして内容をチェックしました。
コメント文を省略してますが下記が定義部です。

public protocol UIViewRepresentable : View where Self.Body == Never {

    /// The type of view to present.
    associatedtype UIViewType : UIView

    /// Creates the view object and configures its initial state.
    func makeUIView(context: Self.Context) -> Self.UIViewType

    /// Updates the state of the specified view with new information from
    /// SwiftUI.
    func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)

    /// Cleans up the presented UIKit view (and coordinator) in anticipation of
    /// their removal.
    static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

    /// A type to coordinate with the view.
    associatedtype Coordinator = Void

    /// Creates the custom instance that you use to communicate changes from
    /// your view to other parts of your SwiftUI interface.
    func makeCoordinator() -> Self.Coordinator

    typealias Context = UIViewRepresentableContext<Self>
}

この中で必ず実装が必要となるのが「makeUIView」と「updateUIView」とのこと。
makeUIView ・・・ラップしたいUIViewインスタンスをこの関数内で生成し戻り値で返す必要がある
updateUIView・・・アプリ内で状態の更新があれば呼び出される

「makeUIView」関数内でUIViewインスタンスを生成し、アニメーション設定を織り込んだUIImageViewを乗っけて戻り値として返すように実装しました。
「updateUIView」関数については特に実装したい処理はないため、空関数としました。

ここで壁にぶち当たります。

UIViewRepresentable使用時にUIViewのサイズをどう設定するか

「makeUIView」関数内でUIViewインスタンスを生成する際にフレームサイズの設定を行っておらず、イメージ画像が表示されませんでした。
配置するビューのフレームサイズは親Viewのみぞ知る情報。どうやって設定すりゃいいのでしょう?

イメージ

解決策

自作ビュー構造体(UIViewRepresentable)にwidth,heightプロパティを定義、親ビューからプロパティをセットするだけでOKでした。
このプロパティの定義がよくわからなかったのですが、変数宣言に対し”@State”を付与する必要がありました。”@State”って何でしょうか…
このあたりは次回の記事で紹介したいと思います。

@State var width : CGFloat
@State var height : CGFloat

実装

上記を踏まえ、UIViewRepresentableプロトコルに準拠したビュー構造体を実装しました。
あまり綺麗に書けていませんが、下記が実際のソースコードになります。
もう少し汎用的に作った方が良かったですね。次回から気を付けます。

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

import SwiftUI

struct ControlView: View {
    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()
                        startCurtainAnimation(width: geometry.size.width * 0.9, height:  geometry.size.height * 0.45, curtainImages: openImages)
                    }
                    .frame(width: geometry.size.width * 0.9, height: geometry.size.height * 0.45)
                    
                    Spacer()
                        .frame(height: geometry.size.height * 0.1)
                    
                    HStack(){
                        Spacer()
                        // カーテン開けるボタン
                        Image("openBtn")
                            .resizable()
                            .scaledToFit()
                            .frame(width: geometry.size.width * 0.4)

                        Spacer()
                        // カーテン閉めるボタン
                        Image("closeBtn")
                            .resizable()
                            .scaledToFit()
                            .frame(width: geometry.size.width * 0.4)
                        Spacer()
                    }
                    Spacer()
                        .frame(height: geometry.size.height * 0.05)
                    // 作動を止めるボタン
                    Image("stopBtn")
                        .resizable()
                        .scaledToFit()
                        .frame(width: geometry.size.width * 0.4)
                    Spacer()
                }
            }
        }
    }
}

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

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) {
    }
}

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

import UIKit
import SwiftUI

struct BleStateView: View {
    var body: some View {
        
        ZStack{
            LinearGradient(gradient: Gradient(colors: [Color("bleview_top"), Color("bleview_bottom")]), startPoint: .top, endPoint: .bottom)
            GeometryReader{ geometry in
                HStack{
                    Spacer()
                        .frame(width: geometry.size.width * 0.1)
                    VStack(){
                        Spacer()
                            .frame(height: geometry.size.height * 0.1)
                        startWaveAnimation(height: geometry.size.height * 0.7)
                            .scaledToFit()
                            .frame(height: geometry.size.height * 0.7)
                    }
                    VStack{
                        Spacer()
                            .frame(height: geometry.size.height * 0.5)
                        Text("デバイス検索中")
                            .bold()
                            .frame(width: geometry.size.width * 0.35, alignment: .leading)
                    }
                    VStack{
                        Spacer()
                            .frame(height: geometry.size.height * 0.2)
                        Image("searchBtn")
                            .renderingMode(.original)
                            .resizable()
                            .scaledToFit()
                            .frame(width: geometry.size.width * 0.25)
                    }
                }
            }
        }
    }
}

struct BleStateView_Previews: PreviewProvider {
    static var previews: some View {
        BleStateView()
    }
}

struct startWaveAnimation: UIViewRepresentable {
    
    @State var height: CGFloat
    
    func makeUIView(context: Self.Context) -> UIView {

        let waveImages : [UIImage]! = [UIImage(named: "wave02")!, UIImage(named: "wave03")!, UIImage(named: "wave04")!, UIImage(named: "wave05")!]
        let waveAnimation = UIImage.animatedImage(with: waveImages, duration: 2.3)
        let ratio = height / waveImages[0].size.height

        let animationView = UIView(frame: CGRect(x: 0, y: 0, width: waveImages[0].size.width * ratio, height: height))
        let waveImage = UIImageView(frame: CGRect(x:0,y:0, width: waveImages[0].size.width * ratio, height: height))
        
        waveImage.clipsToBounds = true
        waveImage.autoresizesSubviews = true
        waveImage.contentMode = UIView.ContentMode.scaleAspectFit
        waveImage.image = waveAnimation

        animationView.addSubview(waveImage)
        
        return animationView

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

					

動作確認

無事にアニメーション動作させることができました。

おわりに

以上、SwiftUIのビュー上でパラパラ漫画風アニメーションを実現するまでの奮闘結果でした。
今回も手探りでしたが、無事に動作できて良かったです。
次回はSwiftUIビュー間でのデータの受け渡し方法についてご紹介させていただきます。

やぎ星人

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

コメント