👋

【Flutter】Looking up a deactivated widget's ancestor〜をBuilderで解決した

2022/03/07に公開2

はじめに

この記事はAlartDialogから元の画面に戻る際excepitonが表示され、元の画面に戻れなくなった際、Builderの使って解決した時に調べたことをまとめたものになります。

分かりづらい点や、誤り等ございましたら、ご指摘いただけるとありがたいです。
(Twitterでご連絡ください。)
この記事がどなたかの参考になれば幸いです。

環境

MacOS12.1
flutter_slidable: ^1.2.0

flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[] Flutter (Channel stable, 2.10.0, on macOS 12.1 21C52 darwin-arm, locale ja-JP)
[] Android toolchain - develop for Android devices (Android SDK version 31.0.0)
[] Xcode - develop for iOS and macOS (Xcode 13.2.1)
[] Chrome - develop for the web
[] Android Studio (version 2020.3)
[] Connected device (2 available)
[] HTTP Host Availability

• No issues found!

Builderとは

Builder (Flutter Widget of the Week) - YouTube
の動画内では以下説明がありました。

  • 同一build関数の中で、子Widgetが親WidgetのBuildContextを参照する時に使うWidget。
  • 主に、Scaffold、Navigator、Provider、などの.of(context)を使っている場合に使う。
  • WidgetをBuilderでラップする == 別の独立したWidgetを作る
    だが、独立Widgetを作るには小さすぎる場合にBuilderを使う。
  • Builderは親Widgetが完全にBuildされてから、子Widgetが親のBuildContextを参照できる様にする。

主に、Scaffold.of(context)とか、Navigator.of(context).pop等 が絡む時に使うようです。

サンプルコード

今回エラーになったコードになります。
やっていることは以下になります。

  • MainPageで、ListView、ListTileを使ってtodo一覧を表示
  • ListTileはSlidableを使った削除ボタンがある。
  • 削除ボタンをクリックすると削除確認画面(AlertDialog)が出る。
  • 削除確認画面で「いいえ」ボタンを押すと元の画面に戻る。
    (戻る時にNavigator.of(context).popを使っている。)
    →ここでexception

main()とかその辺りは省いてます。

Main.dart

class MainPage extends StatelessWidget {
  const MainPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Builderサンプル'),
      ),
      body: ListView(children: widgets),
    );
  }
}

// ListViewで表示するためのListTileのList widgets
List<Widget> widgets = todoList
    .map(
      (todo) => Slidable(
        endActionPane: ActionPane(
          motion: const ScrollMotion(),
          children: [
            SlidableAction(
              onPressed: (context) {
                return showConfirmDialog(context, todo);
              },
              backgroundColor: Colors.red,
              foregroundColor: Colors.white,
              icon: Icons.delete,
              label: '削除',
            ),
          ],
        ),
        child: ListTile(
          title: Text(todo),
        ),
      ),
    )
    .toList();

// todoのList
List<String> todoList = ['ゴミを出す', 'スッキリJava読む', '就職面接にいく'];

// 削a除確認画面(AlertDialog)を表示するメソッド
// 「いいえ」をタップすると元に戻る。
void showConfirmDialog(BuildContext context, String todo) {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (_) {
      return AlertDialog(
        title: const Text("削除の確認"),
        content: Text('"$todo"を削除しますか?'),
        actions: [
          TextButton(
            child: const Text("いいえ(エラーになる)"),
            onPressed: () => Navigator.pop(context),
          ),
          TextButton(
            child: const Text("はい"),
            onPressed: () => deleteTodo,
          ),
        ],
      );
    },
  );
}

サンプルを動かすとこんな感じです。
Simulator Screen Recording - iPhone 13 - 2022-03-07 at 14.25.10.gif

Exception

いいえボタンを押した時の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.

(訳)

無効化されたウィジェットの祖先を検索することは安全ではありません。
この時点で、Widgetの要素ツリーの状態は安定しなくなります。
dispose() メソッドでウィジェットの祖先を安全に参照するには、ウィジェットの didChangeDependencies() メソッドで dependOnInhzeritedWidgetOfExactType() を呼び出し、祖先への参照を保存してください。

解決方法

先ほどの「いいえ」ボタン(TextButton)をBuilderでラップすると解決します。

          Builder(builder: (context) {
            return TextButton(
              child: const Text("いいえ(エラーにならない)"),
              onPressed: () => Navigator.pop(context),
            );
          })

