見出し画像

Flutter勉強日記: iOSのネイティブビューをFlutterのWidget上に描画する方法

某月某日

前回、iOSのネイティブ側のメソッドをFlutter側から呼び出すことにも成功した。Androidのネイティブビューを作ったときの日記にも書いたが、Method Channelはasync関数、つまり非同期関数なので、何かをリアルタイムでレンダリングしたいときにネックになる。アニメーションフレームを60fps - 30fpsで描画したいときや、センサーの値を数ミリ秒ごとにグラフに反映したい場合、間にメソッドチャンネルで非同期の通信を入れることは現実的ではない。今回はiOSのネイティブ側からビューに直接描画する方法を、公式ドキュメントにかかれている手順で実装してみた。

大まかな手順としてはAndroidのネイティブビュー組み込みのときとほとんど同じで下のようになる

1.ネイティブ側にFlutterPlatformViewクラスのサブクラスを定義し、そのなかで自分の描画したいものを描く

2.Factoryクラスをつくり、Flutter側から1で作ったネイティブビューの取得ができるようにしてあげる

3.Flutter側に1でつくったネイティブビューを表示するためのラッパーのようなWidgetクラスを作る

4.3で作ったWidgetを描画する


(ここからデスマス調になります)

今回も前回の日記でメソッドチャンネルを追加したプロジェクトをそのまま使ってカスタムビューを作ります。ただし前回の機能が必須ではないので、新しくFlutterプロジェクトをつくって以降の実装に進んでも問題ありません。

前回と同じようにXCodeでiosフォルダを開きます。

まずRunnerフォルダの下(AppDelegate.swiftがある場所)にviewという名前のグループ(フォルダ)を追加します。

Runnerの下にNew Groupを追加する。Groupの名前はviewにする。

viewという名前のグループをRunnerの直下に配置したら、今度はその下にCustomNativeView.swiftというファイルを配置します。

viewグループを右クリックして、New Fileを選択


CustomNativeView.swiftというファイルを作成


このようになりました。


同様の手順で
CustomNativeViewFactory.swift
ProgressView.swift
という2つのSwiftのソースファイルを追加します。

役割は、CustomNativeViewがFlutter側から取得するためのView、 ProgressViewは実際にCustomNativeViewのなかで、プログレスバーのアニメーションを描画するView、そしてCustomNativeViewはFlutter側からCustomNativeViewを取得するために必要なFactoryクラスになります。

それでは1つ1つのクラスを見ていきます。

CustomNativeView

import Flutter
import UIKit

class CustomNativeView: NSObject, FlutterPlatformView {
    private var progressView: ProgressView

    init(
        frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?,
        binaryMessenger messenger: FlutterBinaryMessenger?
    ) {
        progressView = ProgressView()
        progressView.startProgress()
        super.init()
    }

    func view() -> UIView {
        return progressView
    }
}

このクラスはFlutterPlatformViewというViewクラスを継承したクラスになっています。Flutter側でネイティブのViewを描画するためにはこのFlutterPlatformViewを継承したクラスを作る必要があります。
FlutterPlatformViewの小クラスのなかでオーバーライドしないと行けないメソッドはview()になります。

view()は公式ドキュメントには、

Returns a reference to the UIView that is wrapped by this FlutterPlatformView.

https://api.flutter.dev/objcdoc/Protocols/FlutterPlatformView.html#/c:objc(pl)FlutterPlatformView(im)view

とあり、のview()メソッドはFlutterアプリのUIの階層に埋め込むiOSのネイティブビュー(UIViewのサブクラス)を返す関数になります。つまりこのview()でFlutter側に描画したいネイティブビューを返すようにします。

func view() -> UIView {
    return progressView
}

今回はこのように後述するProgressViewのインスタンスを返しています。これでProgressViewをFlutter側に描画しようというのが今回の目的です。

最後にinit関数ですが、

   init(
        frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?,
        binaryMessenger messenger: FlutterBinaryMessenger?
    ) {
        progressView = ProgressView()
        progressView.startProgress()
        super.init()
    }

この中で、ProgressViewのインスタンスを生成して、startProgress() という プログレスアニメーションをスタートする関数、を呼び出しています。

CustomNativeViewFactory

こちらはCustomNativeViewを取得するためのFactoryクラスです。このFactoryクラスを後述するFlutterPlatformViewRegistryに登録することで、Flutter側からCustomNativeViewが取得できるようになります。

import Flutter
import UIKit

class CustomNativeViewFactory: NSObject, FlutterPlatformViewFactory {
    private var messenger: FlutterBinaryMessenger

    init(messenger: FlutterBinaryMessenger) {
        self.messenger = messenger
        super.init()
    }

    func create(
        withFrame frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?
    ) -> FlutterPlatformView {
        return CustomNativeView(
            frame: frame,
            viewIdentifier: viewId,
            arguments: args,
            binaryMessenger: messenger)
    }
}

オーバーライドして実装する関数はcreate()関数のみで、このなかで、CustomNativeViewを初期化して返すようにします。

ProgressView

今回iOSネイティブ側でアニメーションを描画したものをFlutter側で表示したかっため、簡単なプログレスバーを実装しました。

import UIKit

class ProgressView: UIView {

    private var progress: CGFloat = 0.0
    private var displayLink: CADisplayLink?
    private var startTime: CFTimeInterval = 0.0
    private let duration: CFTimeInterval = 10.0

    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.clear
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        backgroundColor = UIColor.clear
    }

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        let color = UIColor(red: 0/255, green: 241/255, blue: 244/255, alpha: 1.0)
        color.set()
        let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: bounds.width * progress, height: bounds.height))
        path.fill()
    }

    func startProgress() {
        self.startTime = CACurrentMediaTime()
        self.displayLink = CADisplayLink(target: self, selector: #selector(updateProgress))
        self.displayLink?.add(to: .current, forMode: .default)
    }

    func stopProgress() {
        self.displayLink?.invalidate()
        self.displayLink = nil
    }

    @objc func updateProgress() {
        let elapsed = CACurrentMediaTime() - startTime
        progress = CGFloat(elapsed / duration)
        if progress >= 1.0 {
            stopProgress()
        }
        self.setNeedsDisplay()
    }
}

詳細な説明は省きますが、
10秒のタイマーを動作させ、10秒で100%になる簡単なプログレスバーです。
startProgress()のなかで、17ミリ秒ごとに時間経過を測って10秒のうち何%に到達しているかを計算し、progressという変数に入れます。

オーバーライドしたonDrawメソッドのなかでcanvasに左端からprogressの位置までライム色の矩形を描画します。

override func draw(_ rect: CGRect) {
    super.draw(rect)

    let color = UIColor(red: 0/255, green: 241/255, blue: 244/255, alpha: 1.0)
    color.set()
    let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: bounds.width * progress, height: bounds.height))
    path.fill()
}

stopProgress()関数でタイマーを止めます。

このProgressViewをCustomNativeViewのview()メソッドから返すようにして、FlutterのWidgetの上にプログレスバーが描画できたら成功です。

AppDelegate.swift

最後にAppDelegate.swiftを編集します。メソッドチャンネルを登録したときに、実装したapplication: didFinishLaunchingWithOptions()メソッドの中に、下のようにweak var registrar = から始まる3行のコードを追加します。

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        if let registrar = self.registrar(forPlugin: "ios_method_channel_experiment")  {
             let factory = CustomNativeViewFactory(messenger: registrar.messenger())
             registrar.register(factory, withId: "custom_native_view")
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func initializeNativeSetup() -> Bool {
        return false;
    }
}

(見やすさを優先して前回の日記で足したメソッドチャンネルのコードを上のコードスニペットからは削ってあります)

