Skip to content

[material_ui] Modal bottom sheets retain obsolete CurvedAnimation status listeners after drags #193944

Closed
flutter/packages
#13167

Description

@hangy233

Steps to reproduce

Repeated nonclosing drags on a modal bottom sheet leave obsolete CurvedAnimation helpers subscribed to the route animation. They accumulate while the sheet is open. After the sheet is dismissed and its state is disposed, the subscriptions also remain if the application keeps its caller-owned transitionAnimationController alive.

The affected framework code is _ModalBottomSheetState in bottom_sheet.dart. The reproduction uses package:flutter/material.dart and the framework implementation in that checkout.

A caller-owned controller may be retained for its host widget's lifetime and disposed when that widget is removed. Controller ownership does not require retaining helpers belonging to an already-disposed sheet. If the route owns and eventually disposes its controller, disposal releases these subscriptions; the accumulation while the sheet is open still occurs.

Ordinary usage that triggers the problem passes a host-owned controller and repeatedly performs short drags that return the sheet to its open position:

showModalBottomSheet<void>(
  context: context,
  transitionAnimationController: controller,
  builder: (BuildContext context) =>
      const SizedBox(height: 300, child: Text('Bottom sheet')),
);

Automated demo

The single-file demo has one Run 200 cycles button. It opens the sheet, runs 200 actual library callback cycles, closes the sheet automatically, and prints numeric readings before, during, and after the run. The application keeps its controller alive after closing and releases it before the next run.

The fast loop invokes the actual BottomSheet callbacks:

for (var i = 1; i <= 200; i++) {
  sheet.onDragStart!(DragStartDetails());
  sheet.onDragEnd!(DragEndDetails(primaryVelocity: 0), isClosing: false);
}

This bypasses pointer recognition and gesture animation waiting. These are callback cycles, not 200 simulated user gestures.

To make the retention visible quickly, the demo deliberately attaches a Uint8List(1000000) test buffer to each forwarded status subscription. The callback captures and accesses that buffer; unregistering the callback releases the subscription's ownership of it. The printed MB value is the number of registered subscriptions multiplied by 1,000,000 bytes.

The 1 MB buffers are deliberate amplification. Ordinary drags do not leak 1 MB each. The readings describe test buffers still owned by registered callbacks; they are not a measured browser heap, total Chrome memory, or proof that garbage collection has already reclaimed released buffers.

Run the reproduction

On Windows, use PowerShell and Git. Clone the branch and check out the demo commit:

git clone --single-branch --branch demo-modal-bottom-sheet-memory https://github.com/hangy233/flutter.git flutter-sheet-leak
cd flutter-sheet-leak
git checkout --detach 540676e356949938eff22c70ade2186b1e14bb2b
.\bin\flutter.bat pub get
cd dev\manual_tests
..\..\bin\flutter.bat run -d web-server --web-hostname 127.0.0.1 --web-port 8768 -t lib/bottom_sheet_memory.dart

The first Flutter command initializes this checkout's SDK and downloads its dependencies. Open http://127.0.0.1:8768, select Run 200 cycles, and wait for After close in the log.

Stop the server with q before switching commits. From dev/manual_tests, run the same demo with the fix:

cd ..\..
git checkout --detach eaa927e74927edc5033df9ba06e2edbdfe074542
.\bin\flutter.bat pub get
cd dev\manual_tests
..\..\bin\flutter.bat run -d web-server --web-hostname 127.0.0.1 --web-port 8768 -t lib/bottom_sheet_memory.dart

Refresh the browser and select Run 200 cycles again. Use bin/flutter.bat from this checkout so the selected commit's framework is compiled. No PACKAGE_FIXED flag, package override, or patch-preparation script is involved; the second commit changes the framework source directly.

Observed readings

Native widget testing of the same demo produced:

Stage Demo commit: listeners / test buffers Fix commit: listeners / test buffers
Before cycles 2 / 2 MB 2 / 2 MB
After 200 cycles, while open 202 / 202 MB 3 / 3 MB
After closing 200 / 200 MB 0 / 0 MB

The ordinary regression test passes with no drags on the original framework, but fails after three real nonclosing drags because the caller-owned controller is still listening after dismissal. Both cases pass after the fix.

Cause and proposed fix

handleDragEnd creates a new CurvedAnimation and assigns it to _sheetAnimation.parent. Its constructor registers a status listener on its parent animation. Rebinding the ProxyAnimation detaches the proxy's listeners, but does not dispose the outgoing curve's own status subscription. The state only stores and disposes its initial _curvedSheetAnimation.

