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?
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?
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:
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.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)
onPointerDown|Move, start detecting; onPointerUp you can clear/etc.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)
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.
RenderBox that our SCROW creates._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.