How to dynamically build rich text with FutureBuilder in Flutter

Issue

I am trying to look for ‘@’ in a piece of text and then asynchronously gather a displayName for that tag. The problem I am running into is that my FutureBuilder keeps returning null.

The error I get is this.

════════ Exception caught by widgets library ════════

The following assertion was thrown building FutureBuilder(dirty, state: _FutureBuilderState#151f7):
A build function returned null.

The offending widget is: FutureBuilder
Build functions must never return null.

My code is this:

Container(
 width: double.infinity,
 child: FutureBuilder(
   initialData: SelectableText(''),
   future: buildSelectableText(context),
   builder: (BuildContext context, AsyncSnapshot<SelectableText> snapshot) {
     if (snapshot.connectionState == ConnectionState.waiting)
       return Center(child: CircularProgressIndicator());
     return snapshot.data;
   }
                ),
              ),

  Future<SelectableText> buildSelectableText(BuildContext context) async {
    return SelectableText.rich(
      TextSpan(
        text:'',
        children: data["comment"].split(' ').map<InlineSpan>((word) async {
          return word.startsWith('@') && word.length > 1 ?
          TextSpan(
            text: ' '+ (await context.read<UserDataModel>().fetchUser(word)).displayName,
            style: TextStyle(color: Colors.blue),
          ): TextSpan(text: ' ' + word);
        }).toList()
      ),
      textAlign: TextAlign.justify,
    );
  }

There should probably be a simple fix but I can’t find it

Solution

Obviously, your snapshot.data was null when you returned it.

I would just skip the connection states and just query snapshot.hasData and snapshot.hasError to find out whether data is available.

 builder: (BuildContext context, AsyncSnapshot<SelectableText> snapshot) {
   if (snapshot.hasData) {
     return snapshot.data;
   }

   // what if snapshot.hasError? 
   // you don't want to stay in loading mode forever in case of errors

   return Center(child: CircularProgressIndicator());
 }

That said, you should improve two things here: Your future should be a field in your state. If you just recreate it every build cycle, it will be recreated in situations you do not need to recreate it. For example it will be run again and again and again if the user changes landscape to portrait mode, although that will not change their display name at all. But you will query it every time, because that is how you set up your build function.

The second thing is you are mixing data retrieval and UI code. Your future is getting data. But you mixed it in with creating the control. Put your UI code in the build function and move your async data retrieval code out of there. That data retrieval code is the future you should have in your builder.


This is the function that actually does the async task of getting your data:

  Future<Map<String, String>> CreateUserNameMap(String data, UserDataModel model) async {
    var userNames = data.split(' ')
                        .where((word)=> word.startsWith('@') && word.length > 1)
                        .toSet();
    
    var result = Map<String, String>();
    
    for(var name in userNames) {
      var user = await model.fetchUser(name);
      
      result[name] = user.displayName;
    }
    
    return result;
  }

Now your futureBuilder could look like this:

FutureBuilder(
   future: gettingUserData,
   builder: (context, snapshot) {
     if (snapshot.hasData) {
       return SelectableText.rich(
          TextSpan(
            text:'',
            children: data["comment"].split(' ').map<InlineSpan>((word) {
              return word.startsWith('@') && word.length > 1 ?
              TextSpan(
                text: ' ' + snapshot.data[word]
               style: TextStyle(color: Colors.blue),
              ) : TextSpan(text: ' ' + word);
            }).toList()
          ),
          textAlign: TextAlign.justify,
        );
     }

     if(snapshot.hasError) {
         // you decide...
     }

     return Center(child: CircularProgressIndicator());
}

Where gettingUserData is a Future<Map<string, string>> field in your state class, that you set whenever data['comment'] changes:

gettingUserData = CreateUserNameMap(data['comment'], context.read<UserDataModel>());

A good place would probably be initState. But maybe you have a different setup.

Answered By – nvoigt

Answer Checked By – Clifford M. (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *