diff --git a/packages/flutter/lib/src/material/refresh_indicator.dart b/packages/flutter/lib/src/material/refresh_indicator.dart index 08d6c33002..41d89ceaa7 100644 --- a/packages/flutter/lib/src/material/refresh_indicator.dart +++ b/packages/flutter/lib/src/material/refresh_indicator.dart @@ -5,8 +5,8 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart' show clampDouble; -import 'package:flutter/widgets.dart'; import 'debug.dart'; import 'material_localizations.dart'; @@ -59,6 +59,8 @@ enum RefreshIndicatorTriggerMode { onEdge, } +enum _IndicatorType { material, adaptive } + /// A widget that supports the Material "swipe to refresh" idiom. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM} @@ -138,7 +140,38 @@ class RefreshIndicator extends StatefulWidget { this.semanticsValue, this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, this.triggerMode = RefreshIndicatorTriggerMode.onEdge, - }); + }) : _indicatorType = _IndicatorType.material; + + /// Creates an adaptive [RefreshIndicator] based on whether the target + /// platform is iOS or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// When the descendant overscrolls, a different spinning progress indicator + /// is shown depending on platform. On iOS and macOS, + /// [CupertinoActivityIndicator] is shown, but on all other platforms, + /// [CircularProgressIndicator] appears. + /// + /// If a [CupertinoActivityIndicator] is shown, the following parameters are ignored: + /// [backgroundColor], [semanticsLabel], [semanticsValue], [strokeWidth]. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + /// + /// Noteably the scrollable widget itself will have slightly different behavior + /// from [CupertinoSliverRefreshControl], due to a difference in structure. + const RefreshIndicator.adaptive({ + super.key, + required this.child, + this.displacement = 40.0, + this.edgeOffset = 0.0, + required this.onRefresh, + this.color, + this.backgroundColor, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + }) : _indicatorType = _IndicatorType.adaptive; /// The widget below this widget in the tree. /// @@ -207,6 +240,8 @@ class RefreshIndicator extends StatefulWidget { /// By default, the value of [strokeWidth] is 2.0 pixels. final double strokeWidth; + final _IndicatorType _indicatorType; + /// Defines how this [RefreshIndicator] can be triggered when users overscroll. /// /// The [RefreshIndicator] can be pulled out in two cases, @@ -555,7 +590,7 @@ class RefreshIndicatorState extends State with TickerProviderS child: AnimatedBuilder( animation: _positionController, builder: (BuildContext context, Widget? child) { - return RefreshProgressIndicator( + final Widget materialIndicator = RefreshProgressIndicator( semanticsLabel: widget.semanticsLabel ?? MaterialLocalizations.of(context).refreshIndicatorSemanticLabel, semanticsValue: widget.semanticsValue, value: showIndeterminateIndicator ? null : _value.value, @@ -563,6 +598,29 @@ class RefreshIndicatorState extends State with TickerProviderS backgroundColor: widget.backgroundColor, strokeWidth: widget.strokeWidth, ); + + final Widget cupertinoIndicator = CupertinoActivityIndicator( + color: widget.color, + ); + + switch(widget._indicatorType) { + case _IndicatorType.material: + return materialIndicator; + + case _IndicatorType.adaptive: { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return materialIndicator; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return cupertinoIndicator; + } + } + } }, ), ), diff --git a/packages/flutter/test/material/refresh_indicator_test.dart b/packages/flutter/test/material/refresh_indicator_test.dart index f1271a6026..d1cd626be1 100644 --- a/packages/flutter/test/material/refresh_indicator_test.dart +++ b/packages/flutter/test/material/refresh_indicator_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -792,6 +793,48 @@ void main() { expect(refreshCalled, false); }); + testWidgets('RefreshIndicator.adaptive', (WidgetTester tester) async { + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: RefreshIndicator.adaptive( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: ['A', 'B', 'C', 'D', 'E', 'F'].map((String item) { + return SizedBox( + height: 200.0, + child: Text(item), + ); + }).toList(), + ), + ), + ); + } + + for (final TargetPlatform platform in [ TargetPlatform.iOS, TargetPlatform.macOS ]) { + await tester.pumpWidget(buildFrame(platform)); + await tester.pumpAndSettle(); // Finish the theme change animation. + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + expect(find.byType(RefreshProgressIndicator), findsNothing); + } + + for (final TargetPlatform platform in [ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows ]) { + await tester.pumpWidget(buildFrame(platform)); + await tester.pumpAndSettle(); // Finish the theme change animation. + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + expect(tester.getSemantics(find.byType(RefreshProgressIndicator)), matchesSemantics( + label: 'Refresh', + )); + expect(find.byType(CupertinoActivityIndicator), findsNothing); + } + }); + testWidgets('RefreshIndicator color defaults to ColorScheme.primary', (WidgetTester tester) async { const Color primaryColor = Color(0xff4caf50); final ThemeData theme = ThemeData.from(colorScheme: const ColorScheme.light().copyWith(primary: primaryColor));