Skip to content

ToggleableStateMixin reaction animation duration does not respect overrides #190866

Description

@victorsanni

Steps to reproduce

  1. Make a class that uses ToggleableStateMixin
  2. Make a painter that extends ToggleablePainter
  3. Override reactionAnimationDuration to set a desired duration different from the 100ms default.
  4. Set the painter's reaction to track the Toggleable state reaction

Expected results

Override applies, so painter's reaction duration matches override duration.

Actual results

Duration always 100ms.

Code sample

Code sample
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)!,
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

Logs

Logs

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[!] Flutter (Channel master, 3.47.0-1.0.pre-316, on macOS 26.5 25F71 darwin-arm64, locale en-US)
    ! Upstream repository [email protected]:victorsanni/flutter.git is not a standard remote.
      Set environment variable "FLUTTER_GIT_URL" to [email protected]:victorsanni/flutter.git to dismiss this error.
[!] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    ✗ Flutter requires Android SDK 36 and the Android BuildTools 28.0.3
      To update the Android SDK visit https://flutter.dev/to/macos-android-setup for detailed instructions.
    ! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses
[!] Xcode - develop for iOS and macOS (Xcode 26.6)
    ! CocoaPods 1.14.3 out of date (1.16.2 is recommended).
        CocoaPods is a package manager for iOS or macOS platform code.
        Without CocoaPods, plugins will not work on iOS or macOS.
        For more info, see https://flutter.dev/to/platform-plugins
      To update CocoaPods, see https://guides.cocoapods.org/using/getting-started.html#updating-cocoapods
[✓] Chrome - develop for the web
[✓] Connected device (2 available)
    ! Error: Browsing on the local area network for Victor’s iPhone. Ensure the device is unlocked and attached with a cable or associated with the same local
      area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources

! Doctor found issues in 3 categories.

Activity

  1. changed the title [-]ToggleableStateMixin reaction animation duration is not overridable[/-] [+]ToggleableStateMixin reaction animation duration does not respect overrides[/+] on Aug 10, 2026
  2. added
    in triagePresently being triaged by the triage team
    frameworkflutter/packages/flutter repository. See also f: labels.
    and removed
    in triagePresently being triaged by the triage team
    on Aug 11, 2026
  3. github-actions commented on Aug 26, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  4. locked as resolved and limited conversation to collaborators on Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listframeworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework teamwaiting for PR to land (fixed)A fix is in flight

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions