Flutter «Поиск предка деактивированного виджета небезопасен». при возвращении из «showSearch»

#flutter

#трепетание

Вопрос:

У нас есть пользовательский интерфейс, который показывает список со многими элементами, и мы хотим иметь возможность легко сортировать их. Мы уже используем ReorderableListView , но поскольку для этого требуется перетаскивание пользователем, это не очень хорошо работает для списков со многими элементами. Мы пришли с идеей использовать поиск для него следующим образом:

  1. На первом шаге пользователю разрешается изменять порядок элементов путем перетаскивания, но он также может щелкнуть начало IconButton , чтобы выбрать элемент «источник» для перемещения. Чтобы помочь быстро выбрать правильный элемент «source», пользователю разрешено выполнять поиск / фильтрацию отображаемых элементов по запросу.
  2. Выбор элемента «источник» на шаге 1 запускает второй шаг: отображается список с теми же данными (с отключенным элементом, выбранным на шаге 1), и пользователь может щелкнуть по началу IconButton , чтобы выбрать элемент «цель». Чтобы помочь быстро выбрать правильный «целевой» элемент, пользователю разрешено искать / фильтровать отображаемые элементы по запросу.
  3. Выбор «целевого» элемента запускает шаг 3: отображается небольшое диалоговое окно, в котором пользователь может выбрать, следует ли поместить «исходный» элемент с шага 1 до или после «целевого» элемента с шага 2.
  4. После выбора срабатывает логика (в моем случае используется блок).

Шаги 1 и 2 реализованы с использованием showSearch функций Flutters с делегатами, т. Е. Выбор элемента в поиске на шаге 1 запускает новый «дочерний» поиск с новым делегатом. Пожалуйста, взгляните на следующий GIF, где я сначала выбираю «исходный» «Элемент 0» на шаге 1, затем «целевой» «Элемент 4» на шаге 2, а затем «После» на шаге 3:

В общем, это работает так, как мы хотим, если мы передаем экземпляр блока и не ищем его с помощью контекста. Тем не менее, мы хотим использовать контекст, поскольку реальное приложение намного сложнее, и передача блока по кругу нежизнеспособна. Однако использование 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 интересна, остальное — Блок и т. Д. что работает и, вероятно, не имеет значения, но кто знает. Код должен выполняться без проблем с 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
 

main.dart:

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

Ответ №1:

Оказывается, причина в том, что после выполнения второго showSearch вызова все виджеты, созданные для отображения пользовательского интерфейса первого, удаляются (что связано с тем, что для внутреннего используемого маршрута maintainState установлено значение false ). Это означает, что как только возвращается результат второго поиска, и я пытаюсь получить блок из контекста, контекст устаревает, отсюда и ошибка.

Мы пробовали различные решения, чтобы заставить Flutter сохранять состояние, в том числе AutomaticKeepAliveClientMixin , но ничего не сработало.

В конце концов, жизнеспособным решением в нашем случае было немного реструктурировать код и выполнить поиск по блокам глубже, во втором поиске, вместо того, чтобы возвращать результат и выполнять логику перемещения в первом. В нашем реальном приложении это имеет тот недостаток, что нам нужно переносить некоторые данные с собой во второй поиск, но у нас нет ничего лучше этого…

Вот полный измененный и рабочий код на случай, если кому-то интересно (сравните с первой версией, чтобы точно увидеть, как именно мы исправили проблему):

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