Flutter FocusNode 焦点那点事-(一)

13,394 阅读6分钟

很多时候, flutter 中需要处理输入的焦点, 咱们今天就来看看控件怎么用

本篇可以视为简单使用, 而不会深入源码去探讨怎么附着, 主要是 Focus 系列控件的使用, 和怎么在多输入框之间反复横跳

环境说明

  1. 本篇基本基于 flutter sdk 的 1.17.5 版本来看, 其他版本应该大同小异, 但很多东西可能会随时间变化, 未来是否有效请继续验证
  2. 本篇基本是针对移动端来说的
  3. 写本文时, flutter web 的焦点比较迷, 似乎和移动版不太一样, 所以暂时略过不表
  4. desktop 版只尝试了 macOS, 其他的桌面引擎请自行校验对错

相关 dart class

flutter 中, 和焦点相关联类有如下几个:

  • FocusNode: 这个可以说是最常用到的, 核心类之一
  • FocusManager: 单例类, 整个 flutter 应用的焦点管理核心都是这东西在处理, 包括和原生交互弹出软键盘之类的操作
  • Focus: 一个 Widget, 用于给控件"添加"焦点能力, 包起来就行, InkWell 之类的控件能获取焦点能力都是靠这东西
  • FocusScope: 一个 Widget, Focus 的子类, 被这东西包起来的所有的子 widget 的 FocusNode 都会被自动注册到这个里面, 接受统一管理
  • FocusScopeNode: 这东西本身是 FocusNode 的子类, 但是它主要是给 FocusScope 用的,扩展了 FocusNode 的行为
  • FocusTraversalPolicy, FocusTraversalGroup: 这两个东西是 focus node 的策略, 用于排序哪个是下一个焦点的问题, 这两个东西本篇应该不讲, 有兴趣的可以去看官方文档, 目前个人认为应该用不上

FocusNode

这东西讲的人很多, 我也就不展开了, 简单的说一下几个方法

  • canRequestFocus: 是否能请求焦点
  • context: 焦点"附着"的 widget 的 BuildContext
  • hasFocus: 是否有焦点
  • unfocus: 放弃焦点, 如果当前 node 有焦点,并调用这个, 就放弃了焦点, 如果同时有软键盘弹起, 则软键盘收起
  • requestFocus: 请求焦点, 这个方法调用后, 会把焦点移到当前

备注: 有很多其他的方法, 对于普通朋友和正常的应用场景很难用到, 作为程序框架有提供, 但是个人观点不必一定要了解, 只要知道主要方法即可

FocusManager

这东西是一个单例的,通过FocusManager.instance获取

有一个常用方法了解一下: FocusManager.instance.primaryFocus.unfocus();, 调用一下, 软键盘就下去了

1595307999

这东西里面基本都是私有方法, 能调用的并不多

FocusHighlightMode 这东西是焦点的"模式", 对应触摸和鼠标键盘, 个人认为一般情况下用不到, 移动端就 touch 就可以了

Focus

这东西一般情况下很少能用到, SDK 里有一些地方会用到, Focus 对象本身内部会维护一个 FocusNode, 比如按钮能响应键盘回车之类的焦点就是因为内部有这东西

这个类在 flutter 项目中使用率不算高, 但都是关键处

1595308605
1595311067

_FocusableActionDetectorState: 对应 FocusableActionDetector 的状态, 这个类被用于 CheckBox, Radio, Switch

FocusScope

这东西很少见有文档讲, 这里我简单的解析一下, 这个也可以说是后面使用的重点, 我在实际开发中遇到有输入框的情况下, 这个控件是我的首选

简单来说, 就是在这东西子控件内的 FocusNode 都会被统一维护

1595316377

这东西构造方法可以传一些参数, 常用的无非就是 node, canRequestFocus, 之类的.

这里有一个 skipTraversal, 这个参数后面结合例子来看才能说明白

FocusScopeNode

一般和FocusScope成对使用

写代码

入门级写法

嗯, 前面都是概念性的东西, 很多朋友都不想看, 而且也没啥意思

比如有一个这样的场景

1595317002

用 app 来说, 就是 4 个输入框, 一个个的点击自然可以, 但是如果要用户体验好是不是应该可以回车一直下一步, 然后最后一条直接提交呢?

模拟一下这个东西很多人的写法

1595317293

嗯, 点评一下, 嗯 很整齐, 那么... 当你有 10 个的时候怎么办呢? 想想就很美