まずselfからregisterForPlugin関数を使ってregistrarという変数にいれます。このときforPlugin引数にわたす文字列はアプリのプロジェクト名などで良いでしょう(この文字列をFlutter側で後のステップで使うことはありません。)
このregistrarオブジェクトの関数messenger()を呼び、返ってくるBinaryMessengerオブジェクトを使ってCustomNativeViewFactoryのインスタンスを初期化します。
次に、registrarオブジェクトのregister()を呼び、CustomNativeViewFactoryのインスタンスを渡します。このときregister()メソッドには、"custom_native_view"という文字列を渡しています。これがネイティブビューをFlutter側から取得する際の識別に使われる名前になります
Flutter側ではこの文字列を使ってCustomNativeViewFactoryを取得し、CustomNativeViewを取得します。

ここのAppDelegate.swiftのコードは公式ドキュメントのコードとは少し違っています。
公式ドキュメントのAppDelegate.swiftのコードは私が執筆した2023年春の時点では少しだけ間違っていて動かないので、上のやり方を参考に実装してみてください。

これでSwift側の作業は終わりです。次にいよいよFlutter/Dart側の作業に入ります。

custom_native_view.dart

libフォルダのしたに、あらたにcustom_native_view.dartというDartのソースファイルを作成します。

このなかで実際にCustomNativeViewを描画するWidgetクラスを定義します。

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class CustomNativeViewWidget extends StatelessWidget {
  const CustomNativeViewWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // This is used in the platform side to register the view.
    const String viewType = 'custom_native_view';
    // Pass parameters to the platform side.
    const Map<String, dynamic> creationParams = <String, dynamic>{};

    return const UiKitView(
      viewType: viewType,
      layoutDirection: TextDirection.ltr,
      creationParams: creationParams,
      creationParamsCodec: StandardMessageCodec(),
    );
  }
}

このWidgetのbuildメソッドで、UIKitViewのインスタンスを返すようにします。この際viewTypeパラメータには、先程iOSネイティブ側で指定した文字列と同じ"custom_native_view"を指定します。

UIKitViewの仕様はこちらにありますので、興味のある方はコンストラクタの内容など以下を参照してください。

UIKitViewコンストラクタの4番目に渡しているパラメータStandardMessageCodec()の説明は以下にあります。
https://api.flutter.dev/flutter/services/StandardMessageCodec-class.html

ネイティブ側のカスタムビューにわたすパラメータがMap型でそのValueがIntやStringなどのスタンダードデータタイプの時はこのコデックを指定するようです。
ほかにも以下のBinaryCodecなど、いくつかのコデックの種類が調べてみるとありました。

↑を読むと、NSDataを渡す時はこのコデックを使うようです。

CustomNativeViewWidgetができたところで、いよいよこのウィジェットをmain.dartのFlutterのUI階層の中に入れてみます。

main.dart

main.dartを開き、先程作成したcustom_native_view.dartをimportします。

import 'custom_native_view.dart';

_MyHomePageStateクラスのScaffoldウィジェットを返している箇所に移動します。Scaffold > Center > Column > childrenの中に、

const SizedBox(height: 400, child: CustomNativeViewWidget()),

を追加しました。
追加した結果は↓のようになります。

 return Scaffold(
      appBar: AppBar(        
        title: Text(widget.title),
      ),
      body: Center(        
        child: Column(          
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const SizedBox(height: 400, child: CustomNativeViewWidget()),
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );

高さ400のサイズボックスの中にCustomNativeViewWidgetをchildとして埋め込みました。

アプリを実行すると、下のように画面中央にプログレスバーがレンダリングされました!

Gifアニメーションなのでカクカクしていますが、実際はもっとスムーズなププログレスバーとなっています↓


今回この方法でiOS側で作ったViewをFlutter側で表示することができることが確認できました。

ProgressViewの部分を自分の好きなViewに置き換えればどのようなViewでもFlutter側で表示させることができます。

ユースケースとしてはあまり多くないかもしれませんが、ネイティブ側で取得したBluetooth経由の値やセンサーの値などを高速にグラフなどに描画したいときなどでこの方法が役立つかもしれません。

続く

このブログに関する質問やiOS・Android・Webアプリの開発の相談はこちらから↓↓↓

@mizutory
mizutori@goldrushcomputing.com


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