Flutter adaptation remote control solution

我正在参加「掘金·启航计划」

foreword

In the practice of Flutter on desktop devices, we have implemented several projects to fully verify the feasibility of its technology and business. At the same time, more requirements are put forward for Flutter, including: the adaptation of the remote control!

Unfortunately, the Flutter official has not considered the adaptation of the remote control at all, and even its focus control has many problems [such as: switching flickering, the focus cannot respond under fast operation], so we have to break this barrier by ourselves.

How does the remote control work?

The remote control is actually a button event at the software level, which is equivalent to our keyboard . Android itself has TV equipment, so it already has a complete set of control mechanisms.

In Android, we only need pairs view声明可以获取焦点,指定焦点关系即可. The framework will help us automatically focus on the corresponding control; the simple UI state can be solved with a SelectDrawable.

Compared with the declarative UI of Flutter,Android命名式UI有天然的优势,通过生成的R文件,框架层可以通过FocusFinder轻易的找到对应的view,处理好焦点的分发流转和事件处理。

Difficulties of Flutter remote control

  • Flutter is a declarative UI, and it is difficult to generate resource classes at compile time;
  • Widget is just configuration information. If we want to get the information of the widget during the development process, we must obtain the Rander object through the Key value. It is very troublesome to obtain the information of each component to determine the direction and execute the event;
  • Through practice, we found that there are still many problems in the Flutter focusNode. During the request process, the focus occasionally repeats the request, and rapid switching of the focus will respond to errors; it is more obvious on Android devices;
  • Some controls in Flutter's Material component library hold the focusNode themselves, and respond to keyboard events by default , which will conflict with our own focus management.

Realization principle

Abandon Flutter's built-in FocusNode transfer mechanism , because Flutter doesn't help us with this transfer at all.
Listen to events by yourself, distribute focus, and the business side joins ControlManager by itself to know which focus is currently triggered.个人.png

Implementation steps

1. Global remote control event receiving component

既然Flutter无法轻松的生成焦点的流转关系,那我们就自行处理,定义一个全局的事件接收器。这个组件需要接收一个child,一般把MaterialApp传入。
组件内部接收按键事件,解析后进行事件分发。

class ControlApp extends StatelessWidget {
  ControlApp({Key? key, required this.app}) : super(key: key);

  final Widget app;

  final FocusNode remoteControl = FocusNode(debugLabel: 'Flutter遥控器');

  final EventBus _eventBus = EventBus.instance;

  @override
  Widget build(BuildContext context) {
    return RawKeyboardListener(
      onKey: (RawKeyEvent event) {
        if (event is RawKeyDownEvent) {
          RawKeyEventDataWindows rawKeyEventData =
              event.data as RawKeyEventDataWindows;
          switch (rawKeyEventData.keyCode) {
            case 38: //KEY_UP
              _eventBus.fire(EventType.up);
              break;
            case 40: //KEY_DOWN
              _eventBus.fire(EventType.down);
              break;
            case 37: //KEY_LEFT
              _eventBus.fire(EventType.left);
              break;
            case 39: //KEY_RIGHT
              _eventBus.fire(EventType.right);
              break;
            case 13: //KEY_ENTER
              _eventBus.fire(EventType.enter);
              break;
            case 8: //KEY_BACK
              _eventBus.fire(EventType.back);
              break;
            default:
              break;
          }
        }
      },
      focusNode: remoteControl,
      child: app,
    );
  }
}
复制代码

2. 统一焦点接收类管理

ControlApp统一分发后,ControlManager会进行接收,同时ControlManager将管理所有的遥控器监听者,提供addListenerremoveListenersyncFocusKeychangeTopFocus等接口;保证遥控事件只发送给最后加入的监听者。

final controlManager = ControlManager.instance;

class ControlManager {
  ControlManager._() {
    initEventListen();
  }

  static final ControlManager instance = ControlManager._();

  final EventBus _eventBus = EventBus.instance;

  final ObserverList<ControlListener> _listeners =
      ObserverList<ControlListener>();

  bool receiveEvents = true;

