У нас есть пользовательский интерфейс, который показывает список со многими элементами, и мы хотим иметь возможность легко их сортировать. Мы уже используем ReorderableListView
, но поскольку он требует от пользователя перетаскивания, он не очень хорошо работает для списков с большим количеством элементов. Мы пришли к идее использовать поиск следующим образом:
IconButton
, чтобы выбрать «исходный» элемент для перемещения. Чтобы помочь быстро выбрать правильный «исходный» элемент, пользователю разрешено искать/фильтровать показанные элементы по запросу.IconButton
, чтобы выбрать «целевой» элемент. Чтобы помочь быстро выбрать правильный «целевой» элемент, пользователю разрешено искать/фильтровать отображаемые элементы по запросу.Шаги 1 и 2 реализованы с использованием функций Flutters showSearch
с делегатами, т. е. выбор элемента в поиске на шаге 1 запускает новый «дочерний» поиск с новым делегатом. Пожалуйста, взгляните на следующий GIF, где я сначала выбираю «источник», «Элемент 0» на шаге 1, затем «целевой» «Элемент 4» на шаге 2, а затем «После» на шаге 3:
В общем, это работает так, как мы хотим, если мы передаем экземпляр BLoC и не ищем его, используя контекст. Тем не менее, мы хотим использовать контекст, поскольку реальное приложение намного сложнее, и передача BLoC нецелесообразна. Однако использование context.read
вызывает следующую ошибку:
[VERBOSE-2:ui_dart_state.cc(177)] Unhandled Exception: Looking up a deactivated widget's ancestor is unsafe.
At this point the state of the widget's element tree is no longer stable.
To safely refer to a widget's ancestor in its dispose() method, save a reference to the ancestor by calling dependOnInheritedWidgetOfExactType() in the widget's didChangeDependencies() method.
#0 Element._debugCheckStateIsActiveForAncestorLookup.<anonymous closure>
package:flutter/…/widgets/framework.dart:3906
#1 Element._debugCheckStateIsActiveForAncestorLookup
package:flutter/…/widgets/framework.dart:3920
#2 Element.getElementForInheritedWidgetOfExactType
package:flutter/…/widgets/framework.dart:3986
#3 Provider._inheritedElementOf
package:provider/src/provider.dart:324
#4 Provider.of
package:provider/src/provider.dart:281
#5 ReadContext.read
package:provider/src/provider.dart:614
#6 SortingTile._moveItem
package:flutter_test_app/main.dart:80
<as<…>
На всю жизнь я не могу заставить его работать. Я пытался добавить виджет Builder
здесь и там, чтобы получить другой экземпляр контекста, но это просто не сработает.
Вот полный код (строка, вызывающая ошибку, помеченная <--- HERE
), он немного длинный, поэтому, пожалуйста, дайте мне знать, если я должен загрузить его куда-нибудь. Я не думаю, что смогу сделать его намного короче и по-прежнему иметь возможность воспроизвести проблему (возможно, я мог бы, но я этого не понимаю, так что это так). На самом деле первая половина main.dart
интересна, остальное - это BLoC и т. д., которые работают и, вероятно, не имеют значения, но кто знает. Код должен работать без проблем с Flutter 1.22.5.
pubspec.yaml:
name: flutter_sorting_app
version: 1.0.0+1
environment:
sdk: '>=2.7.0 <3.0.0'
dependencies:
bloc: ^6.1.0
flutter_bloc: ^6.1.0
flutter:
sdk: flutter
flutter:
uses-material-design: true
основной.дротик:
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
runApp(
BlocProvider(
create: (context) => DataBloc(),
child: MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
Builder(
builder: (context) => IconButton(
icon: Icon(Icons.search),
onPressed: () {
context.read<DataBloc>().add(DataRequested());
showSearch(
context: context,
delegate: SortingSearchDelegate(),
);
},
),
),
],
),
),
),
),
);
}
// SearchDelegates
// Step 1 - sort by drag & drop (no step 2 necessary) or pick the item to move
class SortingSearchDelegate extends DataBlocSearchDelegate<DataBloc, void> {
@override
Widget buildListDataWidget(BuildContext context, List<IndexedItem> data) {
return ReorderableListView(
onReorder: (from, to) {
context.read<DataBloc>().add(DataItemPositionChanged(from, to));
},
children: data.map((indexedItem) => SortingTile(indexedItem)).toList(),
);
}
}
class SortingTile extends StatelessWidget {
final IndexedItem indexedItem;
SortingTile(this.indexedItem) : super(key: ValueKey(indexedItem.item));
@override
Widget build(BuildContext context) {
return ListTile(
leading: IconButton(
onPressed: () => _moveItem(context),
icon: Icon(Icons.swap_vert),
),
title: Text('${indexedItem.item}'),
trailing: Icon(Icons.drag_handle),
);
}
Future<void> _moveItem(BuildContext context) async {
// Trigger step 2.
// There is no need to request any data, the same BLoC is reused.
final insertionPoint = await showSearch(
context: context,
delegate: InsertionPointPickerSearchDelegate(indexedItem.index),
);
if (insertionPoint != null) {
var targetIndex = insertionPoint.index;
if (insertionPoint.placement == Placement.after) {
++targetIndex;
}
context
.read<DataBloc>() // <--- HERE
.add(DataItemPositionChanged(indexedItem.index, targetIndex));
}
}
}
// Step 2 - pick placement of picked item in step 1
enum Placement { before, after }
class InsertionPoint {
final Placement placement;
final int index;
InsertionPoint(this.placement, this.index);
}
class InsertionPointPickerSearchDelegate
extends DataBlocSearchDelegate<DataBloc, InsertionPoint> {
final int movedItemIndex;
InsertionPointPickerSearchDelegate(this.movedItemIndex);
@override
Widget buildListDataWidget(BuildContext context, List<IndexedItem> listData) {
return ListView.builder(
itemCount: listData.length,
itemBuilder: (context, index) {
final indexedItem = listData[index];
return ListTile(
enabled: indexedItem.index != movedItemIndex,
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_right_alt),
),
title: Text(indexedItem.item),
onTap: () => _pickPlacement(context, indexedItem.index),
);
},
);
}
// Step 3
Future<void> _pickPlacement(BuildContext context, int index) async {
final placement = await showDialog<Placement>(
context: context,
builder: (context) => SimpleDialog(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
child: const Text('Before'),
onPressed: () => Navigator.of(context).pop(Placement.before),
),
TextButton(
child: const Text('After'),
onPressed: () => Navigator.of(context).pop(Placement.after),
),
],
),
],
),
);
if (placement != null) {
close(context, InsertionPoint(placement, index));
}
}
}
// DataBloc
// Events
abstract class DataEvent {}
class DataRequested extends DataEvent {}
class DataItemPositionChanged extends DataEvent {
final int fromIndex;
final int toIndex;
DataItemPositionChanged(this.fromIndex, this.toIndex);
}
// States
abstract class DataState {}
class DataInitial extends DataState {}
class DataLoadingInProgress extends DataState {}
class DataLoadingSuccess extends DataState {
final List<String> listData;
DataLoadingSuccess(this.listData);
}
// BLoC
class DataBloc extends Bloc<DataEvent, DataState> {
DataBloc() : super(DataInitial());
@override
Stream<DataState> mapEventToState(DataEvent event) async* {
if (event is DataRequested) {
yield DataLoadingInProgress();
await Future.delayed(Duration(milliseconds: 500));
yield DataLoadingSuccess(List.generate(5, (index) => 'Item $index'));
} else if (event is DataItemPositionChanged) {
yield* _mapPositionChanged(event);
}
}
Stream<DataState> _mapPositionChanged(DataItemPositionChanged event) async* {
final successState = state as DataLoadingSuccess;
final listData = [...successState.listData];
final item = listData.removeAt(event.fromIndex);
var to = event.toIndex;
if (event.fromIndex < to) {
// When moving to a later index, the list has just been made smaller
// by 1 (the removal above) so decrease the target index.
to -= 1;
}
listData.insert(to, item);
yield DataLoadingSuccess(listData);
}
}
// DataBlocSearchDelegate
class IndexedItem {
final String item;
final int index;
IndexedItem(this.item, this.index);
}
abstract class DataBlocSearchDelegate<DB extends DataBloc, R>
extends SearchDelegate<R> {
Widget buildListDataWidget(BuildContext context, List<IndexedItem> listData);
@override
Widget buildLeading(BuildContext context) {
return IconButton(
onPressed: () => close(context, null),
icon: const BackButtonIcon(),
);
}
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
onPressed: () => query = '',
icon: Icon(Icons.clear),
),
];
}
@override
Widget buildSuggestions(BuildContext context) {
return BlocBuilder<DB, DataState>(
builder: (context, state) {
Widget body;
if (state is DataLoadingSuccess) {
body = buildListDataWidget(
context,
state.listData
.asMap()
.entries
.map((e) => IndexedItem(e.value, e.key))
.where((indexedItem) => indexedItem.item
.toLowerCase()
.contains(query.toLowerCase()))
.toList(),
);
} else if (state is DataInitial || state is DataLoadingInProgress) {
body = Text('Loading...');
} else {
body = Text('Invalid state: $state');
}
return body;
},
);
}
@override
Widget buildResults(BuildContext context) => throw UnimplementedError();
}
Оказывается, причина в том, что после второго вызова showSearch
все виджеты, созданные для отображения пользовательского интерфейса первого, удаляются (это связано с тем, что для внутреннего маршрута maintainState
установлено значение false
). Это означает, что как только результат второго поиска будет возвращен, и я попытаюсь получить BLoC из контекста, контекст устарел, отсюда и ошибка.
Мы пробовали различные решения, чтобы заставить Flutter сохранять состояние, в том числе AutomaticKeepAliveClientMixin
, но ничего не работало.
В конце концов, жизнеспособным решением в нашем случае было немного реструктурировать код и выполнить более глубокий поиск BLoC во втором поиске вместо того, чтобы возвращать результат и выполнять логику перемещения в первом. В нашем реальном приложении у него есть тот недостаток, что нам нужно нести некоторые данные с собой во второй поиск, но у нас нет ничего лучше, чем это...
Вот полностью измененный и работающий код, если кому-то интересно (сравните с первой версией, чтобы увидеть, как именно мы исправили проблему):
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
runApp(
BlocProvider(
create: (context) => DataBloc(),
child: MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
Builder(
builder: (context) => IconButton(
icon: Icon(Icons.search),
onPressed: () {
context.read<DataBloc>().add(DataRequested());
showSearch(
context: context,
delegate: SortingSearchDelegate(),
);
},
),
),
],
),
),
),
),
);
}
// Delegates
// Step 1 - sort by drag & drop (no step 2 necessary) or pick the item to move
class SortingSearchDelegate extends DataBlocSearchDelegate<DataBloc, void> {
@override
Widget buildListDataWidget(BuildContext context, List<IndexedItem> data) {
return ReorderableListView(
onReorder: (from, to) {
context.read<DataBloc>().add(DataItemPositionChanged(from, to));
},
children: data.map((indexedItem) => SortingTile(indexedItem)).toList(),
);
}
}
class SortingTile extends StatelessWidget {
final IndexedItem indexedItem;
SortingTile(this.indexedItem) : super(key: ValueKey(indexedItem.item));
@override
Widget build(BuildContext context) {
return ListTile(
leading: IconButton(
onPressed: () => _moveItem(context),
icon: Icon(Icons.swap_vert),
),
title: Text('${indexedItem.item}'),
trailing: Icon(Icons.drag_handle),
);
}
Future<void> _moveItem(BuildContext context) {
// Trigger step 2.
// There is no need to request any data, the same BLoC is reused.
return showSearch(
context: context,
delegate: InsertionPointPickerSearchDelegate(indexedItem.index),
);
}
}
// Step 2 - pick placement of picked item in step 1
enum Placement { before, after }
class InsertionPointPickerSearchDelegate
extends DataBlocSearchDelegate<DataBloc, void> {
final int movedItemIndex;
InsertionPointPickerSearchDelegate(this.movedItemIndex);
@override
Widget buildListDataWidget(BuildContext context, List<IndexedItem> listData) {
return ListView.builder(
itemCount: listData.length,
itemBuilder: (context, index) {
final indexedItem = listData[index];
return ListTile(
enabled: indexedItem.index != movedItemIndex,
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_right_alt),
),
title: Text(indexedItem.item),
onTap: () => _pickPlacement(context, indexedItem.index),
);
},
);
}
// Step 3
Future<void> _pickPlacement(BuildContext context, int index) async {
final placement = await showDialog<Placement>(
context: context,
builder: (context) => SimpleDialog(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
child: const Text('Before'),
onPressed: () => Navigator.of(context).pop(Placement.before),
),
TextButton(
child: const Text('After'),
onPressed: () => Navigator.of(context).pop(Placement.after),
),
],
),
],
),
);
if (placement != null) {
if (placement == Placement.after) {
++index;
}
context
.read<DataBloc>()
.add(DataItemPositionChanged(movedItemIndex, index));
close(context, null);
}
}
}
// DataBloc
// Events
abstract class DataEvent {}
class DataRequested extends DataEvent {}
class DataItemPositionChanged extends DataEvent {
final int fromIndex;
final int toIndex;
DataItemPositionChanged(this.fromIndex, this.toIndex);
}
// States
abstract class DataState {}
class DataInitial extends DataState {}
class DataLoadingInProgress extends DataState {}
class DataLoadingSuccess extends DataState {
final List<String> listData;
DataLoadingSuccess(this.listData);
}
// BLoC
class DataBloc extends Bloc<DataEvent, DataState> {
DataBloc() : super(DataInitial());
@override
Stream<DataState> mapEventToState(DataEvent event) async* {
if (event is DataRequested) {
yield DataLoadingInProgress();
await Future.delayed(Duration(milliseconds: 500));
yield DataLoadingSuccess(List.generate(5, (index) => 'Item $index'));
} else if (event is DataItemPositionChanged) {
yield* _mapPositionChanged(event);
}
}
Stream<DataState> _mapPositionChanged(DataItemPositionChanged event) async* {
final successState = state as DataLoadingSuccess;
final listData = [...successState.listData];
final item = listData.removeAt(event.fromIndex);
var to = event.toIndex;
if (event.fromIndex < to) {
// When moving to a later index, the list has just been made smaller
// by 1 (the removal above) so decrease the target index.
to -= 1;
}
listData.insert(to, item);
yield DataLoadingSuccess(listData);
}
}
// DataBlocSearchDelegate
class IndexedItem {
final String item;
final int index;
IndexedItem(this.item, this.index);
}
abstract class DataBlocSearchDelegate<DB extends DataBloc, R>
extends SearchDelegate<R> {
Widget buildListDataWidget(BuildContext context, List<IndexedItem> listData);
@override
Widget buildLeading(BuildContext context) {
return IconButton(
onPressed: () => close(context, null),
icon: const BackButtonIcon(),
);
}
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
onPressed: () => query = '',
icon: Icon(Icons.clear),
),
];
}
@override
Widget buildSuggestions(BuildContext context) {
return BlocBuilder<DB, DataState>(
builder: (context, state) {
Widget body;
if (state is DataLoadingSuccess) {
body = buildListDataWidget(
context,
state.listData
.asMap()
.entries
.map((e) => IndexedItem(e.value, e.key))
.where((indexedItem) => indexedItem.item
.toLowerCase()
.contains(query.toLowerCase()))
.toList(),
);
} else if (state is DataInitial || state is DataLoadingInProgress) {
body = Text('Loading...');
} else {
body = Text('Invalid state: $state');
}
return body;
},
);
}
@override
Widget buildResults(BuildContext context) => throw UnimplementedError();
}