1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterAdvent Calendar 2024

Day 20

[Flutter] MethodChannelでAndroidのボリュームキーを検知する

Last updated at Posted at 2024-12-19

スマホアプリは画面をタップして操作するのが基本ですが、次々と画面を切り替えたり、手さぐりで咄嗟に何かしたいときは、物理ボタンを使いたい。スマホに付いてる物理ボタンと言えばボリュームキーだが、これを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);

裏画面で処理されるのは用途によっては便利かもしれないが、そういう挙動をすることは頭に入れておこう。

重い処理は避ける

バックグラウンドで動いてしまうので、画面処理が終らないうちにキー処理要求が次々飛んでくることがある。そうするとメインスレッドが応答なしになって、次のような警告がでてしまう。
アプリ応答なし.png
こうなってしまうと、待機を押せばまだしばらく操作できるものの、すぐに同じダイアログがでてしまって、結局ほとんど使えない状態になるので気をつけたい。

MethodChannelによるキーイベントの処理は、重いものは避けた方がいいだろう。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?