flutter - 如何将焦点固定在 Flutter 中的 ListView 项目上?

标签 flutter dart flutter-desktop

我有一个 ListView ,我想启用 Ctrl+cEnter 等快捷方式,这可以改善用户体验。

enter image description here

问题是在我单击/点击某个项目后,它失去了焦点并且快捷键不再起作用。

是否有针对此问题的修复或解决方法?

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';

void main() {
  runApp(const MyApp());
}

class SomeIntent extends Intent {}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.orange,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GetBuilder<Controller>(
      init: Get.put(Controller()),
      builder: (controller) {
        final List<MyItemModel> myItemModelList = controller.myItemModelList;
        return Scaffold(
          appBar: AppBar(
            title: RawKeyboardListener(
              focusNode: FocusNode(),
              onKey: (event) {
                if (event.logicalKey.keyLabel == 'Arrow Down') {
                  FocusScope.of(context).nextFocus();
                }
              },
              child: const TextField(
                autofocus: true,
              ),
            ),
          ),
          body: myItemModelList.isEmpty
              ? const Center(child: CircularProgressIndicator())
              : ListView.builder(
                  itemBuilder: (context, index) {
                    final MyItemModel item = myItemModelList[index];
                    return Shortcuts(
                      shortcuts: {
                        LogicalKeySet(LogicalKeyboardKey.enter): SomeIntent(),
                      },
                      child: Actions(
                        actions: {
                          SomeIntent: CallbackAction<SomeIntent>(
                            // this will not launch if I manually focus on the item and press enter
                            onInvoke: (intent) => print(
                                'SomeIntent action was launched for item ${item.name}'),
                          )
                        },
                        child: InkWell(
                          focusColor: Colors.blue,
                          onTap: () {
                            print('clicked item $index');
                            controller.toggleIsSelected(item);
                          },
                          child: Padding(
                            padding: const EdgeInsets.all(8.0),
                            child: Container(
                              color: myItemModelList[index].isSelected
                                  ? Colors.green
                                  : null,
                              height: 50,
                              child: ListTile(
                                title: Text(myItemModelList[index].name),
                                subtitle: Text(myItemModelList[index].detail),
                              ),
                            ),
                          ),
                        ),
                      ),
                    );
                  },
                  itemCount: myItemModelList.length,
                ),
        );
      },
    );
  }
}

class Controller extends GetxController {
  List<MyItemModel> myItemModelList = [];

  @override
  void onReady() {
    myItemModelList = buildMyItemModelList(100);

    update();

    super.onReady();
  }

  List<MyItemModel> buildMyItemModelList(int count) {
    return Iterable<MyItemModel>.generate(
      count,
      (index) {
        return MyItemModel('$index - check debug console after pressing Enter.',
            '$index - click me & press Enter... nothing happens\nfocus by pressing TAB/Arrow Keys and press Enter.');
      },
    ).toList();
  }

  toggleIsSelected(MyItemModel item) {
    for (var e in myItemModelList) {
      if (e == item) {
        e.isSelected = !e.isSelected;
      }
    }

    update();
  }
}

class MyItemModel {
  final String name;
  final String detail;
  bool isSelected = false;

  MyItemModel(this.name, this.detail);
}
  • 使用 Windows 10 和 flutter 3.0.1 进行测试
  • 使用Get状态管理器。

最佳答案

在 Flutter 中,ListViewGridView 包含许多 ListTile 小部件,您可能会注意到选择和焦点是分开的。我们还有 tap() 的问题,它理想地设置了选择和焦点 - 但默认情况下,点击不会影响焦点或选择。

ListTile selected属性官方demo https://api.flutter.dev/flutter/material/ListTile/selected.html 展示了我们如何手动实现一个 selected ListTile 并获取 tap() 来更改选定的 ListTile。但这在同步焦点方面对我们没有任何帮助。

Note: As that demo shows, tracking the selected ListTile needs to be done manualy, by having e.g. a selectedIndex variable, then setting the selected property of a ListTile to true if the index matches the selectedIndex.