Simulator Screen Recording - iPhone 13 - 2022-03-07 at 14.58.41.gif

解説のようなもの

Exceptionが出る時、いいえボタンを押した時の動作をデバッガで追いかけると、
showConfirmDialog内の、いいえボタンにある、
Navigator.pop(context)のcontextは、
StatelessElement#00197(DEFUNCT)(no widget)
となっています。
(DEFUNCTは廃棄済みという意味らしいです。)
で、このcontextはDEFUNCTされる前は何かというと、
CustomSlidableAction
となってました。
先ほどのNavigator.of(context).popのcontextは以下の
SlidableActionの中のonPressed()のcontextになります。

            SlidableAction(
              onPressed: (context) {  // このcontext
                return showConfirmDialog(context, todo);
              },
              backgroundColor: Colors.red,
              foregroundColor: Colors.white,
              icon: Icons.delete,
              label: '削除',
            )

そして、CustomSlidableAction
の親はこれもデバッガで追いかけると、
SlidableActionになります。

SlidableActionは、パッケージSlidableにある、ListTileをスライドしてボタンを
表示するアクションを表すWidgetです。

/// An action for [Slidable] which can show an icon, a label, or both.
class SlidableAction extends StatelessWidget

つまり、アクションを表す(操作しないとつくられない?)WidgetをAlertDialogのNavigator.of(context)のcontextで参照してしまっており、AlertDialogのいいえボタンから参照された時には当然作られていない(まだ、DEFUNCTの状態)ので、これが先ほどのExceptionの

無効化されたウィジェットの祖先を検索することは安全ではありません。

の状態にになったのだと思います。

そこで、Navigator.of(context).popのcontextの参照先を変えるのが
Builderになります。
Builderでラップすると、

Builderは親Widgetが完全にBuildされてから、子Widgetが親のBuildContextを参照できる様にする。

ので、TextButtonをBuilderでラップすると、
Navigator.of(context).popのcontextが
CustomSlidableAction
ではなく、
Builderに変わり、
表示されるようになります。

コード全体

最後に解決したコード全体を載せておきます。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';

Future<void> main() async {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TODO アプリ',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MainPage(),
    );
  }
}


class MainPage extends StatelessWidget {
  const MainPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Builderサンプル'),
      ),
      body: ListView(children: widgets),
    );
  }
}

// ListViewで表示するためのListTileのList widgets
List<Widget> widgets = todoList
    .map(
      (todo) => Slidable(
        endActionPane: ActionPane(
          motion: const ScrollMotion(),
          children: [
            SlidableAction(
              onPressed: (context) {
                return showConfirmDialog(context, todo);
              },
              backgroundColor: Colors.red,
              foregroundColor: Colors.white,
              icon: Icons.delete,
              label: '削除',
            ),
          ],
        ),
        child: ListTile(
          title: Text(todo),
        ),
      ),
    )
    .toList();

// todoのList
List<String> todoList = ['ゴミを出す', 'スッキリJava読む', '就職面接にいく'];

// 削除確認画面(AlertDialog)を表示するメソッド
// 「いいえ」をタップすると元に戻る。
void showConfirmDialog(BuildContext context, String todo) {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (_) {
      return AlertDialog(
        title: const Text("削除の確認"),
        content: Text('"$todo"を削除しますか?'),
        actions: [
          Builder(builder: (context) {
            return TextButton(
              child: const Text("いいえ(エラーにならない)"),
              onPressed: () => Navigator.pop(context),
            );
          }),
          TextButton(
            child: const Text("はい"),
            onPressed: () => deleteTodo,
          ),
        ],
      );
    },
  );
}

// 削除確認用のダイアログで「いいえ」ボタンのTextButtonをWidgetで作る。
class NoButtonInConfirmDialog extends StatelessWidget {
  const NoButtonInConfirmDialog({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return TextButton(
      child: const Text("いいえ"),
      onPressed: () => Navigator.pop(context),
    );
  }
}

// 「はい」ボタンを押してtodoを削除する処理
void deleteTodo(BuildContext context) {
  // do something
}

最後に

ここまで読んでいただきありがとうございました!

参考サイト

Slidableとか、Todoアプリの作り方の参考
【2021年8月最新】Firestoreのupdateメソッドで値を更新しよう - YouTube
AlertDialogの出し方参考
【2021年8月最新】Firestoreのdeleteメソッドで値を削除しよう - YouTube
Builderの参考
Builder (Flutter Widget of the Week) - YouTube

Discussion