スマホアプリは画面をタップして操作するのが基本ですが、次々と画面を切り替えたり、手さぐりで咄嗟に何かしたいときは、物理ボタンを使いたい。スマホに付いてる物理ボタンと言えばボリュームキーだが、これをFlutterからすんなり検知できなくて悩んでた。
HardwareKeyboardクラスで困ること
Flutterでキーボード入力を検知するには、HardwareKeyboardクラスを使うのが定番みたいだが、Widgetにフォーカスが当たってないと検知できないことがあって、入力欄を一回タッチしないと反応しない。これが使いにくい。
Android 8あたりでは取れてたようだが、いつからかこうなっていてAndroid15でも同じ動きをする。
MethodChannelで検知する
MethodChannelはFlutterとOSのKotlinで同じ名前のChannelオブジェクトを作成し、それを使って互いのメソッドを呼び出す仕組みだ。通常FlutterからOS機能を呼び出すのに使うが、逆にOS側からFlutterを呼び出すこともできる。これを利用して、OS側のKotlinプログラムがキーを検知するたびにFlutterのメソッドを呼び出すように設定してやる。
以下はFlutter新規作成したデフォルトのアプリにMethodChannelを設定する方法の説明。
Kotlinの設定
package定義はそのままにして、MainActivity.ktを次のように書き換える。
1.FlutterEngine, MethodChannel, FlutterActivityなどのインポートを追加する。
2.MethodChannelオブジェクト変数を定義する。
3.configureFlutterEngine()をオーバーライドして、MethodChannelオブジェクト変数の作成を行う。
4.MainActivityにdispatchKeyEventメソッドを追加して、キーイベントが発生したら、mChannel.invokeMethodでFlutterのメソッドを呼び出すようにする。
結果は次のようになる。
package jp.picpie.keytest // パッケージはそれぞれに合わせる
import android.view.KeyEvent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private lateinit var mChannel: MethodChannel
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
mChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jp.picpie.keytest/") // Channel名はFlutterと合わせる
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
if (::mChannel.isInitialized) { // チャネルが初期化されているかを確認
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
mChannel.invokeMethod("onVUP", null)
return true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
mChannel.invokeMethod("onVDOWN", null)
return true
}
}
} else {
println("MethodChannel is not initialized.")
}
}
return super.dispatchKeyEvent(event)
}
}
Flutter側の設定
1.import 'package:flutter/services.dart'; を追加。
2.Stateオブジェクトに static const mChannel = MethodChannel("jp.picpie.keytest/");を追加。MethodChannelのパラメータはMainActivity.ktのチャネル名と同じにする。
3.initStateにmChannel.setMethodCallHandler(platformCallHandler);を追加。
4.disposeをオーバーライドしてmChannel.setMethodCallHandler(null);を追加。
5.以下のメソッドを追加する。
void decrementCounter() {
setState(() { counter--; });
}
void incrementCounter() {
setState(() { counter++; });
}
Future<void> platformCallHandler(MethodCall call) async {
switch (call.method) {
case 'onVUP':
decrementCounter();
break;
case 'onVDOWN':
incrementCounter();
break;
default:
debugPrint('Unknowm method ${call.method} ${call.arguments}');
throw MissingPluginException();
}
}
platformCallHandlerがKotlinからの呼び出しに応答するメソッドである。MethodCallオブジェクトのパラメータcallに、Kotlin側でinvokeMethodしたときのパラメータが格納されている。call.methodの内容をみれば、ボリュームアップかボリュームダウンかを判定できる。case 'onVUP': case 'onVDOWN':のブロックで、それぞれのキーが押されたときに処理したい内容を書けばよい。上の例ではボリュームアップキーでdecrementCounter()が、ボリュームダウンキーでincrementCounter()が呼び出されている。
最終的なmain.dartの全体は以下のとおり。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
static const mChannel = MethodChannel("jp.picpie.keytest/");
int counter = 0;
@override
void initState() {
super.initState();
mChannel.setMethodCallHandler(platformCallHandler);
}
@override
void dispose() {
mChannel.setMethodCallHandler(null);
super.dispose();
}
void decrementCounter() {
setState(() { counter--; });
}
void incrementCounter() {
setState(() { counter++; });
}
Future<void> platformCallHandler(MethodCall call) async {
switch (call.method) {
case 'onVUP':
decrementCounter();
break;
case 'onVDOWN':
incrementCounter();
break;
default:
debugPrint('Unknowm method ${call.method} ${call.arguments}');
throw MissingPluginException();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
);
}
}
これで、Widgetにフォーカスがあるかどうかに関係なく、ボリュームキーを押すとplatformCallHandlerに設定したメソッドが呼び出されるようになる。
バックグラウンドで動いてしまう
注意しなければならないことは、画面がどうなっていようがボリュームキーを押せば登録されたメソッドが呼び出されてしまうということだ。先のカウントアップダウンの例で言うと、何かの操作でNavigator.push()経由で別の画面が表示されていたとしても、ボリュームキーを押したときに呼び出されるのは、元のplatformCallHandlerである。
別画面に移行してボリュームキーを操作すると、戻ったときにカウンターの値が変っている。これを抑止するには、次のように画面の変わり目でハンドラーを切り替えればいいかと思ったが、やってみたところ画面切り替えから最初のキー入力は切替前のメソッドで処理されてしまった。画面を切り替えてからボリュームキーを何回か押したら、カウンタは一個だけ進んだのである。
mChannel.setMethodCallHandler(null);
await Navigator.push( context,
MaterialPageRoute( builder: (context) => SecondPage()));
mChannel.setMethodCallHandler(platformCallHandler);
裏画面で処理されるのは用途によっては便利かもしれないが、そういう挙動をすることは頭に入れておこう。
重い処理は避ける
バックグラウンドで動いてしまうので、画面処理が終らないうちにキー処理要求が次々飛んでくることがある。そうするとメインスレッドが応答なしになって、次のような警告がでてしまう。
こうなってしまうと、待機を押せばまだしばらく操作できるものの、すぐに同じダイアログがでてしまって、結局ほとんど使えない状態になるので気をつけたい。
MethodChannelによるキーイベントの処理は、重いものは避けた方がいいだろう。