这里有几个解决同步焦点问题的方法,在 ListView 中选择并点击。

方案一(已弃用,不推荐):

主要问题是访问焦点行为——默认情况下我们无权访问 到每个 ListTile 的 FocusNode。

UPDATE: Actually it turns out that there is a way to access a focusnode, and thus allocating our own focusnodes is not necessary - see Solution 2 below. You use the Focus widget with a child: Builder(builder: (BuildContext context) then you can access the focusnode with FocusScope.of(context).focusedChild. I am leaving this first solution here for study, but recommend solution 2 instead.

但是通过为列表中的每个 ListTile 项分配一个焦点节点 ListView,我们接着做。你看,通常一个 ListTile 项分配它自己的焦点 节点,但这对我们不利,因为我们想从访问每个焦点节点 外。所以我们自己分配焦点节点,传递给 ListTile 项目,因为我们构建它们,这意味着 ListTile 不再需要 自己分配一个 FocusNode - 注意:这不是 hack - 提供自定义 ListTile API 支持 FocusNodes。我们现在可以访问 每个 ListTile 项的 FocusNode 对象,以及

  • 调用它的 requestFocus() 选择更改时的方法。
  • 我们也在 FocusNode 中监听 焦点变化的对象,并在焦点出现时更新选择 变化。

我们自己提供给每个 ListTile 的自定义焦点节点的好处 是:

  1. 我们可以从 ListTile 小部件外部访问焦点节点。
  2. 我们可以使用焦点节点来请求焦点。
  3. 我们可以听到焦点的变化。
  4. 奖励:我们可以将快捷方式直接连接到焦点节点,而无需通常的 Flutter 快捷方式复杂性。

此代码同步选择、焦点和点击行为,并支持向上和向下箭头更改选择。

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// Enhancements to the official ListTile 'selection' demo
// https://api.flutter.dev/flutter/material/ListTile/selected.html to
// incorporate Andy's enhancements to sync tap, focus and selected.
// This version includes up/down arrow key support.

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  static const String _title =
      'Synchronising ListTile selection, focus and tap - with up/down arrow key support';

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const MyStatefulWidget(),
      ),
    );
  }
}

class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({super.key});

  @override
  State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  int _selectedIndex = 0;
  late List _focusNodes; // our custom focus nodes

  void changeSelected(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  void changeFocus(int index) {
    _focusNodes[index].requestFocus(); // this works!
  }

  // initstate
  @override
  void initState() {
    super.initState();

    _focusNodes = List.generate(
        10,
        (index) => FocusNode(onKeyEvent: (node, event) {
              print(
                  'focusnode detected: ${event.logicalKey.keyLabel} ${event.runtimeType} $index ');
              // The focus change that happens when the user presses TAB,
              // SHIFT+TAB, UP and DOWN arrow keys happens on KeyDownEvent (not
              // on the KeyUpEvent), so we ignore the KeyDownEvent and let
              // Flutter do the focus change. That way we don't need to worry
              // about programming manual focus change ourselves, say, via
              // methods on the focus nodes, which would be an unecessary
              // duplication.
              //
              // Once the focus change has happened naturally, all we need to do
              // is to change our selected state variable (which we are manually
              // managing) to the new item position (where the focus is now) -
              // we can do this in the KeyUpEvent.  The index of the KeyUpEvent
              // event will be item we just moved focus to (the KeyDownEvent
              // supplies the old item index and luckily the corresponding
              // KeyUpEvent supplies the new item index - where the focus has
              // just moved to), so we simply set the selected state value to
              // that index.

              if (event.runtimeType == KeyUpEvent &&
                  (event.logicalKey == LogicalKeyboardKey.arrowUp ||
                      event.logicalKey == LogicalKeyboardKey.arrowDown ||
                      event.logicalKey == LogicalKeyboardKey.tab)) {
                changeSelected(index);
              }

              return KeyEventResult.ignored;
            }));
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 10,
      itemBuilder: (BuildContext context, int index) {
        return ListTile(
          focusNode: _focusNodes[
              index], // allocate our custom focus node for each item
          title: Text('Item $index'),
          selected: index == _selectedIndex,
          onTap: () {
            changeSelected(index);
            changeFocus(index);
          },
        );
      },
    );
  }
}

