我该如何在Flutter中修复ListView项目的焦点?

3

我有一个列表视图,想要启用类似于 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状态管理器。
2个回答

1
在Flutter中,如果一个包含多个ListTile小部件的ListView或GridView,您可能会注意到选择和焦点是分开的。我们还面临着tap()问题,理想情况下,它应该同时设置选择和焦点,但默认情况下,tap不会对焦点或选择产生影响。
ListTile的selected属性的官方演示https://api.flutter.dev/flutter/material/ListTile/selected.html展示了我们如何手动实现一个选定的ListTile,并让tap()更改所选的ListTile。但这对我们在同步焦点方面没有任何帮助。
引用:
注意:正如demo所示,跟踪所选的ListTile需要手动完成,例如通过具有selectedIndex变量,然后将ListTile的selected属性设置为true,如果index匹配selectedIndex。
以下是解决ListView中同步焦点、选中和点击的几种方案。
解决方案1(已弃用,不建议使用):
主要问题是访问焦点行为 - 默认情况下,我们无法访问每个ListTile的FocusNode。
注:实际上有一种访问focusnode的方法,因此不需要分配我们自己的focusnodes-请参见下面的Solution 2。您可以使用Focus widget并使用,然后可以使用访问焦点节点。我将保留这个第一解决方案以供研究,但建议使用解决方案2。
通过为ListView中的每个ListTile项目分配一个焦点节点,我们实现了这一点。你看,通常情况下,ListTile项目会分配自己的焦点节点,但这对我们来说不好,因为我们想从外部访问每个焦点节点。因此,我们自己分配焦点节点并在构建ListTile项目时将它们传递给它们,这意味着ListTile不再需要分配FocusNode本身 - 注意:这不是一个hack - 在ListTile API中支持提供自定义FocusNodes。现在,我们可以访问每个ListTile项目的FocusNode对象,并且:
  • 在选择更改时调用其requestFocus()方法。
  • 我们还监听FocusNode对象的焦点变化,并在焦点变化时更新选择。
我们为每个ListTile提供自定义焦点节点的好处是:
  1. 我们可以从ListTile小部件外部访问焦点节点。
  2. 我们可以使用焦点节点请求焦点。
  3. 我们可以监听焦点的变化。
  4. BONUS:我们可以直接将快捷方式连接到焦点节点,而无需使用通常的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 中分配的,而 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),
            ),
          );
        }),
      ),
    );
  }
}

我认为我已经发现了一个错误...所以将10个项目更改为100个,现在重新加载应用程序并按下直到您到达第35项,然后使用鼠标单击30,现在按上箭头,它应该转到29,但所选的瓷砖项是34。 - fenchai
是的,事实证明上述解决方案在更改项目数量时不起作用,因为所有的焦点节点都是在initState期间分配的,而这只会被调用一次。例如,如果项目数量增加,则没有足够的焦点节点可供使用,甚至可能导致build步骤崩溃。唯一的解决方案是重新构建整个小部件,这将在您切换到另一个选项卡并再次返回时发生-这并不理想。我正在研究另一种解决方案,并将附加到上面的答案中。 - abulka

0

编辑: 这个方法可以恢复焦点,但是焦点会从 顶部 的小部件开始,而不是从被单击的小部件开始。我希望这个答案仍然有所帮助。


编辑2 我找到了一个解决方案,您需要为listview()上的每个元素创建一个单独的FocusNode() 并在inkwell中对其进行requestFocus()。完整的更新工作示例(请使用此示例,而不是原始答案中的示例):

import 'package:flutter/material.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: MyHomePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final myItemModelList = List.generate(10, (index) => Text('${index + 1}'));
    final _focusNodes = List.generate(myItemModelList.length, (index) => FocusNode());

    return Scaffold(
      appBar: AppBar(),
      body: myItemModelList.isEmpty
          ? const Center(child: CircularProgressIndicator())
          : ListView.builder(
              itemBuilder: (context, index) {
                final item = myItemModelList[index];
                return RawKeyboardListener(
                  focusNode: _focusNodes[index],
                  onKey: (event) {
                    if (event.logicalKey.keyLabel == 'Arrow Down') {
                      FocusScope.of(context).nextFocus();
                    }
                  },
                  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}'),
                      )
                    },
                    child: InkWell(
                      focusColor: Colors.blue,
                      onTap: () {
                        _focusNodes[index].requestFocus();
                      },
                      child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Container(
                          color: Colors.blue,
                          height: 50,
                          child: ListTile(
                              title: myItemModelList[index],
                              subtitle: myItemModelList[index]),
                        ),
                      ),
                    ),
                  ),
                );
              },
              itemCount: myItemModelList.length,
            ),
    );
  }
}

