From 10fb6eb201857f9007c54084717a62296fdcbec8 Mon Sep 17 00:00:00 2001 From: ronnnnn Date: Sat, 7 Oct 2023 18:34:47 +0900 Subject: [PATCH] feat: shrink_wrapped_scroll_view --- packages/nilts/README.md | 32 ++++ packages/nilts/lib/nilts.dart | 2 + packages/nilts/lib/src/change_priority.dart | 3 + .../src/lints/shrink_wrapped_scroll_view.dart | 148 ++++++++++++++++++ .../lints/shrink_wrapped_scroll_view.dart | 79 ++++++++++ 5 files changed, 264 insertions(+) create mode 100644 packages/nilts/lib/src/lints/shrink_wrapped_scroll_view.dart create mode 100644 packages/nilts_test/test/lints/shrink_wrapped_scroll_view.dart diff --git a/packages/nilts/README.md b/packages/nilts/README.md index f62424a..8b4f1ac 100644 --- a/packages/nilts/README.md +++ b/packages/nilts/README.md @@ -82,6 +82,7 @@ Some of lint rules support quick fixes on IDE. | [defined\_void\_callback\_type](#defined_void_callback_type) | Checks `void Function()` definitions. | Any versions nilts supports | Practice | Experimental | ✅️ | | [fixed\_text\_scale\_factor\_rich\_text](#fixed_text_scale_factor_rich_text) | Checks usage of `textScaleFactor` in `RichText` constructor. | Any versions nilts supports | Practice | Experimental | ✅️ | | [flaky\_tests\_with\_set\_up\_all](#flaky_tests_with_set_up_all) | Checks `setUpAll` usages. | Any versions nilts supports | Practice | Experimental | ✅️ | +| [shrink\_wrapped\_scroll\_view](#shrink_wrapped_scroll_view) | Checks the content of the scroll view is shrink wrapped. | Any versions nilts supports | Practice | Experimental | ✅️ | | [unnecessary\_rebuilds\_from\_media\_query](#unnecessary_rebuilds_from_media_query) | Checks `MediaQuery.xxxOf(context)` or `MediaQuery.maybeXxxOf(context)` usages. | >= Flutter 3.10.0 (Dart 3.0.0) | Practice | Experimental | ✅️ | ### Details @@ -181,6 +182,37 @@ See also: - [setUpAll function - flutter_test library - Dart API](https://api.flutter.dev/flutter/flutter_test/setUpAll.html) - [setUp function - flutter_test library - Dart API](https://api.flutter.dev/flutter/flutter_test/setUp.html) +#### shrink_wrapped_scroll_view + +- Target SDK: Any versions nilts supports +- Rule type: Practice +- Maturity level: Experimental +- Quick fix: ✅ + +**Consider** removing `shrinkWrap` argument and update the Widget not to shrink wrap. +Shrink wrapping the content of the scroll view is significantly more expensive than expanding to the maximum allowed size because the content can expand and contract during scrolling, which means the size of the scroll view needs to be recomputed whenever the scroll position changes. + +You can avoid shrink wrap with 3 steps below in case of your scroll view is nested. + +1. Replace the parent scroll view with `CustomScrollView`. +2. Replace the child scroll view with `SliverListView` or `SliverGridView`. +3. Set `SliverChildBuilderDelegate` to `delegate` argument of the `SliverListView` or `SliverGridView`. + +**BAD:** +```dart +ListView(shrinkWrap: true) +``` + +**GOOD:** +```dart +ListView(shrinkWrap: false) +``` + +See also: + +- [shrinkWrap property - ScrollView class - widgets library - Dart API](https://api.flutter.dev/flutter/widgets/ScrollView/shrinkWrap.html) +- [ShrinkWrap vs Slivers | Decoding Flutter - YouTube](https://youtu.be/LUqDNnv_dh0) + #### unnecessary_rebuilds_from_media_query - Target SDK: >= Flutter 3.10.0 (Dart 3.0.0) diff --git a/packages/nilts/lib/nilts.dart b/packages/nilts/lib/nilts.dart index 80893d4..80d6830 100644 --- a/packages/nilts/lib/nilts.dart +++ b/packages/nilts/lib/nilts.dart @@ -2,6 +2,7 @@ import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'package:nilts/src/lints/defined_void_callback_type.dart'; import 'package:nilts/src/lints/fixed_text_scale_factor_rich_text.dart'; import 'package:nilts/src/lints/flaky_tests_with_set_up_all.dart'; +import 'package:nilts/src/lints/shrink_wrapped_scroll_view.dart'; import 'package:nilts/src/lints/unnecessary_rebuilds_from_media_query.dart'; /// custom_lint integrates the nilts's plugin from this method on @@ -15,6 +16,7 @@ class _NiltsLint extends PluginBase { const DefinedVoidCallbackType(), const FixedTextScaleFactorRichText(), const FlakyTestsWithSetUpAll(), + const ShrinkWrappedScrollView(), const UnnecessaryRebuildsFromMediaQuery(), ]; } diff --git a/packages/nilts/lib/src/change_priority.dart b/packages/nilts/lib/src/change_priority.dart index 6bfe56e..7fd00b0 100644 --- a/packages/nilts/lib/src/change_priority.dart +++ b/packages/nilts/lib/src/change_priority.dart @@ -5,6 +5,9 @@ class ChangePriority { /// The priority for [_AddTextScaleFactor]. static const int addTextScaleFactor = 100; + /// The priority for [_RemoveShrinkWrap]. + static const int removeShrinkWrap = 100; + /// The priority for [_ReplaceWithMediaQueryXxxOf]. static const int replaceWithMediaQueryXxxOf = 100; diff --git a/packages/nilts/lib/src/lints/shrink_wrapped_scroll_view.dart b/packages/nilts/lib/src/lints/shrink_wrapped_scroll_view.dart new file mode 100644 index 0000000..f91c630 --- /dev/null +++ b/packages/nilts/lib/src/lints/shrink_wrapped_scroll_view.dart @@ -0,0 +1,148 @@ +// ignore_for_file: comment_references + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/error/error.dart'; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; +import 'package:nilts/src/change_priority.dart'; +import 'package:nilts/src/utils/library_element_ext.dart'; + +/// A class for `shrink_wrapped_scroll_view` rule. +/// +/// This rule checks if the content of the scroll view is shrink wrapped. +/// +/// - Target SDK: Any versions nilts supports +/// - Rule type: Practice +/// - Maturity level: Experimental +/// - Quick fix: ✅ +/// +/// **Consider** removing `shrinkWrap` argument and update the Widget not to +/// shrink wrap. +/// Shrink wrapping the content of the scroll view is +/// significantly more expensive than expanding to the maximum allowed size +/// because the content can expand and contract during scrolling, +/// which means the size of the scroll view needs to be recomputed +/// whenever the scroll position changes. +/// +/// You can avoid shrink wrap with 3 steps below +/// in case of your scroll view is nested. +/// +/// 1. Replace the parent scroll view with [CustomScrollView]. +/// 2. Replace the child scroll view with [SliverListView] or [SliverGridView]. +/// 3. Set [SliverChildBuilderDelegate] to `delegate` argument of +/// [SliverListView] or [SliverGridView]. +/// +/// **BAD:** +/// ```dart +/// ListView(shrinkWrap: true) +/// ``` +/// +/// **GOOD:** +/// ```dart +/// ListView(shrinkWrap: false) +/// ``` +/// +/// See also: +/// +/// - [shrinkWrap property - ScrollView class - widgets library - Dart API](https://api.flutter.dev/flutter/widgets/ScrollView/shrinkWrap.html) +/// - [ShrinkWrap vs Slivers | Decoding Flutter - YouTube](https://youtu.be/LUqDNnv_dh0) +class ShrinkWrappedScrollView extends DartLintRule { + /// Create a new instance of [ShrinkWrappedScrollView]. + const ShrinkWrappedScrollView() : super(code: _code); + + static const _code = LintCode( + name: 'shrink_wrapped_scroll_view', + problemMessage: 'Shrink wrapping the content of the scroll view is ' + 'significantly more expensive than ' + 'expanding to the maximum allowed size.', + url: 'https://github.com/ronnnnn/nilts#shrink_wrapped_scroll_view', + ); + + @override + void run( + CustomLintResolver resolver, + ErrorReporter reporter, + CustomLintContext context, + ) { + context.registry.addInstanceCreationExpression((node) { + // Do nothing if the package of constructor is not `flutter`. + final constructorName = node.constructorName; + final library = constructorName.staticElement?.library; + if (library == null) return; + if (!library.isFlutter) return; + + // Do nothing if the constructor is not sub class of `ScrollView`. + if (!_scrollViewSubClasses.contains(constructorName.type.element?.name)) { + return; + } + + // Do nothing if the constructor doesn't have `shrinkWrap` argument. + final arguments = node.argumentList.arguments; + final isShrinkWrapSet = arguments.any( + (argument) => + argument is NamedExpression && + argument.name.label.name == 'shrinkWrap', + ); + if (!isShrinkWrapSet) return; + + // Do nothing if `shrinkWrap: true` is not set. + final isShrinkWrapped = arguments.any( + (argument) => + argument is NamedExpression && + argument.name.label.name == 'shrinkWrap' && + argument.expression is BooleanLiteral && + (argument.expression as BooleanLiteral).value, + ); + if (!isShrinkWrapped) return; + + reporter.reportErrorForNode(_code, node); + }); + } + + @override + List getFixes() => [ + _RemoveShrinkWrapArgument(), + ]; +} + +class _RemoveShrinkWrapArgument extends DartFix { + @override + void run( + CustomLintResolver resolver, + ChangeReporter reporter, + CustomLintContext context, + AnalysisError analysisError, + List others, + ) { + context.registry.addInstanceCreationExpression((node) { + if (!node.sourceRange.intersects(analysisError.sourceRange)) return; + + // Do nothing if the constructor is not sub class of `ScrollView`. + final constructorName = node.constructorName; + if (!_scrollViewSubClasses.contains(constructorName.type.element?.name)) { + return; + } + + reporter + .createChangeBuilder( + message: 'Remove shrinkWrap', + priority: ChangePriority.removeShrinkWrap, + ) + .addDartFileEdit((builder) { + final arguments = node.argumentList.arguments; + final argument = arguments.firstWhere( + (argument) => + argument is NamedExpression && + argument.name.label.name == 'shrinkWrap', + ); + builder.addDeletion(argument.sourceRange); + }); + }); + } +} + +const _scrollViewSubClasses = [ + 'ListView', + 'GridView', + 'CustomScrollView', +]; diff --git a/packages/nilts_test/test/lints/shrink_wrapped_scroll_view.dart b/packages/nilts_test/test/lints/shrink_wrapped_scroll_view.dart new file mode 100644 index 0000000..c4cfc3e --- /dev/null +++ b/packages/nilts_test/test/lints/shrink_wrapped_scroll_view.dart @@ -0,0 +1,79 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter/material.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + ListView(), + ListView(shrinkWrap: false), + // expect_lint: shrink_wrapped_scroll_view + ListView(shrinkWrap: true), + // expect_lint: shrink_wrapped_scroll_view + ListView.builder( + itemBuilder: (_, __) => null, + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + ListView.custom( + childrenDelegate: SliverChildListDelegate([]), + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + ListView.separated( + itemBuilder: (_, __) => null, + separatorBuilder: (_, __) => const SizedBox.shrink(), + itemCount: 0, + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + GridView( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + ), + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + ), + itemBuilder: (_, __) => null, + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + GridView.custom( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + ), + childrenDelegate: SliverChildListDelegate([]), + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + GridView.extent( + maxCrossAxisExtent: 2, + shrinkWrap: true, + ), + // expect_lint: shrink_wrapped_scroll_view + const CustomScrollView(shrinkWrap: true), + ], + ), + ), + ); + } +}