diff --git a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart index 82bee01d071..006058fd074 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart @@ -249,6 +249,14 @@ class _FramesChartState extends State with AutoDisposeMixin { _framesScrollController = ScrollController( initialScrollOffset: initialScrollOffset, ); + + // Snap after layout so [atScrollBottom] matches for live follow. + if (_selectedFrameIndex == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_framesScrollController.hasClients) return; + unawaited(_framesScrollController.autoScrollToBottom(jump: true)); + }); + } } @override @@ -261,13 +269,18 @@ class _FramesChartState extends State with AutoDisposeMixin { } double _calculateInitialHorizontalScrollOffset() { - final selectedIndex = _selectedFrameIndex; - if (selectedIndex == null) return 0.0; - final chartWidthWithoutAxisLabels = - widget.constraints.maxWidth - _yAxisUnitsSpace; + math.max(0.0, widget.constraints.maxWidth - _yAxisUnitsSpace); final totalFramesInView = chartWidthWithoutAxisLabels ~/ _defaultFrameWidthWithPadding; + + final selectedIndex = _selectedFrameIndex; + if (selectedIndex == null) { + // Dock to the live edge when remounting with no selection (#9525). + final framesOutOfView = widget.frames.length - totalFramesInView; + return math.max(0.0, framesOutOfView * _defaultFrameWidthWithPadding); + } + final fullFrameRangeInView = Range(0, totalFramesInView); if (fullFrameRangeInView.contains(selectedIndex)) return 0.0; diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 9bbf8ecebc3..2f49eaea7e4 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -23,7 +23,9 @@ TODO: Remove this section if there are not any updates. ## Performance updates -TODO: Remove this section if there are not any updates. +* Fixed a bug where the Flutter frames chart could appear frozen after + switching away from the Performance screen and coming back. + [#9960](https://github.com/flutter/devtools/pull/9960) ## CPU profiler updates diff --git a/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart b/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart index 0945d8321f1..10679f09c74 100644 --- a/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart +++ b/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart @@ -155,7 +155,29 @@ void main() { expect(scrollController.offset, equals(expectedOffset)); } - testWidgets('is zero for no selected frame', (WidgetTester tester) async { + void verifyScrollAtLiveEdge(WidgetTester tester) { + final scrollbar = tester.widget(find.byType(Scrollbar)); + final scrollController = scrollbar.controller!; + expect( + scrollController.offset, + equals(scrollController.position.maxScrollExtent), + ); + } + + FlutterFrame createFrame(int number) { + return FlutterFrame.fromJson({ + 'number': number, + 'startTime': 10000 + number * 50000, + 'elapsed': 20000, + 'build': 10000, + 'raster': 12000, + 'vsyncOverhead': 10, + }); + } + + testWidgets('is at live edge for no selected frame', ( + WidgetTester tester, + ) async { expect(framesController.selectedFrame.value, isNull); await pumpChart(tester); @@ -165,7 +187,39 @@ void main() { findsNWidgets(totalFramesInView), ); - verifyScrollOffset(tester, 0.0); + verifyScrollAtLiveEdge(tester); + }); + + testWidgets('stays at live edge when frames are added', ( + WidgetTester tester, + ) async { + expect(framesController.selectedFrame.value, isNull); + + await pumpChart(tester); + verifyScrollAtLiveEdge(tester); + + framesController.addFrame(createFrame(totalNumFrames)); + await tester.pumpAndSettle(); + verifyScrollAtLiveEdge(tester); + }); + + testWidgets('docks to live edge after remount with no selection', ( + WidgetTester tester, + ) async { + expect(framesController.selectedFrame.value, isNull); + + await pumpChart(tester); + verifyScrollAtLiveEdge(tester); + + // Leave Performance (dispose chart), then remount after new frames. + await tester.pumpWidget(wrap(const SizedBox.shrink())); + await tester.pumpAndSettle(); + + framesController.addFrame(createFrame(totalNumFrames)); + framesController.addFrame(createFrame(totalNumFrames + 1)); + + await pumpChart(tester); + verifyScrollAtLiveEdge(tester); }); testWidgets('is offset for selected frame', (WidgetTester tester) async {