如何在Flutter中通过拖动选择小部件,但也可以单独点击它们?

9

我想创建一个界面,可以在其中拖动手指到不同的区域,从而将这些区域的状态更改为选中状态(详见图像)。

如何最好地实现这个功能?

起始位置:
起始位置

开始拖动:
开始拖动

选择第一个区域: 选择第一个区域

选择所有区域: 选择所有区域


内部小部件的区域大小固定吗?圆形形状呢? - Yeasin Sheikh
4个回答

6

这段代码需要更新以适应当前的Flutter/Dart版本,但这份代码对我有用。

更新后的代码:


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


我正要回答。我的代码略有不同,所以我认为它可以工作。 - mario francois
非常感谢您的回答。这正是我所想要的! - Philipp Redeker

1

我使用 Rect 类。

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


1
这个包drag_select_grid_view提供了另一种相关的方法。从代码中可以看到:其中包括以下有趣的事情:
  • 使用GestureDetector来封装选择区域(这里是GridView)
  • GridView.itemBuilder中,自定义ProxyWidget(他的Selectable)包装可选择项的常规小部件生成器。这用于公开挂载/卸载点,以便悬挂相应的自定义ProxyElement
  • 检测到轻击/移动时,他使用当前上下文获取覆盖选择区域的RenderObject,通过使用选区坐标系中每个项的边界框,检查任何缓存的元素是否包含该点,并使用本地位置偏移量进行手动命中测试。 (请参见_findIndexOfSelectableSelectable.containsOffset(这与@mario的答案类似,如果屏幕上有许多可能的元素可供选择,则可能比@a.shak's的成本高。)
  • 结果通过ValueNotifier传递给用户,该值允许用户控制清除或设置自定义选择。 (请参阅控制器代码

为了对比,我将试图用言语描述@a.shak的答案:

  • 在你的GridState类中,使用一个Listener包裹代表选择区域的子树。 (虽然GestureDetector也可以)

    • onPointerDown|Move中开始检测;在onPointerUp中可以清除等操作。
    • 检测需要获取子树的RenderBox(即RenderObject),以便使用指针的本地位置进行hitTest以查找其他相交的ROs。给定选择区域的RB,请将指针转换为其本地坐标并进行RenderBox.hitTest,然后沿着相交对象的BoxHitTestResult.path行走,检查任何HitTestEntry是否属于我们知道可被选择的类型(即_Foo extends RenderProxyBox类 - 请参见下文)
      • 如果匹配成功,则跟踪其信息以进行UI更新,并在其他地方稍后使用。
  • 使用GlobalKeyGridView一起使用,以获取在命中测试期间对应于选择区域的范围的RenderBox(可能不需要这个,因为可以使用状态自己的context...)

  • GridView.itemBuilder中,将可选择的对象包装在自定义的SingleChildRenderObjectWidget中,用于获取项目的RenderBox以进行命中测试和存储信息。

    • 在此处存储信息,例如您的项目索引,并将其推入我们的SCROW创建的自定义RenderBox中。
    • 使用RenderProxyBox,因为我们实际上并不关心控制渲染;只需将其全部委托给子项即可。此自定义类还使我们更轻松地在命中测试期间找到我们感兴趣的可选择对象(请参见_detectTapedItem)。
因此,在这两种情况下,您需要实现一些额外的自定义类(ProxyWidget+ProxyElementSingleChildRenderObjectWidget+RenderProxyBox),以便使用屏幕上选择的点进行命中测试并获取正确的RenderBox,并存储杂项信息,如项目的索引以更新UI并稍后使用。
对于自定义形状,您可以使您的CustomPainter覆盖其hitTest方法,利用Path.contains()来限制触摸仅在路径内。请参见此答案。或者只需使用像touchable这样的包,为您的形状提供手势回调。

1
为了让@a.shak的答案更易于使用,我将答案抽象成了一个可重用的Widgets对组件,包括DragRegionDragRegionTarget。请参考每个组件的使用说明。
此外,我还添加了ValueKeys和泛型,使其不仅限于索引或整数。享受其中包含的注释和文档。
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);
}

这是一个例子。与 @a.shak 的例子完全一样,只是整理过了。
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,
            ),
          );
        },
      ),
    );
  }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接