Flutter PlatformView (iOS) の作成

FlutterでiOSのPlatformView (UiKitView) を作るまでに手こずったのでメモしておく。

### 前提
- 対象とするiOSのバージョンは11.0以上
- Swift
- Xcode 10.3 (Xcode 11入れていなかった。。)
- できるだけPlatformViewにだけフォーカスしたコードにする
- View作成時にパラメータを指定して、それを表示するだけ
- Androidは追って

iOS, Swiftは初心者なので、生暖かい目が嬉しいです。

## プロジェクトの作成と設定

### 作成
https://flutter.dev/docs/development/packages-and-plugins/developing-packages を参考にプロジェクトを作る。

```
$ flutter create --org org.yyyyyyyy --template=plugin -i swift -a kotlin my_first_view
$ cd my_first_view
$ flutter pub get
$ cd example
$ flutter build ios --no-codesign
```

### 対象とするiOSのバージョンの変更
そのままでは対象とするiOSのバージョンが8.0以上となっているので11.0にする為に以下の変更を行う。

- `ios/my_first_view.podspec`
 - s.ios.deployment_target を 11.0 に変更
 -  ※exampleで試すだけならいらないかもしれない
- `example/ios/Runner.xcodeproj/project.pbxproj`
 - IPHONEOS_DEPLOYMENT_TARGET を 11.0 に変更

### PlatformViewを使うための設定
`example/ios/Runner/Info.plist` に以下を加える。

```
<key>io.flutter.embedded_views_preview</key>
<true/>
```

これは https://api.flutter.dev/flutter/widgets/UiKitView-class.html に書いてました。
> Embedding UIViews is still in release preview, to enable the preview for an iOS app add a boolean field with the key 'io.flutter.embedded_views_preview' and the value set to 'YES' to the application's Info.plist file.

### ビルドしてみる
以上が済んだら、一度ビルドし直して、exampleをXcodeで開いて

```
$ flutter clean
$ cd example
$ flutter build ios --no-codesign
$ open ios/Runner.xcworkspace
```
ビルドをしてみる。

#### 2019/02/08追記 Flutter 1.12.13の場合

Flutter 1.12.13のhotfixのいくつから、以下のようなメッセージが `flutter build` で出るようになった。

> Action Required: You must set a build name and number in the pubspec.yaml file version field before submitting to the App Store.


次の「exampleアプリのバージョンの設定」で書いている内容と同じこと言っている気がする。
なので、`example/pubspec.yaml` に `version: 0.0.1+1` を書き加えるということをして再度ビルドしたらメッセージが出なくなった。

_デフォルトで入れといてくれよ。。。_

### exampleアプリのバージョンの設定
記事の主旨とは異なるが、これをしないとビルドのたびにアプリを削除しなければならないので、対応しておいたほうがいい。

記事を書いている時点 (2019/11/21) において作成したpluginのexampleアプリは、

- 1度目のインストールは成功するものの、アプリがインストールされた状態で再インストールしようとすると失敗する
- （一度アプリを削除してから再インストールすればインストールは成功する）

という問題がある。

exampleにバージョンが設定されていないことが原因で、
https://github.com/flutter/flutter/issues/34477 の記事を参考に `example/pubspec.yaml` に `version: 0.0.1+1` を書き加える。

</details>

## 実装

### Plugin (Dart)
`lib/my_first_view.dart`

```dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';

class MyFirstView extends StatelessWidget {
  const MyFirstView({
    Key key,
    this.label,
  }) : super(key: key);

  final String label;

  @override
  Widget build(BuildContext context) {
    return UiKitView(
      viewType: 'my_first_view',
      creationParams: <String, dynamic>{
        'label': label,
      },
      creationParamsCodec: StandardMessageCodec(),
    );
  }
}
```

### Plugin (Swift)
`ios/Classes/SwiftMyFirstViewPlugin.swift`

