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 hitTest
ing 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 RenderBox
es 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.
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,
),
);
},
),
);
}
}
© 2022 - 2024 — McMap. All rights reserved.