
【Flutter】iOSネイティブでPasswordAutoFillを実装してみた
2023.11.08
はじめに
iOSネイティブ側で実装したものをFlutterから呼び出す機能として、MethodChannel, EventChannel, PlatformViewがありますが、今回これらを活用してPasswordAutoFillを実装してみました。
デモ
※パスワード欄の録画ができませんでしたが、1回目にパスワードを手入力し、2回目にパスワードが自動入力されています。
環境
macOS: Venture 13.4
プロセッサ: Intel
Xcode: 14.3.1
Android Studio: Dolphin (2021.3.1)
Flutter SDK: 3.13.7
MethodChannelについて
MethodChannelは非同期メソッド呼び出しを使用してプラットフォーム プラグインと通信するための名前付きチャネルです。
MethodChannelを使うことでiOSネイティブ側のメソッドを呼び出すことができます。
詳しい使い方はドキュメント(Writing custom platform-specific code)に記載されています。
EventChannelについて
EventChannelはイベントストリームを使用してプラットフォームプラグインと通信するための名前付きチャネルです。
EventChannelを使うことでiOSネイティブのイベントをStreamでリッスンすることができます。
※ 使い方
iOSネイティブ側では、FlutterStreamHandlerに準拠したクラスを作成し、作成したクラスをsetStreamHandlerで登録します。
そしてFlutter側ではreceiveBroadcastStreamを使ってイベントをリッスンします。
PlatformViewについて
PlatformViewはFlutterアプリにネイティブビューを埋め込めるようにするものです。
iOSではUiKitViewを使うことでiOSネイティブのUIViewをWidget階層に埋め込むことができます。
詳しい使い方はドキュメント(Hosting native iOS views in your Flutter app with Platform Views
)に記載されています。
MessageCodecについて
iOSネイティブとFlutterのやり取りでは引数の値や戻り値をエンコード/デコードする必要があり、その際にMessageCodecを使用します。
MethodChannel、EventChannel、PlatformViewではMessageCodecが使われています。
標準でStandardMessageCodecが用意されていて、基本的な値はStandardMessageCodecを使えばエンコード/デコードができるようになっています。
StandardMessageCodecでサポートされている値は次の表の非循環値です。
Dart | Swift |
null | nil |
bool | NSNumber(value: Bool) |
int | NSNumber(value: Int32) |
int, if 32 bits not enough | NSNumber(value: Int) |
double | NSNumber(value: Double) |
String | String |
Uint8List | FlutterStandardTypedData(bytes: Data) |
Int32List | TexFlutterStandardTypedData(int32: Data) |
Int64List | FlutterStandardTypedData(int64: Data) |
Float32List | FlutterStandardTypedData(float32: Data) |
Float64List | FlutterStandardTypedData(float64: Data) |
List | Array |
Map | Dictionary |
MessageCodecは拡張することができますが、MessageCodecのエンコード/デコードではByteDataを使用しているため、コールバックを引数として渡すことはできなさそうです。
なので、今回の実装ではEventChannelでイベントを受け取るようにしています。
PasswordAutoFillについて
PasswordAutoFillはログインやアカウント作成を簡単にするものです。
数回タップするだけで、新しいパスワードを作成して保存したり、既存のアカウントにログインできるようになります。
ログイン認証情報はデバイスに保存され、iCloud Keychainを使用して、デバイス間でこれらの認証情報を安全に同期できます。
また、SMSコードの自動入力も設定することができます。
実装
iOS側でPasswordAutoFillのView(ログインID入力欄とパスワード入力欄があるView)を作成し、それをMethodChannel, EventChannel, PlatformViewを活用してFlutterで表示します。
流れ
- iOSネイティブでViewを実装し、PlatformViewで表示
- EventChannelで入力欄の変更を通知
- MethodChannelでパスワード入力欄をクリア
- Flutter側で画面を作成
- PasswordAutoFillの設定
iOSネイティブでViewを実装し、PlatformViewで表示
iOSネイティブ側でloginIdのテキストフィールドとpasswordのテキストフィールドを置いたViewを作成し、PlatformViewを使ってFlutter側にViewを埋め込みます。
まずはViewを作成します。
自分はGUIでUIを作成する方が好きなので、xibでLoginTextFieldViewというViewを作成しました。
次にxibに紐づくLoginTextFieldViewというUIViewクラスを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import UIKit class LoginTextFieldView: UIView { init() { super.init(frame: .zero) // xibで作成したViewをUIViewに追加 if let view = Bundle(for: type(of: self)).loadNibNamed(String(describing: type(of: self)), owner: self, options: nil)?.first as? UIView { self.addSubview(view) // xibで作成したViewがUIViewいっぱいに広がるよう制約を設定 view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: self.topAnchor), view.leftAnchor.constraint(equalTo: self.leftAnchor), view.rightAnchor.constraint(equalTo: self.rightAnchor), view.bottomAnchor.constraint(equalTo: self.bottomAnchor) ]) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } |
このコードではxibで作成したViewを取り出してUIViewに追加し、UIViewいっぱいに広がるように制約を設定しています。
Viewの作成ができたので、次にPlatFormViewを使ってFlutter側にViewを埋め込みます。
まずiOSネイティブ側の実装をします。
iOSネイティブ側ではFlutterPlatformViewとFlutterPlatformViewFactoryに準拠したクラスを作成し、それをAppDelegateのdidFinishLaunchingOptionsで登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | class LoginTextFieldPlatformView: NSObject, FlutterPlatformView { private let loginTextFieldView: LoginTextFieldView init(_ loginTextFieldView: LoginTextFieldView) { self.loginTextFieldView = loginTextFieldView super.init() } func view() -> UIView { return loginTextFieldView } } class LoginTextFieldPlatformViewFactory: NSObject, FlutterPlatformViewFactory { private let loginTextFieldPlatformView: LoginTextFieldPlatformView init(_ loginTextFieldPlatformView: LoginTextFieldPlatformView) { self.loginTextFieldPlatformView = loginTextFieldPlatformView super.init() } func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { return loginTextFieldPlatformView } } |
このコードではFlutterPlatformViewとFlutterPlatformViewFactoryに準拠したクラスを作成しています。
LoginTextFieldPlatformViewではviewメソッドで先ほど作成したLoginTextFieldViewを返すようにし、LoginTextFieldPlatformViewFactoryではcreateメソッドでLoginTextFieldPlatformViewを返すようにします。
引数を設定した場合、createメソッドのargsにFlutter側で設定した引数が渡ってくるのですが、今回は特に不要なため使用していません。
また、引数を使用する場合はFlutterPlatformViewFactoryのcreateArgsCodecでMessageCodecを返す必要があります。
作成したFlutterPlatformViewFactoryをAppDelegateのdidFinishLaunchingOptionsで登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import UIKit import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let loginTextFieldView = LoginTextFieldView() // ←追加 GeneratedPluginRegistrant.register(with: self) let loginTextFieldPlatformView = LoginTextFieldPlatformView(loginTextFieldView) // ←追加 let loginTextFieldPlatformViewFactory = LoginTextFieldPlatformViewFactory(loginTextFieldPlatformView) // ←追加 self.registrar(forPlugin: "login_view_plugin")!.register(loginTextFieldPlatformViewFactory, withId: "login_text_field") // ←追加 return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } |
このコードではregisterメソッドでLoginTextFieldPlatformViewFactoryを登録しています。
"login_view_plugin"と"login_text_field"はアプリ内で一意となるキーで、任意の値を設定しています。
Flutter側で"login_text_field"を使用して、登録されたファクトリーにUIViewの作成をリクエストすることができるようになります。
以上でiOSネイティブ側の実装は完了です。
次にFlutter側でLoginTextFieldViewを呼び出し、Widget階層に埋め込みます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import 'package:flutter/material.dart'; class LoginTextField extends StatefulWidget { const LoginTextField({ super.key, }); @override State<LoginTextField> createState() => LoginTextFieldState(); } class LoginTextFieldState extends State<LoginTextField> { @override Widget build(BuildContext context) { const viewType = 'login_text_field'; return const UiKitView(viewType: viewType); } } |
このコードではiOSネイティブ側で指定したキーを使ってUiKitViewを作成しています。
こうすることで、iOSネイティブのViewをWidget階層に埋め込むことができます。
※UiKitViewについて
- Viewの埋め込みは高コストの操作であるため、Flutterと同等の操作が可能な場合は避けることが推奨されている
- UiKitViewは利用可能なスペース全てを埋めるため、レイアウトの調整が必要
- Viewの構築は非同期で行われ、その間レイアウト制約を維持しながら何も描画されない
- FlutterのUIがプラットフォームスレッドから構成されることにより、OSやプラグイン・メッセージを処理するようなタスクと競合しパフォーマンスが悪くなる
EventChannelで入力欄の変更を通知
EventChannelを使ってログインIDの入力とパスワードの入力をFlutter側で受け取れるようにします。
まずは入力欄の変更を通知できるようDelegateを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | import Flutter import UIKit // ↓追加 protocol LoginTextFieldViewDelegate: AnyObject { func editingChangedLoginId(text: String) -> Void func editingChangedPassword(text: String) -> Void } class LoginTextFieldView: UIView { weak var delegate: LoginTextFieldViewDelegate? // ←追加 ...省略 // ↓追加 @IBAction private func editingChangedLoginIdTextField(_ sender: UITextField) { delegate?.editingChangedLoginId(text: sender.text ?? "") } // ↓追加 @IBAction private func editingChangedPasswordTextField(_ sender: UITextField) { delegate?.editingChangedPassword(text: sender.text ?? "") } } |
このコードではログインID入力欄の値が変化した時に呼ぶeditingChangedLoginIdメソッドとパスワード入力欄の値が変化した時に呼ぶeditingChangedPasswordメソッドを定義しています。
Delegateの定義ができたので、次にEventChannelを使ってFlutter側で変更を受け取れるよう実装していきます。
まずiOSネイティブ側の実装をします。
iOSネイティブ側ではFlutterStreamHandlerに準拠したクラスを作成し、それをAppDelegateのdidFinishLaunchingOptionsで登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import Flutter class LoginTextFieldStreamHandler: NSObject, FlutterStreamHandler { private var eventSink: FlutterEventSink? func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { eventSink = events return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { return nil } } extension LoginTextFieldStreamHandler: LoginTextFieldViewDelegate { func editingChangedLoginId(text: String) { eventSink?(["loginId": text]) } func editingChangedPassword(text: String) { eventSink?(["password": text]) } } |
このコードではFlutterStreamHandlerに準拠したLoginTextFieldStreamHandlerを作成し、extensionでDelegateに準拠させています。
Delegateのメソッドが呼ばれた時にonListenメソッドのコールバックを呼び出すことで、Flutter側でイベントを受け取れるようにしています。
onCancelメソッドはStreamから最後のリスナーが登録解除された時に呼ばれるメソッドで、今回は特に使用しないのでnilを返しています。
作成したLoginTextFieldStreamHandlerをAppDelegateのdidFinishLaunchingOptionsで登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | import UIKit import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let loginTextFieldView = LoginTextFieldView() let controller = window.rootViewController as! FlutterViewController // ←追加 let loginTextFieldStreamHandler = LoginTextFieldStreamHandler() // ←追加 // イベントチャネルに任意の名前を付け、controllerのbinaryMessengerを使って初期化 let loginIdEventChannel = FlutterEventChannel(name: "login_text_field_view_handler", binaryMessenger: controller.binaryMessenger) // ←追加 loginIdEventChannel.setStreamHandler(loginTextFieldStreamHandler) // ←追加 loginTextFieldView.delegate = loginTextFieldStreamHandler // ←追加 ...省略 } } |
このコードではFlutterEventChannelを作成し、LoginTextFieldStreamHandlerを登録しています。
また、LoginTextFieldStreamHandlerはDelegateに準拠しているため、LoginTextFieldViewのdelegateに代入しています。
"login_text_field_view_handler"は識別子になっていて、Flutter側のEventChannelと同じ名前にする必要があります。
以上でiOSネイティブ側の実装は完了です。
次にFlutter側でイベントをリッスンするようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class LoginTextField extends StatefulWidget { final Function(String) onChangedLoginId; // ←追加 final Function(String) onChangedPassword; // ←追加 const LoginTextField({ super.key, required this.onChangedLoginId, // ←追加 required this.onChangedPassword, // ←追加 }); @override State<LoginTextField> createState() => LoginTextFieldState(); } class LoginTextFieldState extends State<LoginTextField> { static const _loginTextFieldEventChannel = EventChannel('login_text_field_view_handler'); // ←追加 // ↓追加 @override void initState() { _loginTextFieldEventChannel.receiveBroadcastStream().listen((event) { const loginIdKey = 'loginId'; const passwordKey = 'password'; if (event.containsKey(loginIdKey)) { widget.onChangedLoginId(event[loginIdKey]); } else if (event.containsKey(passwordKey)) { widget.onChangedPassword(event[passwordKey]); } }); super.initState(); } ...省略 } |
このコードではreceiveBroadcastStreamメソッドを使ってイベントをリッスンするようにしています。
そして、受け取ったイベントをコールバックに渡しています。
'login_text_field_view_handler'は識別子で、iOSネイティブ側で実装したものと一致する必要があります。
MethodChannelでパスワード入力欄をクリア
今回、ログイン成功の遷移から戻った時にパスワードの入力をクリアにしたかったので、MethodChannelを使ってパスワード入力をクリアするメソッドを実装しました。
まずiOSネイティブ側の実装をします。
iOSネイティブ側ではパスワード欄をクリアするメソッドを実装し、それをMethodChannelで呼び出せるようにします。
1 2 3 4 5 6 7 8 9 10 | class LoginTextFieldView: UIView { @IBOutlet private weak var passwordTextField: UITextField! // ←追加 ...省略 // ↓追加 func clearPassword() { passwordTextField.text = "" } } |
LoginTextFieldViewにパスワード入力をクリアにするclearPasswordメソッドを追加しました。
このメソッドをMethodChannelで呼び出せるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | import UIKit import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let loginTextFieldView = LoginTextFieldView() let controller = window.rootViewController as! FlutterViewController // ↓追加 let methodChannel = FlutterMethodChannel(name: "flutter_password_auto_fill/login_text_field_method", binaryMessenger: controller.binaryMessenger) methodChannel.setMethodCallHandler { methodCall, result in guard methodCall.method == "clear_password" else { return result(FlutterMethodNotImplemented) } loginTextFieldView.clearPassword() return result(nil) } ...省略 } } |
このコードでは"flutter_password_auto_fill/login_text_field_method"という名前でMethodChannelを作成し、Flutter側の呼び出しが"clear_password"だったらclearPasswordメソッドを呼び出すという処理をしています。
"flutter_password_auto_fill/login_text_field_method"は識別子で必ず「/」が必要になります。
「/」を付け忘れていたらメソッドを呼び出すことができませんでした。
以上でiOSネイティブ側の実装は完了です。
次にFlutter側でメソッドを呼び出す処理を実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 | class LoginTextFieldState extends State<LoginTextField> { static const _loginTextFieldEventChannel = EventChannel('login_text_field_view_handler'); static const _loginTextFieldMethodChannel = MethodChannel('flutter_password_auto_fill/login_text_field_method'); // ←追加 // ↓追加 Future<void> clearPassword() async { await _loginTextFieldMethodChannel.invokeMethod('clear_password'); } ...省略 } |
このコードではiOSネイティブ側で設定した名前と一致するMethodChannelを作成し、invokeMethodでメソッドを呼び出しています。
親Widgetからここで定義したclearPasswordを呼び出す想定なので、LoginTextFieldStateから「_」をはずしています。
Flutter側で画面を作成
Viewの作成と繋ぎ込みができたので、次にログイン画面を作成していきます。
PasswordAutoFillは画面遷移時に保存処理が走るので、ログインボタンで画面遷移をするようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | import 'package:flutter/material.dart'; import 'package:flutter_password_auto_fill/login/login_text_field.dart'; import 'package:flutter_password_auto_fill/login_success/login_success_screen.dart'; class LoginScreen extends StatelessWidget { const LoginScreen({super.key}); @override Widget build(BuildContext context) { final loginTextFieldKey = GlobalObjectKey<LoginTextFieldState>(context); return Scaffold( appBar: AppBar( title: const Text('ログイン'), ), body: SafeArea( child: Padding( padding: const EdgeInsets.all(32.0), child: Column( children: [ const Icon( Icons.lock_outline, size: 50, color: Colors.black45, ), const SizedBox(height: 32), SizedBox( height: 88, child: LoginTextField( // ←作成したViewを配置 key: loginTextFieldKey, onChangedLoginId: (text) { print('loginId: $text'); }, onChangedPassword: (text) { print('password: $text'); }, ), ), const SizedBox(height: 24), ElevatedButton( onPressed: () async { await Navigator.push( context, MaterialPageRoute( builder: (context) { return const LoginSuccessScreen(); }, ), ); loginTextFieldKey.currentState?.clearPassword(); // ←画面を戻った時にパスワードをクリア }, child: const Text( 'ログイン', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ), ], ), ), ), ); } } |
このコードではログイン画面を作成し、LoginTextFieldを配置しています。
UiKitViewは利用可能なスペースを全て埋めるため、LoginTextFieldはSizedBoxで高さ指定をしています。
そして、画面遷移から戻った時にclearPasswordメソッドを呼び出しています。
遷移しているLoginSuccessScreenは中心にテキストがあるだけの画面です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import 'package:flutter/material.dart'; class LoginSuccessScreen extends StatelessWidget { const LoginSuccessScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('ログイン成功'), ), body: const SafeArea( child: Center( child: Text('ログイン成功'), ), ), ); } } |
PasswordAutoFillの設定
PasswordAutoFillの設定をしていきます。
PasswordAutoFillは以下の2つを行うことで使用することができるようになります。
- テキストフィールドにUITextContentTypeを設定
- AssociatedDomainを設定
テキストフィールドにUITextContentTypeを設定
まずはUITextContentTypeの設定をしていきます。
PasswordAutoFillに使用するUITextContentTypeは以下の4つがあります。
今回はusernameとpasswordを使用します。
UITextContentType | 用途 |
username | ログインIDの自動入力と保存(passwordまたはnewPasswordとセットで使用) |
password | パスワードの自動入力と保存(usernameとセットで使用) |
newPassword | 新しいパスワードの自動生成と保存(usernameとセットで使用) |
oneTimeCode | SMSコードの自動入力 |
xibのインスペクターからusernameとpasswordを設定しました。
AssociatedDomainを設定
次にAssociatedDomainを設定していきます。
まずapple-app-site-association(拡張子なし)ファイルを作成します。
証明書に記載されたApp ID Prefix(Team ID)とBundle IDを確認し、以下のようなapple-app-site-associationファイルを作成します。
1 2 3 4 5 6 7 | { "webcredentials": { "apps": [ "{App ID Prefix(Team ID)}.{Bundle ID}" ] } } |
apple-app-site-associationはWebサーバーの.well-knownディレクトリに配置する必要があります。
そして、ファイルURLは以下の形式と一致する必要があります。
https://<fully qualified domain>/.well-known/apple-app-site-association
今回はFirebaseのHostingを使ってapple-app-site-associationファイルを配置します。
新規でプロジェクトを作成し、ルートディレクトリで以下のコマンドを実行します。
$ firebase init hosting
するとFirebaseのファイルが作成されます。
作成されたら.well-knownディレクトリを作成し、apple-app-site-associationファイルをディレクトリに追加します。
ディレクトリ構成は以下のようになりました。
1 2 3 4 5 6 7 8 9 10 | プロジェクト ├── .firebase ├── public/ │ ├── .well-known │ │ └── apple-app-site-association │ ├── 404.html │ └── index.html ├── .firebaserc ├── .gitignore └── firebase.json |
次にapple-app-site-associationのContent-Typeをapplication/jsonにし、さらにFirebaseにapple-app-site-associationを自動作成させないにします。
firebase.jsonを以下のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | { "hosting": { "public": "public", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "headers": [ { "source": "/.well-known/apple-app-site-association", "headers": [ { "key": "Content-Type", "value": "application/json" } ] } ], "appAssociation": "NONE" } } |
最後にプロジェクトのルートディレクトリで以下のコマンドを実行しデプロイします。
$ firebase deploy --only hosting
apple-app-site-associationファイルの配置ができたので、次にXcodeでAssociatedDomainの設定をします。
XcodeでSigning&Capabilitiesを開き、AssociatedDomainsのCapabilityを追加します。
追加したらDomainsに webcredentials:{Firebaseのドメイン} を追加します。
Firebaseのドメインは {プロジェクトID}.firebaseapp.com の形式になります。
追加するとentitlementsファイルが作成されます。
以上でPasswordAutoFillの設定完了です。
おわりに
今回iOSネイティブでPasswordAutoFillを実装してみました。
今回はViewをWidget階層に埋め込みましたが、iOSネイティブ側で画面を作成してFlutterViewControllerに直接Pushするという方法もあるみたいです。
FlutterViewControllerに直接Pushする方法だとUIViewをWidgetに変換する処理がないと思うので、パフォーマンスが良さそうです。
なので、一つの画面で完結するのであれば、FlutterViewControllerに直接Pushする方法が良いなと思いました。
ここまで読んでいただきありがとうございます。
この記事が誰かのお役に立てば幸いです。
書いた人はこんな人

- 業務ではiOS開発に携わらせていただいています。
まだまだ分からないことだらけで、日々分からないことと戦いながら仕事をしている者です。
ブログ記事は暖かい目で見ていただけるとありがたいです。