Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active August 3, 2023 18:10
Show Gist options
  • Save slightfoot/a002dd1e031f5f012f810c6d5da14a11 to your computer and use it in GitHub Desktop.
Save slightfoot/a002dd1e031f5f012f810c6d5da14a11 to your computer and use it in GitHub Desktop.
Multi Select GridView in Flutter - by Simon Lightfoot (Drag to select multiple items) Try now here: https://dartpad.dev/?id=a002dd1e031f5f012f810c6d5da14a11
// MIT License
//
// Copyright (c) 2019 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// Thanks to Hugo Passos.
//
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.indigo,
accentColor: Colors.pinkAccent,
),
home: ExampleScreen(),
),
);
}
class ExampleScreen extends StatefulWidget {
@override
State<ExampleScreen> createState() => _ExampleScreenState();
}
class _ExampleScreenState extends State<ExampleScreen> {
final colors = <MaterialColor>[];
var _selection = MultiChildSelection.empty;
@override
void initState() {
super.initState();
colors.addAll(Colors.primaries);
colors.addAll(Colors.primaries);
colors.addAll(Colors.primaries);
colors.addAll(Colors.primaries);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: MultiSelectAppBar(
appBar: AppBar(
title: const Text('Multi Select Example'),
),
selection: _selection,
selectingActions: <Widget>[
IconButton(
icon: const Icon(Icons.share),
onPressed: () {},
)
],
),
body: MultiSelectGridView(
onSelectionChanged: (selection) => setState(() => _selection = selection),
padding: const EdgeInsets.all(8.0),
itemCount: colors.length,
itemBuilder: (BuildContext context, int index, bool selected) {
return ExampleChildItem(
index: index,
color: colors[index],
selected: selected,
);
},
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
),
),
);
}
}
class ExampleChildItem extends StatefulWidget {
const ExampleChildItem({
Key? key,
required this.index,
required this.color,
required this.selected,
}) : super(key: key);
final int index;
final MaterialColor color;
final bool selected;
@override
State<ExampleChildItem> createState() => _ExampleChildItemState();
}
class _ExampleChildItemState extends State<ExampleChildItem> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
value: widget.selected ? 1.0 : 0.0,
duration: kThemeChangeDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(CurvedAnimation(
parent: _controller,
curve: Curves.ease,
));
}
@override
void didUpdateWidget(ExampleChildItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
if (widget.selected) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (BuildContext context, Widget? child) {
final color = Color.lerp(widget.color.shade500, widget.color.shade900, _controller.value);
return Transform.scale(
scale: _scaleAnimation.value,
child: DecoratedBox(
decoration: BoxDecoration(color: color),
child: child,
),
);
},
child: Container(
alignment: Alignment.center,
child: Text(
'${widget.index}',
style: const TextStyle(
color: Colors.white,
fontSize: 22.0,
),
),
),
);
}
}
class MultiSelectAppBar extends StatelessWidget implements PreferredSizeWidget {
const MultiSelectAppBar({
Key? key,
required this.appBar,
this.selection = MultiChildSelection.empty,
this.selectingActions,
}) : super(key: key);
final AppBar appBar;
final MultiChildSelection selection;
final List<Widget>? selectingActions;
@override
Widget build(BuildContext context) {
return Container(
color: appBar.backgroundColor ?? AppBarTheme.of(context).backgroundColor ?? Theme.of(context).primaryColor,
child: AnimatedSwitcher(
duration: kThemeAnimationDuration,
child: !selection.selecting
? appBar
: AppBar(
key: const Key('multi-select'),
leading: const BackButton(),
titleSpacing: 0.0,
title: Text('${selection.total} items selected…'),
actions: selectingActions,
),
),
);
}
@override
Size get preferredSize => appBar.preferredSize;
}
typedef MultiSelectWidgetBuilder = Widget Function(BuildContext context, int index, bool selected);
typedef MultiSelectSelectionCb = void Function(MultiChildSelection selection);
class MultiSelectGridView extends StatefulWidget {
const MultiSelectGridView({
Key? key,
this.onSelectionChanged,
this.padding,
required this.itemCount,
required this.itemBuilder,
required this.gridDelegate,
this.scrollPadding = const EdgeInsets.all(48.0),
}) : super(key: key);
final MultiSelectSelectionCb? onSelectionChanged;
final EdgeInsetsGeometry? padding;
final int itemCount;
final MultiSelectWidgetBuilder itemBuilder;
final SliverGridDelegate gridDelegate;
final EdgeInsets scrollPadding;
@override
State<MultiSelectGridView> createState() => _MultiSelectGridViewState();
}
class _MultiSelectGridViewState extends State<MultiSelectGridView> {
final _elements = <_MultiSelectChildElement>[];
bool _isSelecting = false;
int _startIndex = -1;
int _endIndex = -1;
LocalHistoryEntry? _historyEntry;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapUp: _onTapUp,
onLongPressStart: _onLongPressStart,
onLongPressMoveUpdate: _onLongPressUpdate,
onLongPressEnd: _onLongPressEnd,
child: IgnorePointer(
ignoring: _isSelecting,
child: GridView.builder(
padding: widget.padding,
itemCount: widget.itemCount,
itemBuilder: (BuildContext context, int index) {
final start = (_startIndex < _endIndex) ? _startIndex : _endIndex;
final end = (_startIndex < _endIndex) ? _endIndex : _startIndex;
final selected = (index >= start && index <= end);
return _MultiSelectChild(
index: index,
child: widget.itemBuilder(context, index, selected),
);
},
gridDelegate: widget.gridDelegate,
),
),
);
}
void _onTapUp(TapUpDetails details) {
_setSelection(-1, -1);
_updateLocalHistory();
}
void _onLongPressStart(LongPressStartDetails details) {
final startIndex = _findMultiSelectChildFromOffset(details.localPosition);
_setSelection(startIndex, startIndex);
setState(() => _isSelecting = (startIndex != -1));
}
void _onLongPressUpdate(LongPressMoveUpdateDetails details) {
if (_isSelecting) {
_updateEndIndex(details.localPosition);
}
}
void _onLongPressEnd(LongPressEndDetails details) {
_updateEndIndex(details.localPosition);
setState(() => _isSelecting = false);
}
void _updateEndIndex(Offset localPosition) {
final endIndex = _findMultiSelectChildFromOffset(localPosition);
if (endIndex != -1) {
_setSelection(_startIndex, endIndex);
_updateLocalHistory();
}
}
void _setSelection(int start, int end) {
setState(() {
_startIndex = start;
_endIndex = end;
});
if (widget.onSelectionChanged != null) {
final start = (_startIndex < _endIndex) ? _startIndex : _endIndex;
final end = (_startIndex < _endIndex) ? _endIndex : _startIndex;
final total = (start != -1 && end != -1) ? ((end + 1) - start) : 0;
widget.onSelectionChanged?.call(MultiChildSelection(total, start, end));
}
}
void _updateLocalHistory() {
final route = ModalRoute.of(context);
if (route != null) {
if (_startIndex != -1 && _endIndex != -1) {
if (_historyEntry == null) {
_historyEntry = LocalHistoryEntry(
onRemove: () {
_setSelection(-1, -1);
_historyEntry = null;
},
);
route.addLocalHistoryEntry(_historyEntry!);
}
} else {
if (_historyEntry != null) {
route.removeLocalHistoryEntry(_historyEntry!);
_historyEntry = null;
}
}
}
}
int _findMultiSelectChildFromOffset(Offset offset) {
final ancestor = context.findRenderObject()!;
for (_MultiSelectChildElement element in List.from(_elements)) {
if (element.containsOffset(ancestor, offset)) {
element.showOnScreen(widget.scrollPadding);
return element.widget.index;
}
}
return -1;
}
}
class _MultiSelectChild extends ProxyWidget {
const _MultiSelectChild({
Key? key,
required this.index,
required Widget child,
}) : super(key: key, child: child);
final int index;
@override
_MultiSelectChildElement createElement() => _MultiSelectChildElement(this);
}
class _MultiSelectChildElement extends ProxyElement {
_MultiSelectChildElement(_MultiSelectChild widget) : super(widget);
@override
_MultiSelectChild get widget => super.widget as _MultiSelectChild;
_MultiSelectGridViewState? _ancestorState;
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
_ancestorState = findAncestorStateOfType<_MultiSelectGridViewState>();
_ancestorState!._elements.add(this);
}
@override
void unmount() {
_ancestorState!._elements.remove(this);
_ancestorState = null;
super.unmount();
}
bool containsOffset(RenderObject ancestor, Offset offset) {
final box = renderObject as RenderBox;
final rect = box.localToGlobal(Offset.zero, ancestor: ancestor) & box.size;
return rect.contains(offset);
}
void showOnScreen(EdgeInsets scrollPadding) {
final box = renderObject as RenderBox;
box.showOnScreen(
rect: scrollPadding.inflateRect(Offset.zero & box.size),
duration: const Duration(milliseconds: 150),
);
}
@override
void notifyClients(ProxyWidget oldWidget) {
//
}
}
class MultiChildSelection {
const MultiChildSelection(this.total, this.start, this.end);
static const empty = MultiChildSelection(0, -1, -1);
final int total;
final int start;
final int end;
bool get selecting => total != 0;
@override
String toString() => 'MultiChildSelection{$total, $start, $end}';
}
@omidraha
Copy link

omidraha commented Apr 7, 2021

Error: The method 'ancestorStateOfType' isn't defined

@BananaMasterz
Copy link

Error: The method 'ancestorStateOfType' isn't defined

replace
ancestorStateOfType(TypeMatcher<_MultiSelectGridViewState>())
with
findAncestorStateOfType<_MultiSelectGridViewState>()

@slightfoot
Copy link
Author

Updated gist for Flutter v3.0.0+

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment