How can I select Widgets by dragging over them but also clicking them individually in flutter?

Viewed 1745

I want to create an Interface in which it is possible to drag your finger over several Areas. This changes the state of the areas to a selected state (See images).

What is the best way to approach this?

Start Position:
Star Position

Start Dragging:
Start Dragging

Select First Area: Select first area

Selected All Areas: Selected all areas

3 Answers

The code needs some updates for current Flutter/Dart versions but this worked for me.

Updated code:


    import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: Grid(),
    );
  }
}

class Grid extends StatefulWidget {
  @override
  GridState createState() {
    return new GridState();
  }
}

class GridState extends State<Grid> {
  final Set<int> selectedIndexes = Set<int>();
  final key = GlobalKey();
  final Set<_Foo> _trackTaped = Set<_Foo>();

  _detectTapedItem(PointerEvent event) {
    final RenderBox box = key.currentContext!.findAncestorRenderObjectOfType<RenderBox>()!;
    final result = BoxHitTestResult();
    Offset local = box.globalToLocal(event.position);
    if (box.hitTest(result, position: local)) {
      for (final hit in result.path) {
        /// temporary variable so that the [is] allows access of [index]
        final target = hit.target;
        if (target is _Foo && !_trackTaped.contains(target)) {
          _trackTaped.add(target);
          _selectIndex(target.index);
        }
      }
    }
  }

  _selectIndex(int index) {
    setState(() {
      selectedIndexes.add(index);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: _detectTapedItem,
      onPointerMove: _detectTapedItem,
      onPointerUp: _clearSelection,
      child: GridView.builder(
        key: key,
        itemCount: 6,
        physics: NeverScrollableScrollPhysics(),
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          childAspectRatio: 1.0,
          crossAxisSpacing: 5.0,
          mainAxisSpacing: 5.0,
        ),
        itemBuilder: (context, index) {
          return Foo(
            index: index,
            child: Container(
              color: selectedIndexes.contains(index) ? Colors.red : Colors.blue,
            ),
          );
        },
      ),
    );
  }

  void _clearSelection(PointerUpEvent event) {
    _trackTaped.clear();
    setState(() {
      selectedIndexes.clear();
    });
  }
}

class Foo extends SingleChildRenderObjectWidget {
  final int index;

  Foo({required Widget child, required this.index, Key? key}) : super(child: child, key: key);

  @override
  _Foo createRenderObject(BuildContext context) {
    return _Foo(index);
  }

  @override
  void updateRenderObject(BuildContext context, _Foo renderObject) {
    renderObject..index = index;
  }
}

class _Foo extends RenderProxyBox {
  int index;
  _Foo(this.index);
}

I use Rect class.

import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

class StackOverflow extends StatefulWidget {
  const StackOverflow({Key? key}) : super(key: key);

  @override
  _StackOverflowState createState() => _StackOverflowState();
}

class _StackOverflowState extends State<StackOverflow> {
  late List<bool> isSelected;
  late List<GlobalKey> myGlobalKey;
  late List<Offset> offsetWidgets;
  late List<Size> sizeWidgets;
  late List<Rect> listRect;

