import 'package:flutter/material.dart';
void main() => runApp(const ToggleableOverrideDemoApp());
class ToggleableOverrideDemoApp extends StatelessWidget {
const ToggleableOverrideDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Padding(
padding: EdgeInsets.all(24),
child: Text(
'ToggleableStateMixin.reactionAnimationDuration bug\n\n'
'Both toggles override reactionAnimationDuration to 3 seconds.\n'
'PRESS AND HOLD each box and watch how fast the ripple expands.',
textAlign: TextAlign.center,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DemoToggle(
label: 'Current\n(BUG: ripple takes ~100ms)',
applyWorkaround: false,
),
SizedBox(width: 64),
DemoToggle(
label: 'Expected (done with workaround)\n(expected: ripple takes 3s)',
applyWorkaround: true,
),
],
),
],
),
),
),
);
}
}
class DemoToggle extends StatefulWidget {
const DemoToggle({super.key, required this.label, required this.applyWorkaround});
final String label;
final bool applyWorkaround;
@override
State<DemoToggle> createState() => _DemoToggleState();
}
class _DemoToggleState extends State<DemoToggle>
with TickerProviderStateMixin, ToggleableStateMixin {
final _RippleBoxPainter _painter = _RippleBoxPainter();
bool _value = false;
// Silently ignored.
@override
Duration? get reactionAnimationDuration => const Duration(seconds: 3);
@override
void initState() {
super.initState();
if (widget.applyWorkaround) {
// Manually does what the override above should have done.
reactionController.duration = const Duration(seconds: 3);
}
}
@override
ValueChanged<bool?>? get onChanged => (bool? newValue) {
setState(() => _value = newValue ?? false);
animateToValue();
};
@override
bool? get value => _value;
@override
bool get tristate => false;
@override
void dispose() {
_painter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
_painter
..position = position
..reaction = reaction
..activeColor = Colors.indigo
..inactiveColor = Colors.grey;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.label, textAlign: TextAlign.center),
buildToggleable(size: const Size(120, 120), painter: _painter),
Text(
'reactionController.duration:\n'
'${reactionController.duration!.inMilliseconds} ms',
textAlign: TextAlign.center,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
],
);
}
}
class _RippleBoxPainter extends ToggleablePainter {
static const double _maxRippleRadius = 52;
@override
void paint(Canvas canvas, Size size) {
final Offset center = size.center(Offset.zero);
// The press ripple, driven by the mixin's reaction animation.
if (!reaction.isDismissed) {
canvas.drawCircle(
center,
_maxRippleRadius * reaction.value,
Paint()..color = activeColor.withValues(alpha: 0.35),
);
}
// The toggle box itself, filled according to the position animation.
final RRect box = RRect.fromRectAndRadius(
Rect.fromCenter(center: center, width: 36, height: 36),
const Radius.circular(8),
);
canvas.drawRRect(
box,
Paint()..color = Color.lerp(inactiveColor, activeColor, position.value)!,
);
}
}
Steps to reproduce
Expected results
Override applies, so painter's reaction duration matches override duration.
Actual results
Duration always 100ms.
Code sample
Code sample
Screenshots or Video
Screenshots / Video demonstration
Logs
Logs
Flutter Doctor output
Doctor output