The fix commit retains a reference to the current drag-end curve, disposes it after detaching on the next drag start, and also disposes it when the sheet state is removed:

CurvedAnimation? _dragEndCurvedAnimation;

void handleDragStart(DragStartDetails details) {
  _sheetAnimation.parent = widget.route.animation;
  _dragEndCurvedAnimation?.dispose();
  _dragEndCurvedAnimation = null;
}

void handleDragEnd(DragEndDetails details, {bool? isClosing}) {
  final double currentProgress = widget.route.animation!.value;
  _dragEndCurvedAnimation = CurvedAnimation(
    parent: widget.route.animation!,
    curve: Split(
      currentProgress,
      endCurve: widget.animationStyle?.curve ?? _kModalBottomSheetCurve,
    ),
    reverseCurve: Split(
      currentProgress,
      endCurve: widget.animationStyle?.reverseCurve ?? _kModalBottomSheetCurve,
    ),
  );
  _sheetAnimation.parent = _dragEndCurvedAnimation;
}

@override
void dispose() {
  _sheetAnimation.parent = kAlwaysDismissedAnimation;
  _dragEndCurvedAnimation?.dispose();
  _curvedSheetAnimation.dispose();
  super.dispose();
}

Expected results

Expected: obsolete drag-end helpers should be disposed when replaced. After the exit animation completes and the sheet state is disposed, the sheet should leave no animation listeners on a caller-owned controller.

Actual results

Actual: each completed nonclosing drag adds another helper that remains subscribed. Dismissing the sheet removes its current widget listeners but leaves the obsolete helpers attached. Disposing the caller's controller later releases this retention path.

Code sample

See above

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Full flutter doctor -v output
[√] Flutter (Channel stable, 3.47.6, on Microsoft Windows [Version 10.0.26200.9457], locale en-US) [525ms]
    • Flutter version 3.47.6 on channel stable at E:\projects\flutter\sdk-repro
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 5fc346839b (6 days ago), 2026-09-30 15:02:49 -0700
    • Engine revision 692136cb65
    • Dart version 3.13.5
    • DevTools version 2.60.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, no-enable-windows-desktop, no-enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[√] Windows Version (Windows 11 or higher, 25H2, 2009) [991ms]

[√] Chrome - develop for the web [18ms]
    • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe

[√] Connected device (2 available) [210ms]
    • Chrome (web) • chrome • web-javascript • Google Chrome 154.0.8037.98
    • Edge (web)   • edge   • web-javascript • Microsoft Edge 154.0.4258.53

[√] Network resources [519ms]
    • All expected network resources are available.

• No issues found!

Activity

  1. added
    p: material_uimaterial_ui package in flutter/packages
    packageflutter/packages repository. See also p: labels.
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-designOwned by Design Languages team
    on Oct 7, 2026
  2. mbcorona commented on Oct 7, 2026

    @mbcorona
    Member

    Hi @hangy233, thanks for the writeup and the demo.

    Confirmed on the current framework: handleDragEnd assigns a new CurvedAnimation to
    _sheetAnimation.parent on each drag and the outgoing one is never disposed, so its
    status listener stays on the route animation. dispose only releases the initial
    _curvedSheetAnimation.

    void handleDragEnd(DragEndDetails details, {bool? isClosing}) {
    final double currentProgress = widget.route.animation!.value;
    // Rebind the animation using CurvedAnimation and Split so the
    // remaining transition continues smoothly from the exact point
    // where the drag gesture ended.
    _sheetAnimation.parent = CurvedAnimation(
    parent: widget.route.animation!,
    curve: Split(
    currentProgress,
    endCurve: widget.animationStyle?.curve ?? _kModalBottomSheetCurve,
    ),
    reverseCurve: Split(
    currentProgress,
    endCurve: widget.animationStyle?.reverseCurve ?? _kModalBottomSheetCurve,
    ),
    );
    }
    @override

    This is the modal counterpart of #167899, which fixed the same kind of leak for the
    persistent sheet.

    Routing to team-design.

  3. ppamorim commented on Nov 6, 2026

    @ppamorim

    Hi, I can confirm this bug i reproducible on my side. The proposal looks good.

  4. hangy233 commented on Nov 6, 2026

    @hangy233
    Author

    Thanks for confirming, @ppamorim. A fix is in progress in flutter/packages#13167. It disposes the obsolete drag-end CurvedAnimation instances and adds a regression test. The PR has been approved and is awaiting merge.

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 lista: animationAnimation APIshas reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: material_uimaterial_ui package in flutter/packagespackageflutter/packages repository. See also p: labels.team-designOwned by Design Languages teamtriaged-designTriaged by Design Languages 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