  @override
  void initState() {
    super.initState();
    isSelected = List.generate(3, (index) => false);
    myGlobalKey = List.generate(3, (index) => GlobalKey());
    offsetWidgets = <Offset>[];
    sizeWidgets = <Size>[];
    listRect = <Rect>[];
    WidgetsBinding.instance!.addPostFrameCallback((timeStamp) {
      for (final key in myGlobalKey) {
        sizeWidgets
            .add((key.currentContext!.findRenderObject() as RenderBox).size);
        offsetWidgets.add((key.currentContext!.findRenderObject() as RenderBox)
            .localToGlobal(Offset.zero));
      }
      for (int i = 0; i < 3; i++) {
        final dx = offsetWidgets[i].dx + sizeWidgets[i].width;
        final dy = offsetWidgets[i].dy + sizeWidgets[i].height;
        listRect.add(Rect.fromPoints(offsetWidgets[i], Offset(dx, dy)));
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerMove: (PointerMoveEvent pointerMoveEvent) {

        if (listRect[0].contains(pointerMoveEvent.position)) {
          if (!isSelected[0]) {
            setState(() {
              isSelected[0] = true;
            });
          }
        } else if (listRect[1].contains(pointerMoveEvent.position)) {
          if (!isSelected[1]) {
            setState(() {
              isSelected[1] = true;
            });
          }
        } else if (listRect[2].contains(pointerMoveEvent.position)) {
          if (!isSelected[2]) {
            setState(() {
              isSelected[2] = true;
            });
          }
        }
      },

      child: Container(
        color: Colors.amber,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            RawMaterialButton(
              key: myGlobalKey[0],
              fillColor: isSelected[0] ? Colors.blueGrey : Colors.transparent,
              shape:
                  const CircleBorder(side: BorderSide(color: Colors.blueGrey)),
              onPressed: () {
                setState(() {
                  isSelected[0] = false;
                });
              },
            ),
            RawMaterialButton(
              key: myGlobalKey[1],
              fillColor: isSelected[1] ? Colors.blueGrey : Colors.transparent,
              shape:
                  const CircleBorder(side: BorderSide(color: Colors.blueGrey)),
              onPressed: () {
                setState(() {
                  isSelected[1] = false;
                });
              },
            ),
            RawMaterialButton(
              key: myGlobalKey[2],
              fillColor: isSelected[2] ? Colors.blueGrey : Colors.transparent,
              shape:
                  const CircleBorder(side: BorderSide(color: Colors.blueGrey)),
              onPressed: () {
                setState(() {
                  isSelected[2] = false;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

This package: drag_select_grid_view offers another related approach. From the code, you can see among other fun things:

  • Use of a GestureDetector to encapsulate the selection area (a GridView here)
  • In the GridView.itemBuilder, a custom ProxyWidget (his Selectable) wraps the normal widget builder for your selectable items. This is used to expose mount/unmount points in order to hang onto the corresponding custom ProxyElement.
  • When a tap/motion is detected, he uses the current context to get a RenderObject covering the selection area to do manual hit testing with the local position Offset, by checking if any of the cached Elements contain the point, using each item's bounding box in the selection area's coordinate system. (see _findIndexOfSelectable and Selectable.containsOffset) (This is like @mario's answer, and presumably could be costlier than @a.shak's if there are many possible Elements to choose from on screen.)
  • Results are passed back to the user through a ValueNotifier, which also lets the user control clearing or setting a custom the selection. (see the controller code)

For contrast, I'll try to describe @a.shak's answer in words:

  • In his GridState class wrap a Listener around the subtree representing your selection area. (although a GestureDetector could work, too)

    • in onPointerDown|Move, start detecting; onPointerUp you can clear/etc.
    • Detection requires getting the subtree’s RenderBox (a RenderObject) so you can do hitTesting with the pointer’s local position to find other intersecting ROs. Given the selection area's RB, convert the pointer to its local coordinates and do the RenderBox.hitTest, then walk along the BoxHitTestResult.path of intersecting objects to check whether any HitTestEntry is of a type we know can be selected. (i.e. the _Foo extends RenderProxyBox class - see below)
      • If it's a match, success! Track its info for UI updates and later use elsewhere.
  • Use a GlobalKey with the GridView to get the RenderBox corresponding to the selection area’s extents during hit testing. (Probably don’t need this as you can use the State’s own context…)

  • In The GridView.itemBuilder, wrap your selectable objects in a custom SingleChildRenderObjectWidget, used to get the item's RenderBox for hit testing and storing info.

    • Store info here like your item's index and push it down into a custom RenderBox that our SCROW creates.
    • Uses a RenderProxyBox since we don’t actually care about controlling the rendering; just delegate it all to the child. This custom class also lets us find our selectable object(s) of interest more easily during the hit test (see _detectTapedItem).

So in both cases, you need to implement some extra custom classes (ProxyWidget+ProxyElement vs SingleChildRenderObjectWidget+RenderProxyBox) in order to get the right RenderBoxes for hit testing with the selected point on the screen, and to store misc info like an item's index to update the UI and use later.

For custom shapes, you can have your CustomPainter override its hitTest method to leverage Path.contains() to restrict touches to be within the path only. See this answer. Or just use a a package like touchable to give your shapes gesture callbacks.

Related