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

4

9

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

Undershot answered 8/12, 2021 at 15:26 Comment(1)
Area sizes fixed for inner widget? The circular shapes?Laaland
A
6

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

Allerie answered 8/12, 2021 at 19:48 Comment(2)
I was about to answer. My code is just a bit different so I think it work.Olden
Thank you very much for your answer. This is exactly what i had in mind!Undershot
O
1

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

Olden answered 8/12, 2021 at 20:14 Comment(0)
D
1

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.

Deli answered 25/2, 2022 at 9:24 Comment(0)
A
1

To make @a.shak's answer easier to use, I abstracted the answer into a reusable pair widget of Widgets DragRegion and DragRegionTarget. See the instructions for how to use each.

Also, I added ValueKeys and generics to make it not only limited by index nor int. Enjoy the comments/documentation included.

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

/// Instructions:
/// 1.  Wrap the area you want to be in your drag region with [DragRegion]
/// 2.  Wrap each element you want to listen to the drag events with [DragRegionTarget]
class DragRegion<T> extends StatefulWidget {
  final Widget child;

  /// Called on the first time the drag enters the target since the drag started
  final void Function(ValueKey<T> valueKey)? onDragFirstEnter;

  /// Called on every time the drag enters the target
  final void Function(ValueKey<T> valueKey)? onDragEnter;

  /// Called on every time the drag moves over the target
  final void Function(ValueKey<T> valueKey)? onDragMove;

  /// Called on every time the drag leaves the target
  final void Function(ValueKey<T> valueKey)? onDragExit;

  /// Called on the initial drag event
  final void Function()? onDragStart;

  /// Called on the ending drag event
  final void Function()? onDragEnd;

  const DragRegion({
    super.key,
    required this.child,
    this.onDragFirstEnter,
    this.onDragEnter,
    this.onDragExit,
    this.onDragMove,
    this.onDragStart,
    this.onDragEnd,
  });
  @override
  State<DragRegion> createState() => _DragRegionState<T>();
}

class _DragRegionState<T> extends State<DragRegion<T>> {
  late Set<ValueKey<T>> touchedValueKeys; // items that have been hit since drag start
  late Set<ValueKey<T>> touchingValueKeys; // items that are currently being hit
  late GlobalKey key; // Global key to identify the container

  @override
  void initState() {
    touchedValueKeys = {};
    touchingValueKeys = {};
    key = GlobalKey();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: detectTappedItem,
      onPointerMove: detectTappedItem,
      onPointerUp: (_) {
        clearTappedItems();
        widget.onDragEnd?.call();
      },
      onPointerCancel: (_) {
        clearTappedItems();
        widget.onDragEnd?.call();
      },
      child: Container(
        key: key,
        color: const Color(0x00000000), // remove this if you want the listener to ignore areas there aren't widgets in the child being displayed
        child: widget.child,
      ),
    );
  }

  void clearTappedItems() {
    for (final valueKey in touchingValueKeys) {
      widget.onDragExit?.call(valueKey);
    }

    touchingValueKeys.clear();
    touchedValueKeys.clear();
  }

  void detectTappedItem(PointerEvent event) {
    final box = key.currentContext?.findAncestorRenderObjectOfType<RenderBox>();
    if (box == null) return;

    final hitTestResult = BoxHitTestResult();
    final local = box.globalToLocal(event.position);
    if (!box.hitTest(hitTestResult, position: local)) return;

    final Set<ValueKey<T>> currentlyTouchingValueKeys = {};

    for (final hit in hitTestResult.path) {
      final target = hit.target;
      if (target is! _ValueKeyHolder<T>) continue;

      final valueKey = target.valueKey;
      currentlyTouchingValueKeys.add(valueKey);

      if (!touchedValueKeys.contains(valueKey)) {
        touchedValueKeys.add(valueKey);
        widget.onDragFirstEnter?.call(valueKey);
      }
    }

    // find out which ones have entered, stayed, and exited
    final exitedValueKeys = touchingValueKeys.difference(currentlyTouchingValueKeys);
    final enteredValueKeys = currentlyTouchingValueKeys.difference(touchingValueKeys);

    for (final valueKey in enteredValueKeys) {
      widget.onDragEnter?.call(valueKey);
    }
    for (final valueKey in currentlyTouchingValueKeys) {
      widget.onDragMove?.call(valueKey);
    }
    for (final valueKey in exitedValueKeys) {
      widget.onDragExit?.call(valueKey);
    }

    touchingValueKeys.clear();
    touchingValueKeys.addAll(currentlyTouchingValueKeys);
  }
}

///
/// Instructions:
/// 1. Identify each child with a unique ValueKey which will be passed to [DragRegion]'s callbacks
///
class DragRegionTarget<T> extends SingleChildRenderObjectWidget {
  final ValueKey<T> valueKey;

  const DragRegionTarget({required Widget child, required this.valueKey, Key? key}) : super(child: child, key: key);

  @override
  _ValueKeyHolder createRenderObject(BuildContext context) {
    return _ValueKeyHolder<T>(valueKey);
  }

  @override
  void updateRenderObject(BuildContext context, _ValueKeyHolder<T> renderObject) {
    renderObject.valueKey = valueKey;
  }
}

///
/// A class for holding the ValueKey
///
class _ValueKeyHolder<T> extends RenderProxyBox {
  ValueKey<T> valueKey;
  _ValueKeyHolder(this.valueKey);
}

Here's an example. Exactly as functional as @a.shak's example, only cleaned up.

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 GridState();
  }
}

class GridState extends State<Grid> {
  final Set<int> selectedIndexes = {};

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

  void _clearSelection() {
    setState(() {
      selectedIndexes.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    return DragRegion<int>(
      onDragFirstEnter: (valueKey) {
        _selectIndex(valueKey.value); 
      },
      onDragEnd: () {
        _clearSelection();
      },
      child: GridView.builder(
        itemCount: 6,
        physics: const NeverScrollableScrollPhysics(),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          childAspectRatio: 1.0,
          crossAxisSpacing: 5.0,
          mainAxisSpacing: 5.0,
        ),
        itemBuilder: (context, index) {
          return DragRegionTarget<int>(
            valueKey: ValueKey(index),
            child: Container(
              color: selectedIndexes.contains(index) ? Colors.red : Colors.blue,
            ),
          );
        },
      ),
    );
  }
}
Algeciras answered 13/6, 2023 at 22:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.