```swift
import Flutter
import UIKit

public class SwiftMyFirstViewPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    registrar.register(MyFirstFlutterPlatformViewFactory(),
                       withId: "my_first_view")
  }
}

class MyFirstFlutterPlatformViewFactory: NSObject, FlutterPlatformViewFactory {
  func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
    return MyFirstView(frame: frame, arguments:args)
  }

  func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
    return FlutterStandardMessageCodec.sharedInstance()
  }
}

class MyFirstView: UIView, FlutterPlatformView {
  let label = UILabel()

  func view() -> UIView {
    return self
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("not implemented")
  }

  init(frame: CGRect, arguments: Any?) {
    super.init(frame: frame)
    backgroundColor = .yellow
    addSubview(label)

    if let arguments = arguments as? [String:Any?] {
      label.text = arguments["label"] as? String ?? "no label"
      label.sizeToFit()
    }
  }
}
```

### example (Dart)
`example/lib/main.dart`

```dart
import 'package:flutter/material.dart';
import 'package:my_first_view/my_first_view.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: SizedBox.fromSize(
            size: Size(300, 300),
            child: MyFirstView(
              label: 'helooooooooooooo',
            ),
          ),
        ),
      ),
    );
  }
}
```

## 結果
できた。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375661/a914ac97-24d2-3e63-ae13-aa42e8e76be3.png" width=300>


## はまった点、気をつけた点

### Pluginでのパラメータの受け渡し

Dartから以下の部分でパラメータを渡している。

```dart
    return UiKitView(
      viewType: 'my_first_view',
      creationParams: <String, dynamic>{
        'label': label,
      },
      creationParamsCodec: StandardMessageCodec(),
    );
```

https://api.flutter.dev/flutter/widgets/UiKitView-class.html のうち `creationParamsCodec` のDocをみるとわかるのだけど、 `creationParams` を指定する際には `creationParamsCodec` の指定が必須、とのこと。

さらに `creationParamsCodec` のDocにPlatform側でも `-[FlutterPlatformViewFactory createArgsCodec:]` で同じcodecを指定する必要がある旨が書いてあるので、Swiftでは

```swift

class MyFirstFlutterPlatformViewFactory: NSObject, FlutterPlatformViewFactory {
  func create(...) -> FlutterPlatformView {
    ...
  }

  func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
    return FlutterStandardMessageCodec.sharedInstance()
  }
}
```

のように `createArgsCodec` で指定している。

ちなみに同じcodecを指定する必要があるとあったので `FlutterStandardMessageCodec()` を指定してて「うまくいかねー」とか思って悩んでてググったら sharedInstance で取得したインスタンスを使うようであることがわかった。
同じだけだとダメなのね。Docだけでわからなかった。。

### SubViewのサイズの変更

今回作成した `MyFirstView` (FlutterPlatformView のサブクラス)に含めたSubViewのサイズや位置をどのタイミングで指定したらいいかわからない。
AutoLayoutで頑張るしかないのかな。

### 開放されない (deinitが呼ばれない)

exampleアプリでページ遷移できるようにし、 `MyFirstView` がリリースされているか確認したけど、呼ばれない。Memory Graphでも確認したところ、 やっぱり残っているようだった。

<img width="360" alt="スクリーンショット 2019-11-21 21.12.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375661/5d2e9355-0464-cf25-b730-d6a5641df72a.png">

`MyFirstView` を `UIView` のサブクラスだったけど `NSObject` にしてみたところ、deinitが呼び出され、Memory Graphをみると開放されているようだった。

```
class MyFirstView: NSObject, FlutterPlatformView {
  let mainView = UIView()

  func view() -> UIView {
    return mainView
  }

  init(frame: CGRect, arguments: Any?) {
    super.init()
    mainView.frame = frame
    // 省略
  }
  deinit {
    print("enter deinit")
  }
}
```

<img width="360" alt="スクリーンショット 2019-11-21 21.18.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375661/cdc19499-94aa-0ee7-2453-8fae50845d95.png">

原因は追っていない。がまあいいや。

# （2019／2／8追記） Flutter 1.12.13で作ってみた