  int _currListenerIndex = 0;

  // 记录所有listener的焦点集合
  final List<List<FocusCollection>> _allCollectionList = [];

  // 记录每个listener选择的焦点集合
  final List<FocusCollection?> _selectedCollectionList = [];

  void addListener(ControlListener listener,
      {required List<FocusCollection> focusList}) {
    _listeners.add(listener);
    _allCollectionList.add(focusList);
    _currListenerIndex++;
    _selectedCollectionList.add(null);
  }

  void removeListener(ControlListener listener) {
    _listeners.remove(listener);
    _selectedCollectionList.removeLast();
    _allCollectionList.removeLast();
    _currListenerIndex--;
  }

  /// 修改顶层焦点集合
  void changeTopFocus(List<FocusCollection> focusList,
      {FocusCollection? curr}) {
    _allCollectionList.last = focusList;
    if (curr == null) {
      _selectedCollectionList[_currListenerIndex - 1] = null;
      _listeners.last.onCancelSelect();
    } else {
      _selectedCollectionList[_currListenerIndex - 1] = curr;
      _listeners.last.onWidgetSelect(curr);
    }
  }

  /// 是否暂停处理遥控器事件
  setReceiveStatus(bool isReceive) {
    receiveEvents = isReceive;
  }

  /// 同步焦点的建值
  syncFocusKey(FocusCollection focus) {
    _selectedCollectionList[_currListenerIndex - 1] = focus;
  }

  bool get focusIsEmpty => _allCollectionList[_currListenerIndex - 1].isEmpty;

  initEventListen() {
    _eventBus.on().listen((event) {
      if (!receiveEvents) return;
      switch (event) {
        case EventType.up:
        case EventType.left:
          if (focusIsEmpty) return;
          _listeners.last.onWidgetSelect(getFocusCollection(event));
          break;
        case EventType.enter:
          if (focusIsEmpty) return;
          if (_selectedCollectionList[_currListenerIndex - 1] == null) return;
          _listeners.last.onEnterEvent(getFocusCollection(event));
          break;

        case EventType.down:
        case EventType.right:
          if (focusIsEmpty) return;
          _listeners.last.onWidgetSelect(getFocusCollection(event));
          break;

        case EventType.back:
          if (focusIsEmpty) {
            _listeners.last.onBackEvent(null);
          } else {
            _listeners.last.onBackEvent(getFocusCollection(event));
          }
          break;
      }
    });
  }

  /// 获取命中的焦点集合
  FocusCollection getFocusCollection(EventType type) {
    if (_selectedCollectionList[_currListenerIndex - 1] == null) {
      var c = _allCollectionList[_currListenerIndex - 1].first;
      _selectedCollectionList[_currListenerIndex - 1] = c;
      return c;
    } else {
      FocusCollection c = _selectedCollectionList[_currListenerIndex - 1]!;
      List<FocusCollection> l = _allCollectionList[_currListenerIndex - 1];
      switch (type) {
        case EventType.up:
          if (c.up != null) c = l.firstWhere((e) => e.currKey == c.up);
          break;
        case EventType.down:
          if (c.down != null) c = l.firstWhere((e) => e.currKey == c.down);
          break;
        case EventType.left:
          if (c.left != null) c = l.firstWhere((e) => e.currKey == c.left);
          break;
        case EventType.right:
          if (c.right != null) c = l.firstWhere((e) => e.currKey == c.right);
          break;
        case EventType.enter:
        case EventType.back:
          break;
      }
      _selectedCollectionList[_currListenerIndex - 1] = c;
      return c;
    }
  }
}
复制代码

3. 提供遥控器监听抽象类

需要监听遥控事件的页面(或组件),需要混入这个类,然后在对应生命钩子中调用ControlManager中的addListener和removeListener事件。

abstract class ControlListener {
  void onWidgetSelect(FocusCollection collection) {}

  void onCancelSelect() {}

  void onEnterEvent(FocusCollection collection) {}

  void onBackEvent(FocusCollection? collection) {}
}
复制代码

