3

I have a listview that I want to enable shortcuts like Ctrl+c, Enter, etc this improves user experience.

enter image description here

The issue is after I click/tap on an item, it loses focus and the shortcut keys no longer work.

Is there a fix or a workaround for this?

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);
}
  • Tested with Windows 10 and flutter 3.0.1
  • Using Get State manager.
fenchai
  • 518
  • 1
  • 7
  • 21

2 Answers2

1

In Flutter, a ListView or GridView containing a number of ListTile widgets, you may notice that the selection and the focus are separate. We also have the issue of tap() which ideally sets both the selection and the focus - but by default tap does nothing to affect focus or selection.

The the official demo of ListTile selected property https://api.flutter.dev/flutter/material/ListTile/selected.html shows how we can manually implement a selected ListTile and get tap() to change the selected ListTile. But this does nothing for us in terms of synchronising focus.

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.

Here are a couple of solutions to the problem of to the syncronising focus, selected and tap in a listview.

Solution 1 (deprecated, not recommended):

The main problem is accessing focus behaviour - by default we have no access to each ListTile's 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.

But by allocating a focus node for each ListTile item in the ListView, we then do. You see, normally a ListTile item allocates its own focus node, but that's bad for us because we want to access each focus node from the outside. So we allocate the focus nodes ourselves and pass them to the ListTile items as we build them, which means a ListTile no longer has to allocate a FocusNode itself - note: this is not a hack - supplying custom FocusNodes is supported in the ListTile API. We now get access to the FocusNode object for each ListTile item, and

  • invoke its requestFocus() method whenever selection changes.
  • we also listen in the FocusNode objects for changes in focus, and update the selection whenever focus changes.

The benefits of custom focus node which we supply ourselves to each ListTile are:

  1. We can access the focus node from outside the ListTile widget.
  2. We can use the focus node to request focus.
  3. We can listen to changes in focus.
  4. BONUS: We can wire shortcuts directly into the focus node without the usual Flutter shortcut complexity.

This code synchronises selection, focus and tap behaviour, as well as supporting up and down arrow changing the selection.

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);
          },
        );
      },
    );
  }
}

Important Note: The above solution doesn't work when changing the number of items, because all the focusnodes are allocated during initState which only gets called once. For example if the number of items increases then there are not enough focusnodes to go around and the build step will crash.

The next solution (below) does not explicitly allocate focusnodes and is a more robust solution which supports rebuilding and adding and removing items dynamically.

Solution 2 (allows rebuilds, recommended)

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),
            ),
          );
        }),
      ),
    );
  }
}
abulka
  • 1,316
  • 13
  • 18
  • I think I already found a bug... so instead of 10 items, change to 100, now reload the app and press down until you reach item 35 then use the mouse and click on 30 now press the up arrow, it is supposed to go to 29 but the selected tile item is 34. – fenchai Oct 15 '22 at 03:04
  • Yeah, it turns out the above solution doesn't work when changing the number of items, because all the focusnodes are allocated during `initState` which only gets called once. For example if the number of items increases then there are not enough focusnodes to go around and the `build` step may even crash. The only solution is to rebuild the entire widget which happens if you switch to a different tab and back again, for example - not ideal. I am working on another solution and will append to the above answer. – abulka Oct 17 '22 at 00:05
0

Edit: this works to regain focus, however, the focus starts again from the top widget and not from the widget that was clicked on. I hope this answer still helps


Edit 2 I found a solution, you'll have to create a separate FocusNode() for each element on your listview() and requestFocus() on that in your inkwell. Complete updated working example (use this one, not the one in the original answer):

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,
            ),
    );
  }
}

Edit 3: To also detect the up key you can try:

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

Original answer (you should still read to understand the complete answer).

First of all, your adding RawKeyboardListener() within your appBar() don't do that, instead add it to the Scaffold().

Now, create a FocusNode() outside of your Build method:

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

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

And assing the _focusNode to the RawKeyboardListener():

RawKeyboardListener(focusNode: _focusNode,
...

And here's the key point. Since you don't want to lose focus in the ListView(), in the onTap of your inkWell you'll have to request focus again:

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

That's it.


Here is a complete working example based on your code. (I needed to modify some things, since I don't have all your data):

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,
              ),
            ),
    );
  }
}

Demo:

enter image description here

MendelG
  • 14,885
  • 4
  • 25
  • 52
  • I tested your code from edit2 and it looks the same as your demo gif but it behaves completely different on my side, the focus skips one row and the focus does not show when going up, only when going down – fenchai Jul 05 '22 at 14:22
  • @fenchai _"does not show when going up, only when going down"_ That's because we only set a listener for the `arrow down` key within the `RawKeyboardListener()`. You haven't included adding the _arrow up_ button in your question. I'll try implementing it though when I have time. – MendelG Jul 05 '22 at 14:36
  • when I press `arrow down` it skips completely 1 row, it behaves different from your demo gif. I am starting to think this is hopeless and I need to forget about the focus system, is just very unreliable. – fenchai Jul 05 '22 at 14:40
  • @fenchai I added edit 3 to also include the up key. this is the most I can provide, – MendelG Jul 05 '22 at 14:44
  • but you haven't provided a working code for me, as I said it skips one row completely (if I am on row 1 and press down key, I go to row 3 instead of 2) when I press the down arrow key. At least need to provide working answer for future viewers. – fenchai Jul 05 '22 at 14:46
  • @fenchai Interesting. Are you only pressing the key _once_? It does work for me. Try running `flutter clean` and `flutter pub get`. Also, try running in a new project. – MendelG Jul 05 '22 at 14:48
  • Also, if you read your question carefully, I did answer it: _" after I click/tap on an item, it loses focus and the shortcut keys no longer work._" I showed you how to regain focus. Then came up a _different_ problem. I suggest you close this question and open a _new_ one. (even if you decide not to award the bounty). – MendelG Jul 05 '22 at 14:52
  • 1
    so I just created a new project and pasted the code and ran and it works as your demo, then updated the onKey toa accept keyUp events, seems to work but the focus color is not applied, that is fine, I don't mind as it works. But when I click on an item then press the keydown it will skip one row, which defeats the purpose of trying to solve this problem. – fenchai Jul 05 '22 at 15:16
  • @fenchai You might want to ask a new question on StackOverflow since what you're asking is not part of your original answer. Or, wait for other answers. Sorry, that I can't help anymore – MendelG Jul 05 '22 at 15:17
  • The skipping of lines when you hit up or down arrow is probably related to there being both a up and down event for each keypress. Note also that Flutter does a focus change for you on the down event, meaning you don't have to duplicate the focus changing in your own code. I don't want to debug the above code, but have supplied my own answer with a similar example. – abulka Oct 12 '22 at 22:12