Flutter 输入处理:集成 GPIO/I2C 等硬件输入事件到手势系统
在现代人机交互领域,触摸屏、鼠标和键盘无疑占据主导地位。然而,在嵌入式系统、工业控制、物联网设备或定制硬件产品中,物理按键、旋钮、传感器等硬件输入仍然不可或缺。Flutter 作为一个出色的 UI 框架,在构建跨平台应用程序方面表现卓越,但其核心设计主要围绕着软输入(触摸、鼠标、键盘)。如何将低层级的硬件输入事件(如 GPIO 状态变化、I2C 传感器数据)无缝集成到 Flutter 丰富的手势系统,从而实现更直观、更可靠的用户体验,是许多开发者面临的挑战。
本讲座将深入探讨这一主题,从硬件接口的底层细节,到 Flutter 平台通道的桥接机制,再到 Flutter 手势系统的内部工作原理,最终提出并详细阐述多种集成策略,并通过具体代码示例加以说明。我们的目标是构建一个能够统一处理软硬件输入的、响应迅速且富有表现力的用户界面。
一、硬件输入的必要性与 Flutter 的挑战
1.1 硬件输入的独特价值
尽管触摸屏提供了极高的灵活性,但物理输入设备在特定场景下拥有不可替代的优势:
- 可靠性与触觉反馈: 物理按键提供明确的触觉反馈,在恶劣环境(如戴手套操作、高震动环境)或需要盲操作时至关重要。
- 精准控制: 旋钮、编码器等提供精确的数值调节,远胜于滑动条或虚拟按钮。
- 特定功能绑定: 专用按键可以直接触发特定功能,简化操作流程。
- 传感器驱动交互: 接近传感器、加速度计等可以实现基于环境或设备状态的隐式交互。
- 功耗与唤醒: 硬件中断可以有效地唤醒系统,降低功耗。
1.2 Flutter 的挑战与机遇
Flutter 的 UI 渲染和事件处理机制高度优化,专为高性能的触摸驱动界面设计。其手势系统能够识别复杂的触摸序列,并将其转化为可理解的交互行为。然而,这种抽象层也意味着硬件事件并非天然地融入其输入管道:
- 事件类型不匹配: 硬件事件通常是离散的(例如,按键按下/释放)或基于数值的(例如,编码器增量),而非 Flutter 期望的
PointerEvent(包含位置、压力、设备类型等信息)。 - 平台差异性: GPIO 和 I2C 是操作系统和硬件平台相关的接口,需要通过原生代码进行访问。
- 并发与性能: 硬件事件可能频率很高,处理不当可能导致 UI 卡顿。
本讲座将针对这些挑战,提供一套系统性的解决方案。
二、理解嵌入式 Linux/IoT 环境下的硬件输入
在嵌入式领域,尤其是在基于 Linux 的设备上,GPIO 和 I2C 是最常见的两种硬件接口。
2.1 GPIO (General Purpose Input/Output)
GPIO 引脚是最基本的数字 I/O 接口,可用于读取开关状态、控制 LED、连接按钮等。
2.1.1 工作模式
- 输入模式: 读取引脚的数字电平(高电平/低电平)。
- 输出模式: 设置引脚的数字电平。
2.1.2 状态检测方法
- 轮询 (Polling): 定期读取 GPIO 引脚的状态。简单但效率低,如果事件不频繁,会浪费 CPU 周期;如果轮询间隔过长,可能错过短暂的事件。
- 中断 (Interrupts): 当 GPIO 引脚的状态发生变化时,硬件会生成一个中断信号,通知 CPU。这是处理按键、开关等事件的首选方式,效率高、实时性好。
2.1.3 Linux 系统接口:libgpiod
在现代 Linux 系统中,libgpiod 是访问 GPIO 的标准和推荐库,它提供了比传统 sysfs 接口更健壮、更安全、功能更丰富的 API。
libgpiod 核心概念:
gpiod_chip: 代表一个 GPIO 控制器(例如,一个 SoC 可能有多个 GPIO 控制器)。gpiod_line: 代表控制器上的一个独立 GPIO 引脚。gpiod_line_request: 请求一个或多个 GPIO line,指定其方向(输入/输出)、模式(活动高/低)以及是否监听事件。gpiod_line_event: 用于读取 GPIO line 上的事件(上升沿/下降沿)。
C/C++ libgpiod 示例 (监听 GPIO 按键事件):
假设我们有一个按钮连接到 GPIO chip0 的 line 23,当按下时为低电平,释放时为高电平。
#include <gpiod.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#ifndef CONSUMER
#define CONSUMER "flutter-gpio-button"
#endif
// Forward declaration for event callback (if needed for a more complex setup)
typedef void (*gpio_event_callback)(int line_offset, int event_type, struct timespec timestamp);
struct GpioMonitorContext {
struct gpiod_chip *chip;
struct gpiod_line *line;
int line_offset;
char *chip_path;
gpio_event_callback callback;
volatile bool running; // Flag to control the event loop
};
// Function to initialize and monitor a GPIO line
int start_gpio_monitor(struct GpioMonitorContext *context) {
context->chip = gpiod_chip_open_by_path(context->chip_path);
if (!context->chip) {
fprintf(stderr, "Failed to open GPIO chip %s: %sn", context->chip_path, strerror(errno));
return -1;
}
context->line = gpiod_chip_get_line(context->chip, context->line_offset);
if (!context->line) {
fprintf(stderr, "Failed to get GPIO line %d from chip %s: %sn", context->line_offset, context->chip_path, strerror(errno));
gpiod_chip_close(context->chip);
return -1;
}
// Request the line as input, with bias pull-up (if button pulls to ground)
// Or bias pull-down (if button pulls to VCC)
// Here, assuming button pulls to ground, so we need pull-up.
// GPIOD_LINE_REQUEST_FLAG_BIAS_PULL_UP is ideal.
// If not available or button is active-high, adjust flags.
int ret = gpiod_line_request_both_edges_events(
context->line,
CONSUMER
);
if (ret < 0) {
fprintf(stderr, "Failed to request GPIO line %d for events: %sn", context->line_offset, strerror(errno));
gpiod_chip_close(context->chip);
return -1;
}
printf("Monitoring GPIO line %d on chip %s for events...n", context->line_offset, context->chip_path);
struct gpiod_line_event event;
struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; // 1 second timeout for polling
context->running = true;
while (context->running) {
// Wait for an event with a timeout
ret = gpiod_line_event_read(context->line, &event);
if (ret == 0) {
// Timeout occurred, no event
// printf("Timeout, still waiting...n");
continue;
} else if (ret < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// Non-blocking read would return this, but gpiod_line_event_read is blocking by default.
// If using gpiod_line_event_read_fd and select/poll, this is normal.
// With gpiod_line_event_read, a negative return typically indicates an error other than timeout.
fprintf(stderr, "Error reading GPIO event: %sn", strerror(errno));
if (errno == EBADF) { // Bad file descriptor, maybe line was closed externally
context->running = false;
}
continue; // Try again or break if fatal
} else {
fprintf(stderr, "Failed to read GPIO event: %sn", strerror(errno));
context->running = false; // Exit loop on error
}
} else {
// Event occurred
const char* event_str = (event.event_type == GPIOD_LINE_EVENT_RISING_EDGE) ? "RISING_EDGE" : "FALLING_EDGE";
printf("GPIO line %d event: %s at %lld.%09ldn",
context->line_offset, event_str,
(long long)event.ts.tv_sec, (long)event.ts.tv_nsec);
if (context->callback) {
context->callback(context->line_offset, event.event_type, event.ts);
}
}
}
printf("Stopping GPIO monitor for line %d.n", context->line_offset);
gpiod_line_release(context->line);
gpiod_chip_close(context->chip);
return 0;
}
// Function to stop the monitoring loop
void stop_gpio_monitor(struct GpioMonitorContext *context) {
if (context) {
context->running = false;
}
}
// Dummy callback for demonstration
void my_event_callback(int line_offset, int event_type, struct timespec timestamp) {
// In a real scenario, this would send data over a pipe/socket to the Dart side
// or call a Dart FFI function.
// For now, just print.
const char* event_str = (event_type == GPIOD_LINE_EVENT_RISING_EDGE) ? "RISING_EDGE" : "FALLING_EDGE";
printf("[Callback] Line %d, Type: %s, Time: %lld.%09ldn",
line_offset, event_str, (long long)timestamp.tv_sec, (long)timestamp.tv_nsec);
}
// Example usage (main function for testing)
/*
int main() {
struct GpioMonitorContext context;
memset(&context, 0, sizeof(context));
context.chip_path = "/dev/gpiochip0"; // Or "/dev/gpiochip1", etc.
context.line_offset = 23; // Or your specific line number
context.callback = my_event_callback;
// Start monitoring in a separate thread in a real application
// For this simple test, it will block main.
start_gpio_monitor(&context);
// After some time or an external signal, call stop_gpio_monitor(&context);
// For example, if running in a separate thread:
// pthread_t gpio_thread;
// pthread_create(&gpio_thread, NULL, (void*(*)(void*))start_gpio_monitor, &context);
// sleep(10); // Monitor for 10 seconds
// stop_gpio_monitor(&context);
// pthread_join(gpio_thread, NULL);
return 0;
}
*/
2.2 I2C (Inter-Integrated Circuit)
I2C 是一种同步、半双工、多主控、多从控的串行通信总线,广泛用于连接传感器(如加速度计、陀螺仪、温度传感器)、EEPROM、实时时钟、LCD 驱动器等。
2.2.1 工作原理
- 两线制: 仅需两条线:SDA (数据线) 和 SCL (时钟线)。
- 主从模式: 主设备发起通信,从设备响应。
- 地址寻址: 每个从设备都有一个唯一的 7 位或 10 位地址。
2.2.2 Linux 系统接口:i2c-dev
在 Linux 中,I2C 设备通过 /dev/i2c-N (N 是总线号,例如 /dev/i2c-1) 文件进行访问。可以使用标准的 open(), ioctl(), read(), write() 系统调用进行操作。
C/C++ i2c-dev 示例 (读取 I2C 传感器数据):
假设我们有一个 I2C 温度传感器,地址为 0x48,读取其温度寄存器 0x00。
#include <linux/i2c-dev.h> // For I2C_SLAVE, I2C_RDWR etc.
#include <sys/ioctl.h> // For ioctl
#include <fcntl.h> // For open
#include <unistd.h> // For close, read, write
#include <stdio.h>
#include <string.h>
#include <errno.h>
// Function to read a register from an I2C device
int i2c_read_register(const char* i2c_dev_path, int slave_address, unsigned char reg_address, unsigned char* data, int num_bytes) {
int file;
if ((file = open(i2c_dev_path, O_RDWR)) < 0) {
fprintf(stderr, "Failed to open I2C bus %s: %sn", i2c_dev_path, strerror(errno));
return -1;
}
// Set the slave address
if (ioctl(file, I2C_SLAVE, slave_address) < 0) {
fprintf(stderr, "Failed to acquire I2C bus access or talk to slave %02x: %sn", slave_address, strerror(errno));
close(file);
return -1;
}
// Write the register address to read from
if (write(file, ®_address, 1) != 1) {
fprintf(stderr, "Failed to write register address %02x to slave %02x: %sn", reg_address, slave_address, strerror(errno));
close(file);
return -1;
}
// Read the data
if (read(file, data, num_bytes) != num_bytes) {
fprintf(stderr, "Failed to read %d bytes from slave %02x, register %02x: %sn", num_bytes, slave_address, reg_address, strerror(errno));
close(file);
return -1;
}
close(file);
return 0; // Success
}
// Function to write to a register of an I2C device
int i2c_write_register(const char* i2c_dev_path, int slave_address, unsigned char reg_address, const unsigned char* data, int num_bytes) {
int file;
unsigned char buffer[num_bytes + 1];
if ((file = open(i2c_dev_path, O_RDWR)) < 0) {
fprintf(stderr, "Failed to open I2C bus %s: %sn", i2c_dev_path, strerror(errno));
return -1;
}
if (ioctl(file, I2C_SLAVE, slave_address) < 0) {
fprintf(stderr, "Failed to acquire I2C bus access or talk to slave %02x: %sn", slave_address, strerror(errno));
close(file);
return -1;
}
buffer[0] = reg_address;
memcpy(&buffer[1], data, num_bytes);
if (write(file, buffer, num_bytes + 1) != (num_bytes + 1)) {
fprintf(stderr, "Failed to write %d bytes to slave %02x, register %02x: %sn", num_bytes, slave_address, reg_address, strerror(errno));
close(file);
return -1;
}
close(file);
return 0; // Success
}
// Example: Read 2 bytes (e.g., a 16-bit temperature reading)
/*
int main() {
const char* i2c_bus = "/dev/i2c-1"; // Your I2C bus
int sensor_addr = 0x48; // Your sensor's I2C address
unsigned char temp_reg = 0x00; // Temperature register address
unsigned char read_data[2];
if (i2c_read_register(i2c_bus, sensor_addr, temp_reg, read_data, 2) == 0) {
// Assuming the sensor returns temperature as 16-bit signed integer (MSB first)
short raw_temp = (read_data[0] << 8) | read_data[1];
printf("Raw temperature data: 0x%02x 0x%02x -> %dn", read_data[0], read_data[1], raw_temp);
// Further processing for actual temperature value (e.g., divide by 256 for some sensors)
double temperature_c = raw_temp / 256.0;
printf("Temperature: %.2f Cn", temperature_c);
} else {
fprintf(stderr, "Failed to read temperature.n");
}
return 0;
}
*/
重要提示: 在实际应用中,对 I2C 设备的读取通常需要在单独的线程中进行,以避免阻塞主线程,尤其是在需要频繁读取或等待设备响应时。
三、原生硬件事件到 Flutter 的桥接:平台通道
Flutter 与原生代码之间的通信主要通过平台通道 (Platform Channels) 实现。它们是 Flutter 框架中用于在 Dart 代码和特定平台原生代码之间传递消息的机制。
3.1 平台通道概述
平台通道基于异步消息传递,支持三种主要类型:
MethodChannel: 用于调用原生方法并接收返回结果。适用于一次性操作,如初始化硬件、设置参数或执行命令。EventChannel: 用于从原生代码发送连续的事件流到 Dart 代码。这对于硬件输入事件(如按钮按下、传感器数据更新)至关重要。BasicMessageChannel: 用于在 Dart 和原生代码之间发送结构化的、任意类型的消息。它提供了最高级的灵活性,但通常用于更复杂的场景。
对于硬件输入,我们将主要依赖 EventChannel 来传输实时事件。
3.2 EventChannel 的工作原理
EventChannel 允许原生代码作为事件源,将事件发送给 Dart 端的订阅者。
- Dart 端: 创建一个
EventChannel实例,并通过receiveBroadcastStream()方法获取一个Stream。 - 原生端: 实现一个
StreamHandler接口(Android)或FlutterStreamHandler协议(iOS/macOS/Linux),负责在onListen回调中启动事件监听,并在onCancel回调中停止监听。 - 事件传输: 当原生代码检测到硬件事件时,通过
EventSink将事件数据发送到 DartStream。
3.3 示例:从 C++ libgpiod 到 Dart EventChannel
我们将构建一个原生插件,用 C++ 监听 GPIO 事件,并通过 EventChannel 将其发送给 Flutter。
3.3.1 定义 Dart API
首先,在 Dart 层面定义我们期望的事件类型和通道名称。
// lib/hardware_input_plugin.dart
import 'dart:async';
import 'package:flutter/services.dart';
enum GpioEventType {
/// Button pressed (active low, so falling edge)
buttonDown,
/// Button released (active low, so rising edge)
buttonUp,
}
/// Represents a hardware button event
class GpioButtonEvent {
final int lineOffset;
final GpioEventType type;
final DateTime timestamp;
GpioButtonEvent(this.lineOffset, this.type, this.timestamp);
factory GpioButtonEvent.fromJson(Map<String, dynamic> json) {
return GpioButtonEvent(
json['lineOffset'] as int,
GpioEventType.values[json['type'] as int], // Assuming integer mapping
DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
);
}
Map<String, dynamic> toJson() => {
'lineOffset': lineOffset,
'type': type.index,
'timestamp': timestamp.millisecondsSinceEpoch,
};
@override
String toString() => 'GpioButtonEvent(lineOffset: $lineOffset, type: $type, timestamp: $timestamp)';
}
class HardwareInputPlugin {
static const String _gpioEventChannelName = 'com.example.hardware_input/gpio_events';
static const String _gpioMethodChannelName = 'com.example.hardware_input/gpio_methods';
static const EventChannel _gpioEventChannel = EventChannel(_gpioEventChannelName);
static const MethodChannel _gpioMethodChannel = MethodChannel(_gpioMethodChannelName);
/// Initializes a GPIO line for event listening.
/// Call this before subscribing to events.
static Future<void> initGpioLine(String chipPath, int lineOffset) async {
await _gpioMethodChannel.invokeMethod('initGpioLine', {
'chipPath': chipPath,
'lineOffset': lineOffset,
});
}
/// Releases a GPIO line.
static Future<void> releaseGpioLine(String chipPath, int lineOffset) async {
await _gpioMethodChannel.invokeMethod('releaseGpioLine', {
'chipPath': chipPath,
'lineOffset': lineOffset,
});
}
/// Returns a stream of GPIO button events.
/// Make sure to call [initGpioLine] first.
static Stream<GpioButtonEvent> get gpioButtonEvents {
return _gpioEventChannel.receiveBroadcastStream().map((event) {
if (event is Map) {
return GpioButtonEvent.fromJson(Map<String, dynamic>.from(event));
}
throw FormatException('Unexpected event type: $event');
});
}
}
3.3.2 原生 (C++) 实现
在 linux/plugins/hardware_input/hardware_input_plugin.cc 中,我们将集成 libgpiod 代码。
hardware_input_plugin.cc (核心逻辑)
#include "hardware_input_plugin.h"
#include <flutter_linux/flutter_linux.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <pthread.h> // For threading
#include <map>
// Global context for GPIO monitoring, managed by the plugin
struct GpioMonitorContext {
struct gpiod_chip *chip;
struct gpiod_line *line;
int line_offset;
char chip_path[256]; // Fixed size buffer for path
pthread_t thread_id; // Thread ID for the monitor
volatile bool running;
FlEventSink event_sink; // The Flutter event sink to send events to
};
// Map to store active GPIO monitors (chipPath + lineOffset -> GpioMonitorContext)
static std::map<std::pair<std::string, int>, GpioMonitorContext*> active_gpio_monitors;
static pthread_mutex_t monitors_mutex = PTHREAD_MUTEX_INITIALIZER;
// Helper to find a monitor context
static GpioMonitorContext* find_monitor_context(const std::string& chip_path, int line_offset) {
pthread_mutex_lock(&monitors_mutex);
auto it = active_gpio_monitors.find({chip_path, line_offset});
GpioMonitorContext* context = (it != active_gpio_monitors.end()) ? it->second : nullptr;
pthread_mutex_unlock(&monitors_mutex);
return context;
}
// Event callback for `libgpiod`
static void gpio_event_callback_internal(int line_offset, int event_type, struct timespec timestamp, FlEventSink event_sink) {
if (!event_sink) return;
// Create a Flutter value (map) to send
FlValue* event_map = fl_value_new_map();
fl_value_set_string(event_map, "lineOffset", fl_value_new_int(line_offset));
fl_value_set_string(event_map, "type", fl_value_new_int(event_type == GPIOD_LINE_EVENT_FALLING_EDGE ? 0 : 1)); // 0 for buttonDown (falling), 1 for buttonUp (rising)
fl_value_set_string(event_map, "timestamp", fl_value_new_int((gint64)timestamp.tv_sec * 1000 + timestamp.tv_nsec / 1000000)); // Milliseconds
fl_event_sink_send(event_sink, event_map);
fl_value_unref(event_map); // Release the value after sending
}
// Thread function for GPIO monitoring
static void* gpio_monitor_thread_func(void* arg) {
GpioMonitorContext *context = static_cast<GpioMonitorContext*>(arg);
if (!context) {
fprintf(stderr, "GPIO monitor thread started with null context.n");
return nullptr;
}
context->chip = gpiod_chip_open_by_path(context->chip_path);
if (!context->chip) {
fprintf(stderr, "Failed to open GPIO chip %s in thread: %sn", context->chip_path, strerror(errno));
context->running = false;
return nullptr;
}
context->line = gpiod_chip_get_line(context->chip, context->line_offset);
if (!context->line) {
fprintf(stderr, "Failed to get GPIO line %d from chip %s in thread: %sn", context->line_offset, context->chip_path, strerror(errno));
gpiod_chip_close(context->chip);
context->chip = nullptr;
context->running = false;
return nullptr;
}
// Request the line for both edge events
int ret = gpiod_line_request_both_edges_events(
context->line,
"flutter-gpio-consumer" // Consumer name
);
if (ret < 0) {
fprintf(stderr, "Failed to request GPIO line %d for events in thread: %sn", context->line_offset, strerror(errno));
gpiod_chip_close(context->chip);
context->chip = nullptr;
context->line = nullptr;
context->running = false;
return nullptr;
}
fprintf(stdout, "GPIO monitor thread for %s:%d started.n", context->chip_path, context->line_offset);
struct gpiod_line_event event;
struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; // 1 second poll timeout
context->running = true;
while (context->running) {
ret = gpiod_line_event_read(context->line, &event);
if (ret == 0) {
// Timeout, no event
continue;
} else if (ret < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// Should not happen with gpiod_line_event_read, but good to check
continue;
} else {
fprintf(stderr, "Error reading GPIO event in thread: %sn", strerror(errno));
context->running = false; // Exit loop on error
}
} else {
gpio_event_callback_internal(context->line_offset, event.event_type, event.ts, context->event_sink);
}
}
fprintf(stdout, "GPIO monitor thread for %s:%d stopping.n", context->chip_path, context->line_offset);
if (context->line) {
gpiod_line_release(context->line);
context->line = nullptr;
}
if (context->chip) {
gpiod_chip_close(context->chip);
context->chip = nullptr;
}
// No need to free context here, it's managed by active_gpio_monitors map
return nullptr;
}
// Event Channel Handler
static FlMethodResponse* gpio_event_stream_handler_on_listen(FlEventChannel* channel, FlValue* arguments, FlEventSink sink, gpointer user_data) {
if (arguments == nullptr || !fl_value_is_map(arguments)) {
return FL_METHOD_RESPONSE_ERROR("INVALID_ARGUMENTS", "Arguments must be a map.", nullptr);
}
FlValue* chip_path_value = fl_value_lookup_string(arguments, "chipPath");
FlValue* line_offset_value = fl_value_lookup_string(arguments, "lineOffset");
if (!chip_path_value || !fl_value_is_string(chip_path_value) ||
!line_offset_value || !fl_value_is_int(line_offset_value)) {
return FL_METHOD_RESPONSE_ERROR("INVALID_ARGUMENTS", "Missing chipPath (string) or lineOffset (int).", nullptr);
}
const char* chip_path = fl_value_get_string(chip_path_value);
int line_offset = fl_value_get_int(line_offset_value);
pthread_mutex_lock(&monitors_mutex);
GpioMonitorContext* context = find_monitor_context(chip_path, line_offset);
if (context) {
if (context->running) {
fprintf(stderr, "GPIO monitor for %s:%d already running, re-attaching sink.n", chip_path, line_offset);
context->event_sink = sink; // Update the sink if already running
pthread_mutex_unlock(&monitors_mutex);
return FL_METHOD_RESPONSE_SUCCESS(nullptr);
} else {
// Monitor exists but is not running, possibly error state. Clean up and restart.
fprintf(stderr, "GPIO monitor for %s:%d found but not running. Cleaning up and restarting.n", chip_path, line_offset);
// Assuming cleanup done by thread func, just remove from map and create new
active_gpio_monitors.erase({chip_path, line_offset});
free(context); // Free the old context
context = nullptr;
}
}
// If context is null, create a new one
if (!context) {
context = (GpioMonitorContext*)calloc(1, sizeof(GpioMonitorContext));
if (!context) {
pthread_mutex_unlock(&monitors_mutex);
return FL_METHOD_RESPONSE_ERROR("ALLOCATION_FAILED", "Failed to allocate GpioMonitorContext.", nullptr);
}
strncpy(context->chip_path, chip_path, sizeof(context->chip_path) - 1);
context->chip_path[sizeof(context->chip_path) - 1] = ''; // Ensure null-termination
context->line_offset = line_offset;
context->event_sink = sink;
active_gpio_monitors[{chip_path, line_offset}] = context;
// Start the monitoring thread
int result = pthread_create(&context->thread_id, nullptr, gpio_monitor_thread_func, context);
if (result != 0) {
fprintf(stderr, "Failed to create GPIO monitor thread: %sn", strerror(result));
active_gpio_monitors.erase({chip_path, line_offset});
free(context);
pthread_mutex_unlock(&monitors_mutex);
return FL_METHOD_RESPONSE_ERROR("THREAD_CREATION_FAILED", "Failed to create native thread.", nullptr);
}
}
pthread_mutex_unlock(&monitors_mutex);
return FL_METHOD_RESPONSE_SUCCESS(nullptr);
}
static FlMethodResponse* gpio_event_stream_handler_on_cancel(FlEventChannel* channel, FlValue* arguments, gpointer user_data) {
if (arguments == nullptr || !fl_value_is_map(arguments)) {
return FL_METHOD_RESPONSE_ERROR("INVALID_ARGUMENTS", "Arguments must be a map.", nullptr);
}
FlValue* chip_path_value = fl_value_lookup_string(arguments, "chipPath");
FlValue* line_offset_value = fl_value_lookup_string(arguments, "lineOffset");
if (!chip_path_value || !fl_value_is_string(chip_path_value) ||
!line_offset_value || !fl_value_is_int(line_offset_value)) {
return FL_METHOD_RESPONSE_ERROR("INVALID_ARGUMENTS", "Missing chipPath (string) or lineOffset (int).", nullptr);
}
const char* chip_path = fl_value_get_string(chip_path_value);
int line_offset = fl_value_get_int(line_offset_value);
pthread_mutex_lock(&monitors_mutex);
GpioMonitorContext* context = find_monitor_context(chip_path, line_offset);
if (context) {
context->running = false; // Signal thread to stop
// Detach event sink, but keep context until thread actually stops
context->event_sink = nullptr;
// pthread_join(context->thread_id, nullptr); // Do not join here, it will block UI thread
active_gpio_monitors.erase({chip_path, line_offset});
free(context); // Free the context after removing from map
}
pthread_mutex_unlock(&monitors_mutex);
return FL_METHOD_RESPONSE_SUCCESS(nullptr);
}
static void gpio_method_channel_handler(FlMethodChannel* channel, FlMethodCall* method_call) {
const char* method = fl_method_call_get_name(method_call);
FlValue* args = fl_method_call_get_args(method_call);
if (strcmp(method, "initGpioLine") == 0) {
// This method is now implicitly handled by the event channel's onListen.
// For explicit setup, we could put the gpiod_chip_open/get_line/request_line here
// and store in a map, then onListen would just use existing context.
// For simplicity, we'll let onListen handle the full lifecycle.
fl_method_call_respond_success(method_call, nullptr, nullptr);
} else if (strcmp(method, "releaseGpioLine") == 0) {
// This method is now implicitly handled by the event channel's onCancel.
fl_method_call_respond_success(method_call, nullptr, nullptr);
} else {
fl_method_call_respond_not_implemented(method_call, nullptr);
}
}
void hardware_input_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
// Register Method Channel
g_autoptr(FlMethodChannel) method_channel =
fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
"com.example.hardware_input/gpio_methods",
FL_METHOD_CODEC(fl_standard_method_codec_new()));
fl_method_channel_set_method_call_handler(method_channel, gpio_method_channel_handler, nullptr);
// Register Event Channel
g_autoptr(FlEventChannel) event_channel =
fl_event_channel_new(fl_plugin_registrar_get_messenger(registrar),
"com.example.hardware_input/gpio_events",
FL_METHOD_CODEC(fl_standard_method_codec_new()));
fl_event_channel_set_stream_handler(event_channel,
gpio_event_stream_handler_on_listen,
gpio_event_stream_handler_on_cancel,
nullptr, nullptr); // No user_data
}
hardware_input_plugin.h
#ifndef FLUTTER_PLUGIN_HARDWARE_INPUT_PLUGIN_H_
#define FLUTTER_PLUGIN_HARDWARE_INPUT_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
#else
#define FLUTTER_PLUGIN_EXPORT
#endif
FLUTTER_PLUGIN_EXPORT void hardware_input_plugin_register_with_registrar(FlPluginRegistrar* registrar);
G_END_DECLS
#endif // FLUTTER_PLUGIN_HARDWARE_INPUT_PLUGIN_H_
CMakeLists.txt (添加 libgpiod 依赖)
在 linux/CMakeLists.txt 中找到 target_link_libraries,添加 gpiod。
# ... (existing CMakeLists.txt content) ...
target_link_libraries(${PROJECT_NAME}_plugin PUBLIC
flutter_linux_wrapper
gpiod # Add this line
)
注意:
- 原生代码中需要处理线程管理,确保 GPIO 监听不在 UI 线程上进行。
pthread用于创建新线程。 FlEventSink必须在onListen回调中获取,并在onCancel回调中释放。FlValue是 Flutter 平台通道的数据类型,用于在 C++ 和 Dart 之间传递数据。- 为了简化,
initGpioLine和releaseGpioLine方法通道的逻辑被合并到EventChannel的onListen和onCancel中。这意味着第一次订阅事件时会自动初始化 GPIO,取消所有订阅时会自动释放。如果需要更精细的控制,可以独立使用MethodChannel。
四、Flutter 手势系统解析
在深入集成硬件事件之前,理解 Flutter 的手势系统至关重要。
4.1 输入事件流
Flutter 的输入事件处理是一个多层次的管道:
- 平台原始事件: 操作系统(例如,Android、iOS、Linux)报告原始输入事件(触摸、鼠标、键盘)。
GestureBinding: Flutter 引擎接收这些原始事件,并将其转换为 Flutter 内部的PointerEvent对象。PointerRouter:GestureBinding将PointerEvent分发给所有注册的PointerRouter客户端。GestureArena: 当多个GestureRecognizer可能对同一组PointerEvent感兴趣时,GestureArena会介入,通过竞争机制来决定哪个GestureRecognizer最终赢得手势识别权。GestureRecognizer: 负责识别特定手势(如点击、滑动、缩放)的逻辑单元。- Widget Tree: 识别出的手势通过回调通知到 Widget 树中的
GestureDetector或RawGestureDetector。
4.2 PointerEvent 的类型
PointerEvent 是 Flutter 手势系统的基础,它封装了触摸、鼠标或笔输入的详细信息。关键属性包括:
pointer:唯一标识符,区分不同的触摸点或输入设备。timeStamp:事件发生的时间。position:事件在屏幕上的坐标。delta:自上次事件以来的位置变化。kind:输入设备的类型(触摸、鼠标、笔)。buttons:鼠标按钮状态。
常见的 PointerEvent 子类:
PointerDownEvent:指针接触屏幕。PointerMoveEvent:指针在屏幕上移动。PointerUpEvent:指针离开屏幕。PointerCancelEvent:指针事件被取消(例如,父级手势胜出)。
4.3 GestureRecognizer
GestureRecognizer 是 Flutter 手势系统的核心抽象,用于识别特定手势。
- 生命周期:
addAllowedPointer(指针进入竞技场) ->handleEvent(处理事件) ->acceptGesture(赢得竞技场) /rejectGesture(失去竞技场) ->stopTrackingPointer(指针离开竞技场)。 - 竞技场 (GestureArena): 当一个
PointerDownEvent发生时,所有可能识别该手势的GestureRecognizer都会加入一个GestureArena。它们通过声明accept或reject来竞争。最终,只有一个GestureRecognizer能赢得竞技场,其余的都会被拒绝。这解决了手势冲突问题(例如,是滚动还是点击)。 - 自定义
GestureRecognizer: 扩展GestureRecognizer基类是实现自定义手势的关键。这允许我们定义自己的状态机,根据一系列PointerEvent或自定义事件来识别手势。
4.4 GestureDetector 与 RawGestureDetector
GestureDetector: 方便的 Widget,封装了常用的GestureRecognizer(如TapGestureRecognizer,PanGestureRecognizer等)。RawGestureDetector: 提供了更大的灵活性,可以直接传入一个Map<Type, GestureRecognizerFactory>来自定义GestureRecognizer。这是集成硬件事件时经常使用的 Widget。
五、集成策略:将硬件事件映射到 Flutter 手势系统
将原生硬件事件集成到 Flutter 手势系统有多种策略,每种都有其适用场景和优缺点。
5.1 策略一:简单事件分发 (非手势系统集成)
这是最直接的方式,不涉及 Flutter 的手势系统,只是将硬件事件作为数据流直接用于更新 UI 或触发业务逻辑。
- 机制: 在 Dart 端订阅
EventChannel返回的Stream,然后使用StreamBuilder或手动订阅Stream来更新StatefulWidget的状态。 - 优点: 实现简单,适用于按钮切换状态、传感器数据显示等非手势交互。
- 缺点: 无法利用 Flutter 手势系统的强大功能(如手势竞技场、组合手势),不能与触摸手势进行竞争或协作。
- 适用场景: 独立的硬件控制,不与屏幕触摸交互产生冲突。
代码示例 (基于 GpioButtonEvent):
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:hardware_input_plugin/hardware_input_plugin.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Hardware Input Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const GpioButtonScreen(),
);
}
}
class GpioButtonScreen extends StatefulWidget {
const GpioButtonScreen({super.key});
@override
State<GpioButtonScreen> createState() => _GpioButtonScreenState();
}
class _GpioButtonScreenState extends State<GpioButtonScreen> {
String _buttonState = 'Released';
DateTime? _lastEventTimestamp;
@override
void initState() {
super.initState();
_initAndListenGpio();
}
Future<void> _initAndListenGpio() async {
// Replace with your actual GPIO chip path and line offset
const String chipPath = '/dev/gpiochip0';
const int lineOffset = 23;
try {
// Initialize the GPIO line (this might be handled by the first stream listener)
// await HardwareInputPlugin.initGpioLine(chipPath, lineOffset);
print('Initialized GPIO line $lineOffset on $chipPath');
// Subscribe to events
HardwareInputPlugin.gpioButtonEvents.listen((event) {
if (event.lineOffset == lineOffset) {
setState(() {
_buttonState = event.type == GpioEventType.buttonDown ? 'Pressed' : 'Released';
_lastEventTimestamp = event.timestamp;
print('Button event: ${event.type} at ${event.timestamp}');
});
}
}).onError((error) {
print('Error listening to GPIO events: $error');
setState(() {
_buttonState = 'Error: $error';
});
});
} catch (e) {
print('Failed to initialize GPIO or listen to events: $e');
setState(() {
_buttonState = 'Init Error: $e';
});
}
}
@override
void dispose() {
// Optionally release GPIO line, but `onCancel` in native code handles stream close.
// However, if you have multiple listeners and only want to stop when all are gone,
// or stop explicitly, you might manage it here.
// For this simple example, we rely on the native cleanup when stream is cancelled.
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('GPIO Button Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Hardware Button State:',
style: TextStyle(fontSize: 24),
),
Text(
_buttonState,
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: _buttonState == 'Pressed' ? Colors.red : Colors.green,
),
),
if (_lastEventTimestamp != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Last event: ${_lastEventTimestamp!.toLocal()}',
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 30),
const Text(
'This demonstrates direct event consumption, bypassing Flutter's gesture system.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
),
);
}
}
5.2 策略二:合成 PointerEvent 并注入 (通常不推荐)
这种策略试图将硬件事件伪装成 Flutter 的 PointerEvent,并将其注入到 GestureBinding.instance.handlePointerEvent 方法中。
- 机制:
- 硬件事件发生时,通过
EventChannel传递到 Dart。 - 在 Dart 端,根据硬件事件的类型和状态,构造一个
PointerDownEvent、PointerMoveEvent或PointerUpEvent。 - 使用
GestureBinding.instance.addPointer(pointerEvent)或GestureBinding.instance.handlePointerEvent(pointerEvent)将合成的事件注入到 Flutter 的事件管道中。
- 硬件事件发生时,通过
- 优点: 理论上可以利用所有现有的
GestureDetector和GestureRecognizer。 - 缺点:
- 语义不匹配: 许多硬件事件(如按钮按下)没有天然的屏幕坐标 (
position) 或移动 (delta) 概念。合成这些值通常是武断且无意义的。 pointerID 管理: 需要手动管理pointerID,以确保每个“虚拟指针”的唯一性和生命周期。- 与真实触摸冲突: 合成的
PointerEvent会与真实的触摸事件在GestureArena中竞争,可能导致不可预测的行为。 - 难以模拟复杂手势: 难以从离散的硬件事件中合成出流畅的
PointerMoveEvent序列。
- 语义不匹配: 许多硬件事件(如按钮按下)没有天然的屏幕坐标 (
- 适用场景: 仅适用于硬件本身提供类似触摸板或操纵杆的 位置信息 的情况。例如,一个自定义的轨迹球或触摸板,它可以精确地映射到屏幕坐标和移动。对于简单的按钮或旋钮,此方法通常是过度设计或错误应用。
代码示例 (概念性,不推荐在实际按钮中使用):
假设我们有一个自定义的轨迹球,它报告相对移动。
// This is a highly conceptual and usually ill-advised approach for simple buttons.
// It's primarily for devices that genuinely provide positional input.
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hardware_input_plugin/hardware_input_plugin.dart';
// Assuming HardwareInputPlugin provides a stream of {x_delta, y_delta} for a virtual trackball
// class TrackballMoveEvent {
// final double dx, dy;
// final DateTime timestamp;
// TrackballMoveEvent(this.dx, this.dy, this.timestamp);
// }
// For a button, synthesizing pointer events is problematic.
// Let's illustrate with a hypothetical trackball for which this strategy might apply.
// We'll skip the native side for this hypothetical TrackballMoveEvent and focus on Flutter injection.
class SynthesizedPointerEventScreen extends StatefulWidget {
const SynthesizedPointerEventScreen({super.key});
@override
State<SynthesizedPointerEventScreen> createState() => _SynthesizedPointerEventScreenState();
}
class _SynthesizedPointerEventScreenState extends State<SynthesizedPointerEventScreen> {
Offset _currentPosition = const Offset(100, 100); // Initial virtual pointer position
int _pointerId = 0; // Unique ID for our virtual pointer
bool _isDown = false; // Is our virtual button currently "pressed"?
@override
void initState() {
super.initState();
// Simulate initial PointerDown for the virtual pointer
_injectPointerDown();
// Example: Listen to a hypothetical stream of trackball events
// For GPIO button, this would be `HardwareInputPlugin.gpioButtonEvents`
// but mapping button presses to positional PointerEvents is fundamentally flawed.
HardwareInputPlugin.gpioButtonEvents.listen((event) {
if (event.type == GpioEventType.buttonDown && !_isDown) {
_isDown = true;
_injectPointerDown(); // Re-inject down to simulate a "click" or new press
} else if (event.type == GpioEventType.buttonUp && _isDown) {
_isDown = false;
_injectPointerUp();
}
// For a real trackball, we'd get dx, dy and inject PointerMove
// Example: _injectPointerMove(event.dx, event.dy);
});
}
void _injectPointerDown() {
// Generate a unique pointer ID
_pointerId = PointerEvent.syntheticFactory.initialPointer;
// GestureBinding.instance.addPointer(PointerDownEvent(
// pointer: _pointerId,
// timeStamp: DateTime.now(),
// position: _currentPosition,
// kind: PointerDeviceKind.mouse, // Or PointerDeviceKind.touch for a virtual touch
// ));
GestureBinding.instance.handlePointerEvent(PointerDownEvent(
pointer: _pointerId,
timeStamp: Duration(microseconds: DateTime.now().microsecondsSinceEpoch),
position: _currentPosition,
kind: PointerDeviceKind.mouse, // Or PointerDeviceKind.touch for a virtual touch
));
print('Injected PointerDown at $_currentPosition with ID $_pointerId');
}
void _injectPointerMove(double dx, double dy) {
if (!_isDown) return; // Only move if "button" is down
_currentPosition = _currentPosition + Offset(dx, dy);
// GestureBinding.instance.handlePointerEvent(PointerMoveEvent(
// pointer: _pointerId,
// timeStamp: DateTime.now(),
// position: _currentPosition,
// delta: Offset(dx, dy),
// kind: PointerDeviceKind.mouse,
// ));
GestureBinding.instance.handlePointerEvent(PointerMoveEvent(
pointer: _pointerId,
timeStamp: Duration(microseconds: DateTime.now().microsecondsSinceEpoch),
position: _currentPosition,
delta: Offset(dx, dy),
kind: PointerDeviceKind.mouse,
));
print('Injected PointerMove to $_currentPosition, delta ($dx, $dy)');
}
void _injectPointerUp() {
// GestureBinding.instance.handlePointerEvent(PointerUpEvent(
// pointer: _pointerId,
// timeStamp: DateTime.now(),
// position: _currentPosition,
// kind: PointerDeviceKind.mouse,
// ));
GestureBinding.instance.handlePointerEvent(PointerUpEvent(
pointer: _pointerId,
timeStamp: Duration(microseconds: DateTime.now().microsecondsSinceEpoch),
position: _currentPosition,
kind: PointerDeviceKind.mouse,
));
print('Injected PointerUp at $_currentPosition with ID $_pointerId');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Synthesized PointerEvent Demo'),
),
body: GestureDetector(
onTap: () {
print('Tap detected by GestureDetector!');
},
onPanUpdate: (details) {
print('Pan detected by GestureDetector: ${details.delta}');
setState(() {
_currentPosition += details.delta;
});
},
child: Container(
color: Colors.lightBlue.shade100,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Try clicking the virtual button (via actual hardware button) or dragging this area.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 20),
Text(
'Virtual Pointer Position: (${_currentPosition.dx.toStringAsFixed(1)}, ${_currentPosition.dy.toStringAsFixed(1)})',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 20),
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: _isDown ? Colors.red : Colors.green,
shape: BoxShape.circle,
),
child: Center(
child: Text(
_isDown ? 'Down' : 'Up',
style: const TextStyle(color: Colors.white),
),
),
),
],
),
),
),
);
}
}
5.3 策略三:自定义 GestureRecognizer 处理硬件事件 (推荐)
这是将硬件事件深度集成到 Flutter 手势系统的最强大和推荐的方法。它允许我们为硬件输入创建自己的手势识别器,并让它们与标准的触摸手势识别器在 GestureArena 中竞争。
- 机制:
- 创建一个继承自
GestureRecognizer(或其子类,如OneSequenceGestureRecognizer) 的自定义类。 - 在自定义识别器内部,订阅
HardwareInputPlugin提供的事件流(如gpioButtonEvents)。 - 根据接收到的硬件事件序列,实现手势识别逻辑(例如,检测按钮的“点击”、“长按”或旋钮的“滚动”)。
- 通过调用
resolve(GestureDisposition.accepted)或resolve(GestureDisposition.rejected)来参与GestureArena。 - 当手势被识别时,触发自定义回调(例如
onTap,onLongPress)。
- 创建一个继承自
- 优点:
- 完全融入
GestureArena,能够与其他手势竞争或协作,解决手势冲突。 - 高度灵活,可以定义任意复杂的硬件手势。
- 遵循 Flutter 的手势系统架构,代码结构清晰,易于维护。
- 不依赖于虚假的
PointerEvent,语义更清晰。
- 完全融入
- 缺点: 实现比简单事件分发复杂,需要理解
GestureRecognizer的内部机制。 - 适用场景: 需要将硬件事件转化为与触摸手势类似的、需要竞争或组合的交互行为,例如:
- 物理按钮的点击、长按。
- 旋钮的旋转作为滚动或数值调节。
- 多按键组合手势。
5.3.1 示例:HardwareButtonTapGestureRecognizer
我们将创建一个识别物理按钮“点击”手势的识别器。
// lib/hardware_button_recognizer.dart
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/foundation.dart';
import 'package:hardware_input_plugin/hardware_input_plugin.dart';
// Callback types for our custom recognizer
typedef GestureHardwareButtonTapCallback = void Function(int lineOffset);
typedef GestureHardwareButtonLongPressCallback = void Function(int lineOffset);
/// A custom gesture recognizer that recognizes tap and long press gestures
/// from a specific hardware GPIO button.
///
/// This recognizer listens to the `GpioButtonEvent` stream from the native plugin
/// and translates sequences of `buttonDown` and `buttonUp` into tap or long press gestures.
class HardwareButtonTapGestureRecognizer extends OneSequenceGestureRecognizer {
/// The GPIO line offset this recognizer is interested in.
final int targetLineOffset;
/// Called when a tap is detected.
GestureHardwareButtonTapCallback? onTap;
/// Called when a long press is detected.
GestureHardwareButtonLongPressCallback? onLongPress;
/// The maximum duration between a button down and button up event to be considered a tap.
/// Defaults to [kTapTimeout].
Duration tapTimeout = kTapTimeout;
/// The duration a button must be held down to be considered a long press.
/// Defaults to [kLongPressTimeout].
Duration longPressTimeout = kLongPressTimeout;
// Internal state for gesture recognition
StreamSubscription<GpioButtonEvent>? _eventSubscription;
GpioButtonEvent? _lastDownEvent;
Timer? _longPressTimer;
HardwareButtonTapGestureRecognizer({
super.debugOwner,
this.targetLineOffset = -1, // -1 means listen to all, or require specific target
this.onTap,
this.onLongPress,
super.supportedDevices,
}) : assert(targetLineOffset >= 0); // Must specify a target line offset
@override
void addAllowedPointer(PointerDownEvent event) {
// This recognizer does not directly process PointerEvents.
// It exists in the arena but listens to its own event stream.
// However, it *must* add a pointer to participate in the arena.
// We'll immediately accept it to allow it to receive the "down" signal
// and then manage its own resolution based on hardware events.
// This is a bit of a hack to integrate with the PointerEvent-based arena.
// If we only want *hardware* events to trigger this recognizer,
// we should prevent it from accepting real PointerEvents.
// For simplicity, we'll let it join the arena, but its internal logic
// will only react to hardware events.
// A more robust approach might involve a custom GestureDetector that
// *only* feeds hardware events to its recognizers, bypassing PointerEvents entirely.
// For now, let's assume it only cares about its internal stream.
// We call `startTrackingPointer` to make it a participant in the arena.
// But then, we won't process the pointer event in `handleEvent`.
super.addAllowedPointer(event);
// If we want to participate in the arena based on PointerEvents, we'd process them here.
// But since this is a HARDWARE button recognizer, we defer to our stream.
// The key is to start listening to the hardware stream when the recognizer is active.
_startListeningToHardwareEvents();
}
void _startListeningToHardwareEvents() {
if (_eventSubscription == null) {
_eventSubscription = HardwareInputPlugin.gpioButtonEvents.listen(_handleHardwareEvent);
print('HardwareButtonTapGestureRecognizer for line $targetLineOffset started listening.');
}
}
void _stopListeningToHardwareEvents() {
_eventSubscription?.cancel();
_eventSubscription = null;
print('HardwareButtonTapGestureRecognizer for line $targetLineOffset stopped listening.');
}
void _handleHardwareEvent(GpioButtonEvent event) {
if (event.lineOffset != targetLineOffset) {
return; // Not our button
}
switch (event.type) {
case GpioEventType.buttonDown:
_handleButtonDown(event);
break;
case GpioEventType.buttonUp:
_handleButtonUp(event);
break;
}
}
void _handleButtonDown(GpioButtonEvent event) {
// If a previous down event wasn't resolved, it means a double-down without up,
// or a long press timer was still active. Cancel any pending long press.
_longPressTimer?.cancel();
_lastDownEvent = event;
// Start long press timer
_longPressTimer = Timer(longPressTimeout, () {
if (onLongPress != null && _lastDownEvent != null) {
invokeCallback<void>('onLongPress', () => onLongPress!(targetLineOffset));
_lastDownEvent = null; // Consume the long press
// Resolve as accepted here, because a long press takes precedence.
resolve(GestureDisposition.accepted);
}
});
// In a `OneSequenceGestureRecognizer`, we typically call `startTrackingPointer`
// with the *synthetic* pointer that initiated this (if any).
// Here, we're driven by hardware. We need to tell the arena we're interested.
// Since we don't have a PointerDownEvent directly from hardware,
// this part needs careful thought if it's competing with *actual* touch.
// For now, we assume this recognizer acts mostly independently or wins easily.
// If it needs to compete, it must receive a PointerDownEvent.
// A simple button might not have a corresponding PointerDownEvent.
// If we are only responding to HW events, we don't need to resolve for PointerEvents.
// But to participate in the arena, we *must* resolve.
// We accept immediately, assuming our HW button has higher priority or is exclusive.
// If this recognizer needs to *compete* with touch gestures for the *same visual area*,
// then this approach has limitations without synthesizing PointerEvents.
// However, for hardware buttons that control non-visual elements or specific widgets,
// this works by being the 'only' recognizer for its type.
// For now, let's make it resolve itself after a sequence.
// We don't resolve immediately on down, but wait for up or long press.
}
void _handleButtonUp(GpioButtonEvent event) {
_longPressTimer?.cancel(); // Cancel long press timer if button released
if (_lastDownEvent == null) {
// Button up without a preceding down event (e.g., app started while button pressed)
// or long press already consumed it.
return;
}
final Duration duration = event.timestamp.difference(_lastDownEvent!.timestamp);
if (duration < tapTimeout) {
// It's a tap!
if (onTap != null) {
invokeCallback<void>('onTap', () => onTap!(targetLineOffset));
resolve(GestureDisposition.accepted); // Tap gesture won the arena
} else {
// No tap handler, but still a valid sequence. Reject or don't resolve.
resolve(GestureDisposition.rejected);
}
} else {
// Duration exceeded tap timeout but wasn't long press (e.g., long hold then release,
// but long press already fired, or no long press handler).
// If long press already fired, _lastDownEvent would be null.
// So this is a release after a hold that wasn't a long press.
resolve(GestureDisposition.rejected);
}
_lastDownEvent = null; // Reset for next gesture
}
@override
void handleEvent(PointerEvent event) {
// This recognizer does not directly consume PointerEvents for its core logic.
// The `_handleHardwareEvent` method is the primary driver.
// If this recognizer *also* needed to react to PointerEvents, this is where it would happen.
// Since our focus is on HARDWARE events, we just ensure we track the pointer if added.
// The `addAllowedPointer` method takes care of registering it with the arena.
// We do NOT call `super.handleEvent` here if we don't want to process PointerEvents.
}
@override
void acceptGesture(int pointer) {
// Our custom recognizer accepted the gesture.
// No specific action needed here beyond what `resolve` already did.
print('HardwareButtonTapGestureRecognizer accepted gesture for pointer $pointer.');
}
@override
void rejectGesture(int pointer) {
// Our custom recognizer rejected the gesture.
// Clean up any pending state.
_longPressTimer?.cancel();
_lastDownEvent = null;
print('HardwareButtonTapGestureRecognizer rejected gesture for pointer $pointer.');
}
@override
String get debugDescription => 'hardwareButtonTap';
@override
void didStopTrackingLastPointer(int pointer) {
// The last pointer associated with this recognizer has stopped being tracked.
// This is a good place to clean up internal state and stop listening to hardware events.
_longPressTimer?.cancel();
_lastDownEvent = null;
_stopListeningToHardwareEvents();
}
@override
void dispose() {
_longPressTimer?.cancel();
_eventSubscription?.cancel(); // Ensure subscription is cancelled
super.dispose();
}
}
/// A widget that detects taps and long presses on a specific hardware button.
class HardwareButtonGestureDetector extends StatelessWidget {
/// The GPIO line offset to listen for.
final int lineOffset;
/// Called when a tap is detected on the hardware button.
final GestureHardwareButtonTapCallback? onTap;
/// Called when a long press is detected on the hardware button.
final GestureHardwareButtonLongPressCallback? onLongPress;
/// The widget below this widget in the tree.
final Widget child;
const HardwareButtonGestureDetector({
super.key,
required this.lineOffset,
this.onTap,
this.onLongPress,
required this.child,
});
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
HardwareButtonTapGestureRecognizer: GestureRecognizerFactoryWith