重要说明:上述解决方案在更改项目数量时不起作用,因为所有焦点节点都是在 initState 期间分配的,它只被调用一次。例如,如果项目数量增加,则没有足够的焦点节点可以绕过,构建步骤将崩溃。

下一个解决方案(如下)没有显式分配焦点节点,是一个更强大的解决方案,支持动态重建和添加和删除项目。

方案二(允许重建,推荐)

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:developer' as developer;

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  static const String _title = 'Flutter selectable listview - solution 2';

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: _title,
      home: HomeWidget(),
    );
  }
}

// ╦ ╦┌─┐┌┬┐┌─┐╦ ╦┬┌┬┐┌─┐┌─┐┌┬┐
// ╠═╣│ ││││├┤ ║║║│ │││ ┬├┤  │
// ╩ ╩└─┘┴ ┴└─┘╚╩╝┴─┴┘└─┘└─┘ ┴

class HomeWidget extends StatefulWidget {
  const HomeWidget({super.key});

  @override
  State<HomeWidget> createState() => _HomeWidgetState();
}

class _HomeWidgetState extends State<HomeWidget> {
  // generate a list of 10 string items
  List<String> _items = List<String>.generate(10, (int index) => 'Item $index');
  String currentItem = '';
  int currentIndex = 0;
  int redrawTrigger = 0;

  // clear items method inside setstate
  void _clearItems() {
    setState(() {
      currentItem = '';
      _items.clear();
    });
  }

  // add items method inside setstate
  void _rebuildItems() {
    setState(() {
      currentItem = '';
      _items.clear();
      _items.addAll(List<String>.generate(5, (int index) => 'Item $index'));
    });
  }

  // set currentItem method inside setstate
  void _setCurrentItem(String item) {
    setState(() {
      currentItem = item;
      currentIndex = _items.indexOf(item);
    });
  }

  // set currentindex method inside setstate
  void _setCurrentIndex(int index) {
    setState(() {
      currentIndex = index;
      if (index < 0 || index >= _items.length) {
        currentItem = '';
      } else {
        currentItem = _items[index];
      }
    });
  }

  // delete current index method inside setstate
  void _deleteCurrentIndex() {
    // ensure that the index is valid
    if (currentIndex >= 0 && currentIndex < _items.length) {
      setState(() {
        String removedValue = _items.removeAt(currentIndex);
        if (removedValue.isNotEmpty) {
          print('Item index $currentIndex deleted, which was $removedValue');

          // calculate new focused index, if have deleted the last item
          int newFocusedIndex = currentIndex;
          if (newFocusedIndex >= _items.length) {
            newFocusedIndex = _items.length - 1;
          }
          _setCurrentIndex(newFocusedIndex);
          print('setting new newFocusedIndex to $newFocusedIndex');
        } else {
          print('Failed to remove $currentIndex');
        }
      });
    } else {
      print('Index $currentIndex is out of range');
    }
  }

  @override
  Widget build(BuildContext context) {
    // print the current time
    print('HomeView build at ${DateTime.now()} $_items');
    return Scaffold(
      body: Column(
        children: [
          // display currentItem
          Text(currentItem),
          Text(currentIndex.toString()),
          ElevatedButton(
            child: Text("Force Draw"),
            onPressed: () => setState(() {
              redrawTrigger = redrawTrigger + 1;
            }),
          ),
          ElevatedButton(
            onPressed: () {
              _setCurrentItem('Item 0');
              redrawTrigger = redrawTrigger + 1;
            },
            child: const Text('Set to Item 0'),
          ),
          ElevatedButton(
            onPressed: () {
              _setCurrentIndex(1);
              redrawTrigger = redrawTrigger + 1;
            },
            child: const Text('Set to index 1'),
          ),
          // button to clear items
          ElevatedButton(
            onPressed: _clearItems,
            child: const Text('Clear Items'),
          ),
          // button to add items
          ElevatedButton(
            onPressed: _rebuildItems,
            child: const Text('Rebuild Items'),
          ),
          // button to delete current item
          ElevatedButton(
            onPressed: _deleteCurrentIndex,
            child: const Text('Delete Current Item'),
          ),
          Expanded(
            key: ValueKey('${_items.length} $redrawTrigger'),
            child: ListView.builder(
              itemBuilder: (BuildContext context, int index) {
                // print('  building listview index $index');
                return FocusableText(
                  _items[index],
                  autofocus: index == currentIndex,
                  updateCurrentItemParentCallback: _setCurrentItem,
                  deleteCurrentItemParentCallback: _deleteCurrentIndex,
                );
              },
              itemCount: _items.length,
            ),
          ),
        ],
      ),
    );
  }
}

// ╔═╗┌─┐┌─┐┬ ┬┌─┐┌─┐┌┐ ┬  ┌─┐╔╦╗┌─┐─┐ ┬┌┬┐
// ╠╣ │ ││  │ │└─┐├─┤├┴┐│  ├┤  ║ ├┤ ┌┴┬┘ │
// ╚  └─┘└─┘└─┘└─┘┴ ┴└─┘┴─┘└─┘ ╩ └─┘┴ └─ ┴

class FocusableText extends StatelessWidget {
  const FocusableText(
    this.data, {
    super.key,
    required this.autofocus,
    required this.updateCurrentItemParentCallback,
    required this.deleteCurrentItemParentCallback,
  });

  /// The string to display as the text for this widget.
  final String data;

  /// Whether or not to focus this widget initially if nothing else is focused.
  final bool autofocus;

  final updateCurrentItemParentCallback;
  final deleteCurrentItemParentCallback;

  @override
  Widget build(BuildContext context) {
    return CallbackShortcuts(
      bindings: {
        const SingleActivator(LogicalKeyboardKey.keyX): () {
          print('X pressed - attempting to delete $data');
          deleteCurrentItemParentCallback();
        },
      },
      child: Focus(
        autofocus: autofocus,
        onFocusChange: (value) {
          print(
              '$data onFocusChange ${FocusScope.of(context).focusedChild}: $value');
          if (value) {
            updateCurrentItemParentCallback(data);
          }
        },
        child: Builder(builder: (BuildContext context) {
        // The contents of this Builder are being made focusable. It is inside
        // of a Builder because the builder provides the correct context
        // variable for Focus.of() to be able to find the Focus widget that is
        // the Builder's parent. Without the builder, the context variable used
        // would be the one given the FocusableText build function, and that
        // would start looking for a Focus widget ancestor of the FocusableText
        // instead of finding the one inside of its build function.
          developer.log('build $data', name: '${Focus.of(context)}');
          return GestureDetector(
            onTap: () {
              Focus.of(context).requestFocus();
              // don't call updateParentCallback('data') here, it will be called by onFocusChange
            },
            child: ListTile(
              leading: Icon(Icons.map),
              selectedColor: Colors.red,
              selected: Focus.of(context).hasPrimaryFocus,
              title: Text(data),
            ),
          );
        }),
      ),
    );
  }
}

关于flutter - 如何将焦点固定在 Flutter 中的 ListView 项目上?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/72757996/

相关文章:

dialog - polymer 纸对话框底部的空白

flutter - 如何为 macos 桌面应用启用 Flutter 上网权限?

android - Flutter 应用中的短信收发

Flutter 推送通知应用程序背景 : firebase_messaging

html - 如何检查自定义元素是否已注册?

windows - 是否有任何解决方案可以使用带有 Flutter 桌面的笔记本电脑相机拍照?

Flutter Desktop——系统托盘实现

flutter - 单个容器中的多个 listview.builder 方法

flutter - 使用 VS Code 在 Genymotion 机器上运行 Flutter

flutter - 类没有实例获取方法 'length'