重写ControlListener中提供的接口,就能准确接收到ControlManager发出的事件拉!

class _MyHomePageState extends State<MyHomePage> with ControlListener {
  int _counter = 0;

  String selectKey = ""; // 选择的焦点key

  // 自行维护焦点的关系
  List<FocusCollection> focusList = [
    FocusCollection("one", right: "two", down: "three"),
    FocusCollection("two", left: "three", up: "one"),
    FocusCollection("three", up: "one", right: "two")
  ];

  @override
  void initState() {
    super.initState();
    controlManager.addListener(this, focusList: focusList);
  }

  @override
  void dispose() {
    super.dispose();
    controlManager.removeListener(this);
  }

  @override
  void onWidgetSelect(FocusCollection collection) {
    super.onWidgetSelect(collection);
    setState(() {
      selectKey = collection.currKey;
    });
    print(">>>>> onWidgetSelect ${collection.currKey}");
  }

  @override
  void onBackEvent(FocusCollection? collection) {
    super.onBackEvent(collection);
    print(">>>>> onBackEvent ${collection?.currKey}");
  }

  @override
  void onEnterEvent(FocusCollection collection) {
    super.onEnterEvent(collection);
    int i = focusList.indexOf(collection);
    if (i == 2) {
      _incrementCounter();
    } else if (i == 0) {
      Navigator.push(
          context, MaterialPageRoute(builder: (context) => SecondPage()));
    }
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(children: [
        Positioned(
          top: 20,
          left: 20,
          child: FocusWidget(
            hasFocus: selectKey == "one",
            child: const Text('You have pushed the button this many times:'),
          ),
        ),
        Positioned(
          top: 60,
          right: 20,
          child: FocusWidget(
            hasFocus: selectKey == "two",
            child: Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ),
        ),
        Positioned(
          top: 60,
          left: 20,
          child: FocusWidget(
            hasFocus: selectKey == "three",
            child: GestureDetector(
              onTap: _incrementCounter,
              child: Icon(Icons.add, color: Theme.of(context).primaryColor),
            ),
          ),
        ),
      ]),
    );
  }
}
复制代码
  • 从上面的例子可以看到,业务端是需要提供焦点列表,自行指定好上下左右的焦点关键字;这个步骤其实就跟Android中,在xml指定焦点关系一样。
 android:nextFocusDown="@id/button1"
 android:nextFocusUp="@id/button2"
 android:nextFocusLeft="@id/button3"
 android:nextFocusRight="@id/button4"
复制代码
  • 同时例子中也有FocusWidget这个控件,我目前是完全摈弃了Flutter自带的这套FoucusNode,而是明确告诉Widget你被选中了、被点击了。这能解决FoucusNode本身的一些缺陷。
  • 当遇到带有FocusNode的组件,比如Input、Button、InkWell等,请一定要自主传入FoucusNode,并且设置skipTraversal为true,这样这个焦点就不会自动接收键盘事件,不会导致焦点冲突。
FocusNode focusNode_1 = FocusNode(skipTraversal: true);
复制代码

继续优化

在上面的方案中,我们发现一个最通过的点就是:接入相对复杂,特别是点击事件还要由页面级别通知到对应组件自行去执行。对于封装程度高的项目,这是很致命的。

因此我们把FocusWidget抽象出来,页面级别不再维护复杂的焦点关系列表;而是在需要处理焦点的控件外部包裹FocusWidget,传入需要的focus、nextFocusDown、nextFocusUp、nextFocusLeft、nextFocusRight;以及需要的UI状态、点击事件等。之后遥控器插件会自动找寻对应的FocusWidget,显示其选中状态,并触发传入的点击事件!

将焦点关系和事件处理完全融合在一起,不需要单独声明和维护,从而快速的让我们的应用适配遥控器 ~

写在最后

这个方案目前在业务上已经得到实践认可,但是细节以及优化方案还有很多需要注意的。
总之遥控器在Flutter上是个相对难啃的坑,只能不断往前爬,往前填。

Guess you like

Origin juejin.im/post/7228888718656127036