我们改写下,也许可以这样?

1595317499

好的, 算你基础扎实, 这样写自然是可以的.

进阶

上面的写法很 dart, 但是不 flutter, 我们 flutter 的写法可以改成这样

import 'package:flutter/material.dart';

class Example3 extends StatefulWidget {
  @override
  _Example3State createState() => _Example3State();
}

class _Example3State extends State<Example3> {
  FocusScopeNode node = FocusScopeNode();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: FocusScope(
        node: node,
        child: SingleChildScrollView(
          child: Column(
            children: <Widget>[
              for (var i = 0; i < 10; i++) buildTextField(),
            ],
          ),
        ),
      ),
    );
  }

  TextField buildTextField() {
    return TextField(
      onEditingComplete: () {
        if (node.focusedChild == node.children.last) {
          print('submit');
        } else {
          node.nextFocus();
        }
      },
    );
  }
}

这次连 FocusNode 都不需要自己写了, 直接用 Scope 里的

这个 example 的样子:

1595319363

这是因为 TextFieldEditableText 的封装

1595318868
1595318898
1595318882

然后是在 EditableText 里, attach 到了 context 上

1595319082

看到这里, 是不是发现其实有的东西很简单, 接下来复杂一下

再进阶

import 'package:flutter/material.dart';

class Example3 extends StatefulWidget {
  @override
  _Example3State createState() => _Example3State();
}

class _Example3State extends State<Example3> {
  FocusScopeNode node = FocusScopeNode();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: FocusScope(
        node: node,
        child: SingleChildScrollView(
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              children: <Widget>[
                for (var i = 0; i < 5; i++) buildTextField(),
                Row(
                  children: <Widget>[
                    Expanded(
                      child: TextField(
                        onEditingComplete: onEdit,
                      ),
                    ),
                    RaisedButton(
                      onPressed: () {},
                      child: Text('假装获取验证码'),
                    ),
                  ],
                ),
                for (var i = 0; i < 5; i++) buildTextField(),
              ],
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          print(node.traversalChildren.length);
        },
        child: Icon(Icons.check),
      ),
    );
  }

  TextField buildTextField() {
    return TextField(
      onEditingComplete: onEdit,
    );
  }

  void onEdit() {
    node.nextFocus();
  }
}

这种偶尔旁边多了一个按钮的, 属于比较常见的方式, 然后上面代码突然就不好用了

1595323601

这时候就需要改代码了

floatingActionButton: FloatingActionButton(
onPressed: () {
    print(node.children.length); // 12
},
child: Icon(Icons.check),
),

为啥变 12 了呢, 不是只有 11 个输入框吗?

这里就和我开始说的对上了, 很多按钮也有 focus.

那么怎么在回车时跳过这个按钮呢

   RaisedButton(
    onPressed: () {},
    focusNode: FocusNode(skipTraversal: true),
    child: Text('假装获取验证码'),
),

是的, 就是这样, 给按钮手动传入一个 FocusNode, 然后 skip 就可以了

1595324883

完整代码:

import 'package:flutter/material.dart';

class Example3 extends StatefulWidget {
  @override
  _Example3State createState() => _Example3State();
}

class _Example3State extends State<Example3> {
  FocusScopeNode node = FocusScopeNode();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: FocusScope(
        node: node,
        child: SingleChildScrollView(
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              children: <Widget>[
                for (var i = 0; i < 5; i++) buildTextField(),
                Row(
                  children: <Widget>[
                    Expanded(
                      child: TextField(
                        onEditingComplete: onEdit,
                      ),
                    ),
                    RaisedButton(
                      onPressed: () {},
                      focusNode: FocusNode(skipTraversal: true),
                      child: Text('假装获取验证码'),
                    ),
                  ],
                ),
                for (var i = 0; i < 5; i++) buildTextField(),
              ],
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          print(node.traversalChildren.length);
        },
        child: Icon(Icons.check),
      ),
    );
  }

  TextField buildTextField() {
    return TextField(
      onEditingComplete: onEdit,
    );
  }

  void onEdit() {
    node.nextFocus();
  }
}

所以总结一下步骤

  1. 将所有的输入框包在一个 FocusScope 里, 设置 FocusScopeNode.
  2. 将有焦点但不是输入框的控件设置一个 FocusNode(skipTraversal: true)
  3. 使用FocusScopeNodenextFocus方法

后记

本篇到此, 本系列的后续预计要深爬一下源码

以上