I am developing a Flutter application that uses Rive animations for checkboxes.
While the checkboxes visually toggle correctly when tapped, the programmatic state does not reflect these changes. Specifically, the submittedMissions
list does not update correctly—it fails to remove IDs when checkboxes are unchecked, leading to inaccurate state representation in the application logic.
Despite unchecking two boxes in the UI, the application still reports 2 boxes as checked, demonstrating a failure in state management:
Conversely, replacing Rive's checkboxes with Flutter's native Checkbox widget resolves this issue, underscoring a problem specific to how Rive handles state changes:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:rive/rive.dart';
void main() {
runApp(
MaterialApp(
home: MissionListScreen(),
debugShowCheckedModeBanner: false,
),
);
}
class MissionListScreen extends StatefulWidget {
@override
_MissionListScreenState createState() => _MissionListScreenState();
}
class _MissionListScreenState extends State<MissionListScreen> {
List<MissionItem> missions = [
MissionItem(missionId: '1', missionName: 'Mission 1'),
MissionItem(missionId: '2', missionName: 'Mission 2'),
MissionItem(missionId: '3', missionName: 'Mission 3'),
];
List<String> submittedMissions = [];
@override
void initState() {
super.initState();
loadRiveFiles();
}
void loadRiveFiles() async {
for (var mission in missions) {
await _loadRiveFile(mission);
}
setState(() {});
}
Future<void> _loadRiveFile(MissionItem missionItem) async {
try {
final data = await rootBundle.load('assets/checkbox.riv');
final file = RiveFile.import(data);
final artboard = file.mainArtboard;
var controller =
StateMachineController.fromArtboard(artboard, 'State Machine 1');
if (controller != null) {
artboard.addController(controller);
missionItem.checkButtonInput = controller.findInput<bool>('Tap');
missionItem.checkButtonArtboard = artboard;
// Set the initial state of the Tap input
missionItem.checkButtonInput?.value =
submittedMissions.contains(missionItem.missionId);
}
} catch (e) {
print('Failed to load Rive file: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
//show the total number of missions checked
Text(
'There are ${submittedMissions.length} boxes checked.',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: missions.length,
itemBuilder: (context, index) {
MissionItem missionItem = missions[index];
return buildMissionListTile(missionItem, index);
},
),
),
],
),
);
}
Widget buildMissionListTile(MissionItem mission, int index) {
return ListTile(
title: Text(mission.missionName),
trailing: GestureDetector(
onTap: () => toggleMission(mission, index),
child: SizedBox(
height: 32,
width: 32,
child: Rive(artboard: mission.checkButtonArtboard!)),
// this code using Checkbox widget DOES work
// child: Checkbox(
// value: mission.checkButtonInput?.value ?? false,
// onChanged: (value) => toggleMission(mission, index),
// )),
),
);
}
void toggleMission(MissionItem mission, int index) {
bool currentState = mission.checkButtonInput?.value ?? false;
setState(() {
mission.checkButtonInput?.value = !currentState;
MissionItem newMission = mission;
if (currentState) {
submittedMissions.removeWhere((id) => id == mission.missionId);
} else {
if (!submittedMissions.contains(mission.missionId)) {
submittedMissions.add(mission.missionId);
}
}
missions.removeAt(index);
missions.insert(index, newMission);
});
print("Submitted Missions: $submittedMissions"); // Debugging
}
}
class MissionItem {
final String missionId;
final String missionName;
SMIInput<bool>? checkButtonInput;
Artboard? checkButtonArtboard;
MissionItem({
required this.missionId,
required this.missionName,
});
}
Even if I were to add a UniqueKey
the data still isn't correctly changing.
How can I ensure that state changes triggered by Rive animations in a Flutter are recognized and handled correctly?
Despite visual indications that checkboxes are being toggled, the internal state does not reflect these changes.
You can download this rive (.riv) file from Rive's official website or from Google Drive.
Don't forget to add the above file to your assets folder and update the pubspec.yaml
file.
After playing quite while I have found the rive file itself using Trigger input instead of Boolean /-;
before noticing I came up with separating context for each but has little bug if users press the same button within animation active duration(1 sec ig)
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:rive/rive.dart';
void main() {
runApp(
MaterialApp(
home: MissionListScreen(),
debugShowCheckedModeBanner: false,
),
);
}
class MissionListScreen extends StatefulWidget {
@override
_MissionListScreenState createState() => _MissionListScreenState();
}
class _MissionListScreenState extends State<MissionListScreen> {
List<MissionItem> missions = [
MissionItem(missionId: '1', missionName: 'Mission 1'),
MissionItem(missionId: '2', missionName: 'Mission 2'),
MissionItem(missionId: '3', missionName: 'Mission 3'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
...missions.map(
(e) => Text(" Selected ${e.missionName} ${e.isChecked}"),
),
Expanded(
child: ListView.builder(
itemCount: missions.length,
itemBuilder: (context, index) {
MissionItem mission = missions[index];
return ListTile(
title: Text(mission.missionName),
trailing: RiveCheckBox(
initValue: mission.isChecked,
onChanged: (_) {
missions[index] = missions[index].copyWith(
isChecked: !missions[index].isChecked,
);
setState(() {});
}),
);
},
),
),
],
),
);
}
}
class RiveCheckBox extends StatefulWidget {
const RiveCheckBox({
super.key,
this.initValue = false,
required this.onChanged,
});
final bool initValue;
final ValueChanged<bool> onChanged;
@override
State<RiveCheckBox> createState() => _RiveCheckBoxState();
}
class _RiveCheckBoxState extends State<RiveCheckBox> {
Artboard? _artboard;
SMITrigger? smiInput;
late StateMachineController controller;
late final future = _loadRiveFile();
Future _loadRiveFile() async {
print('load Rive file:');
try {
final data = await rootBundle.load('assets/checkbox.riv');
final file = RiveFile.import(data);
final artboard = file.mainArtboard;
controller =
StateMachineController.fromArtboard(artboard, 'State Machine 1')!;
///! while I am certain on null assert
smiInput = controller.findSMI('Tap');
smiInput!.value = widget.initValue;
artboard.addController(controller);
_artboard = artboard;
return artboard;
} catch (e) {
print('Failed to load Rive file: $e');
}
}
@override
Widget build(BuildContext context) {
print("rebuilding RiveCheckBox ");
return FutureBuilder(
future: future,
builder: (context, snapshot) => snapshot.data == null
? const Text("Loading...")
: SizedBox(
width: 32,
height: 32,
child: GestureDetector(
onTap: () async {
smiInput!.value = !smiInput!.value;
widget.onChanged(smiInput!.value);
await Future.delayed(const Duration(seconds: 1));
},
child: Rive(artboard: _artboard!),
),
),
);
}
}
class MissionItem {
final String missionId;
final String missionName;
final bool isChecked;
const MissionItem({
required this.missionId,
required this.missionName,
this.isChecked = false,
});
MissionItem copyWith({
String? missionId,
String? missionName,
bool? isChecked,
}) {
return MissionItem(
missionId: missionId ?? this.missionId,
missionName: missionName ?? this.missionName,
isChecked: isChecked ?? this.isChecked,
);
}
}