编辑3: 为了也检测到向上键,你可以尝试:

 onKey: (event) {
                    if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
                      FocusScope.of(context).nextFocus();
                    } else if (event.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
                      FocusScope.of(context).previousFocus();
                    }
                  },

原始答案(您仍应阅读以了解完整的答案)。

首先,不要在appBar()中添加RawKeyboardListener(),而是将其添加到Scaffold()中。

现在,在Build方法之外创建一个FocusNode()

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

  final _focusNode = FocusNode();
  @override
  Widget build(BuildContext context) {}
  ...
  ...

_focusNode 分配给 RawKeyboardListener()
RawKeyboardListener(focusNode: _focusNode,
...

这里是关键点。由于您不想在ListView()中失去焦点,在inkWellonTap中,您将需要再次请求焦点:

InkWell(
    focusColor: Colors.blue,
    onTap: () {
      _focusNode.requestFocus();
      print('clicked item $index');
    },
 ...

就是这样。


这是一个完整的工作示例,基于您的代码。(由于我没有您的所有数据,我需要进行一些修改):

import 'package:flutter/material.dart';
import 'package:flutter/services.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: MyHomePage(),
    );
  }
}

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

  final _focusNode = FocusNode();
  @override
  Widget build(BuildContext context) {
    final myItemModelList = List.generate(10, (index) => Text('${index + 1}'));

    return Scaffold(
      appBar: AppBar(),
      body: myItemModelList.isEmpty
          ? const Center(child: CircularProgressIndicator())
          : RawKeyboardListener(
              focusNode: _focusNode,
              onKey: (event) {
                if (event.logicalKey.keyLabel == 'Arrow Down') {
                  FocusScope.of(context).nextFocus();
                }
              },
              child: ListView.builder(
                itemBuilder: (context, index) {
                  final 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}'),
                        )
                      },
                      child: InkWell(
                        focusColor: Colors.blue,
                        onTap: () {
                          _focusNode.requestFocus();
                          print('clicked item $index');
                        },
                        child: Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Container(
                            color: Colors.blue,
                            height: 50,
                            child: ListTile(
                                title: myItemModelList[index],
                                subtitle: myItemModelList[index]),
                          ),
                        ),
                      ),
                    ),
                  );
                },
                itemCount: myItemModelList.length,
              ),
            ),
    );
  }
}

演示:

enter image description here


@fenchai,我添加了第三个编辑,也包括向上键。这是我能提供的最多的内容。 - MendelG
但是你没有为我提供可用的代码,正如我所说,当我按下向下箭头键时,它会完全跳过一行(如果我在第1行并按下向下箭头键,则会跳到第3行而不是第2行)。至少需要为未来的观众提供可用的答案。 - fenchai
1
所以我刚刚创建了一个新项目,粘贴了代码并运行,它像您的演示一样工作,然后将onKey更新为接受keyUp事件,似乎可以工作,但是焦点颜色没有应用,这没关系,因为它能正常工作。但是当我点击一个项目然后按下keydown时,它会跳过一行,这就打败了解决这个问题的目的。 - fenchai
@fenchai 你可能想在StackOverflow上提出一个新问题,因为你所问的不是原始答案的一部分。或者,等待其他答案。很抱歉,我不能再帮忙了。 - MendelG
当你按下或向上箭头时跳过行可能与每次按键都有向上和向下事件有关。请注意,Flutter会在按下事件上为您执行焦点更改,这意味着您不必在自己的代码中重复更改焦点。我不想调试以上代码,但已提供了一个类似示例的答案。 - abulka
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接