I have an AnimatedList that contains several custom Card widgets, each with two inputs (one for a term and one for the definition of that term). The AnimatedList provides a sliding animation for the card when added to the list or when deleted.
The strange behavior I'm unable to track down is that, when I delete one of the cards, when the cards below shift upwards to fill the space and are rebuilt, any cards that were below the deleted card are rendered with empty inputs, even though the underlying data is still present.
Here are two screenshots before and after deleting the second card (item2 and def2):
The cards higher up in the ListView (presumably not being re-built) are unaffected, but all the cards below are rebuilt but without the data that fills the card. However, the placeholder data is still there. This would make me think that there's somewhere that objects aren't being updated in state and are still blank from being newly created.
The structure of the TermWithHint object, which bundles together a few different pieces of data:
{
term: {
term: {
item: String,
language: String
},
definition: {
item: String,
language: String
}
},
hint: {
term: String,
definition: String
}
}
EDIT: Here's the code for the Term data type. The ID of the term is temporarily a static counter for each time a term is created.
class Term {
static int ID_COUNTER = 0; /* TODO: Go to a better system */
late int id;
TermItem term;
TermItem definition;
final DateTime created;
late DateTime lastChecked;
int scheduleIndex = 0;
int failedAttempts = 0;
int successfulAttempts = 0;
Term.fromExisting(
this.term, this.definition, this.created, this.lastChecked, this.id);
Term.blank()
: created = DateTime.now(),
term = TermItem.blank(),
definition = TermItem.blank() {
id = _getNextAvailableID();
lastChecked = created;
}
int _getNextAvailableID() {
/* TODO: Get from database query */
return ID_COUNTER++;
}
String getAgeString() {
// Start from years and check difference, then go to smaller unit as needed
return "TODO";
}
String getNextCheckString() {
// Similarly, start from years and check difference
return "TODO";
}
@override
String toString() {
return "($id) ${term.item} (${term.language}) - ${definition.item} (${definition.language})";
}
}
There is a named constructor that builds a TermWithHint where all fields of the term property are empty strings and the hint is populated as the two strings from a randomly selected language. Because the hint is being rendered to the placeholder correctly after the card shifts up, but not the actual term data, this seems to support my theory above that the ListView only sees the old objects that are blank and not yet edited.
The problem: when I log the TermWithHint objects from the slidingItem builder (see below), from the create or delete methods, or from internally in the TermInputCard widget's build method, the term data is up to date every time the card is rebuilt after shifting, yet it doesn't appear on my phone.
Here is the relevant code for the layout and AnimatedList:
class _EnterState extends State<Enter> {
final ScrollController _listScrollController = ScrollController();
final GlobalKey<AnimatedListState> listKey = GlobalKey<AnimatedListState>();
final int ANIM_DURATION = 350;
Logger logger = Logger();
final List<TermWithHint> _allTerms = [];
@override
void initState() {
super.initState();
_createTerm();
}
void _handleSubmit() {
for (var thisTerm in _allTerms) {
// This logs all the data on the cards correctly too - even though the cards are blank, their data is present
logger.i(thisTerm);
}
}
void _deleteTerm(int id) {
for (var i = 0; i < _allTerms.length; i++) {
Term thisTerm = _allTerms[i].term;
if (thisTerm.id == id) {
TermWithHint removed = _allTerms.removeAt(i);
listKey.currentState?.removeItem(
i,
(context, animation) => slidingItem(context, removed, animation),
duration: Duration(milliseconds: (ANIM_DURATION / 2).floor()),
);
break;
}
}
}
void _createTerm() {
Term term = Term.blank();
HintOption hint = allHints.elementAt(Random().nextInt(allHints.length));
_allTerms.add(TermWithHint(term, hint));
listKey.currentState?.insertItem(
_allTerms.length - 1,
duration: Duration(milliseconds: ANIM_DURATION),
);
Timer(
Duration(milliseconds: ANIM_DURATION + 100),
() {
_listScrollController.animateTo(
_listScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.ease,
);
},
);
}
void _notifyStateUpdate() {
setState(() {});
}
Widget slidingItem(
BuildContext context, TermWithHint data, Animation<double> animation) {
return SlideTransition(
position: animation.drive(
Tween<Offset>(
begin: const Offset(-1, 0),
end: const Offset(0, 0),
).chain(
CurveTween(curve: Curves.easeOut),
),
),
child: TermInputCard(
data,
onDelete: _deleteTerm,
afterUpdate: _notifyStateUpdate,
key: Key(data.term.id.toString()),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: ThemedAppBar(
"Add New Vocabulary",
actionButton: IconButton(
icon: const Icon(
Icons.check_rounded,
color: ThemeColors.black,
size: 30,
),
onPressed: _handleSubmit,
),
),
body: SingleChildScrollView(
controller: _listScrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedList(
physics: const NeverScrollableScrollPhysics(),
key: listKey,
initialItemCount: _allTerms.length,
itemBuilder: (context, index, animation) {
return slidingItem(context, _allTerms[index], animation);
},
scrollDirection: Axis.vertical,
shrinkWrap: true,
),
const SizedBox(height: 20),
AddButton(onPressed: _createTerm, text: "New Term"),
const SizedBox(height: 20),
],
),
),
backgroundColor: ThemeColors.accent,
);
}
}
Here is the corresponding code for the TermInputCard widget:
class TermInputCard extends StatelessWidget {
final void Function(int) onDelete;
final void Function() afterUpdate;
final TermWithHint _data;
const TermInputCard(
this._data, {
required this.onDelete,
required this.afterUpdate,
required super.key,
});
Widget buildInputLine(
{required String current,
required String label,
required String language,
required void Function(String) onChangeItem,
required void Function(String) onChangeLanguage}) {
return Column(
children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 2.5),
child: TextField(
onChanged: onChangeItem,
decoration: InputDecoration(
hintText:
label == "Term" ? _data.hint.term : _data.hint.definition,
hintStyle: TextStyle(
color: ThemeColors.black.withOpacity(.4),
fontSize: 18,
fontStyle: FontStyle.italic),
border: const UnderlineInputBorder(
borderSide: BorderSide(color: ThemeColors.black),
),
),
style: const TextStyle(
color: ThemeColors.black,
fontWeight: FontWeight.w500,
fontSize: 18),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
color: ThemeColors.black,
fontSize: 14,
fontWeight: FontWeight.w300),
),
InkWell(
onTap: () {
/* TODO: Open language selector and send the result to onChangeLanguage */
},
child: Text(
language,
style: const TextStyle(
color: ThemeColors.blue, fontWeight: FontWeight.w500),
),
)
],
)
],
);
}
@override
Widget build(BuildContext context) {
Term termObj = _data.term;
return Container(
margin: const EdgeInsets.fromLTRB(10, 10, 10, 0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: ThemeColors.black.withOpacity(.2),
width: .5,
strokeAlign: BorderSide.strokeAlignCenter,
),
color: ThemeColors.primary,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Container(
padding: const EdgeInsets.only(left: 25, bottom: 20),
child: Column(
children: [
buildInputLine(
current: termObj.term.item,
label: "Term",
language: termObj.term.language,
onChangeItem: (newText) {
termObj.term.item = newText;
afterUpdate();
},
onChangeLanguage: (newLanguage) {
termObj.term.language = newLanguage;
afterUpdate();
},
),
buildInputLine(
current: termObj.definition.item,
label: "Definition",
language: termObj.definition.language,
onChangeItem: (newText) {
termObj.definition.item = newText;
afterUpdate();
},
onChangeLanguage: (newLanguage) {
termObj.definition.language = newLanguage;
afterUpdate();
},
),
],
),
),
),
Container(
decoration: BoxDecoration(
border: Border.all(color: ThemeColors.red, width: 1),
shape: BoxShape.circle,
),
margin: const EdgeInsets.only(left: 25, right: 25),
child: IconButton(
onPressed: () => onDelete(termObj.id),
icon: const Icon(
Icons.delete_outline,
size: 30,
color: ThemeColors.red,
),
),
),
],
),
);
}
}
I can't seem to track down where the issue comes from and would appreciate suggestions. Thank you!


I was finally able to track down my issue. As someone who is familiar with React/React Native primarily, I can most relate this as the need to create a "controlled component" using a
TextEditingControllerfor each blank of these cards.What was happening is there was no initial value supplied to the
TextFieldwidgets when they were first created. This is find if it's a blank card, but the cards were being re-built when they shifted up and since I wasn't supplying what to put into theTextField, it rendered as blank even though the data populating the card is correct. This is why the hint and the language text was correct, which is a hard-coded value passed toTextelements.The solution was to turn my card into a Stateful widget with two TextEditingControllers that have their text sent to whatever the term passed in holds in
initState()and listening for changes usingaddListener()rather than theonChangeproperty of theTextField. I then passed the correct Controller intobuildInputLinemethod and set thecontrollerproperty on theTextFieldwidget.My new TermInputCard component, with these modifications:
Additionally, I updated the key passed into the class to be an
ObjectKeyfor the TermWithHint passed to the constructor as Randal Schwartz suggested. Thank you for your help!