flutterdart

WidgetsBinding's addPostFrameCallback doesn't work as expected


In my code, I have a method to determine the position of a Widget based on its key, I am calling this function when onTap is called. The issue is that my code crashes in startup because it is trying to get a not-rendered Widget position.

Now I am creating an exercise that is basically a copy of Duolingo's sentence-formation exercise. After so many errors and having to change the whole layout several times, I thought about having a main stack with the words and a column with both containers (the word deck and where you place them), and put "placeholders" in them. Then, when you launch the app the words will be in the same position as their respective placeholder, and when you tap them they'll go to a new one created in the screen. Like moving a p Check what I mean here.

The problem I am facing is that when I try to use the placeholder's keys (both the target and the original) to move the widgets, I have a null check operator exception because they are technically not rendered yet. I've tried using WidgetsBinding.instance.addPostFrameCallback but it does not fix the error. I need a way to calculate their positions and "spawn" the words in them at the begining of the app.

To calculate the offsets I am using a method ("getOffset")

This is an example code:

This is the method:

Offset getOffset(GlobalKey key) {
    Offset offset = Offset.zero;

    BuildContext context = key.currentContext!;  //<----- This is where the exception is thrown

    RenderBox renderBox = context.findRenderObject() as RenderBox;
    offset = renderBox.localToGlobal(Offset.zero);

    return offset;
}

and this is my code:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Animation Proof of Concept',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.black),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  Container xo =
      Container(color: Colors.white, width: 65, height: 65, child: Text("Xo"));
  Container vo =
      Container(color: Colors.white, width: 65, height: 65, child: Text("Vo"));

  List<Container> letters = [];
  List<Widget> targets = [];
  Map<int, GlobalKey> targetKeys = {};
  Map<int, GlobalKey> homeKeys = {};
  List<bool> isMovedList = [];

  @override
  void initState() {
    super.initState();
    letters = [vo];

    isMovedList = [for (var widget in letters) false];

    targetKeys = {
      for (var widget in letters)
        letters.indexOf(widget):
            GlobalKey(debugLabel: "targetKey${letters.indexOf(widget)}")
    };
    homeKeys = {
      for (var widget in letters)
        letters.indexOf(widget):
            GlobalKey(debugLabel: "homeKey${letters.indexOf(widget)}")
    };
  }

  getOffset(GlobalKey key) {
    Offset offset = Offset.zero;
    BuildContext context = key.currentContext!;
    RenderBox renderbox = context.findRenderObject() as RenderBox;
    offset = renderbox.localToGlobal(Offset.zero);
    return offset;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.purple,
      body: Stack(
        children: [
          Column(
            children: [
              Expanded(
                  child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: targets,
              )),
              Expanded(
                  child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: letters.map((letter) {
                  return Opacity(
                    key: homeKeys[letters.indexOf(letter)],
                    opacity: 0.2,
                    child: letter,
                  );
                }).toList(),
              ))
            ],
          ),
          ...letters.map((letter) {
            Offset targetOffset = Offset.zero;
            Offset homeOffset = Offset.zero;
            return AnimatedPositioned(
                left: isMovedList[letters.indexOf(letter)] ? 312.5 : 312.5,
                top: isMovedList[letters.indexOf(letter)] ? 235 : 55,
                duration: const Duration(milliseconds: 250),
                child: GestureDetector(
                    onTap: () {
                      setState(() {
                        WidgetsBinding.instance.addPostFrameCallback((_) {});
                        isMovedList[letters.indexOf(letter)] =
                            !isMovedList[letters.indexOf(letter)];
                        if (isMovedList[letters.indexOf(letter)] == false) {
                          targets.add(Container(
                              key: targetKeys[letters.indexOf(letter)],
                              color: Colors.red,
                              width: 65,
                              height: 65,
                              child: Center(
                                  child: Text(
                                "T${letters.indexOf(letter)}",
                                style: TextStyle(
                                    fontWeight: FontWeight.bold, fontSize: 20),
                              ))));
                        } else {
                          targets.removeWhere((element) =>
                              element.key ==
                              targetKeys[letters.indexOf(letter)]);
                        }
                      });
                    },
                    child: letter));
          }),
        ],
      ),
    );
  }
}

I tried relocating WidgetsBinding.instance.addPostFramCallback() but it still doesn't work.


Solution

  • There was several problems with my code,

    1. I was trying to get the targetKeys (from a widget that is only created when I tap another one). Thus, when the Offset tried to be calculated, it was null.

    Fix: wrap the offset calculation of targetKeys inside an if statement:

    WidgetsBinding.instance.addPostFrameCallback((_) {
                      setState(() {
                        for (int i = 0; i < letters.length; i++) {
                          if (targetKeys[i]?.currentContext != null) {
                            targetOffsets[i] = getOffset(targetKeys[i]!);
                          }
                        }
                      });
                    });
    
    1. The WidgetsBinding.instance.addPostFrameCallback() had to be calculated in initState(){} to work properly. Fix: add it to `initState(){}

    I created two Maps for storing both the targetOffsets and homeOffsets with the same amount of offsets as the key's Maps and default values of Offset.zero. So at the first seconds of running the code (where not every widget is created), there is a default value. That were the main errors that made it impossible. After fixing those errors and tweaking the code to be nicer, it worked as expected.