diff --git a/.ci.yaml b/.ci.yaml index de150662eff7e..59787e09d0ed2 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -1349,7 +1349,6 @@ targets: task_name: channels_integration_test - name: Linux_android clipper_cache_perf__e2e_summary - bringup: true recipe: devicelab/devicelab_drone presubmit: false timeout: 60 @@ -3355,6 +3354,7 @@ targets: - name: Mac_ios microbenchmarks_ios recipe: devicelab/devicelab_drone presubmit: false + bringup: true # Flaky: https://github.com/flutter/flutter/issues/106753 timeout: 60 properties: tags: > diff --git a/bin/internal/engine.version b/bin/internal/engine.version index f5c6ae9878947..8c8d19fc2bae1 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -b164c5c86d1ce3601af309a7b4d290f78baa3012 +51296a62d98c1e03e1207cedcea0ff9e0d434394 diff --git a/bin/internal/flutter_plugins.version b/bin/internal/flutter_plugins.version index 13ad7e4048150..9ec3b0fccc606 100644 --- a/bin/internal/flutter_plugins.version +++ b/bin/internal/flutter_plugins.version @@ -1 +1 @@ -a6d42f1e01d358b6fd7d83539b0d5d98c168b197 +0d6d03a94ed515c8cfae7517587f5b00f2cbfa0a diff --git a/bin/internal/fuchsia-linux.version b/bin/internal/fuchsia-linux.version index 37f9598553761..da1bf99e2cd95 100644 --- a/bin/internal/fuchsia-linux.version +++ b/bin/internal/fuchsia-linux.version @@ -1 +1 @@ -u_FbFbg2malEPTqZZDE26AOs7L68HUcRat50mggUEV4C +ERGTYC7pfsifuKhgfWttuibiwb2UJRhNVg1Inlkxua4C diff --git a/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart b/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart index 080d17155e127..f12de0cce1b0a 100644 --- a/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart +++ b/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart @@ -30,7 +30,6 @@ ThemeData _buildDarkTheme() { primaryColorDark: const Color(0xFF0050a0), primaryColorLight: secondaryColor, indicatorColor: Colors.white, - toggleableActiveColor: const Color(0xFF6997DF), canvasColor: const Color(0xFF202124), scaffoldBackgroundColor: const Color(0xFF202124), backgroundColor: const Color(0xFF202124), @@ -54,7 +53,6 @@ ThemeData _buildLightTheme() { colorScheme: colorScheme, primaryColor: primaryColor, indicatorColor: Colors.white, - toggleableActiveColor: const Color(0xFF1E88E5), splashColor: Colors.white24, splashFactory: InkRipple.splashFactory, canvasColor: Colors.white, diff --git a/dev/tools/gen_defaults/lib/dialog_template.dart b/dev/tools/gen_defaults/lib/dialog_template.dart index 6fe11741fdd90..9fad9b7fd2f58 100644 --- a/dev/tools/gen_defaults/lib/dialog_template.dart +++ b/dev/tools/gen_defaults/lib/dialog_template.dart @@ -27,9 +27,11 @@ class _${blockName}DefaultsM3 extends DialogTheme { @override Color? get iconColor => _colors.secondary; - // TODO(darrenaustin): overlay should be handled by Material widget: https://github.com/flutter/flutter/issues/9160 @override - Color? get backgroundColor => ElevationOverlay.colorWithOverlay(${componentColor("md.comp.dialog.container")}, _colors.primary, ${elevation("md.comp.dialog.container")}); + Color? get backgroundColor => ${componentColor("md.comp.dialog.container")}; + + @override + Color? get surfaceTintColor => ${componentColor("md.comp.dialog.container.surface-tint-layer")}; @override TextStyle? get titleTextStyle => ${textStyle("md.comp.dialog.headline")}; diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index ca1dd34e8f38c..7638e29ef7a68 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -41,6 +41,7 @@ export 'src/cupertino/interface_level.dart'; export 'src/cupertino/list_section.dart'; export 'src/cupertino/list_tile.dart'; export 'src/cupertino/localizations.dart'; +export 'src/cupertino/magnifier.dart'; export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/page_scaffold.dart'; export 'src/cupertino/picker.dart'; diff --git a/packages/flutter/lib/fix_data.yaml b/packages/flutter/lib/fix_data.yaml index be4a44a03dbcc..9ab97a1a377ae 100644 --- a/packages/flutter/lib/fix_data.yaml +++ b/packages/flutter/lib/fix_data.yaml @@ -17,6 +17,438 @@ version: 1 transforms: + # Changes made in https://github.com/flutter/flutter/pull/97972/ + - title: "Migrate 'ThemeData.toggleableActiveColor' to individual themes" + date: 2022-05-18 + element: + uris: [ 'material.dart' ] + constructor: '' + inClass: 'ThemeData' + changes: + - kind: 'removeParameter' + name: 'toggleableActiveColor' + - kind: 'addParameter' + index: 96 + name: 'checkboxTheme' + style: optional_named + argumentValue: + expression: "CheckboxThemeData(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && checkboxTheme == ''" + - kind: 'addParameter' + index: 97 + name: 'checkboxTheme' + style: optional_named + argumentValue: + expression: "{% checkboxTheme %}.copyWith(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && checkboxTheme != ''" + - kind: 'addParameter' + index: 98 + name: 'radioTheme' + style: optional_named + argumentValue: + expression: "RadioThemeData(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && radioTheme == ''" + - kind: 'addParameter' + index: 99 + name: 'radioTheme' + style: optional_named + argumentValue: + expression: "{% radioTheme %}.copyWith(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && radioTheme != ''" + - kind: 'addParameter' + index: 100 + name: 'switchTheme' + style: optional_named + argumentValue: + expression: "SwitchThemeData(\n + thumbColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + trackColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && switchTheme == ''" + - kind: 'addParameter' + index: 101 + name: 'switchTheme' + style: optional_named + argumentValue: + expression: "{% switchTheme %}.copyWith(\n + thumbColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + trackColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && switchTheme != ''" + variables: + checkboxTheme: + kind: 'fragment' + value: 'arguments[checkboxTheme]' + radioTheme: + kind: 'fragment' + value: 'arguments[radioTheme]' + switchTheme: + kind: 'fragment' + value: 'arguments[switchTheme]' + toggleableActiveColor: + kind: 'fragment' + value: 'arguments[toggleableActiveColor]' + + # Changes made in https://github.com/flutter/flutter/pull/97972/ + - title: "Migrate 'ThemeData.raw.toggleableActiveColor' to individual themes" + date: 2022-05-18 + element: + uris: [ 'material.dart' ] + constructor: 'raw' + inClass: 'ThemeData' + changes: + - kind: 'removeParameter' + name: 'toggleableActiveColor' + - kind: 'addParameter' + index: 96 + name: 'checkboxTheme' + style: optional_named + argumentValue: + expression: "CheckboxThemeData(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && checkboxTheme == ''" + - kind: 'addParameter' + index: 97 + name: 'checkboxTheme' + style: optional_named + argumentValue: + expression: "{% checkboxTheme %}.copyWith(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && checkboxTheme != ''" + - kind: 'addParameter' + index: 98 + name: 'radioTheme' + style: optional_named + argumentValue: + expression: "RadioThemeData(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && radioTheme == ''" + - kind: 'addParameter' + index: 99 + name: 'radioTheme' + style: optional_named + argumentValue: + expression: "{% radioTheme %}.copyWith(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && radioTheme != ''" + - kind: 'addParameter' + index: 100 + name: 'switchTheme' + style: optional_named + argumentValue: + expression: "SwitchThemeData(\n + thumbColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + trackColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && switchTheme == ''" + - kind: 'addParameter' + index: 101 + name: 'switchTheme' + style: optional_named + argumentValue: + expression: "{% switchTheme %}.copyWith(\n + thumbColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + trackColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && switchTheme != ''" + variables: + checkboxTheme: + kind: 'fragment' + value: 'arguments[checkboxTheme]' + radioTheme: + kind: 'fragment' + value: 'arguments[radioTheme]' + switchTheme: + kind: 'fragment' + value: 'arguments[switchTheme]' + toggleableActiveColor: + kind: 'fragment' + value: 'arguments[toggleableActiveColor]' + + # Changes made in https://github.com/flutter/flutter/pull/97972/ + - title: "Migrate 'ThemeData.copyWith.toggleableActiveColor' to individual themes" + date: 2022-05-18 + element: + uris: [ 'material.dart' ] + method: 'copyWith' + inClass: 'ThemeData' + changes: + - kind: 'removeParameter' + name: 'toggleableActiveColor' + - kind: 'addParameter' + index: 96 + name: 'checkboxTheme' + style: optional_named + argumentValue: + expression: "CheckboxThemeData(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && checkboxTheme == ''" + - kind: 'addParameter' + index: 97 + name: 'checkboxTheme' + style: optional_named + argumentValue: + expression: "{% checkboxTheme %}.copyWith(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && checkboxTheme != ''" + - kind: 'addParameter' + index: 98 + name: 'radioTheme' + style: optional_named + argumentValue: + expression: "RadioThemeData(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && radioTheme == ''" + - kind: 'addParameter' + index: 99 + name: 'radioTheme' + style: optional_named + argumentValue: + expression: "{% radioTheme %}.copyWith(\n + fillColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && radioTheme != ''" + - kind: 'addParameter' + index: 100 + name: 'switchTheme' + style: optional_named + argumentValue: + expression: "SwitchThemeData(\n + thumbColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + trackColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && switchTheme == ''" + - kind: 'addParameter' + index: 101 + name: 'switchTheme' + style: optional_named + argumentValue: + expression: "{% switchTheme %}.copyWith(\n + thumbColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + trackColor: MaterialStateProperty.resolveWith((Set states) {\n + if (states.contains(MaterialState.disabled)) { + return null; + }\n + if (states.contains(MaterialState.selected)) { + return {% toggleableActiveColor %}; + }\n + return null;\n + }),\n + )" + requiredIf: "toggleableActiveColor != '' && switchTheme != ''" + variables: + checkboxTheme: + kind: 'fragment' + value: 'arguments[checkboxTheme]' + radioTheme: + kind: 'fragment' + value: 'arguments[radioTheme]' + switchTheme: + kind: 'fragment' + value: 'arguments[switchTheme]' + toggleableActiveColor: + kind: 'fragment' + value: 'arguments[toggleableActiveColor]' + # Changes made in https://github.com/flutter/flutter/pull/105291 - title: "Migrate 'ElevatedButton.styleFrom(primary)' to 'ElevatedButton.styleFrom(backgroundColor)'" date: 2022-05-27 diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 5276c6f87ce35..164dbe31cdd7f 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -102,6 +102,7 @@ export 'src/material/input_date_picker_form_field.dart'; export 'src/material/input_decorator.dart'; export 'src/material/list_tile.dart'; export 'src/material/list_tile_theme.dart'; +export 'src/material/magnifier.dart'; export 'src/material/material.dart'; export 'src/material/material_button.dart'; export 'src/material/material_localizations.dart'; diff --git a/packages/flutter/lib/src/cupertino/magnifier.dart b/packages/flutter/lib/src/cupertino/magnifier.dart new file mode 100644 index 0000000000000..59bb4ce91a9d3 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/magnifier.dart @@ -0,0 +1,323 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'package:flutter/widgets.dart'; + +/// A [CupertinoMagnifier] used for magnifying text in cases where a user's +/// finger may be blocking the point of interest, like a selection handle. +/// +/// Delegates styling to [CupertinoMagnifier] with its position depending on +/// [magnifierOverlayInfoBearer]. +/// +/// Specifically, the [CupertinoTextMagnifier] follows the following rules. +/// [CupertinoTextMagnifier]: +/// - is positioned horizontally inside the screen width, with [horizontalScreenEdgePadding] padding. +/// - is hidden if a gesture is detected [hideBelowThreshold] units below the line +/// that the magnifier is on, shown otherwise. +/// - follows the x coordinate of the gesture directly (with respect to rule 1). +/// - has some vertical drag resistance; i.e. if a gesture is detected k units below the field, +/// then has vertical offset [dragResistance] * k. +class CupertinoTextMagnifier extends StatefulWidget { + /// Construct a [RawMagnifier] in the Cupertino style, positioning with respect to + /// [magnifierOverlayInfoBearer]. + /// + /// The default constructor parameters and constants were eyeballed on + /// an iPhone XR iOS v15.5. + const CupertinoTextMagnifier({ + super.key, + this.animationCurve = Curves.easeOut, + required this.controller, + this.dragResistance = 10.0, + this.hideBelowThreshold = 48.0, + this.horizontalScreenEdgePadding = 10.0, + required this.magnifierOverlayInfoBearer, + }); + + /// The curve used for the in / out animations. + final Curve animationCurve; + + /// This magnifier's controller. + /// + /// The [CupertinoTextMagnifier] requires a [MagnifierController] + /// in order to show / hide itself without removing itself from the + /// overlay. + final MagnifierController controller; + + /// A drag resistance on the downward Y position of the lens. + final double dragResistance; + + /// The difference in Y between the gesture position and the caret center + /// so that the magnifier hides itself. + final double hideBelowThreshold; + + /// The padding on either edge of the screen that any part of the magnifier + /// cannot exist past. + /// + /// This includes any part of the magnifier, not just the center; for example, + /// the left edge of the magnifier cannot be outside the [horizontalScreenEdgePadding].v + /// + /// If the screen has width w, then the magnifier is bound to + /// `_kHorizontalScreenEdgePadding, w - _kHorizontalScreenEdgePadding`. + final double horizontalScreenEdgePadding; + + /// [CupertinoTextMagnifier] will determine its own positioning + /// based on the [MagnifierOverlayInfoBearer] of this notifier. + final ValueNotifier + magnifierOverlayInfoBearer; + + /// The duration that the magnifier drags behind its final position. + static const Duration _kDragAnimationDuration = Duration(milliseconds: 45); + + @override + State createState() => + _CupertinoTextMagnifierState(); +} + +class _CupertinoTextMagnifierState extends State + with SingleTickerProviderStateMixin { + // Initalize to dummy values for the event that the inital call to + // _determineMagnifierPositionAndFocalPoint calls hide, and thus does not + // set these values. + Offset _currentAdjustedMagnifierPosition = Offset.zero; + double _verticalFocalPointAdjustment = 0; + late AnimationController _ioAnimationController; + late Animation _ioAnimation; + + @override + void initState() { + super.initState(); + _ioAnimationController = AnimationController( + value: 0, + vsync: this, + duration: CupertinoMagnifier._kInOutAnimationDuration, + )..addListener(() => setState(() {})); + + widget.controller.animationController = _ioAnimationController; + widget.magnifierOverlayInfoBearer + .addListener(_determineMagnifierPositionAndFocalPoint); + + _ioAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _ioAnimationController, + curve: widget.animationCurve, + )); + } + + @override + void dispose() { + widget.controller.animationController = null; + _ioAnimationController.dispose(); + widget.magnifierOverlayInfoBearer + .removeListener(_determineMagnifierPositionAndFocalPoint); + super.dispose(); + } + + @override + void didUpdateWidget(CupertinoTextMagnifier oldWidget) { + if (oldWidget.magnifierOverlayInfoBearer != widget.magnifierOverlayInfoBearer) { + oldWidget.magnifierOverlayInfoBearer.removeListener(_determineMagnifierPositionAndFocalPoint); + widget.magnifierOverlayInfoBearer.addListener(_determineMagnifierPositionAndFocalPoint); + } + super.didUpdateWidget(oldWidget); + } + + @override + void didChangeDependencies() { + _determineMagnifierPositionAndFocalPoint(); + super.didChangeDependencies(); + } + + void _determineMagnifierPositionAndFocalPoint() { + final MagnifierOverlayInfoBearer textEditingContext = + widget.magnifierOverlayInfoBearer.value; + + // The exact Y of the center of the current line. + final double verticalCenterOfCurrentLine = + textEditingContext.caretRect.center.dy; + + // If the magnifier is currently showing, but we have dragged out of threshold, + // we should hide it. + if (verticalCenterOfCurrentLine - + textEditingContext.globalGesturePosition.dy < + -widget.hideBelowThreshold) { + // Only signal a hide if we are currently showing. + if (widget.controller.shown) { + widget.controller.hide(removeFromOverlay: false); + } + return; + } + + // If we are gone, but got to this point, we shouldn't be: show. + if (!widget.controller.shown) { + _ioAnimationController.forward(); + } + + // Never go above the center of the line, but have some resistance + // going downward if the drag goes too far. + final double verticalPositionOfLens = math.max( + verticalCenterOfCurrentLine, + verticalCenterOfCurrentLine - + (verticalCenterOfCurrentLine - + textEditingContext.globalGesturePosition.dy) / + widget.dragResistance); + + // The raw position, tracking the gesture directly. + final Offset rawMagnifierPosition = Offset( + textEditingContext.globalGesturePosition.dx - + CupertinoMagnifier.kDefaultSize.width / 2, + verticalPositionOfLens - + (CupertinoMagnifier.kDefaultSize.height - + CupertinoMagnifier.kMagnifierAboveFocalPoint), + ); + + final Rect screenRect = Offset.zero & MediaQuery.of(context).size; + + // Adjust the magnifier position so that it never exists outside the horizontal + // padding. + final Offset adjustedMagnifierPosition = MagnifierController.shiftWithinBounds( + bounds: Rect.fromLTRB( + screenRect.left + widget.horizontalScreenEdgePadding, + // iOS doesn't reposition for Y, so we should expand the threshold + // so we can send the whole magnifier out of bounds if need be. + screenRect.top - + (CupertinoMagnifier.kDefaultSize.height + + CupertinoMagnifier.kMagnifierAboveFocalPoint), + screenRect.right - widget.horizontalScreenEdgePadding, + screenRect.bottom + + (CupertinoMagnifier.kDefaultSize.height + + CupertinoMagnifier.kMagnifierAboveFocalPoint)), + rect: rawMagnifierPosition & CupertinoMagnifier.kDefaultSize, + ).topLeft; + + setState(() { + _currentAdjustedMagnifierPosition = adjustedMagnifierPosition; + // The lens should always point to the center of the line. + _verticalFocalPointAdjustment = + verticalCenterOfCurrentLine - verticalPositionOfLens; + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedPositioned( + duration: CupertinoTextMagnifier._kDragAnimationDuration, + curve: widget.animationCurve, + left: _currentAdjustedMagnifierPosition.dx, + top: _currentAdjustedMagnifierPosition.dy, + child: CupertinoMagnifier( + inOutAnimation: _ioAnimation, + additionalFocalPointOffset: Offset(0, _verticalFocalPointAdjustment), + ), + ); + } +} + +/// A [RawMagnifier] used for magnifying text in cases where a user's +/// finger may be blocking the point of interest, like a selection handle. +/// +/// [CupertinoMagnifier] is a wrapper around [RawMagnifier] that handles styling +/// and transitions. +/// +/// {@macro flutter.widgets.magnifier.intro} +/// +/// See also: +/// +/// * [RawMagnifier], the backing implementation. +/// * [CupertinoTextMagnifier], a widget that positions [CupertinoMagnifier] based on +/// [MagnifierOverlayInfoBearer]. +/// * [MagnifierController], the controller for this magnifier. +class CupertinoMagnifier extends StatelessWidget { + /// Creates a [RawMagnifier] in the Cupertino style. + /// + /// The default constructor parameters and constants were eyeballed on + /// an iPhone XR iOS v15.5. + const CupertinoMagnifier({ + super.key, + this.size = kDefaultSize, + this.borderRadius = const BorderRadius.all(Radius.elliptical(60, 50)), + this.additionalFocalPointOffset = Offset.zero, + this.shadows = const [ + BoxShadow( + color: Color.fromARGB(25, 0, 0, 0), + blurRadius: 11, + spreadRadius: 0.2, + ), + ], + this.borderSide = + const BorderSide(color: Color.fromARGB(255, 232, 232, 232)), + this.inOutAnimation, + }); + + /// The shadows displayed under the magnifier. + final List shadows; + + /// The border, or "rim", of this magnifier. + final BorderSide borderSide; + + /// The vertical offset that the magnifier is along the Y axis above + /// the focal point. + @visibleForTesting + static const double kMagnifierAboveFocalPoint = -26; + + /// The default size of the magnifier. + /// + /// This is public so that positioners can choose to depend on it, although + /// it is overrideable. + @visibleForTesting + static const Size kDefaultSize = Size(80, 47.5); + + /// The duration that this magnifier animates in / out for. + /// + /// The animation is a translation and a fade. The translation + /// begins at the focal point, and ends at [kMagnifierAboveFocalPoint]. + /// The opacity begins at 0 and ends at 1. + static const Duration _kInOutAnimationDuration = Duration(milliseconds: 150); + + /// The size of this magnifier. + final Size size; + + /// The border radius of this magnifier. + final BorderRadius borderRadius; + + /// This [RawMagnifier]'s controller. + /// + /// Since [CupertinoMagnifier] has no knowledge of shown / hidden state, + /// this animation should be driven by an external actor. + final Animation? inOutAnimation; + + /// Any additional focal point offset, applied over the regular focal + /// point offset defined in [kMagnifierAboveFocalPoint]. + final Offset additionalFocalPointOffset; + + @override + Widget build(BuildContext context) { + Offset focalPointOffset = + Offset(0, (kDefaultSize.height / 2) - kMagnifierAboveFocalPoint); + focalPointOffset.scale(1, inOutAnimation?.value ?? 1); + focalPointOffset += additionalFocalPointOffset; + + return Transform.translate( + offset: Offset.lerp( + const Offset(0, -kMagnifierAboveFocalPoint), + Offset.zero, + inOutAnimation?.value ?? 1, + )!, + child: RawMagnifier( + size: size, + focalPointOffset: focalPointOffset, + decoration: MagnifierDecoration( + opacity: inOutAnimation?.value ?? 1, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: borderSide, + ), + shadows: shadows, + ), + ), + ); + } +} diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index b32748b4bf1f7..638112fdcbdf9 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -13,6 +13,7 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'desktop_text_selection.dart'; import 'icons.dart'; +import 'magnifier.dart'; import 'text_selection.dart'; import 'theme.dart'; @@ -273,6 +274,7 @@ class CupertinoTextField extends StatefulWidget { this.restorationId, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.magnifierConfiguration, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -434,6 +436,7 @@ class CupertinoTextField extends StatefulWidget { this.restorationId, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.magnifierConfiguration, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -783,6 +786,21 @@ class CupertinoTextField extends StatefulWidget { /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} final bool enableIMEPersonalizedLearning; + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and Android nothing on all other + /// platforms. If it is desired to supress the magnifier, consider passing + /// [TextMagnifierConfiguration.disabled]. + /// + // TODO(antholeole): https://github.com/flutter/flutter/issues/108041 + // once the magnifier PR lands, I should enrich this area of the + // docs with images of what a magnifier is. + final TextMagnifierConfiguration? magnifierConfiguration; + @override State createState() => _CupertinoTextFieldState(); @@ -827,6 +845,27 @@ class CupertinoTextField extends StatefulWidget { properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); } + + static final TextMagnifierConfiguration _iosMagnifierConfiguration = TextMagnifierConfiguration( + magnifierBuilder: ( + BuildContext context, + MagnifierController controller, + ValueNotifier magnifierOverlayInfoBearer + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return CupertinoTextMagnifier( + controller: controller, + magnifierOverlayInfoBearer: magnifierOverlayInfoBearer, + ); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return null; + } + }); } class _CupertinoTextFieldState extends State with RestorationMixin, AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { @@ -1274,6 +1313,7 @@ class _CupertinoTextFieldState extends State with Restoratio maxLines: widget.maxLines, minLines: widget.minLines, expands: widget.expands, + magnifierConfiguration: widget.magnifierConfiguration ?? CupertinoTextField._iosMagnifierConfiguration, // Only show the selection highlight when the text field is focused. selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null, selectionControls: widget.selectionEnabled diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index fe6d4f855aa44..a274c695caac5 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart @@ -147,7 +147,7 @@ class Checkbox extends StatefulWidget { /// The color to use when this checkbox is checked. /// - /// Defaults to [ThemeData.toggleableActiveColor]. + /// Defaults to [ColorScheme.secondary]. /// /// If [fillColor] returns a non-null color in the [MaterialState.selected] /// state, it will be used instead of this color. @@ -185,7 +185,7 @@ class Checkbox extends StatefulWidget { /// If null, then the value of [activeColor] is used in the selected /// state. If that is also null, the value of [CheckboxThemeData.fillColor] /// is used. If that is also null, then [ThemeData.disabledColor] is used in - /// the disabled state, [ThemeData.toggleableActiveColor] is used in the + /// the disabled state, [ColorScheme.secondary] is used in the /// selected state, and [ThemeData.unselectedWidgetColor] is used in the /// default state. final MaterialStateProperty? fillColor; @@ -272,7 +272,7 @@ class Checkbox extends StatefulWidget { /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the /// pressed, focused and hovered state. If that is also null, /// the value of [CheckboxThemeData.overlayColor] is used. If that is - /// also null, then the value of [ThemeData.toggleableActiveColor] with alpha + /// also null, then the value of [ColorScheme.secondary] with alpha /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] /// is used in the pressed, focused and hovered state. final MaterialStateProperty? overlayColor; @@ -385,7 +385,7 @@ class _CheckboxState extends State with TickerProviderStateMixin, Togg return themeData.disabledColor; } if (states.contains(MaterialState.selected)) { - return themeData.toggleableActiveColor; + return themeData.colorScheme.secondary; } return themeData.unselectedWidgetColor; }); diff --git a/packages/flutter/lib/src/material/checkbox_list_tile.dart b/packages/flutter/lib/src/material/checkbox_list_tile.dart index 93ba8d9abb428..94e7731fb2e63 100644 --- a/packages/flutter/lib/src/material/checkbox_list_tile.dart +++ b/packages/flutter/lib/src/material/checkbox_list_tile.dart @@ -5,8 +5,10 @@ import 'package:flutter/widgets.dart'; import 'checkbox.dart'; +import 'checkbox_theme.dart'; import 'list_tile.dart'; import 'list_tile_theme.dart'; +import 'material_state.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -29,7 +31,7 @@ import 'theme_data.dart'; /// /// The [selected] property on this widget is similar to the [ListTile.selected] /// property. This tile's [activeColor] is used for the selected item's text color, or -/// the theme's [ThemeData.toggleableActiveColor] if [activeColor] is null. +/// the theme's [CheckboxThemeData.overlayColor] if [activeColor] is null. /// /// This widget does not coordinate the [selected] state and the [value] state; to have the list tile /// appear selected when the checkbox is checked, pass the same value to both. @@ -372,28 +374,34 @@ class CheckboxListTile extends StatelessWidget { trailing = control; break; } + final ThemeData theme = Theme.of(context); + final CheckboxThemeData checkboxTheme = CheckboxTheme.of(context); + final Set states = { + if (selected) MaterialState.selected, + }; + final Color effectiveActiveColor = activeColor + ?? checkboxTheme.fillColor?.resolve(states) + ?? theme.colorScheme.secondary; return MergeSemantics( - child: ListTileTheme.merge( - selectedColor: activeColor ?? Theme.of(context).toggleableActiveColor, - child: ListTile( - leading: leading, - title: title, - subtitle: subtitle, - trailing: trailing, - isThreeLine: isThreeLine, - dense: dense, - enabled: enabled ?? onChanged != null, - onTap: onChanged != null ? _handleValueChange : null, - selected: selected, - autofocus: autofocus, - contentPadding: contentPadding, - shape: shape, - selectedTileColor: selectedTileColor, - tileColor: tileColor, - visualDensity: visualDensity, - focusNode: focusNode, - enableFeedback: enableFeedback, - ), + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + enabled: enabled ?? onChanged != null, + onTap: onChanged != null ? _handleValueChange : null, + selected: selected, + autofocus: autofocus, + contentPadding: contentPadding, + shape: shape, + selectedTileColor: selectedTileColor, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + enableFeedback: enableFeedback, ), ); } diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index a85dd27d0e1cc..21dd15228f8fc 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -11,7 +11,6 @@ import 'color_scheme.dart'; import 'colors.dart'; import 'debug.dart'; import 'dialog_theme.dart'; -import 'elevation_overlay.dart'; import 'ink_well.dart'; import 'material.dart'; import 'material_localizations.dart'; @@ -46,6 +45,8 @@ class Dialog extends StatelessWidget { super.key, this.backgroundColor, this.elevation, + this.shadowColor, + this.surfaceTintColor, this.insetAnimationDuration = const Duration(milliseconds: 100), this.insetAnimationCurve = Curves.decelerate, this.insetPadding = _defaultInsetPadding, @@ -53,7 +54,8 @@ class Dialog extends StatelessWidget { this.shape, this.alignment, this.child, - }) : assert(clipBehavior != null); + }) : assert(clipBehavior != null), + assert(elevation == null || elevation >= 0.0); /// {@template flutter.material.dialog.backgroundColor} /// The background color of the surface of this [Dialog]. @@ -67,12 +69,53 @@ class Dialog extends StatelessWidget { /// {@template flutter.material.dialog.elevation} /// The z-coordinate of this [Dialog]. /// - /// If null then [DialogTheme.elevation] is used, and if that's null then the - /// dialog's elevation is 24.0. + /// Controls how far above the parent the dialog will appear. Elevation is + /// represented by a drop shadow if [shadowColor] is non null, + /// and a surface tint overlay on the background color if [surfaceTintColor] is + /// non null. + /// + /// If null then [DialogTheme.elevation] is used, and if that is null then + /// the elevation will match the Material Design specification for Dialogs. + /// + /// See also: + /// * [Material.elevation], which describes how [elevation] effects the + /// drop shadow or surface tint overlay. + /// * [shadowColor], color of the drop shadow used to indicate the elevation. + /// * [surfaceTintColor], color of an overlay on top of the background + /// color used to indicate the elevation. + /// * , the Material + /// Design specification for dialogs. /// {@endtemplate} - /// {@macro flutter.material.material.elevation} final double? elevation; + /// {@template flutter.material.dialog.shadowColor} + /// The color to paint the [elevation] shadow under the dialog's [Material]. + /// + /// If null then no drop shadow will be painted. + /// + /// See also: + /// * [Material.shadowColor], which describes how the drop shadow is painted. + /// * [elevation], effects how the drop shadow is painted. + /// * [surfaceTintColor], if non-null will also provide a surface tint + /// overlay on the background color to indicate elevation. + /// {@endtemplate} + final Color? shadowColor; + + /// {@template flutter.material.dialog.surfaceTintColor} + /// The color used as a surface tint overlay on the dialog's background color, + /// which reflects the dialog's [elevation]. + /// + /// If null then no surface tint will be applied. + /// + /// See also: + /// * [Material.surfaceTintColor], which describes how the surface tint will + /// be applied to the background color of the dialog. + /// * [elevation], effects the opacity of the surface tint. + /// * [shadowColor], if non-null will also provide a drop shadow to + /// indicate elevation. + /// {@endtemplate} + final Color? surfaceTintColor; + /// {@template flutter.material.dialog.insetAnimationDuration} /// The duration of the animation to show when the system keyboard intrudes /// into the space that the dialog is placed in. @@ -155,6 +198,8 @@ class Dialog extends StatelessWidget { child: Material( color: backgroundColor ?? dialogTheme.backgroundColor ?? Theme.of(context).dialogBackgroundColor, elevation: elevation ?? dialogTheme.elevation ?? defaults.elevation!, + shadowColor: shadowColor ?? dialogTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: surfaceTintColor ?? dialogTheme.surfaceTintColor ?? defaults.surfaceTintColor, shape: shape ?? dialogTheme.shape ?? defaults.shape!, type: MaterialType.card, clipBehavior: clipBehavior, @@ -280,6 +325,8 @@ class AlertDialog extends StatelessWidget { this.buttonPadding, this.backgroundColor, this.elevation, + this.shadowColor, + this.surfaceTintColor, this.semanticLabel, this.insetPadding = _defaultInsetPadding, this.clipBehavior = Clip.none, @@ -478,9 +525,14 @@ class AlertDialog extends StatelessWidget { final Color? backgroundColor; /// {@macro flutter.material.dialog.elevation} - /// {@macro flutter.material.material.elevation} final double? elevation; + /// {@macro flutter.material.dialog.shadowColor} + final Color? shadowColor; + + /// {@macro flutter.material.dialog.surfaceTintColor} + final Color? surfaceTintColor; + /// The semantic label of the dialog used by accessibility frameworks to /// announce screen transitions when the dialog is opened and closed. /// @@ -695,6 +747,8 @@ class AlertDialog extends StatelessWidget { return Dialog( backgroundColor: backgroundColor, elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, insetPadding: insetPadding, clipBehavior: clipBehavior, shape: shape, @@ -860,6 +914,8 @@ class SimpleDialog extends StatelessWidget { this.contentPadding = const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0), this.backgroundColor, this.elevation, + this.shadowColor, + this.surfaceTintColor, this.semanticLabel, this.insetPadding = _defaultInsetPadding, this.clipBehavior = Clip.none, @@ -915,9 +971,14 @@ class SimpleDialog extends StatelessWidget { final Color? backgroundColor; /// {@macro flutter.material.dialog.elevation} - /// {@macro flutter.material.material.elevation} final double? elevation; + /// {@macro flutter.material.dialog.shadowColor} + final Color? shadowColor; + + /// {@macro flutter.material.dialog.surfaceTintColor} + final Color? surfaceTintColor; + /// The semantic label of the dialog used by accessibility frameworks to /// announce screen transitions when the dialog is opened and closed. /// @@ -1031,6 +1092,8 @@ class SimpleDialog extends StatelessWidget { return Dialog( backgroundColor: backgroundColor, elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, insetPadding: insetPadding, clipBehavior: clipBehavior, shape: shape, @@ -1296,6 +1359,9 @@ class _DialogDefaultsM2 extends DialogTheme { @override Color? get backgroundColor => Theme.of(context).dialogBackgroundColor; + @override + Color? get shadowColor => Theme.of(context).shadowColor; + @override TextStyle? get titleTextStyle => _textTheme.headline6; @@ -1330,9 +1396,11 @@ class _DialogDefaultsM3 extends DialogTheme { @override Color? get iconColor => _colors.secondary; - // TODO(darrenaustin): overlay should be handled by Material widget: https://github.com/flutter/flutter/issues/9160 @override - Color? get backgroundColor => ElevationOverlay.colorWithOverlay(_colors.surface, _colors.primary, 6.0); + Color? get backgroundColor => _colors.surface; + + @override + Color? get surfaceTintColor => _colors.surfaceTint; @override TextStyle? get titleTextStyle => _textTheme.headlineSmall; diff --git a/packages/flutter/lib/src/material/dialog_theme.dart b/packages/flutter/lib/src/material/dialog_theme.dart index b3c7cd3218d80..a53afee5ca5a7 100644 --- a/packages/flutter/lib/src/material/dialog_theme.dart +++ b/packages/flutter/lib/src/material/dialog_theme.dart @@ -30,6 +30,8 @@ class DialogTheme with Diagnosticable { const DialogTheme({ this.backgroundColor, this.elevation, + this.shadowColor, + this.surfaceTintColor, this.shape, this.alignment, this.iconColor, @@ -44,6 +46,12 @@ class DialogTheme with Diagnosticable { /// Overrides the default value for [Dialog.elevation]. final double? elevation; + /// Overrides the default value for [Dialog.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value for [Dialog.surfaceTintColor]. + final Color? surfaceTintColor; + /// Overrides the default value for [Dialog.shape]. final ShapeBorder? shape; @@ -69,6 +77,8 @@ class DialogTheme with Diagnosticable { DialogTheme copyWith({ Color? backgroundColor, double? elevation, + Color? shadowColor, + Color? surfaceTintColor, ShapeBorder? shape, AlignmentGeometry? alignment, Color? iconColor, @@ -79,6 +89,8 @@ class DialogTheme with Diagnosticable { return DialogTheme( backgroundColor: backgroundColor ?? this.backgroundColor, elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, shape: shape ?? this.shape, alignment: alignment ?? this.alignment, iconColor: iconColor ?? this.iconColor, @@ -103,6 +115,8 @@ class DialogTheme with Diagnosticable { return DialogTheme( backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), shape: ShapeBorder.lerp(a?.shape, b?.shape, t), alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), @@ -126,6 +140,8 @@ class DialogTheme with Diagnosticable { return other is DialogTheme && other.backgroundColor == backgroundColor && other.elevation == elevation + && other.shadowColor == shadowColor + && other.surfaceTintColor == surfaceTintColor && other.shape == shape && other.alignment == alignment && other.iconColor == iconColor @@ -139,6 +155,8 @@ class DialogTheme with Diagnosticable { super.debugFillProperties(properties); properties.add(ColorProperty('backgroundColor', backgroundColor)); properties.add(DoubleProperty('elevation', elevation)); + properties.add(ColorProperty('shadowColor', shadowColor)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor)); properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); properties.add(DiagnosticsProperty('alignment', alignment, defaultValue: null)); properties.add(ColorProperty('iconColor', iconColor)); diff --git a/packages/flutter/lib/src/material/expansion_tile.dart b/packages/flutter/lib/src/material/expansion_tile.dart index b61b7990dc1a0..ece7892e970fc 100644 --- a/packages/flutter/lib/src/material/expansion_tile.dart +++ b/packages/flutter/lib/src/material/expansion_tile.dart @@ -10,7 +10,6 @@ import 'expansion_tile_theme.dart'; import 'icons.dart'; import 'list_tile.dart'; import 'list_tile_theme.dart'; -import 'material.dart'; import 'theme.dart'; const Duration _kExpand = Duration(milliseconds: 200); @@ -433,13 +432,11 @@ class _ExpansionTileState extends State with SingleTickerProvider offstage: closed, child: TickerMode( enabled: !closed, - child: Material( - child: Padding( - padding: widget.childrenPadding ?? expansionTileTheme.childrenPadding ?? EdgeInsets.zero, - child: Column( - crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center, - children: widget.children, - ), + child: Padding( + padding: widget.childrenPadding ?? expansionTileTheme.childrenPadding ?? EdgeInsets.zero, + child: Column( + crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center, + children: widget.children, ), ), ), diff --git a/packages/flutter/lib/src/material/magnifier.dart b/packages/flutter/lib/src/material/magnifier.dart new file mode 100644 index 0000000000000..f4522f3179a32 --- /dev/null +++ b/packages/flutter/lib/src/material/magnifier.dart @@ -0,0 +1,337 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; + +/// {@template widgets.material.magnifier.magnifier} +/// A [Magnifier] positioned by rules dictated by the native Android magnifier. +/// {@endtemplate} +/// +/// {@template widgets.material.magnifier.positionRules} +/// Positions itself based on [magnifierInfo]. Specifically, follows the +/// following rules: +/// - Tracks the gesture's x coordinate, but clamped to the beginning and end of the +/// currently editing line. +/// - Focal point may never contain anything out of bounds. +/// - Never goes out of bounds vertically; offset until the entire magnifier is in the screen. The +/// focal point, regardless of this transformation, always points to the touch y coordinate. +/// - If just jumped between lines (prevY != currentY) then animate for duration +/// [jumpBetweenLinesAnimationDuration]. +/// {@endtemplate} +class TextMagnifier extends StatefulWidget { + /// {@macro widgets.material.magnifier.magnifier} + /// + /// {@template widgets.material.magnifier.androidDisclaimer} + /// These constants and default parameters were taken from the + /// Android 12 source code where directly transferable, and eyeballed on + /// a Pixel 6 running Android 12 otherwise. + /// {@endtemplate} + /// + /// {@macro widgets.material.magnifier.positionRules} + const TextMagnifier({ + super.key, + required this.magnifierInfo, + }); + + /// A [TextMagnifierConfiguration] that returns a [CupertinoTextMagnifier] on iOS, + /// [TextMagnifier] on Android, and null on all other platforms, and shows the editing handles + /// only on iOS. + static TextMagnifierConfiguration adaptiveMagnifierConfiguration = TextMagnifierConfiguration( + shouldDisplayHandlesInMagnifier: defaultTargetPlatform == TargetPlatform.iOS, + magnifierBuilder: ( + BuildContext context, + MagnifierController controller, + ValueNotifier magnifierOverlayInfoBearer, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return CupertinoTextMagnifier( + controller: controller, + magnifierOverlayInfoBearer: magnifierOverlayInfoBearer, + ); + case TargetPlatform.android: + return TextMagnifier( + magnifierInfo: magnifierOverlayInfoBearer, + ); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return null; + } + } + ); + + /// The duration that the position is animated if [TextMagnifier] just switched + /// between lines. + @visibleForTesting + static const Duration jumpBetweenLinesAnimationDuration = + Duration(milliseconds: 70); + + /// [TextMagnifier] positions itself based on [magnifierInfo]. + /// + /// {@macro widgets.material.magnifier.positionRules} + final ValueNotifier + magnifierInfo; + + @override + State createState() => _TextMagnifierState(); +} + +class _TextMagnifierState extends State { + // Should _only_ be null on construction. This is because of the animation logic. + // + // Animations are added when `last_build_y != current_build_y`. This condition + // is true on the inital render, which would mean that the inital + // build would be animated - this is undesired. Thus, this is null for the + // first frame and the condition becomes `magnifierPosition != null && last_build_y != this_build_y`. + Offset? _magnifierPosition; + + // A timer that unsets itself after an animation duration. + // If the timer exists, then the magnifier animates its position - + // if this timer does not exist, the magnifier tracks the gesture (with respect + // to the positioning rules) directly. + Timer? _positionShouldBeAnimatedTimer; + bool get _positionShouldBeAnimated => _positionShouldBeAnimatedTimer != null; + + Offset _extraFocalPointOffset = Offset.zero; + + @override + void initState() { + super.initState(); + widget.magnifierInfo + .addListener(_determineMagnifierPositionAndFocalPoint); + } + + @override + void dispose() { + widget.magnifierInfo + .removeListener(_determineMagnifierPositionAndFocalPoint); + _positionShouldBeAnimatedTimer?.cancel(); + super.dispose(); + } + + @override + void didChangeDependencies() { + _determineMagnifierPositionAndFocalPoint(); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(TextMagnifier oldWidget) { + if (oldWidget.magnifierInfo != widget.magnifierInfo) { + oldWidget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); + widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); + } + super.didUpdateWidget(oldWidget); + } + + /// {@macro widgets.material.magnifier.positionRules} + void _determineMagnifierPositionAndFocalPoint() { + final MagnifierOverlayInfoBearer selectionInfo = + widget.magnifierInfo.value; + final Rect screenRect = Offset.zero & MediaQuery.of(context).size; + + // Since by default we draw at the top left corner, this offset + // shifts the magnifier so we draw at the center, and then also includes + // the "above touch point" shift. + final Offset basicMagnifierOffset = Offset( + Magnifier.kDefaultMagnifierSize.width / 2, + Magnifier.kDefaultMagnifierSize.height + + Magnifier.kStandardVerticalFocalPointShift); + + // Since the magnifier should not go past the edges of the line, + // but must track the gesture otherwise, constrain the X of the magnifier + // to always stay between line start and end. + final double magnifierX = clampDouble( + selectionInfo.globalGesturePosition.dx, + selectionInfo.currentLineBoundries.left, + selectionInfo.currentLineBoundries.right); + + // Place the magnifier at the previously calculated X, and the Y should be + // exactly at the center of the handle. + final Rect unadjustedMagnifierRect = + Offset(magnifierX, selectionInfo.caretRect.center.dy) - basicMagnifierOffset & + Magnifier.kDefaultMagnifierSize; + + // Shift the magnifier so that, if we are ever out of the screen, we become in bounds. + // This probably won't have much of an effect on the X, since it is already bound + // to the currentLineBoundries, but will shift vertically if the magnifier is out of bounds. + final Rect screenBoundsAdjustedMagnifierRect = + MagnifierController.shiftWithinBounds( + bounds: screenRect, rect: unadjustedMagnifierRect); + + // Done with the magnifier position! + final Offset finalMagnifierPosition = screenBoundsAdjustedMagnifierRect.topLeft; + + // The insets, from either edge, that the focal point should not point + // past lest the magnifier displays something out of bounds. + final double horizontalMaxFocalPointEdgeInsets = + (Magnifier.kDefaultMagnifierSize.width / 2) / Magnifier._magnification; + + // Adjust the focal point horizontally such that none of the magnifier + // ever points to anything out of bounds. + final double newGlobalFocalPointX; + + // If the text field is so narrow that we must show out of bounds, + // then settle for pointing to the center all the time. + if (selectionInfo.fieldBounds.width < + horizontalMaxFocalPointEdgeInsets * 2) { + newGlobalFocalPointX = selectionInfo.fieldBounds.center.dx; + } else { + // Otherwise, we can clamp the focal point to always point in bounds. + newGlobalFocalPointX = clampDouble( + screenBoundsAdjustedMagnifierRect.center.dx, + selectionInfo.fieldBounds.left + horizontalMaxFocalPointEdgeInsets, + selectionInfo.fieldBounds.right - horizontalMaxFocalPointEdgeInsets); + } + + // Since the previous value is now a global offset (i.e. `newGlobalFocalPoint` + // is now a global offset), we must subtract the magnifier's global offset + // to obtain the relative shift in the focal point. + final double newRelativeFocalPointX = + newGlobalFocalPointX - screenBoundsAdjustedMagnifierRect.center.dx; + + // The Y component means that if we are pressed up against the top of the screen, + // then we should adjust the focal point such that it now points to how far we moved + // the magnifier. screenBoundsAdjustedMagnifierRect.top == unadjustedMagnifierRect.top for most cases, + // but when pressed up against the top of the screen, we adjust the focal point by + // the amount that we shifted from our "natural" position. + final Offset focalPointAdjustmentForScreenBoundsAdjustment = Offset( + newRelativeFocalPointX, + unadjustedMagnifierRect.top - screenBoundsAdjustedMagnifierRect.top, + ); + + Timer? positionShouldBeAnimated = _positionShouldBeAnimatedTimer; + + if (_magnifierPosition != null && finalMagnifierPosition.dy != _magnifierPosition!.dy) { + if (_positionShouldBeAnimatedTimer != null && + _positionShouldBeAnimatedTimer!.isActive) { + _positionShouldBeAnimatedTimer!.cancel(); + } + + // Create a timer that deletes itself when the timer is complete. + // This is `mounted` safe, since the timer is canceled in `dispose`. + positionShouldBeAnimated = Timer( + TextMagnifier.jumpBetweenLinesAnimationDuration, + () => setState(() { + _positionShouldBeAnimatedTimer = null; + })); + } + + setState(() { + _magnifierPosition = finalMagnifierPosition; + _positionShouldBeAnimatedTimer = positionShouldBeAnimated; + _extraFocalPointOffset = focalPointAdjustmentForScreenBoundsAdjustment; + }); + } + + @override + Widget build(BuildContext context) { + assert(_magnifierPosition != null, + 'Magnifier position should only be null before the first build.'); + + return AnimatedPositioned( + top: _magnifierPosition!.dy, + left: _magnifierPosition!.dx, + // Material magnifier typically does not animate, unless we jump between lines, + // in which case we animate between lines. + duration: _positionShouldBeAnimated + ? TextMagnifier.jumpBetweenLinesAnimationDuration + : Duration.zero, + child: Magnifier( + additionalFocalPointOffset: _extraFocalPointOffset, + ), + ); + } +} + +/// A Material styled magnifying glass. +/// +/// {@macro flutter.widgets.magnifier.intro} +/// +/// This widget focuses on mimicking the _style_ of the magnifier on material. For a +/// widget that is focused on mimicking the behavior of a material magnifier, see [TextMagnifier]. +class Magnifier extends StatelessWidget { + /// Creates a [RawMagnifier] in the Material style. + /// + /// {@macro widgets.material.magnifier.androidDisclaimer} + const Magnifier({ + super.key, + this.additionalFocalPointOffset = Offset.zero, + this.borderRadius = const BorderRadius.all(Radius.circular(_borderRadius)), + this.filmColor = const Color.fromARGB(8, 158, 158, 158), + this.shadows = const [ + BoxShadow( + blurRadius: 1.5, + offset: Offset(0, 2), + spreadRadius: 0.75, + color: Color.fromARGB(25, 0, 0, 0)) + ], + this.size = Magnifier.kDefaultMagnifierSize, + }); + + /// The default size of this [Magnifier]. + /// + /// The size of the magnifier may be modified through the constructor; + /// [kDefaultMagnifierSize] is extracted from the default parameter of + /// [Magnifier]'s constructor so that positioners may depend on it. + @visibleForTesting + static const Size kDefaultMagnifierSize = Size(77.37, 37.9); + + /// The vertical distance that the magnifier should be above the focal point. + /// + /// [kStandardVerticalFocalPointShift] is an unmodifiable constant so that positioning of this + /// [Magnifier] can be done with a garunteed size, as opposed to an estimate. + @visibleForTesting + static const double kStandardVerticalFocalPointShift = 22; + + static const double _borderRadius = 40; + static const double _magnification = 1.25; + + /// Any additional offset the focal point requires to "point" + /// to the correct place. + /// + /// This is useful for instances where the magnifier is not pointing to something + /// directly below it. + final Offset additionalFocalPointOffset; + + /// The border radius for this magnifier. + final BorderRadius borderRadius; + + /// The color to tint the image in this [Magnifier]. + /// + /// On native Android, there is a almost transparent gray tint to the + /// magnifier, in order to better distinguish the contents of the lens from + /// the background. + final Color filmColor; + + /// The shadows for this [Magnifier]. + final List shadows; + + /// The [Size] of this [Magnifier]. + /// + /// This size does not include the border. + final Size size; + + @override + Widget build(BuildContext context) { + return RawMagnifier( + decoration: MagnifierDecoration( + shape: RoundedRectangleBorder(borderRadius: borderRadius), + shadows: shadows, + ), + magnificationScale: _magnification, + focalPointOffset: additionalFocalPointOffset + + Offset(0, kStandardVerticalFocalPointShift + kDefaultMagnifierSize.height / 2), + size: size, + child: ColoredBox( + color: filmColor, + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index 7daff69b5edac..128688daeec5f 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -175,7 +175,7 @@ class Radio extends StatefulWidget { /// The color to use when this radio button is selected. /// - /// Defaults to [ThemeData.toggleableActiveColor]. + /// Defaults to [ColorScheme.secondary]. /// /// If [fillColor] returns a non-null color in the [MaterialState.selected] /// state, it will be used instead of this color. @@ -214,7 +214,7 @@ class Radio extends StatefulWidget { /// If null, then the value of [activeColor] is used in the selected state. If /// that is also null, then the value of [RadioThemeData.fillColor] is used. /// If that is also null, then [ThemeData.disabledColor] is used in - /// the disabled state, [ThemeData.toggleableActiveColor] is used in the + /// the disabled state, [ColorScheme.secondary] is used in the /// selected state, and [ThemeData.unselectedWidgetColor] is used in the /// default state. final MaterialStateProperty? fillColor; @@ -281,7 +281,7 @@ class Radio extends StatefulWidget { /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the /// pressed, focused and hovered state. If that is also null, /// the value of [RadioThemeData.overlayColor] is used. If that is also null, - /// then the value of [ThemeData.toggleableActiveColor] with alpha + /// then the value of [ColorScheme.secondary] with alpha /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] /// is used in the pressed, focused and hovered state. final MaterialStateProperty? overlayColor; @@ -361,7 +361,7 @@ class _RadioState extends State> with TickerProviderStateMixin, Togg return themeData.disabledColor; } if (states.contains(MaterialState.selected)) { - return themeData.toggleableActiveColor; + return themeData.colorScheme.secondary; } return themeData.unselectedWidgetColor; }); diff --git a/packages/flutter/lib/src/material/radio_list_tile.dart b/packages/flutter/lib/src/material/radio_list_tile.dart index ff180812e9275..6c64e1ba52e27 100644 --- a/packages/flutter/lib/src/material/radio_list_tile.dart +++ b/packages/flutter/lib/src/material/radio_list_tile.dart @@ -6,7 +6,9 @@ import 'package:flutter/widgets.dart'; import 'list_tile.dart'; import 'list_tile_theme.dart'; +import 'material_state.dart'; import 'radio.dart'; +import 'radio_theme.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -346,36 +348,42 @@ class RadioListTile extends StatelessWidget { trailing = control; break; } + final ThemeData theme = Theme.of(context); + final RadioThemeData radioThemeData = RadioTheme.of(context); + final Set states = { + if (selected) MaterialState.selected, + }; + final Color effectiveActiveColor = activeColor + ?? radioThemeData.fillColor?.resolve(states) + ?? theme.colorScheme.secondary; return MergeSemantics( - child: ListTileTheme.merge( - selectedColor: activeColor ?? Theme.of(context).toggleableActiveColor, - child: ListTile( - leading: leading, - title: title, - subtitle: subtitle, - trailing: trailing, - isThreeLine: isThreeLine, - dense: dense, - enabled: onChanged != null, - shape: shape, - tileColor: tileColor, - selectedTileColor: selectedTileColor, - onTap: onChanged != null ? () { - if (toggleable && checked) { - onChanged!(null); - return; - } - if (!checked) { - onChanged!(value); - } - } : null, - selected: selected, - autofocus: autofocus, - contentPadding: contentPadding, - visualDensity: visualDensity, - focusNode: focusNode, - enableFeedback: enableFeedback, - ), + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + enabled: onChanged != null, + shape: shape, + tileColor: tileColor, + selectedTileColor: selectedTileColor, + onTap: onChanged != null ? () { + if (toggleable && checked) { + onChanged!(null); + return; + } + if (!checked) { + onChanged!(value); + } + } : null, + selected: selected, + autofocus: autofocus, + contentPadding: contentPadding, + visualDensity: visualDensity, + focusNode: focusNode, + enableFeedback: enableFeedback, ), ); } diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart index 1bf0e6d090b11..9b023eea3c396 100644 --- a/packages/flutter/lib/src/material/selectable_text.dart +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart'; import 'desktop_text_selection.dart'; import 'feedback.dart'; +import 'magnifier.dart'; import 'text_selection.dart'; import 'theme.dart'; @@ -203,6 +204,7 @@ class SelectableText extends StatefulWidget { this.textHeightBehavior, this.textWidthBasis, this.onSelectionChanged, + this.magnifierConfiguration, }) : assert(showCursor != null), assert(autofocus != null), assert(dragStartBehavior != null), @@ -260,6 +262,7 @@ class SelectableText extends StatefulWidget { this.textHeightBehavior, this.textWidthBasis, this.onSelectionChanged, + this.magnifierConfiguration, }) : assert(showCursor != null), assert(autofocus != null), assert(dragStartBehavior != null), @@ -427,6 +430,17 @@ class SelectableText extends StatefulWidget { /// {@macro flutter.widgets.editableText.onSelectionChanged} final SelectionChangedCallback? onSelectionChanged; + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on + /// Android, and builds nothing on all other platforms. If it is desired to supress + /// the magnifier, consider passing [TextMagnifierConfiguration.disabled]. + final TextMagnifierConfiguration? magnifierConfiguration; + @override State createState() => _SelectableTextState(); @@ -705,6 +719,7 @@ class _SelectableTextState extends State implements TextSelectio paintCursorAboveText: paintCursorAboveText, backgroundCursorColor: CupertinoColors.inactiveGray, enableInteractiveSelection: widget.enableInteractiveSelection, + magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, dragStartBehavior: widget.dragStartBehavior, scrollPhysics: widget.scrollPhysics, autofillHints: null, diff --git a/packages/flutter/lib/src/material/selection_area.dart b/packages/flutter/lib/src/material/selection_area.dart index ba49cfdd5ca6b..6de143a116c51 100644 --- a/packages/flutter/lib/src/material/selection_area.dart +++ b/packages/flutter/lib/src/material/selection_area.dart @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart'; import 'desktop_text_selection.dart'; +import 'magnifier.dart'; import 'text_selection.dart'; import 'theme.dart'; @@ -34,9 +35,21 @@ class SelectionArea extends StatefulWidget { super.key, this.focusNode, this.selectionControls, + this.magnifierConfiguration, required this.child, }); + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on + /// Android, and builds nothing on all other platforms. If it is desired to supress + /// the magnifier, consider passing [TextMagnifierConfiguration.disabled]. + final TextMagnifierConfiguration? magnifierConfiguration; + /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; @@ -92,6 +105,7 @@ class _SelectionAreaState extends State { return SelectableRegion( focusNode: _effectiveFocusNode, selectionControls: controls, + magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, child: widget.child, ); } diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index 0c0ca3e3bd25a..7732a30e337ff 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -182,7 +182,7 @@ class Switch extends StatelessWidget { /// The color to use when this switch is on. /// - /// Defaults to [ThemeData.toggleableActiveColor]. + /// Defaults to [ColorScheme.secondary]. /// /// If [thumbColor] returns a non-null color in the [MaterialState.selected] /// state, it will be used instead of this color. @@ -190,7 +190,7 @@ class Switch extends StatelessWidget { /// The color to use on the track when this switch is on. /// - /// Defaults to [ThemeData.toggleableActiveColor] with the opacity set at 50%. + /// Defaults to [ColorScheme.secondary] with the opacity set at 50%. /// /// Ignored if this switch is created with [Switch.adaptive]. /// @@ -273,7 +273,7 @@ class Switch extends StatelessWidget { /// | State | Light theme | Dark theme | /// |----------|-----------------------------------|-----------------------------------| /// | Default | `Colors.grey.shade50` | `Colors.grey.shade400` | - /// | Selected | [ThemeData.toggleableActiveColor] | [ThemeData.toggleableActiveColor] | + /// | Selected | [ColorScheme.secondary] | [ColorScheme.secondary] | /// | Disabled | `Colors.grey.shade400` | `Colors.grey.shade800` | final MaterialStateProperty? thumbColor; @@ -393,7 +393,7 @@ class Switch extends StatelessWidget { /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the /// pressed, focused and hovered state. If that is also null, /// the value of [SwitchThemeData.overlayColor] is used. If that is - /// also null, then the value of [ThemeData.toggleableActiveColor] with alpha + /// also null, then the value of [ColorScheme.secondary] with alpha /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] /// is used in the pressed, focused and hovered state. final MaterialStateProperty? overlayColor; @@ -614,7 +614,7 @@ class _MaterialSwitchState extends State<_MaterialSwitch> with TickerProviderSta return isDark ? Colors.grey.shade800 : Colors.grey.shade400; } if (states.contains(MaterialState.selected)) { - return theme.toggleableActiveColor; + return theme.colorScheme.secondary; } return isDark ? Colors.grey.shade400 : Colors.grey.shade50; }); diff --git a/packages/flutter/lib/src/material/switch_list_tile.dart b/packages/flutter/lib/src/material/switch_list_tile.dart index 8180dd6f4cef3..3d9a94e477856 100644 --- a/packages/flutter/lib/src/material/switch_list_tile.dart +++ b/packages/flutter/lib/src/material/switch_list_tile.dart @@ -6,7 +6,9 @@ import 'package:flutter/widgets.dart'; import 'list_tile.dart'; import 'list_tile_theme.dart'; +import 'material_state.dart'; import 'switch.dart'; +import 'switch_theme.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -37,7 +39,7 @@ enum _SwitchListTileType { material, adaptive } /// /// The [selected] property on this widget is similar to the [ListTile.selected] /// property. This tile's [activeColor] is used for the selected item's text color, or -/// the theme's [ThemeData.toggleableActiveColor] if [activeColor] is null. +/// the theme's [SwitchThemeData.overlayColor] if [activeColor] is null. /// /// This widget does not coordinate the [selected] state and the /// [value]; to have the list tile appear selected when the @@ -423,29 +425,35 @@ class SwitchListTile extends StatelessWidget { break; } + final ThemeData theme = Theme.of(context); + final SwitchThemeData switchTheme = SwitchTheme.of(context); + final Set states = { + if (selected) MaterialState.selected, + }; + final Color effectiveActiveColor = activeColor + ?? switchTheme.thumbColor?.resolve(states) + ?? theme.colorScheme.secondary; return MergeSemantics( - child: ListTileTheme.merge( - selectedColor: activeColor ?? Theme.of(context).toggleableActiveColor, - child: ListTile( - leading: leading, - title: title, - subtitle: subtitle, - trailing: trailing, - isThreeLine: isThreeLine, - dense: dense, - contentPadding: contentPadding, - enabled: onChanged != null, - onTap: onChanged != null ? () { onChanged!(!value); } : null, - selected: selected, - selectedTileColor: selectedTileColor, - autofocus: autofocus, - shape: shape, - tileColor: tileColor, - visualDensity: visualDensity, - focusNode: focusNode, - enableFeedback: enableFeedback, - hoverColor: hoverColor, - ), + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + contentPadding: contentPadding, + enabled: onChanged != null, + onTap: onChanged != null ? () { onChanged!(!value); } : null, + selected: selected, + selectedTileColor: selectedTileColor, + autofocus: autofocus, + shape: shape, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + enableFeedback: enableFeedback, + hoverColor: hoverColor, ), ); } diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 7d5408e7af9c0..ef4f3ce6ed36c 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -14,7 +14,7 @@ import 'debug.dart'; import 'desktop_text_selection.dart'; import 'feedback.dart'; import 'input_decorator.dart'; -import 'material.dart'; +import 'magnifier.dart'; import 'material_localizations.dart'; import 'material_state.dart'; import 'selectable_text.dart' show iOSHorizontalOffset; @@ -330,6 +330,7 @@ class TextField extends StatefulWidget { this.restorationId, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.magnifierConfiguration, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -392,6 +393,17 @@ class TextField extends StatefulWidget { paste: true, ))); + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on + /// Android, and builds nothing on all other platforms. If it is desired to supress + /// the magnifier, consider passing [TextMagnifierConfiguration.disabled]. + final TextMagnifierConfiguration? magnifierConfiguration; + /// Controls the text being edited. /// /// If null, this widget will create its own [TextEditingController]. @@ -1312,6 +1324,7 @@ class _TextFieldState extends State with RestorationMixin implements restorationId: 'editable', scribbleEnabled: widget.scribbleEnabled, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, ), ), ); diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 1e2f6e3ccdd38..29ae6d5e5dcef 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -313,7 +313,6 @@ class ThemeData with Diagnosticable { Color? selectedRowColor, Color? shadowColor, Color? splashColor, - Color? toggleableActiveColor, Color? unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY String? fontFamily, @@ -405,6 +404,13 @@ class ThemeData with Diagnosticable { 'This feature was deprecated after v2.13.0-0.0.pre.' ) AndroidOverscrollIndicator? androidOverscrollIndicator, + @Deprecated( + 'No longer used by the framework, please remove any reference to it. ' + 'For more information, consult the migration guide at ' + 'https://flutter.dev/docs/release/breaking-changes/toggleable-active-color#migration-guide. ' + 'This feature was deprecated after v2.13.0-0.4.pre.', + ) + Color? toggleableActiveColor, }) { // GENERAL CONFIGURATION cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); @@ -614,7 +620,6 @@ class ThemeData with Diagnosticable { selectedRowColor: selectedRowColor, shadowColor: shadowColor, splashColor: splashColor, - toggleableActiveColor: toggleableActiveColor, unselectedWidgetColor: unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY iconTheme: iconTheme, @@ -665,6 +670,7 @@ class ThemeData with Diagnosticable { fixTextFieldOutlineLabel: fixTextFieldOutlineLabel, primaryColorBrightness: primaryColorBrightness, androidOverscrollIndicator: androidOverscrollIndicator, + toggleableActiveColor: toggleableActiveColor, ); } @@ -719,7 +725,6 @@ class ThemeData with Diagnosticable { required this.selectedRowColor, required this.shadowColor, required this.splashColor, - required this.toggleableActiveColor, required this.unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY required this.iconTheme, @@ -810,6 +815,13 @@ class ThemeData with Diagnosticable { 'This feature was deprecated after v2.13.0-0.0.pre.' ) this.androidOverscrollIndicator, + @Deprecated( + 'No longer used by the framework, please remove any reference to it. ' + 'For more information, consult the migration guide at ' + 'https://flutter.dev/docs/release/breaking-changes/toggleable-active-color#migration-guide. ' + 'This feature was deprecated after v2.13.0-0.4.pre.', + ) + Color? toggleableActiveColor, }) : // DEPRECATED (newest deprecations at the bottom) // should not be `required`, use getter pattern to avoid breakages. _accentColor = accentColor, @@ -819,6 +831,7 @@ class ThemeData with Diagnosticable { _buttonColor = buttonColor, _fixTextFieldOutlineLabel = fixTextFieldOutlineLabel, _primaryColorBrightness = primaryColorBrightness, + _toggleableActiveColor = toggleableActiveColor, // GENERAL CONFIGURATION assert(applyElevationOverlayColor != null), assert(extensions != null), @@ -1341,7 +1354,8 @@ class ThemeData with Diagnosticable { /// The color used to highlight the active states of toggleable widgets like /// [Switch], [Radio], and [Checkbox]. - final Color toggleableActiveColor; + Color get toggleableActiveColor => _toggleableActiveColor!; + final Color? _toggleableActiveColor; /// The color used for widgets in their inactive (but enabled) /// state. For example, an unchecked checkbox. See also [disabledColor]. @@ -1673,7 +1687,6 @@ class ThemeData with Diagnosticable { Color? selectedRowColor, Color? shadowColor, Color? splashColor, - Color? toggleableActiveColor, Color? unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY IconThemeData? iconTheme, @@ -1764,6 +1777,13 @@ class ThemeData with Diagnosticable { 'This feature was deprecated after v2.13.0-0.0.pre.' ) AndroidOverscrollIndicator? androidOverscrollIndicator, + @Deprecated( + 'No longer used by the framework, please remove any reference to it. ' + 'For more information, consult the migration guide at ' + 'https://flutter.dev/docs/release/breaking-changes/toggleable-active-color#migration-guide. ' + 'This feature was deprecated after v2.13.0-0.4.pre.', + ) + Color? toggleableActiveColor, }) { cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); return ThemeData.raw( @@ -1807,7 +1827,6 @@ class ThemeData with Diagnosticable { selectedRowColor: selectedRowColor ?? this.selectedRowColor, shadowColor: shadowColor ?? this.shadowColor, splashColor: splashColor ?? this.splashColor, - toggleableActiveColor: toggleableActiveColor ?? this.toggleableActiveColor, unselectedWidgetColor: unselectedWidgetColor ?? this.unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY iconTheme: iconTheme ?? this.iconTheme, @@ -1858,6 +1877,7 @@ class ThemeData with Diagnosticable { fixTextFieldOutlineLabel: fixTextFieldOutlineLabel ?? this.fixTextFieldOutlineLabel, primaryColorBrightness: primaryColorBrightness ?? this.primaryColorBrightness, androidOverscrollIndicator: androidOverscrollIndicator ?? this.androidOverscrollIndicator, + toggleableActiveColor: toggleableActiveColor ?? this.toggleableActiveColor, ); } @@ -2005,7 +2025,6 @@ class ThemeData with Diagnosticable { selectedRowColor: Color.lerp(a.selectedRowColor, b.selectedRowColor, t)!, shadowColor: Color.lerp(a.shadowColor, b.shadowColor, t)!, splashColor: Color.lerp(a.splashColor, b.splashColor, t)!, - toggleableActiveColor: Color.lerp(a.toggleableActiveColor, b.toggleableActiveColor, t)!, unselectedWidgetColor: Color.lerp(a.unselectedWidgetColor, b.unselectedWidgetColor, t)!, // TYPOGRAPHY & ICONOGRAPHY iconTheme: IconThemeData.lerp(a.iconTheme, b.iconTheme, t), @@ -2056,6 +2075,7 @@ class ThemeData with Diagnosticable { fixTextFieldOutlineLabel: t < 0.5 ? a.fixTextFieldOutlineLabel : b.fixTextFieldOutlineLabel, primaryColorBrightness: t < 0.5 ? a.primaryColorBrightness : b.primaryColorBrightness, androidOverscrollIndicator:t < 0.5 ? a.androidOverscrollIndicator : b.androidOverscrollIndicator, + toggleableActiveColor: Color.lerp(a.toggleableActiveColor, b.toggleableActiveColor, t), ); } @@ -2105,7 +2125,6 @@ class ThemeData with Diagnosticable { other.selectedRowColor == selectedRowColor && other.shadowColor == shadowColor && other.splashColor == splashColor && - other.toggleableActiveColor == toggleableActiveColor && other.unselectedWidgetColor == unselectedWidgetColor && // TYPOGRAPHY & ICONOGRAPHY other.iconTheme == iconTheme && @@ -2155,7 +2174,8 @@ class ThemeData with Diagnosticable { other.buttonColor == buttonColor && other.fixTextFieldOutlineLabel == fixTextFieldOutlineLabel && other.primaryColorBrightness == primaryColorBrightness && - other.androidOverscrollIndicator == androidOverscrollIndicator; + other.androidOverscrollIndicator == androidOverscrollIndicator && + other.toggleableActiveColor == toggleableActiveColor; } @override @@ -2202,7 +2222,6 @@ class ThemeData with Diagnosticable { selectedRowColor, shadowColor, splashColor, - toggleableActiveColor, unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY iconTheme, @@ -2253,6 +2272,7 @@ class ThemeData with Diagnosticable { fixTextFieldOutlineLabel, primaryColorBrightness, androidOverscrollIndicator, + toggleableActiveColor, ]; return Object.hashAll(values); } @@ -2301,7 +2321,6 @@ class ThemeData with Diagnosticable { properties.add(ColorProperty('selectedRowColor', selectedRowColor, defaultValue: defaultData.selectedRowColor, level: DiagnosticLevel.debug)); properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: defaultData.shadowColor, level: DiagnosticLevel.debug)); properties.add(ColorProperty('splashColor', splashColor, defaultValue: defaultData.splashColor, level: DiagnosticLevel.debug)); - properties.add(ColorProperty('toggleableActiveColor', toggleableActiveColor, defaultValue: defaultData.toggleableActiveColor, level: DiagnosticLevel.debug)); properties.add(ColorProperty('unselectedWidgetColor', unselectedWidgetColor, defaultValue: defaultData.unselectedWidgetColor, level: DiagnosticLevel.debug)); // TYPOGRAPHY & ICONOGRAPHY properties.add(DiagnosticsProperty('iconTheme', iconTheme, level: DiagnosticLevel.debug)); @@ -2352,6 +2371,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('fixTextFieldOutlineLabel', fixTextFieldOutlineLabel, level: DiagnosticLevel.debug)); properties.add(EnumProperty('primaryColorBrightness', primaryColorBrightness, defaultValue: defaultData.primaryColorBrightness, level: DiagnosticLevel.debug)); properties.add(EnumProperty('androidOverscrollIndicator', androidOverscrollIndicator, defaultValue: null, level: DiagnosticLevel.debug)); + properties.add(ColorProperty('toggleableActiveColor', toggleableActiveColor, defaultValue: defaultData.toggleableActiveColor, level: DiagnosticLevel.debug)); } } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 2d91e47f81180..3653cd5d3f5c9 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -636,6 +636,7 @@ class EditableText extends StatefulWidget { this.scrollBehavior, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, }) : assert(controller != null), assert(focusNode != null), assert(obscuringCharacter != null && obscuringCharacter.length == 1), @@ -1547,6 +1548,13 @@ class EditableText extends StatefulWidget { /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} final bool enableIMEPersonalizedLearning; + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + final TextMagnifierConfiguration magnifierConfiguration; + bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText); // Infer the keyboard type of an `EditableText` if it's not specified. @@ -2629,6 +2637,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien selectionDelegate: this, dragStartBehavior: widget.dragStartBehavior, onSelectionHandleTapped: widget.onSelectionHandleTapped, + magnifierConfiguration: widget.magnifierConfiguration, ); } diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 39ef6bd311b3d..a70318a1789e2 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -739,7 +739,7 @@ class Image extends StatefulWidget { /// child: child, /// ); /// }, - /// loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) { + /// loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { /// return Center(child: child); /// }, /// ) diff --git a/packages/flutter/lib/src/widgets/magnifier.dart b/packages/flutter/lib/src/widgets/magnifier.dart new file mode 100644 index 0000000000000..9a5c9fcb64628 --- /dev/null +++ b/packages/flutter/lib/src/widgets/magnifier.dart @@ -0,0 +1,536 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; + +import 'basic.dart'; +import 'container.dart'; +import 'framework.dart'; +import 'inherited_theme.dart'; +import 'navigator.dart'; +import 'overlay.dart'; + +/// [MagnifierController]'s main benefit over holding a raw [OverlayEntry] is that +/// [MagnifierController] will handle logic around waiting for a magnifier to animate in or out. +/// +/// If a magnifier chooses to have an entry / exit animation, it should provide the animation +/// controller to [MagnifierController.animationController]. [MagnifierController] will then drive +/// the [AnimationController] and wait for it to be complete before removing it from the +/// [Overlay]. +/// +/// To check the status of the magnifier, see [MagnifierController.shown]. +// TODO(antholeole): This whole paradigm can be removed once portals +// lands - then the magnifier can be controlled though a widget in the tree. +// https://github.com/flutter/flutter/pull/105335 +class MagnifierController { + /// If there is no in / out animation for the magnifier, [animationController] should be left + /// null. + MagnifierController({this.animationController}) { + animationController?.value = 0; + } + + /// The controller that will be driven in / out when show / hide is triggered, + /// respectively. + AnimationController? animationController; + + /// The magnifier's [OverlayEntry], if currently in the overlay. + /// + /// This is public in case other overlay entries need to be positioned + /// above or below this [overlayEntry]. Anything in the paint order after + /// the [RawMagnifier] will not be displayed in the magnifier; this means that if it + /// is desired for an overlay entry to be displayed in the magnifier, + /// it _must_ be positioned below the magnifier. + /// + /// {@tool snippet} + /// ```dart + /// void magnifierShowExample(BuildContext context) { + /// final MagnifierController myMagnifierController = MagnifierController(); + /// + /// // Placed below the magnifier, so it will show. + /// Overlay.of(context)!.insert(OverlayEntry( + /// builder: (BuildContext context) => const Text('I WILL display in the magnifier'))); + /// + /// // Will display in the magnifier, since this entry was passed to show. + /// final OverlayEntry displayInMagnifier = OverlayEntry( + /// builder: (BuildContext context) => + /// const Text('I WILL display in the magnifier')); + /// + /// Overlay.of(context)! + /// .insert(displayInMagnifier); + /// myMagnifierController.show( + /// context: context, + /// below: displayInMagnifier, + /// builder: (BuildContext context) => const RawMagnifier( + /// size: Size(100, 100), + /// )); + /// + /// // By default, new entries will be placed over the top entry. + /// Overlay.of(context)!.insert(OverlayEntry( + /// builder: (BuildContext context) => const Text('I WILL NOT display in the magnifier'))); + /// + /// Overlay.of(context)!.insert( + /// below: + /// myMagnifierController.overlayEntry, // Explicitly placed below the magnifier. + /// OverlayEntry( + /// builder: (BuildContext context) => const Text('I WILL display in the magnifier'))); + /// } + /// ``` + /// {@end-tool} + /// + /// A null check on [overlayEntry] will not suffice to check if a magnifier is in the + /// overlay or not; instead, you should check [shown]. This is because it is possible, + /// such as in cases where [hide] was called with `removeFromOverlay` false, that the magnifier + /// is not shown, but the entry is not null. + OverlayEntry? get overlayEntry => _overlayEntry; + OverlayEntry? _overlayEntry; + + /// If the magnifier is shown or not. + /// + /// [shown] is: + /// - false when nothing is in the overlay. + /// - false when [animationController] is [AnimationStatus.dismissed]. + /// - false when [animationController] is animating out. + /// and true in all other circumstances. + bool get shown { + if (overlayEntry == null) { + return false; + } + + if (animationController != null) { + return animationController!.status == AnimationStatus.completed || + animationController!.status == AnimationStatus.forward; + } + + return true; + } + + /// Shows the [RawMagnifier] that this controller controls. + /// + /// Returns a future that completes when the magnifier is fully shown, i.e. done + /// with its entry animation. + /// + /// To control what overlays are shown in the magnifier, utilize [below]. See + /// [overlayEntry] for more details on how to utilize [below]. + /// + /// If the magnifier already exists (i.e. [overlayEntry] != null), then [show] will + /// override the old overlay and not play an exit animation. Consider awaiting [hide] + /// first, to guarantee + Future show({ + required BuildContext context, + required WidgetBuilder builder, + Widget? debugRequiredFor, + OverlayEntry? below, + }) async { + if (overlayEntry != null) { + overlayEntry!.remove(); + } + + final OverlayState? overlayState = Overlay.of( + context, + rootOverlay: true, + debugRequiredFor: debugRequiredFor, + ); + + final CapturedThemes capturedThemes = InheritedTheme.capture( + from: context, + to: Navigator.maybeOf(context)?.context, + ); + + _overlayEntry = OverlayEntry( + builder: (BuildContext context) => capturedThemes.wrap(builder(context)), + ); + overlayState!.insert(overlayEntry!, below: below); + + if (animationController != null) { + await animationController?.forward(); + } + } + + /// Schedules a hide of the magnifier. + /// + /// If this [MagnifierController] has an [AnimationController], + /// then [hide] reverses the animation controller and waits + /// for the animation to complete. Then, if [removeFromOverlay] + /// is true, remove the magnifier from the overlay. + /// + /// In general, [removeFromOverlay] should be true, unless + /// the magnifier needs to preserve states between shows / hides. + Future hide({bool removeFromOverlay = true}) async { + if (overlayEntry == null) { + return; + } + + if (animationController != null) { + await animationController?.reverse(); + } + + if (removeFromOverlay) { + this.removeFromOverlay(); + } + } + + /// Remove the [OverlayEntry] from the [Overlay]. + /// + /// This method removes the [OverlayEntry] synchronously, + /// regardless of exit animation: this leads to abrupt removals + /// of [OverlayEntry]s with animations. + /// + /// To allow the [OverlayEntry] to play its exit animation, consider calling + /// [hide] with `removeFromOverlay` true, and optionally awaiting the future + @visibleForTesting + void removeFromOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + /// A utility for calculating a new [Rect] from [rect] such that + /// [rect] is fully constrained within [bounds]. + /// + /// Any point in the output rect is guaranteed to also be a point contained in [bounds]. + /// + /// It is a runtime error for [rect].width to be greater than [bounds].width, + /// and it is also an error for [rect].height to be greater than [bounds].height. + /// + /// This algorithm translates [rect] the shortest distance such that it is entirely within + /// [bounds]. + /// + /// If [rect] is already within [bounds], no shift will be applied to [rect] and + /// [rect] will be returned as-is. + /// + /// It is perfectly valid for the output rect to have a point along the edge of the + /// [bounds]. If the desired output rect requires that no edges are parallel to edges + /// of [bounds], see [Rect.deflate] by 1 on [bounds] to achieve this effect. + static Rect shiftWithinBounds({ + required Rect rect, + required Rect bounds, + }) { + assert(rect.width <= bounds.width, + 'attempted to shift $rect within $bounds, but the rect has a greater width.'); + assert(rect.height <= bounds.height, + 'attempted to shift $rect within $bounds, but the rect has a greater height.'); + + Offset rectShift = Offset.zero; + if (rect.left < bounds.left) { + rectShift += Offset(bounds.left - rect.left, 0); + } else if (rect.right > bounds.right) { + rectShift += Offset(bounds.right - rect.right, 0); + } + + if (rect.top < bounds.top) { + rectShift += Offset(0, bounds.top - rect.top); + } else if (rect.bottom > bounds.bottom) { + rectShift += Offset(0, bounds.bottom - rect.bottom); + } + + return rect.shift(rectShift); + } +} + +/// A decoration for a [RawMagnifier]. +/// +/// [MagnifierDecoration] does not expose [ShapeDecoration.color], [ShapeDecoration.image], +/// or [ShapeDecoration.gradient], since they will be covered by the [RawMagnifier]'s lens. +/// +/// Also takes an [opacity] (see https://github.com/flutter/engine/pull/34435). +class MagnifierDecoration extends ShapeDecoration { + /// Constructs a [MagnifierDecoration]. + /// + /// By default, [MagnifierDecoration] is a rectangular magnifier with no shadows, and + /// fully opaque. + const MagnifierDecoration({ + this.opacity = 1, + super.shadows, + super.shape = const RoundedRectangleBorder(), + }); + + /// The magnifier's opacity. + final double opacity; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return super == other && other is MagnifierDecoration && other.opacity == opacity; + } + + @override + int get hashCode => Object.hash(super.hashCode, opacity); +} + +/// A common base class for magnifiers. +/// +/// {@template flutter.widgets.magnifier.intro} +/// This magnifying glass is useful for scenarios on mobile devices where +/// the user's finger may be covering part of the screen where a granular +/// action is being performed, such as navigating a small cursor with a drag +/// gesture, on an image or text. +/// {@endtemplate} +/// +/// A magnifier can be convienently managed by [MagnifierController], which handles +/// showing and hiding the magnifier, with an optional entry / exit animation. +/// +/// See: +/// * [MagnifierController], a controller to handle magnifiers in an overlay. +class RawMagnifier extends StatelessWidget { + /// Constructs a [RawMagnifier]. + /// + /// {@template flutter.widgets.magnifier.RawMagnifier.invisibility_warning} + /// By default, this magnifier uses the default [MagnifierDecoration], + /// the focal point is directly under the magnifier, and there is no magnification: + /// This means that a default magnifier will be entirely invisible to the naked eye, + /// since it is painting exactly what is under it, exactly where it was painted + /// orignally. + /// {@endtemplate} + const RawMagnifier({ + super.key, + this.child, + this.decoration = const MagnifierDecoration(), + this.focalPointOffset = Offset.zero, + this.magnificationScale = 1, + required this.size, + }) : assert(magnificationScale != 0, + 'Magnification scale of 0 results in undefined behavior.'); + + /// An optional widget to posiiton inside the len of the [RawMagnifier]. + /// + /// This is positioned over the [RawMagnifier] - it may be useful for tinting the + /// [RawMagnifier], or drawing a crosshair like UI. + final Widget? child; + + /// This magnifier's decoration. + /// + /// {@macro flutter.widgets.magnifier.RawMagnifier.invisibility_warning} + final MagnifierDecoration decoration; + + + /// The offset of the magnifier from [RawMagnifier]'s center. + /// + /// {@template flutter.widgets.magnifier.offset} + /// For example, if [RawMagnifier] is globally positioned at Offset(100, 100), + /// and [focalPointOffset] is Offset(-20, -20), then [RawMagnifier] will see + /// the content at global offset (80, 80). + /// + /// If left as [Offset.zero], the [RawMagnifier] will show the content that + /// is directly below it. + /// {@endtemplate} + final Offset focalPointOffset; + + /// How "zoomed in" the magnification subject is in the lens. + final double magnificationScale; + + /// The size of the magnifier. + /// + /// This does not include added border; it only includes + /// the size of the magnifier. + final Size size; + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + ClipPath.shape( + shape: decoration.shape, + child: Opacity( + opacity: decoration.opacity, + child: _Magnifier( + shape: decoration.shape, + focalPointOffset: focalPointOffset, + magnificationScale: magnificationScale, + child: SizedBox.fromSize( + size: size, + child: child, + ), + ), + ), + ), + // Because `BackdropFilter` will filter any widgets before it, we should + // apply the style after (i.e. in a younger sibling) to avoid the magnifier + // from seeing its own styling. + Opacity( + opacity: decoration.opacity, + child: _MagnifierStyle( + decoration, + size: size, + ), + ) + ], + ); + } +} + +class _MagnifierStyle extends StatelessWidget { + const _MagnifierStyle(this.decoration, {required this.size}); + + final MagnifierDecoration decoration; + final Size size; + + @override + Widget build(BuildContext context) { + double largestShadow = 0; + for (final BoxShadow shadow in decoration.shadows ?? []) { + largestShadow = math.max( + largestShadow, + (shadow.blurRadius + shadow.spreadRadius) + + math.max(shadow.offset.dy.abs(), shadow.offset.dx.abs())); + } + + return ClipPath( + clipBehavior: Clip.hardEdge, + clipper: _DonutClip( + shape: decoration.shape, + spreadRadius: largestShadow, + ), + child: DecoratedBox( + decoration: decoration, + child: SizedBox.fromSize( + size: size, + ), + ), + ); + } +} + +/// A `clipPath` that looks like a donut if you were to fill its area. +/// +/// This is necessary because the shadow must be added after the magnifier is drawn, +/// so that the shadow does not end up in the magnifier. Without this clip, the magnifier would be +/// entirely covered by the shadow. +/// +/// The negative space of the donut is clipped out (the donut hole, outside the donut). +/// The donut hole is cut out exactly like the shape of the magnifier. +class _DonutClip extends CustomClipper { + _DonutClip({required this.shape, required this.spreadRadius}); + + final double spreadRadius; + final ShapeBorder shape; + + @override + Path getClip(Size size) { + final Path path = Path(); + final Rect rect = Offset.zero & size; + + path.fillType = PathFillType.evenOdd; + path.addPath(shape.getOuterPath(rect.inflate(spreadRadius)), Offset.zero); + path.addPath(shape.getInnerPath(rect), Offset.zero); + return path; + } + + @override + bool shouldReclip(_DonutClip oldClipper) => oldClipper.shape != shape; +} + +class _Magnifier extends SingleChildRenderObjectWidget { + /// Construct a [_Magnifier]. + const _Magnifier({ + super.child, + required this.shape, + this.magnificationScale = 1, + this.focalPointOffset = Offset.zero, + }); + + /// [focalPointOffset] is the area the center of the + /// [_Magnifier] points to, relative to the center of the magnifier. + /// + /// {@macro flutter.widgets.magnifier.offset} + final Offset focalPointOffset; + + /// The scale of the magnification. + /// + /// A [magnificationScale] of 1 means that the content in the magnifier + /// is true to it's real size. Anything greater than one will appear bigger + /// in the magnifier, and anything less than one will appear smaller in + /// the magnifier. + final double magnificationScale; + + /// The shape of the magnifier is dictated by [shape.getOuterPath]. + final ShapeBorder shape; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderMagnification(focalPointOffset, magnificationScale, shape); + } + + @override + void updateRenderObject( + BuildContext context, _RenderMagnification renderObject) { + renderObject + ..focalPointOffset = focalPointOffset + ..shape = shape + ..magnificationScale = magnificationScale; + } +} + +class _RenderMagnification extends RenderProxyBox { + _RenderMagnification( + this._focalPointOffset, + this._magnificationScale, + this._shape, { + RenderBox? child, + }) : super(child); + + Offset get focalPointOffset => _focalPointOffset; + Offset _focalPointOffset; + set focalPointOffset(Offset value) { + if (_focalPointOffset == value) { + return; + } + _focalPointOffset = value; + markNeedsPaint(); + } + + double get magnificationScale => _magnificationScale; + double _magnificationScale; + set magnificationScale(double value) { + if (_magnificationScale == value) { + return; + } + _magnificationScale = value; + markNeedsPaint(); + } + + ShapeBorder get shape => _shape; + ShapeBorder _shape; + set shape(ShapeBorder value) { + if (_shape == value) { + return; + } + _shape = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + BackdropFilterLayer? get layer => super.layer as BackdropFilterLayer?; + + @override + void paint(PaintingContext context, Offset offset) { + final Offset thisCenter = Alignment.center.alongSize(size) + offset; + final Matrix4 matrix = Matrix4.identity() + ..translate( + magnificationScale * ((focalPointOffset.dx * -1) - thisCenter.dx) + thisCenter.dx, + magnificationScale * ((focalPointOffset.dy * -1) - thisCenter.dy) + thisCenter.dy) + ..scale(magnificationScale); + final ImageFilter filter = ImageFilter.matrix(matrix.storage, filterQuality: FilterQuality.high); + + if (layer == null) { + layer = BackdropFilterLayer( + filter: filter, + ); + } else { + layer!.filter = filter; + } + + context.pushLayer(layer!, super.paint, offset); + } +} diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index e34543df94117..5554a9f8dfbb6 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'actions.dart'; import 'basic.dart'; @@ -179,8 +180,18 @@ class SelectableRegion extends StatefulWidget { required this.focusNode, required this.selectionControls, required this.child, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, }); + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// By default, [SelectableRegion]'s [TextMagnifierConfiguration] is disabled. + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + final TextMagnifierConfiguration magnifierConfiguration; + /// {@macro flutter.widgets.Focus.focusNode} final FocusNode focusNode; @@ -403,7 +414,12 @@ class _SelectableRegionState extends State with TextSelectionD }); return; } - } + } + + void _onAnyDragEnd(DragEndDetails details) { + _selectionOverlay!.hideMagnifier(shouldShowToolbar: true); + _stopSelectionEndEdgeUpdate(); + } void _stopSelectionEndEdgeUpdate() { _scheduledSelectionEndEdgeUpdate = false; @@ -451,11 +467,19 @@ class _SelectableRegionState extends State with TextSelectionD late Offset _selectionStartHandleDragPosition; late Offset _selectionEndHandleDragPosition; + late List points; + void _handleSelectionStartHandleDragStart(DragStartDetails details) { assert(_selectionDelegate.value.startSelectionPoint != null); + final Offset localPosition = _selectionDelegate.value.startSelectionPoint!.localPosition; final Matrix4 globalTransform = _selectable!.getTransformTo(null); _selectionStartHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); + + _selectionOverlay!.showMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.startSelectionPoint!, + )); } void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { @@ -464,6 +488,11 @@ class _SelectableRegionState extends State with TextSelectionD // Offset it to the center of the line to make it feel more natural. _selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); _triggerSelectionStartEdgeUpdate(); + + _selectionOverlay!.updateMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.startSelectionPoint!, + )); } void _handleSelectionEndHandleDragStart(DragStartDetails details) { @@ -471,6 +500,11 @@ class _SelectableRegionState extends State with TextSelectionD final Offset localPosition = _selectionDelegate.value.endSelectionPoint!.localPosition; final Matrix4 globalTransform = _selectable!.getTransformTo(null); _selectionEndHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); + + _selectionOverlay!.showMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.endSelectionPoint!, + )); } void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { @@ -479,6 +513,30 @@ class _SelectableRegionState extends State with TextSelectionD // Offset it to the center of the line to make it feel more natural. _selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); _triggerSelectionEndEdgeUpdate(); + + _selectionOverlay!.updateMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.endSelectionPoint!, + )); + } + + MagnifierOverlayInfoBearer _buildInfoForMagnifier(Offset globalGesturePosition, SelectionPoint selectionPoint) { + final Vector3 globalTransform = _selectable!.getTransformTo(null).getTranslation(); + final Offset globalTransformAsOffset = Offset(globalTransform.x, globalTransform.y); + final Offset globalSelectionPointPosition = selectionPoint.localPosition + globalTransformAsOffset; + final Rect caretRect = Rect.fromLTWH( + globalSelectionPointPosition.dx, + globalSelectionPointPosition.dy - selectionPoint.lineHeight, + 0, + selectionPoint.lineHeight + ); + + return MagnifierOverlayInfoBearer( + globalGesturePosition: globalGesturePosition, + caretRect: caretRect, + fieldBounds: globalTransformAsOffset & _selectable!.size, + currentLineBoundries: globalTransformAsOffset & _selectable!.size, + ); } void _createSelectionOverlay() { @@ -488,7 +546,6 @@ class _SelectableRegionState extends State with TextSelectionD } final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; - late List points; final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; if (startLocalPosition.dy > endLocalPosition.dy) { @@ -509,12 +566,12 @@ class _SelectableRegionState extends State with TextSelectionD lineHeightAtStart: start?.lineHeight ?? end!.lineHeight, onStartHandleDragStart: _handleSelectionStartHandleDragStart, onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, - onStartHandleDragEnd: (DragEndDetails details) => _stopSelectionStartEdgeUpdate(), + onStartHandleDragEnd: _onAnyDragEnd, endHandleType: end?.handleType ?? TextSelectionHandleType.right, lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight, onEndHandleDragStart: _handleSelectionEndHandleDragStart, onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, - onEndHandleDragEnd: (DragEndDetails details) => _stopSelectionEndEdgeUpdate(), + onEndHandleDragEnd: _onAnyDragEnd, selectionEndpoints: points, selectionControls: widget.selectionControls, selectionDelegate: this, @@ -522,6 +579,7 @@ class _SelectableRegionState extends State with TextSelectionD startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, toolbarLayerLink: _toolbarLayerLink, + magnifierConfiguration: widget.magnifierConfiguration ); } @@ -798,6 +856,9 @@ class _SelectableRegionState extends State with TextSelectionD _selectable?.removeListener(_updateSelectionStatus); _selectable?.pushHandleLayers(null, null); _selectionDelegate.dispose(); + // In case dispose was triggered before gesture end, remove the magnifier + // so it doesn't remain stuck in the overlay forever. + _selectionOverlay?.hideMagnifier(shouldShowToolbar: false); _selectionOverlay?.dispose(); _selectionOverlay = null; super.dispose(); diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 79b702bfc15c0..4a125c15d582b 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -20,6 +20,7 @@ import 'debug.dart'; import 'editable_text.dart'; import 'framework.dart'; import 'gesture_detector.dart'; +import 'magnifier.dart'; import 'overlay.dart'; import 'tap_region.dart'; import 'ticker_provider.dart'; @@ -71,6 +72,159 @@ class ToolbarItemsParentData extends ContainerBoxParentData { String toString() => '${super.toString()}; shouldPaint=$shouldPaint'; } +/// {@template flutter.widgets.textSelection.MagnifierBuilder} +/// Signature for a builder that builds a Widget with a [MagnifierController]. +/// +/// Consuming [MagnifierController] or [ValueNotifier]<[MagnifierOverlayInfoBearer]> is not +/// required, although if a Widget intends to have entry or exit animations, it should take +/// [MagnifierController] and provide it an [AnimationController], so that [MagnifierController] +/// can wait before removing it from the overlay. +/// {@endtemplate} +/// +/// See also: +/// +/// - [MagnifierOverlayInfoBearer], the dataclass that updates the +/// magnifier. +typedef MagnifierBuilder = Widget? Function( + BuildContext context, + MagnifierController controller, + ValueNotifier textSelectionData +); + +/// A data class that allows the [SelectionOverlay] to delegate +/// the magnifier's positioning to the magnifier itself, based on the +/// info in [MagnifierOverlayInfoBearer]. +@immutable +class MagnifierOverlayInfoBearer { + /// Construct a [MagnifierOverlayInfoBearer] from raw values. + const MagnifierOverlayInfoBearer({ + required this.globalGesturePosition, + required this.caretRect, + required this.fieldBounds, + required this.currentLineBoundries, + }); + + factory MagnifierOverlayInfoBearer._fromRenderEditable({ + required RenderEditable renderEditable, + required Offset globalGesturePosition, + required TextPosition currentTextPosition, + }) { + final Offset globalRenderEditableTopLeft = renderEditable.localToGlobal(Offset.zero); + final Rect localCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition); + + final TextSelection lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition); + final TextPosition positionAtEndOfLine = TextPosition( + offset: lineAtOffset.extentOffset, + affinity: TextAffinity.upstream, + ); + + // Default affinity is downstream. + final TextPosition positionAtBeginningOfLine = TextPosition( + offset: lineAtOffset.baseOffset, + ); + + final Rect lineBoundries = Rect.fromPoints( + renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter, + renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter + ); + + return MagnifierOverlayInfoBearer( + fieldBounds: globalRenderEditableTopLeft & renderEditable.size, + globalGesturePosition: globalGesturePosition, + caretRect: localCaretRect.shift(globalRenderEditableTopLeft), + currentLineBoundries: lineBoundries.shift(globalRenderEditableTopLeft) + ); + } + + /// Construct an empty [MagnifierOverlayInfoBearer], with all + /// values set to 0. + const MagnifierOverlayInfoBearer.empty() : + globalGesturePosition = Offset.zero, + caretRect = Rect.zero, + currentLineBoundries = Rect.zero, + fieldBounds = Rect.zero; + + /// The offset of the gesture position that the magnifier should be shown at. + final Offset globalGesturePosition; + + /// The rect of the current line the magnifier should be shown at. Do not take + /// into account any padding of the field; only the position of the first + /// and last character. + final Rect currentLineBoundries; + + /// The rect of the handle that the magnifier should follow. + final Rect caretRect; + + /// The bounds of the entire text field that the magnifier is bound to. + final Rect fieldBounds; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! MagnifierOverlayInfoBearer) { + return false; + } + + return other.globalGesturePosition == globalGesturePosition && + other.caretRect == caretRect && + other.currentLineBoundries == currentLineBoundries && + other.fieldBounds == fieldBounds; + } + + @override + int get hashCode => Object.hash( + globalGesturePosition, + caretRect, + fieldBounds, + currentLineBoundries + ); +} + +/// {@template flutter.widgets.text_selection.TextMagnifierConfiguration.intro} +/// A configuration object for a magnifier. +/// {@endtemplate} +/// +/// {@macro flutter.widgets.magnifier.intro} +/// +/// {@template flutter.widgets.text_selection.TextMagnifierConfiguration.details} +/// In general, most features of the magnifier can be configured through +/// [MagnifierBuilder]. [TextMagnifierConfiguration] is used to configure +/// the magnifier's behavior through the [SelectionOverlay]. +/// {@endtemplate} +class TextMagnifierConfiguration { + /// Construct a [TextMagnifierConfiguration] from parts. + /// + /// If [magnifierBuilder] is null, a default [MagnifierBuilder] will be used + /// that never builds a magnifier. + const TextMagnifierConfiguration({ + MagnifierBuilder? magnifierBuilder, + this.shouldDisplayHandlesInMagnifier = true + }) : _magnifierBuilder = magnifierBuilder; + + /// The passed in [MagnifierBuilder]. + /// + /// This is nullable because [disabled] needs to be static const, + /// so that it can be used as a default parameter. If left null, + /// the [magnifierBuilder] getter will be a function that always returns + /// null. + final MagnifierBuilder? _magnifierBuilder; + + /// {@macro flutter.widgets.textSelection.MagnifierBuilder} + MagnifierBuilder get magnifierBuilder => _magnifierBuilder ?? (_, __, ___) => null; + + /// Determines whether a magnifier should show the text editing handles or not. + final bool shouldDisplayHandlesInMagnifier; + + /// A constant for a [TextMagnifierConfiguration] that is disabled. + /// + /// In particular, this [TextMagnifierConfiguration] is considered disabled + /// because it never builds anything, regardless of platform. + static const TextMagnifierConfiguration disabled = TextMagnifierConfiguration(); +} + /// An interface for building the selection UI, to be provided by the /// implementer of the toolbar widget. /// @@ -224,7 +378,7 @@ class TextSelectionOverlay { /// The [context] must not be null and must have an [Overlay] as an ancestor. TextSelectionOverlay({ required TextEditingValue value, - required BuildContext context, + required this.context, Widget? debugRequiredFor, required LayerLink toolbarLayerLink, required LayerLink startHandleLayerLink, @@ -236,6 +390,7 @@ class TextSelectionOverlay { DragStartBehavior dragStartBehavior = DragStartBehavior.start, VoidCallback? onSelectionHandleTapped, ClipboardStatusNotifier? clipboardStatus, + required TextMagnifierConfiguration magnifierConfiguration, }) : assert(value != null), assert(context != null), assert(handlesVisible != null), @@ -245,6 +400,7 @@ class TextSelectionOverlay { renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities); _updateTextSelectionOverlayVisibilities(); _selectionOverlay = SelectionOverlay( + magnifierConfiguration: magnifierConfiguration, context: context, debugRequiredFor: debugRequiredFor, // The metrics will be set when show handles. @@ -253,11 +409,13 @@ class TextSelectionOverlay { lineHeightAtStart: 0.0, onStartHandleDragStart: _handleSelectionStartHandleDragStart, onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, + onEndHandleDragEnd: _handleAnyDragEnd, endHandleType: TextSelectionHandleType.collapsed, endHandlesVisible: _effectiveEndHandleVisibility, lineHeightAtEnd: 0.0, onEndHandleDragStart: _handleSelectionEndHandleDragStart, onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, + onStartHandleDragEnd: _handleAnyDragEnd, toolbarVisible: _effectiveToolbarVisibility, selectionEndpoints: const [], selectionControls: selectionControls, @@ -303,6 +461,13 @@ class TextSelectionOverlay { final ValueNotifier _effectiveStartHandleVisibility = ValueNotifier(false); final ValueNotifier _effectiveEndHandleVisibility = ValueNotifier(false); final ValueNotifier _effectiveToolbarVisibility = ValueNotifier(false); + + /// The context in which the selection handles should appear. + /// + /// This context must have an [Overlay] as an ancestor because this object + /// will display the text selection handles in that [Overlay]. + final BuildContext context; + void _updateTextSelectionOverlayVisibilities() { _effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value; _effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value; @@ -451,7 +616,15 @@ class TextSelectionOverlay { final Size handleSize = selectionControls!.getHandleSize( renderObject.preferredLineHeight, ); + _dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height); + final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition); + + _selectionOverlay.showMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + )); } void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { @@ -459,10 +632,18 @@ class TextSelectionOverlay { return; } _dragEndPosition += details.delta; + final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition); + final TextSelection currentSelection = TextSelection.fromPosition(position); if (_selection.isCollapsed) { - _handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: true); + _selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + )); + + _handleSelectionHandleChanged(currentSelection, isEnd: true); return; } @@ -494,6 +675,12 @@ class TextSelectionOverlay { } _handleSelectionHandleChanged(newSelection, isEnd: true); + + _selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable( + currentTextPosition: newSelection.extent, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + )); } late Offset _dragStartPosition; @@ -506,6 +693,13 @@ class TextSelectionOverlay { renderObject.preferredLineHeight, ); _dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height); + final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition); + + _selectionOverlay.showMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + )); } void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { @@ -516,6 +710,12 @@ class TextSelectionOverlay { final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition); if (_selection.isCollapsed) { + _selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + )); + _handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false); return; } @@ -547,9 +747,17 @@ class TextSelectionOverlay { break; } + _selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable( + currentTextPosition: newSelection.extent.offset < newSelection.base.offset ? newSelection.extent : newSelection.base, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + )); + _handleSelectionHandleChanged(newSelection, isEnd: false); } + void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed); + void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) { final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base; selectionDelegate.userUpdateTextEditingValue( @@ -612,6 +820,7 @@ class SelectionOverlay { this.dragStartBehavior = DragStartBehavior.start, this.onSelectionHandleTapped, Offset? toolbarLocation, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, }) : _startHandleType = startHandleType, _lineHeightAtStart = lineHeightAtStart, _endHandleType = endHandleType, @@ -626,6 +835,81 @@ class SelectionOverlay { /// will display the text selection handles in that [Overlay]. final BuildContext context; + + final ValueNotifier _magnifierOverlayInfoBearer = + ValueNotifier(const MagnifierOverlayInfoBearer.empty()); + + /// [MagnifierController.show] and [MagnifierController.hide] should not be called directly, except + /// from inside [showMagnifier] and [hideMagnifier]. If it is desired to show or hide the magnifier, + /// call [showMagnifier] or [hideMagnifier]. This is because the magnifier needs to orchestrate + /// with other properties in [SelectionOverlay]. + final MagnifierController _magnifierController = MagnifierController(); + + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// By default, [SelectionOverlay]'s [TextMagnifierConfiguration] is disabled. + /// + /// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details} + final TextMagnifierConfiguration magnifierConfiguration; + + /// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier] + /// was called. This is safe to call on platforms not mobile, since + /// a magnifierBuilder will not be provided, or the magnifierBuilder will return null + /// on platforms not mobile. + /// + /// This is NOT the souce of truth for if the magnifier is up or not, + /// since magnifiers may hide themselves. If this info is needed, check + /// [MagnifierController.shown]. + void showMagnifier(MagnifierOverlayInfoBearer initalInfoBearer) { + if (_toolbar != null) { + hideToolbar(); + } + + // Start from empty, so we don't utilize any rememnant values. + _magnifierOverlayInfoBearer.value = initalInfoBearer; + + // Pre-build the magnifiers so we can tell if we've built something + // or not. If we don't build a magnifiers, then we should not + // insert anything in the overlay. + final Widget? builtMagnifier = magnifierConfiguration.magnifierBuilder( + context, + _magnifierController, + _magnifierOverlayInfoBearer, + ); + + if (builtMagnifier == null) { + return; + } + + _magnifierController.show( + context: context, + below: magnifierConfiguration.shouldDisplayHandlesInMagnifier + ? null + : _handles!.first, + builder: (_) => builtMagnifier); + } + + /// Hide the current magnifier, optionally immediately showing + /// the toolbar. + /// + /// This does nothing if there is no magnifier. + void hideMagnifier({required bool shouldShowToolbar}) { + // This cannot be a check on `MagnifierController.shown`, since + // it's possible that the magnifier is still in the overlay, but + // not shown in cases where the magnifier hides itself. + if (_magnifierController.overlayEntry == null) { + return; + } + + _magnifierController.hide(); + + if (shouldShowToolbar) { + showToolbar(); + } + } + /// The type of start selection handle. /// /// Changing the value while the handles are visible causes them to rebuild. @@ -903,6 +1187,7 @@ class SelectionOverlay { /// Hides the entire overlay including the toolbar and the handles. /// {@endtemplate} void hide() { + _magnifierController.hide(); if (_handles != null) { _handles![0].remove(); _handles![1].remove(); @@ -1031,6 +1316,22 @@ class SelectionOverlay { ), ); } + + /// Update the current magnifier with new selection data, so the magnifier + /// can respond accordingly. + /// + /// If the magnifier is not shown, this still updates the magnifier position + /// because the magnifier may have hidden itself and is looking for a cue to reshow + /// itself. + /// + /// If there is no magnifier in the overlay, this does nothing, + void updateMagnifier(MagnifierOverlayInfoBearer magnifierOverlayInfoBearer) { + if (_magnifierController.overlayEntry == null) { + return; + } + + _magnifierOverlayInfoBearer.value = magnifierOverlayInfoBearer; + } } /// This widget represents a selection toolbar. diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 7b41b63c2d29f..b071b66a21585 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -70,6 +70,7 @@ export 'src/widgets/keyboard_listener.dart'; export 'src/widgets/layout_builder.dart'; export 'src/widgets/list_wheel_scroll_view.dart'; export 'src/widgets/localizations.dart'; +export 'src/widgets/magnifier.dart'; export 'src/widgets/media_query.dart'; export 'src/widgets/modal_barrier.dart'; export 'src/widgets/navigation_toolbar.dart'; diff --git a/packages/flutter/test/cupertino/magnifier_test.dart b/packages/flutter/test/cupertino/magnifier_test.dart new file mode 100644 index 0000000000000..9b76ab573043e --- /dev/null +++ b/packages/flutter/test/cupertino/magnifier_test.dart @@ -0,0 +1,259 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@Tags(['reduced-test-set']) + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Offset basicOffset = Offset(CupertinoMagnifier.kDefaultSize.width / 2, + CupertinoMagnifier.kDefaultSize.height - CupertinoMagnifier.kMagnifierAboveFocalPoint); + const Rect reasonableTextField = Rect.fromLTRB(0, 100, 200, 200); + final MagnifierController magnifierController = MagnifierController(); + + // Make sure that your gesture in infoBearer is within the line in infoBearer, + // or else the magnifier status will stay hidden and this will not complete. + Future showCupertinoMagnifier( + BuildContext context, + WidgetTester tester, + ValueNotifier infoBearer, + ) async { + final Future magnifierShown = magnifierController.show( + context: context, + builder: (_) => CupertinoTextMagnifier( + controller: magnifierController, + magnifierOverlayInfoBearer: infoBearer, + )); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pumpAndSettle(); + + await magnifierShown; + } + + tearDown(() async { + magnifierController.removeFromOverlay(); + }); + + group('CupertinoTextEditingMagnifier', () { + group('position', () { + Offset getMagnifierPosition(WidgetTester tester) { + final AnimatedPositioned animatedPositioned = + tester.firstWidget(find.byType(AnimatedPositioned)); + return Offset( + animatedPositioned.left ?? 0, animatedPositioned.top ?? 0); + } + + testWidgets('should be at gesture position if does not violate any positioning rules', (WidgetTester tester) async { + final Key fakeTextFieldKey = UniqueKey(); + final Key outerKey = UniqueKey(); + + await tester.pumpWidget( + Container( + key: outerKey, + color: const Color.fromARGB(255, 0, 255, 179), + child: MaterialApp( + home: Center( + child: Container( + key: fakeTextFieldKey, + width: 10, + height: 10, + color: Colors.red, + child: const Placeholder(), + ), + ), + ), + ), + ); + final BuildContext context = tester.element(find.byType(Placeholder)); + + // Magnifier should be positioned directly over the red square. + final RenderBox tapPointRenderBox = + tester.firstRenderObject(find.byKey(fakeTextFieldKey)) as RenderBox; + final Rect fakeTextFieldRect = + tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size; + + final ValueNotifier magnifier = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: fakeTextFieldRect, + fieldBounds: fakeTextFieldRect, + caretRect: fakeTextFieldRect, + // The tap position is dragBelow units below the text field. + globalGesturePosition: fakeTextFieldRect.center, + ), + ); + + await showCupertinoMagnifier(context, tester, magnifier); + + // Should show two red squares; original, and one in the magnifier, + // directly ontop of one another. + await expectLater( + find.byKey(outerKey), + matchesGoldenFile('cupertino_magnifier.position.default.png'), + ); + }); + + testWidgets('should never horizontally be outside of Screen Padding', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + color: Color.fromARGB(7, 0, 129, 90), + home: Placeholder(), + ), + ); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + await showCupertinoMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is far out of the right side of the app. + globalGesturePosition: + Offset(MediaQuery.of(context).size.width + 100, 0), + ), + ), + ); + + // Should be less than the right edge, since we have padding. + expect(getMagnifierPosition(tester).dx, + lessThan(MediaQuery.of(context).size.width)); + }); + + testWidgets('should have some vertical drag', (WidgetTester tester) async { + final double dragPositionBelowTextField = reasonableTextField.center.dy + 30; + + await tester.pumpWidget( + const MaterialApp( + color: Color.fromARGB(7, 0, 129, 90), + home: Placeholder(), + ), + ); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showCupertinoMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is dragBelow units below the text field. + globalGesturePosition: Offset( + MediaQuery.of(context).size.width / 2, + dragPositionBelowTextField), + ), + ), + ); + + // The magnifier Y should be greater than the text field, since we "dragged" it down. + expect(getMagnifierPosition(tester).dy + basicOffset.dy, + greaterThan(reasonableTextField.center.dy)); + expect(getMagnifierPosition(tester).dy + basicOffset.dy, + lessThan(dragPositionBelowTextField)); + }); + }); + + group('status', () { + testWidgets('should hide if gesture is far below the text field', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + color: Color.fromARGB(7, 0, 129, 90), + home: Placeholder(), + ), + ); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final ValueNotifier magnifierinfo = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is dragBelow units below the text field. + globalGesturePosition: Offset( + MediaQuery.of(context).size.width / 2, reasonableTextField.top), + ), + ); + + // Show the magnifier initally, so that we get it in a not hidden state. + await showCupertinoMagnifier(context, tester, magnifierinfo); + + // Move the gesture to one that should hide it. + magnifierinfo.value = MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: magnifierinfo.value.globalGesturePosition + const Offset(0, 100), + ); + await tester.pumpAndSettle(); + + expect(magnifierController.shown, false); + expect(magnifierController.overlayEntry, isNotNull); + }); + + testWidgets('should re-show if gesture moves back up', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + color: Color.fromARGB(7, 0, 129, 90), + home: Placeholder(), + ), + ); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final ValueNotifier magnifierInfo = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is dragBelow units below the text field. + globalGesturePosition: Offset(MediaQuery.of(context).size.width / 2, reasonableTextField.top), + ), + ); + + // Show the magnifier initally, so that we get it in a not hidden state. + await showCupertinoMagnifier(context, tester, magnifierInfo); + + // Move the gesture to one that should hide it. + magnifierInfo.value = MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: + magnifierInfo.value.globalGesturePosition + const Offset(0, 100)); + await tester.pumpAndSettle(); + + expect(magnifierController.shown, false); + expect(magnifierController.overlayEntry, isNotNull); + + // Return the gesture to one that shows it. + magnifierInfo.value = MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: Offset(MediaQuery.of(context).size.width / 2, + reasonableTextField.top)); + await tester.pumpAndSettle(); + + expect(magnifierController.shown, true); + expect(magnifierController.overlayEntry, isNotNull); + }); + }); + }); +} diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 1062f47c53c02..b5ea6fcb65dd1 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -5961,6 +5961,148 @@ void main() { }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); }); + group('magnifier', () { + late ValueNotifier infoBearer; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + group('magnifier builder', () { + testWidgets('should build custom magnifier if given', (WidgetTester tester) async { + final Widget customMagnifier = Container( + key: UniqueKey(), + ); + final CupertinoTextField defaultCupertinoTextField = CupertinoTextField( + magnifierConfiguration: TextMagnifierConfiguration(magnifierBuilder: (_, __, ___) => customMagnifier), + ); + + await tester.pumpWidget(const CupertinoApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + expect( + defaultCupertinoTextField.magnifierConfiguration!.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isA().having( + (Widget widget) => widget.key, 'key', equals(customMagnifier.key))); + }); + + group('defaults', () { + testWidgets('should build CupertinoMagnifier on iOS and Android', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp( + home: CupertinoTextField(), + )); + + final BuildContext context = tester.firstElement(find.byType(CupertinoTextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isA()); + }, + variant: const TargetPlatformVariant( + {TargetPlatform.iOS, TargetPlatform.android})); + }); + + testWidgets('should build nothing on all platforms but iOS and Android', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp( + home: CupertinoTextField(), + )); + + final BuildContext context = tester.firstElement(find.byType(CupertinoTextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isNull); + }, + variant: TargetPlatformVariant.all( + excluding: {TargetPlatform.iOS, TargetPlatform.android})); + }); + + testWidgets('Can drag handles to show, unshow, and update magnifier', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) => CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: (_, + MagnifierController controller, + ValueNotifier + localInfoBearer) { + infoBearer = localInfoBearer; + return fakeMagnifier; + }), + ), + ), + ), + ), + ); + + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + + // Double tap the 'e' to select 'def'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + + final TextSelection selection = controller.selection; + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + + // Drag the right handle 2 letters to the right. + final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0); + final TestGesture gesture = + await tester.startGesture(handlePos, pointer: 7); + + Offset? firstDragGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2)); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + firstDragGesturePosition = infoBearer.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, + isNot(infoBearer.value.globalGesturePosition)); + + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + group('TapRegion integration', () { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); @@ -6073,31 +6215,34 @@ void main() { variant: TargetPlatformVariant.all(), skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser. ); - testWidgets("Tapping on border doesn't lose focus", (WidgetTester tester) async { - final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); - await tester.pumpWidget( - CupertinoApp( - home: Center( - child: SizedBox( - width: 100, - height: 100, - child: CupertinoTextField( - autofocus: true, - focusNode: focusNode, + + testWidgets("Tapping on border doesn't lose focus", + (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: 100, + height: 100, + child: CupertinoTextField( + autofocus: true, + focusNode: focusNode, + ), ), ), ), - ), - ); - await tester.pump(); - expect(focusNode.hasPrimaryFocus, isTrue); + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); - final Rect borderBox = tester.getRect(find.byType(CupertinoTextField)); - // Tap just inside the border, but not inside the EditableText. - await tester.tapAt(borderBox.topLeft + const Offset(1, 1)); - await tester.pump(); + final Rect borderBox = tester.getRect(find.byType(CupertinoTextField)); + // Tap just inside the border, but not inside the EditableText. + await tester.tapAt(borderBox.topLeft + const Offset(1, 1)); + await tester.pump(); - expect(focusNode.hasPrimaryFocus, isTrue); - }, variant: TargetPlatformVariant.all()); + expect(focusNode.hasPrimaryFocus, isTrue); + }, variant: TargetPlatformVariant.all()); + }); }); } diff --git a/packages/flutter/test/material/checkbox_list_tile_test.dart b/packages/flutter/test/material/checkbox_list_tile_test.dart index ee161a06fdaeb..90b4213d87e7d 100644 --- a/packages/flutter/test/material/checkbox_list_tile_test.dart +++ b/packages/flutter/test/material/checkbox_list_tile_test.dart @@ -36,7 +36,7 @@ void main() { }); testWidgets('CheckboxListTile checkColor test', (WidgetTester tester) async { - const Color checkBoxBorderColor = Color(0xff1e88e5); + const Color checkBoxBorderColor = Color(0xff2196f3); Color checkBoxCheckColor = const Color(0xffFFFFFF); Widget buildFrame(Color? color) { @@ -68,7 +68,13 @@ void main() { Widget buildFrame(Color? themeColor, Color? activeColor) { return wrap( child: Theme( - data: ThemeData(toggleableActiveColor: themeColor), + data: ThemeData( + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.selected) ? themeColor : null; + }), + ), + ), child: CheckboxListTile( value: true, activeColor: activeColor, @@ -291,10 +297,14 @@ void main() { const Color activeColor = Color(0xff00ff00); - Widget buildFrame({ Color? activeColor, Color? toggleableActiveColor }) { + Widget buildFrame({ Color? activeColor, Color? fillColor }) { return MaterialApp( theme: ThemeData.light().copyWith( - toggleableActiveColor: toggleableActiveColor, + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.selected) ? fillColor : null; + }), + ), ), home: Scaffold( body: Center( @@ -314,7 +324,7 @@ void main() { return tester.renderObject(find.text(text)).text.style?.color; } - await tester.pumpWidget(buildFrame(toggleableActiveColor: activeColor)); + await tester.pumpWidget(buildFrame(fillColor: activeColor)); expect(textColor('title'), activeColor); await tester.pumpWidget(buildFrame(activeColor: activeColor)); diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart index 186c04ef088fd..06c163ab72d77 100644 --- a/packages/flutter/test/material/checkbox_test.dart +++ b/packages/flutter/test/material/checkbox_test.dart @@ -353,7 +353,7 @@ void main() { }); testWidgets('CheckBox color rendering', (WidgetTester tester) async { - const Color borderColor = Color(0xff1e88e5); + const Color borderColor = Color(0xff2196f3); Color checkColor = const Color(0xffFFFFFF); Color activeColor; @@ -391,7 +391,11 @@ void main() { activeColor = const Color(0xFF00FF00); - await tester.pumpWidget(buildFrame(themeData: ThemeData(toggleableActiveColor: activeColor))); + await tester.pumpWidget(buildFrame( + themeData: ThemeData( + colorScheme: const ColorScheme.light() + .copyWith(secondary: activeColor))), + ); await tester.pumpAndSettle(); expect(getCheckboxRenderer(), paints..path(color: activeColor)); // paints's color is 0xFF00FF00 (theme) @@ -435,7 +439,7 @@ void main() { Material.of(tester.element(find.byType(Checkbox))), paints ..circle(color: Colors.orange[500]) - ..path(color: const Color(0xff1e88e5)) + ..path(color: const Color(0xff2196f3)) ..path(color: Colors.white), ); @@ -526,7 +530,7 @@ void main() { expect( Material.of(tester.element(find.byType(Checkbox))), paints - ..path(color: const Color(0xff1e88e5)) + ..path(color: const Color(0xff2196f3)) ..path(color: const Color(0xffffffff),style: PaintingStyle.stroke, strokeWidth: 2.0), ); @@ -539,7 +543,7 @@ void main() { expect( Material.of(tester.element(find.byType(Checkbox))), paints - ..path(color: const Color(0xff1e88e5)) + ..path(color: const Color(0xff2196f3)) ..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0), ); diff --git a/packages/flutter/test/material/dialog_test.dart b/packages/flutter/test/material/dialog_test.dart index f71677cf29ec7..63bf9a2986c34 100644 --- a/packages/flutter/test/material/dialog_test.dart +++ b/packages/flutter/test/material/dialog_test.dart @@ -140,9 +140,13 @@ void main() { testWidgets('Custom dialog elevation', (WidgetTester tester) async { const double customElevation = 12.0; + const Color shadowColor = Color(0xFF000001); + const Color surfaceTintColor = Color(0xFF000002); const AlertDialog dialog = AlertDialog( actions: [ ], elevation: customElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, ); await tester.pumpWidget(_buildAppWithDialog(dialog)); @@ -151,6 +155,8 @@ void main() { final Material materialWidget = _getMaterialFromDialog(tester); expect(materialWidget.elevation, customElevation); + expect(materialWidget.shadowColor, shadowColor); + expect(materialWidget.surfaceTintColor, surfaceTintColor); }); testWidgets('Custom Title Text Style', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/dialog_theme_test.dart b/packages/flutter/test/material/dialog_theme_test.dart index 5666af42daef8..54bdf92937522 100644 --- a/packages/flutter/test/material/dialog_theme_test.dart +++ b/packages/flutter/test/material/dialog_theme_test.dart @@ -51,6 +51,8 @@ void main() { const DialogTheme( backgroundColor: Color(0xff123456), elevation: 8.0, + shadowColor: Color(0xff000001), + surfaceTintColor: Color(0xff000002), alignment: Alignment.bottomLeft, iconColor: Color(0xff654321), titleTextStyle: TextStyle(color: Color(0xffffffff)), @@ -63,6 +65,8 @@ void main() { expect(description, [ 'backgroundColor: Color(0xff123456)', 'elevation: 8.0', + 'shadowColor: Color(0xff000001)', + 'surfaceTintColor: Color(0xff000002)', 'alignment: Alignment.bottomLeft', 'iconColor: Color(0xff654321)', 'titleTextStyle: TextStyle(inherit: true, color: Color(0xffffffff))', @@ -89,11 +93,19 @@ void main() { testWidgets('Custom dialog elevation', (WidgetTester tester) async { const double customElevation = 12.0; + const Color shadowColor = Color(0xFF000001); + const Color surfaceTintColor = Color(0xFF000002); const AlertDialog dialog = AlertDialog( title: Text('Title'), actions: [ ], ); - final ThemeData theme = ThemeData(dialogTheme: const DialogTheme(elevation: customElevation)); + final ThemeData theme = ThemeData( + dialogTheme: const DialogTheme( + elevation: customElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + ), + ); await tester.pumpWidget( _appWithDialog(tester, dialog, theme: theme), @@ -103,6 +115,8 @@ void main() { final Material materialWidget = _getMaterialFromDialog(tester); expect(materialWidget.elevation, customElevation); + expect(materialWidget.shadowColor, shadowColor); + expect(materialWidget.surfaceTintColor, surfaceTintColor); }); testWidgets('Custom dialog shape', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/expansion_tile_test.dart b/packages/flutter/test/material/expansion_tile_test.dart index 668d79759f170..8c4cd5a277d10 100644 --- a/packages/flutter/test/material/expansion_tile_test.dart +++ b/packages/flutter/test/material/expansion_tile_test.dart @@ -5,8 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../rendering/mock_canvas.dart'; - class TestIcon extends StatefulWidget { const TestIcon({super.key}); @@ -483,51 +481,6 @@ void main() { expect(columnRect.bottom, paddingRect.bottom - 4); }); - testWidgets('ExpansionTile adds a Material widget above its children when expanded', (WidgetTester tester) async { - // Regression test for https://github.com/flutter/flutter/issues/107030 - const Color childColor = Color(0xff4caf50); - - await tester.pumpWidget( - const MaterialApp( - home: Material( - child: Center( - child: ExpansionTile( - title: Text('title'), - childrenPadding: EdgeInsets.fromLTRB(10, 8, 12, 4), - children: [ - ListTile(tileColor: childColor), - ], - ), - ), - ), - ), - ); - - final Finder rootMaterialFinder = find.ancestor( - of: find.byType(ExpansionTile), - matching: find.byType(Material), - ); - - final Finder expansionTileMaterialFinder = find.descendant( - of: find.byType(ExpansionTile), - matching: find.byType(Material), - ); - - // ExpansionTile should not add a Material widget when it is not expanded - expect(expansionTileMaterialFinder, findsNothing); - - // Expand - await tester.tap(find.text('title')); - await tester.pumpAndSettle(); - - // ExpansionTile adds a Material widget when it is expanded - expect(expansionTileMaterialFinder, findsOneWidget); - - // Child color is painted on the inner Material widget - expect(rootMaterialFinder, isNot(paints..path()..path(color: childColor))); - expect(expansionTileMaterialFinder, paints..path(color: childColor)); - }); - testWidgets('ExpansionTile.collapsedBackgroundColor', (WidgetTester tester) async { const Key expansionTileKey = Key('expansionTileKey'); const Color backgroundColor = Colors.red; diff --git a/packages/flutter/test/material/magnifier_test.dart b/packages/flutter/test/material/magnifier_test.dart new file mode 100644 index 0000000000000..602061565622f --- /dev/null +++ b/packages/flutter/test/material/magnifier_test.dart @@ -0,0 +1,500 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@Tags(['reduced-test-set']) + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final MagnifierController magnifierController = MagnifierController(); + const Rect reasonableTextField = Rect.fromLTRB(50, 100, 200, 100); + final Offset basicOffset = Offset(Magnifier.kDefaultMagnifierSize.width / 2, + Magnifier.kStandardVerticalFocalPointShift + Magnifier.kDefaultMagnifierSize.height); + + Offset getMagnifierPosition(WidgetTester tester, [bool animated = false]) { + if (animated) { + final AnimatedPositioned animatedPositioned = + tester.firstWidget(find.byType(AnimatedPositioned)); + return Offset(animatedPositioned.left ?? 0, animatedPositioned.top ?? 0); + } else { + final Positioned positioned = tester.firstWidget(find.byType(Positioned)); + return Offset(positioned.left ?? 0, positioned.top ?? 0); + } + } + + Future showMagnifier( + BuildContext context, + WidgetTester tester, + ValueNotifier infoBearer, + ) async { + final Future magnifierShown = magnifierController.show( + context: context, + builder: (_) => TextMagnifier( + magnifierInfo: infoBearer, + )); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pumpAndSettle(); + + // Verify that the magnifier is shown. + await magnifierShown; + } + + tearDown(() { + magnifierController.removeFromOverlay(); + magnifierController.animationController = null; + }); + + group('adaptiveMagnifierControllerBuilder', () { + testWidgets('should return a TextEditingMagnifier on Android', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final Widget? builtWidget = + TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + ), + ); + + expect(builtWidget, isA()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('should return a CupertinoMagnifier on iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final Widget? builtWidget = + TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty())); + + expect(builtWidget, isA()); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('should return null on all platforms not Android, iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final Widget? builtWidget = + TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + ), + ); + + expect(builtWidget, isNull); + }, + variant: TargetPlatformVariant.all( + excluding: { + TargetPlatform.iOS, + TargetPlatform.android + }), + ); + }); + + group('magnifier', () { + group('position', () { + testWidgets( + 'should be at gesture position if does not violate any positioning rules', + (WidgetTester tester) async { + final Key textField = UniqueKey(); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + await tester.pumpWidget( + Container( + color: const Color.fromARGB(255, 0, 255, 179), + child: MaterialApp( + home: Center( + child: Container( + key: textField, + width: 10, + height: 10, + color: Colors.red, + child: const Placeholder(), + )), + ), + ), + ); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + // Magnifier should be positioned directly over the red square. + final RenderBox tapPointRenderBox = + tester.firstRenderObject(find.byKey(textField)) as RenderBox; + final Rect fakeTextFieldRect = + tapPointRenderBox.localToGlobal(Offset.zero) & + tapPointRenderBox.size; + + final ValueNotifier magnifierInfo = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: fakeTextFieldRect, + fieldBounds: fakeTextFieldRect, + caretRect: fakeTextFieldRect, + // The tap position is dragBelow units below the text field. + globalGesturePosition: fakeTextFieldRect.center, + )); + + await showMagnifier(context, tester, magnifierInfo); + + // Should show two red squares; original, and one in the magnifier, + // directly ontop of one another. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('magnifier.position.default.png'), + ); + }); + + testWidgets( + 'should never move outside the right bounds of the editing line', + (WidgetTester tester) async { + const double gestureOutsideLine = 100; + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + // Inflate these two to make sure we're bounding on the + // current line boundries, not anything else. + fieldBounds: reasonableTextField.inflate(gestureOutsideLine), + caretRect: reasonableTextField.inflate(gestureOutsideLine), + // The tap position is far out of the right side of the app. + globalGesturePosition: Offset(reasonableTextField.right + gestureOutsideLine, 0), + ), + ), + ); + + // Should be less than the right edge, since we have padding. + expect(getMagnifierPosition(tester).dx, + lessThanOrEqualTo(reasonableTextField.right)); + }); + + testWidgets( + 'should never move outside the left bounds of the editing line', + (WidgetTester tester) async { + const double gestureOutsideLine = 100; + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + // Inflate these two to make sure we're bounding on the + // current line boundries, not anything else. + fieldBounds: reasonableTextField.inflate(gestureOutsideLine), + caretRect: reasonableTextField.inflate(gestureOutsideLine), + // The tap position is far out of the left side of the app. + globalGesturePosition: Offset(reasonableTextField.left - gestureOutsideLine, 0), + ), + ), + ); + + expect(getMagnifierPosition(tester).dx + basicOffset.dx, + greaterThanOrEqualTo(reasonableTextField.left)); + }); + + testWidgets('should position vertically at the center of the line', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ))); + + expect(getMagnifierPosition(tester).dy, + reasonableTextField.center.dy - basicOffset.dy); + }); + + testWidgets('should reposition vertically if mashed against the ceiling', + (WidgetTester tester) async { + final Rect topOfScreenTextFieldRect = + Rect.fromPoints(Offset.zero, const Offset(200, 0)); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: topOfScreenTextFieldRect, + fieldBounds: topOfScreenTextFieldRect, + caretRect: topOfScreenTextFieldRect, + globalGesturePosition: topOfScreenTextFieldRect.topCenter, + ), + ), + ); + + expect(getMagnifierPosition(tester).dy, greaterThanOrEqualTo(0)); + }); + }); + + group('focal point', () { + Offset getMagnifierAdditionalFocalPoint(WidgetTester tester) { + final Magnifier magnifier = tester.firstWidget(find.byType(Magnifier)); + return magnifier.additionalFocalPointOffset; + } + + testWidgets( + 'should shift focal point so that the lens sees nothing out of bounds', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // Gesture on the far right of the magnifier. + globalGesturePosition: reasonableTextField.topLeft, + ), + ), + ); + + expect(getMagnifierAdditionalFocalPoint(tester).dx, + lessThan(reasonableTextField.left)); + }); + + testWidgets( + 'focal point should shift if mashed against the top to always point to text', + (WidgetTester tester) async { + final Rect topOfScreenTextFieldRect = + Rect.fromPoints(Offset.zero, const Offset(200, 0)); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: topOfScreenTextFieldRect, + fieldBounds: topOfScreenTextFieldRect, + caretRect: topOfScreenTextFieldRect, + globalGesturePosition: topOfScreenTextFieldRect.topCenter, + ), + ), + ); + + expect(getMagnifierAdditionalFocalPoint(tester).dy, lessThan(0)); + }); + }); + + group('animation state', () { + bool getIsAnimated(WidgetTester tester) { + final AnimatedPositioned animatedPositioned = + tester.firstWidget(find.byType(AnimatedPositioned)); + return animatedPositioned.duration.compareTo(Duration.zero) != 0; + } + + testWidgets('should not be animated on the inital state', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + await showMagnifier( + context, + tester, + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ), + ); + + expect(getIsAnimated(tester), false); + }); + + testWidgets('should not be animated on horizontal shifts', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final ValueNotifier magnifierPositioner = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ); + + await showMagnifier(context, tester, magnifierPositioner); + + // New position has a horizontal shift. + magnifierPositioner.value = MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: + reasonableTextField.center + const Offset(200, 0), + ); + await tester.pumpAndSettle(); + + expect(getIsAnimated(tester), false); + }); + + testWidgets('should be animated on vertical shifts', + (WidgetTester tester) async { + const Offset verticalShift = Offset(0, 200); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final ValueNotifier magnifierPositioner = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ); + + await showMagnifier(context, tester, magnifierPositioner); + + // New position has a vertical shift. + magnifierPositioner.value = MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField.shift(verticalShift), + fieldBounds: Rect.fromPoints(reasonableTextField.topLeft, + reasonableTextField.bottomRight + verticalShift), + caretRect: reasonableTextField.shift(verticalShift), + globalGesturePosition: reasonableTextField.center + verticalShift, + ); + + await tester.pump(); + expect(getIsAnimated(tester), true); + }); + + testWidgets('should stop being animated when timer is up', + (WidgetTester tester) async { + const Offset verticalShift = Offset(0, 200); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + final ValueNotifier magnifierPositioner = + ValueNotifier( + MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ); + + await showMagnifier(context, tester, magnifierPositioner); + + // New position has a vertical shift. + magnifierPositioner.value = MagnifierOverlayInfoBearer( + currentLineBoundries: reasonableTextField.shift(verticalShift), + fieldBounds: Rect.fromPoints(reasonableTextField.topLeft, + reasonableTextField.bottomRight + verticalShift), + caretRect: reasonableTextField.shift(verticalShift), + globalGesturePosition: reasonableTextField.center + verticalShift, + ); + + await tester.pump(); + expect(getIsAnimated(tester), true); + await tester.pump(TextMagnifier.jumpBetweenLinesAnimationDuration + + const Duration(seconds: 2)); + expect(getIsAnimated(tester), false); + }); + }); + }); +} diff --git a/packages/flutter/test/material/radio_list_tile_test.dart b/packages/flutter/test/material/radio_list_tile_test.dart index aed947486842d..7e720d5981e6f 100644 --- a/packages/flutter/test/material/radio_list_tile_test.dart +++ b/packages/flutter/test/material/radio_list_tile_test.dart @@ -710,10 +710,14 @@ void main() { const Color activeColor = Color(0xff00ff00); - Widget buildFrame({ Color? activeColor, Color? toggleableActiveColor }) { + Widget buildFrame({ Color? activeColor, Color? fillColor }) { return MaterialApp( theme: ThemeData.light().copyWith( - toggleableActiveColor: toggleableActiveColor, + radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.selected) ? fillColor : null; + }), + ), ), home: Scaffold( body: Center( @@ -734,7 +738,7 @@ void main() { return tester.renderObject(find.text(text)).text.style?.color; } - await tester.pumpWidget(buildFrame(toggleableActiveColor: activeColor)); + await tester.pumpWidget(buildFrame(fillColor: activeColor)); expect(textColor('title'), activeColor); await tester.pumpWidget(buildFrame(activeColor: activeColor)); diff --git a/packages/flutter/test/material/radio_test.dart b/packages/flutter/test/material/radio_test.dart index ce8aa1f8f223e..33105435cdae8 100644 --- a/packages/flutter/test/material/radio_test.dart +++ b/packages/flutter/test/material/radio_test.dart @@ -441,8 +441,8 @@ void main() { rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), ) ..circle(color: Colors.orange[500]) - ..circle(color: const Color(0xff1e88e5)) - ..circle(color: const Color(0xff1e88e5)), + ..circle(color: const Color(0xff2196f3)) + ..circle(color: const Color(0xff2196f3)), ); // Check when the radio isn't selected. @@ -519,8 +519,8 @@ void main() { color: const Color(0xffffffff), rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), ) - ..circle(color: const Color(0xff1e88e5)) - ..circle(color: const Color(0xff1e88e5)), + ..circle(color: const Color(0xff2196f3)) + ..circle(color: const Color(0xff2196f3)), ); // Start hovering diff --git a/packages/flutter/test/material/switch_list_tile_test.dart b/packages/flutter/test/material/switch_list_tile_test.dart index df766f40ce417..2529409085921 100644 --- a/packages/flutter/test/material/switch_list_tile_test.dart +++ b/packages/flutter/test/material/switch_list_tile_test.dart @@ -400,10 +400,14 @@ void main() { const Color activeColor = Color(0xff00ff00); - Widget buildFrame({ Color? activeColor, Color? toggleableActiveColor }) { + Widget buildFrame({ Color? activeColor, Color? thumbColor }) { return MaterialApp( theme: ThemeData.light().copyWith( - toggleableActiveColor: toggleableActiveColor, + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.selected) ? thumbColor : null; + }), + ), ), home: Scaffold( body: Center( @@ -423,7 +427,7 @@ void main() { return tester.renderObject(find.text(text)).text.style?.color; } - await tester.pumpWidget(buildFrame(toggleableActiveColor: activeColor)); + await tester.pumpWidget(buildFrame(activeColor: activeColor)); expect(textColor('title'), activeColor); await tester.pumpWidget(buildFrame(activeColor: activeColor)); diff --git a/packages/flutter/test/material/switch_test.dart b/packages/flutter/test/material/switch_test.dart index d4595a6ba323b..9eac14b038bca 100644 --- a/packages/flutter/test/material/switch_test.dart +++ b/packages/flutter/test/material/switch_test.dart @@ -373,13 +373,13 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect( - color: Colors.blue[600]!.withAlpha(0x80), + color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: const Color(0x33000000)) ..circle(color: const Color(0x24000000)) ..circle(color: const Color(0x1f000000)) - ..circle(color: Colors.blue[600]), + ..circle(color: const Color(0xff2196f3)), reason: 'Active enabled switch should match these colors', ); }); @@ -806,14 +806,14 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect( - color: const Color(0x801e88e5), + color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: Colors.orange[500]) ..circle(color: const Color(0x33000000)) ..circle(color: const Color(0x24000000)) ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff1e88e5)), + ..circle(color: const Color(0xff2196f3)), ); // Check the false value. @@ -910,13 +910,13 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect( - color: const Color(0x801e88e5), + color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: const Color(0x33000000)) ..circle(color: const Color(0x24000000)) ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff1e88e5)), + ..circle(color: const Color(0xff2196f3)), ); // Start hovering @@ -930,14 +930,14 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect( - color: const Color(0x801e88e5), + color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: Colors.orange[500]) ..circle(color: const Color(0x33000000)) ..circle(color: const Color(0x24000000)) ..circle(color: const Color(0x1f000000)) - ..circle(color: const Color(0xff1e88e5)), + ..circle(color: const Color(0xff2196f3)), ); // Check what happens when disabled. @@ -1297,7 +1297,7 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect( - color: const Color(0x801e88e5), + color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: const Color(0x1f000000)) @@ -1318,7 +1318,7 @@ void main() { Material.of(tester.element(find.byType(Switch))), paints ..rrect( - color: const Color(0x801e88e5), + color: const Color(0x802196f3), rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), ) ..circle(color: const Color(0x1f000000)) diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index e098749ee5174..034888549315e 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -11785,6 +11785,170 @@ void main() { expect(controller.selection.extentOffset, 5); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); }); + + group('magnifier builder', () { + testWidgets('should build custom magnifier if given', + (WidgetTester tester) async { + final Widget customMagnifier = Container( + key: UniqueKey(), + ); + final TextField textField = TextField( + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: (_, __, ___) => customMagnifier, + ), + ); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + expect( + textField.magnifierConfiguration!.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isA().having( + (Widget widget) => widget.key, + 'built magnifier key equal to passed in magnifier key', + equals(customMagnifier.key))); + }); + + group('defaults', () { + testWidgets('should build Magnifier on Android', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: TextField())) + ); + + final BuildContext context = tester.firstElement(find.byType(TextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isA()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('should build CupertinoMagnifier on iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: TextField())) + ); + + final BuildContext context = tester.firstElement(find.byType(TextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isA()); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('should build nothing on Android and iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: TextField())) + ); + + final BuildContext context = tester.firstElement(find.byType(TextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier( + const MagnifierOverlayInfoBearer.empty(), + )), + isNull); + }, + variant: TargetPlatformVariant.all(excluding: { + TargetPlatform.iOS, + TargetPlatform.android + })); + }); + }); + + group('magnifier', () { + late ValueNotifier infoBearer; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + testWidgets( + 'Can drag handles to show, unshow, and update magnifier', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: ( + _, + MagnifierController controller, + ValueNotifier localInfoBearer + ) { + infoBearer = localInfoBearer; + return fakeMagnifier; + }, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Double tap the 'e' to select 'def'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + + final TextSelection selection = controller.selection; + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + + // Drag the right handle 2 letters to the right. + final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0); + final TestGesture gesture = await tester.startGesture(handlePos); + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2)); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + final Offset firstDragGesturePosition = infoBearer.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, + isNot(infoBearer.value.globalGesturePosition)); + + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }); + group('TapRegion integration', () { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); @@ -12001,8 +12165,9 @@ void main() { case PointerDeviceKind.unknown: expect(focusNode.hasPrimaryFocus, isFalse); break; - } - }, variant: TargetPlatformVariant.all()); - } + } + }, variant: TargetPlatformVariant.all()); + } + }); }); } diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 9afd71bc99dc1..1162969577e00 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -672,7 +672,6 @@ void main() { selectedRowColor: Colors.black, shadowColor: Colors.black, splashColor: Colors.black, - toggleableActiveColor: Colors.black, unselectedWidgetColor: Colors.black, // TYPOGRAPHY & ICONOGRAPHY iconTheme: ThemeData.dark().iconTheme, @@ -723,6 +722,7 @@ void main() { fixTextFieldOutlineLabel: false, primaryColorBrightness: Brightness.dark, androidOverscrollIndicator: AndroidOverscrollIndicator.glow, + toggleableActiveColor: Colors.black, ); final SliderThemeData otherSliderTheme = SliderThemeData.fromPrimaryColors( @@ -782,7 +782,6 @@ void main() { selectedRowColor: Colors.white, shadowColor: Colors.white, splashColor: Colors.white, - toggleableActiveColor: Colors.white, unselectedWidgetColor: Colors.white, // TYPOGRAPHY & ICONOGRAPHY @@ -836,6 +835,7 @@ void main() { fixTextFieldOutlineLabel: true, primaryColorBrightness: Brightness.light, androidOverscrollIndicator: AndroidOverscrollIndicator.stretch, + toggleableActiveColor: Colors.white, ); final ThemeData themeDataCopy = theme.copyWith( @@ -880,7 +880,6 @@ void main() { selectedRowColor: otherTheme.selectedRowColor, shadowColor: otherTheme.shadowColor, splashColor: otherTheme.splashColor, - toggleableActiveColor: otherTheme.toggleableActiveColor, unselectedWidgetColor: otherTheme.unselectedWidgetColor, // TYPOGRAPHY & ICONOGRAPHY @@ -934,6 +933,7 @@ void main() { fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel, primaryColorBrightness: otherTheme.primaryColorBrightness, androidOverscrollIndicator: otherTheme.androidOverscrollIndicator, + toggleableActiveColor: otherTheme.toggleableActiveColor, ); // For the sanity of the reader, make sure these properties are in the same @@ -977,7 +977,6 @@ void main() { expect(themeDataCopy.selectedRowColor, equals(otherTheme.selectedRowColor)); expect(themeDataCopy.shadowColor, equals(otherTheme.shadowColor)); expect(themeDataCopy.splashColor, equals(otherTheme.splashColor)); - expect(themeDataCopy.toggleableActiveColor, equals(otherTheme.toggleableActiveColor)); expect(themeDataCopy.unselectedWidgetColor, equals(otherTheme.unselectedWidgetColor)); // TYPOGRAPHY & ICONOGRAPHY @@ -1036,6 +1035,7 @@ void main() { expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel)); expect(themeDataCopy.primaryColorBrightness, equals(otherTheme.primaryColorBrightness)); expect(themeDataCopy.androidOverscrollIndicator, equals(otherTheme.androidOverscrollIndicator)); + expect(themeDataCopy.toggleableActiveColor, equals(otherTheme.toggleableActiveColor)); }); testWidgets('ThemeData.toString has less than 200 characters output', (WidgetTester tester) async { @@ -1115,7 +1115,6 @@ void main() { 'indicatorColor', 'hintColor', 'errorColor', - 'toggleableActiveColor', // TYPOGRAPHY & ICONOGRAPHY 'typography', 'textTheme', @@ -1165,6 +1164,7 @@ void main() { 'fixTextFieldOutlineLabel', 'primaryColorBrightness', 'androidOverscrollIndicator', + 'toggleableActiveColor', }; final DiagnosticPropertiesBuilder properties = DiagnosticPropertiesBuilder(); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 18b4ee325683a..51ed207f3b130 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -12563,6 +12563,40 @@ void main() { ); }); }); + + group('magnifier', () { + testWidgets('should build nothing by default', (WidgetTester tester) async { + final EditableText editableText = EditableText( + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ); + + await tester.pumpWidget( + MaterialApp( + home: editableText, + ), + ); + + final BuildContext context = tester.firstElement(find.byType(EditableText)); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + ValueNotifier(const MagnifierOverlayInfoBearer.empty()) + ), + isNull + ); + }); + }); } class UnsettableController extends TextEditingController { diff --git a/packages/flutter/test/widgets/magnifier_test.dart b/packages/flutter/test/widgets/magnifier_test.dart new file mode 100644 index 0000000000000..4ae35a637d91a --- /dev/null +++ b/packages/flutter/test/widgets/magnifier_test.dart @@ -0,0 +1,325 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@Tags(['reduced-test-set']) + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _MockAnimationController extends AnimationController { + _MockAnimationController() + : super(duration: const Duration(minutes: 1), vsync: const TestVSync()); + int forwardCalls = 0; + int reverseCalls = 0; + + @override + TickerFuture forward({double? from}) { + forwardCalls++; + return super.forward(from: from); + } + + @override + TickerFuture reverse({double? from}) { + reverseCalls++; + return super.reverse(from: from); + } +} + +void main() { + Future runFakeAsync(Future Function(FakeAsync time) f) async { + return FakeAsync().run((FakeAsync time) async { + bool pump = true; + final Future future = f(time).whenComplete(() => pump = false); + while (pump) { + time.flushMicrotasks(); + } + return future; + }); + } + + group('Raw Magnifier', () { + testWidgets('should render with correct focal point and decoration', + (WidgetTester tester) async { + final Key appKey = UniqueKey(); + const Size magnifierSize = Size(100, 100); + const Offset magnifierFocalPoint = Offset(50, 50); + const Offset magnifierPosition = Offset(200, 200); + const double magnificationScale = 2; + + await tester.pumpWidget(MaterialApp( + key: appKey, + home: Container( + color: Colors.orange, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + Positioned( + // Positioned so that it is right in the center of the magnifier + // focal point. + left: magnifierPosition.dx + magnifierFocalPoint.dx, + top: magnifierPosition.dy + magnifierFocalPoint.dy, + child: Container( + color: Colors.pink, + // Since it is the size of the magnifier but over its + // magnificationScale, it should take up the whole magnifier. + width: (magnifierSize.width * 1.5) / magnificationScale, + height: (magnifierSize.height * 1.5) / magnificationScale, + ), + ), + Positioned( + left: magnifierPosition.dx, + top: magnifierPosition.dy, + child: const RawMagnifier( + size: magnifierSize, + focalPointOffset: magnifierFocalPoint, + magnificationScale: magnificationScale, + decoration: MagnifierDecoration(shadows: [ + BoxShadow( + spreadRadius: 10, + blurRadius: 10, + color: Colors.green, + offset: Offset(5, 5), + ), + ]), + ), + ), + ], + ), + ))); + + await tester.pumpAndSettle(); + + // Should look like an orange screen, with two pink boxes. + // One pink box is in the magnifier (so has a green shadow) and is double + // size (from magnification). Also, the magnifier should be slightly orange + // since it has opacity. + await expectLater( + find.byKey(appKey), + matchesGoldenFile('widgets.magnifier.styled.png'), + ); + }, skip: kIsWeb); // [intended] Bdf does not display on web. + + group('transition states', () { + final AnimationController animationController = AnimationController( + vsync: const TestVSync(), duration: const Duration(minutes: 2)); + final MagnifierController magnifierController = MagnifierController(); + + tearDown(() { + animationController.value = 0; + magnifierController.hide(); + + magnifierController.removeFromOverlay(); + }); + + testWidgets( + 'should immediately remove from overlay on no animation controller', + (WidgetTester tester) async { + await runFakeAsync((FakeAsync async) async { + const RawMagnifier testMagnifier = RawMagnifier( + size: Size(100, 100), + ); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + magnifierController.show( + context: context, + builder: (BuildContext context) => testMagnifier, + ); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pump(); + + expect(magnifierController.overlayEntry, isNot(isNull)); + + magnifierController.hide(); + WidgetsBinding.instance.scheduleFrame(); + await tester.pump(); + + expect(magnifierController.overlayEntry, isNull); + }); + }); + + testWidgets('should update shown based on animation status', + (WidgetTester tester) async { + await runFakeAsync((FakeAsync async) async { + final MagnifierController magnifierController = + MagnifierController(animationController: animationController); + + const RawMagnifier testMagnifier = RawMagnifier( + size: Size(100, 100), + ); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + magnifierController.show( + context: context, + builder: (BuildContext context) => testMagnifier, + ); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pump(); + + // No time has passed, so the animation controller has not completed. + expect(magnifierController.animationController?.status, + AnimationStatus.forward); + expect(magnifierController.shown, true); + + async.elapse(animationController.duration!); + await tester.pumpAndSettle(); + + expect(magnifierController.animationController?.status, + AnimationStatus.completed); + expect(magnifierController.shown, true); + + magnifierController.hide(); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pump(); + + expect(magnifierController.animationController?.status, + AnimationStatus.reverse); + expect(magnifierController.shown, false); + + async.elapse(animationController.duration!); + await tester.pumpAndSettle(); + + expect(magnifierController.animationController?.status, + AnimationStatus.dismissed); + expect(magnifierController.shown, false); + }); + }); + }); + }); + + group('magnifier controller', () { + final MagnifierController magnifierController = MagnifierController(); + + tearDown(() { + magnifierController.removeFromOverlay(); + }); + + group('show', () { + testWidgets('should insert below below widget', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Text('text'), + )); + + final BuildContext context = tester.firstElement(find.byType(Text)); + + final Widget fakeMagnifier = Placeholder(key: UniqueKey()); + final Widget fakeBefore = Placeholder(key: UniqueKey()); + + final OverlayEntry fakeBeforeOverlayEntry = + OverlayEntry(builder: (_) => fakeBefore); + + Overlay.of(context)!.insert(fakeBeforeOverlayEntry); + magnifierController.show( + context: context, + builder: (_) => fakeMagnifier, + below: fakeBeforeOverlayEntry); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pumpAndSettle(); + + final Iterable allOverlayChildren = find + .descendant( + of: find.byType(Overlay), matching: find.byType(Placeholder)) + .evaluate(); + + // Expect the magnifier to be the first child, even though it was inserted + // after the fakeBefore. + expect(allOverlayChildren.last.widget.key, fakeBefore.key); + expect(allOverlayChildren.first.widget.key, fakeMagnifier.key); + }); + + testWidgets('should insert newly built widget without animating out if overlay != null', + (WidgetTester tester) async { + await runFakeAsync((FakeAsync async) async { + final _MockAnimationController animationController = + _MockAnimationController(); + + const RawMagnifier testMagnifier = RawMagnifier( + size: Size(100, 100), + ); + const RawMagnifier testMagnifier2 = RawMagnifier( + size: Size(100, 100), + ); + + await tester.pumpWidget(const MaterialApp( + home: Placeholder(), + )); + + final BuildContext context = + tester.firstElement(find.byType(Placeholder)); + + magnifierController.show( + context: context, + builder: (BuildContext context) => testMagnifier, + ); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pump(); + + async.elapse(animationController.duration!); + await tester.pumpAndSettle(); + + magnifierController.show(context: context, builder: (_) => testMagnifier2); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pump(); + + expect(animationController.reverseCalls, 0, + reason: + 'should not have called reverse on animation controller due to force remove'); + + expect(find.byWidget(testMagnifier2), findsOneWidget); + }); + }); + }); + + group('shift within bounds', () { + final List boundsRects = [ + const Rect.fromLTRB(0, 0, 100, 100), + const Rect.fromLTRB(0, 0, 100, 100), + const Rect.fromLTRB(0, 0, 100, 100), + const Rect.fromLTRB(0, 0, 100, 100), + ]; + final List inputRects = [ + const Rect.fromLTRB(-100, -100, -80, -80), + const Rect.fromLTRB(0, 0, 20, 20), + const Rect.fromLTRB(110, 0, 120, 10), + const Rect.fromLTRB(110, 110, 120, 120) + ]; + final List outputRects = [ + const Rect.fromLTRB(0, 0, 20, 20), + const Rect.fromLTRB(0, 0, 20, 20), + const Rect.fromLTRB(90, 0, 100, 10), + const Rect.fromLTRB(90, 90, 100, 100) + ]; + + for (int i = 0; i < boundsRects.length; i++) { + test( + 'should shift ${inputRects[i]} to ${outputRects[i]} for bounds ${boundsRects[i]}', + () { + final Rect outputRect = MagnifierController.shiftWithinBounds( + bounds: boundsRects[i], rect: inputRects[i]); + expect(outputRect, outputRects[i]); + }); + } + }); + }); +} diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index e8ebaaa406d30..9c753bb549ba3 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -1099,6 +1099,74 @@ void main() { final Map clipboardData = mockClipboard.clipboardData as Map; expect(clipboardData['text'], 'thank'); }, skip: kIsWeb); // [intended] Web uses its native context menu. + + group('magnifier', () { + late ValueNotifier infoBearer; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + testWidgets('Can drag handles to show, unshow, and update magnifier', + (WidgetTester tester) async { + const String text = 'Monkies and rabbits in my soup'; + + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: (_, + MagnifierController controller, + ValueNotifier + localInfoBearer) { + infoBearer = localInfoBearer; + return fakeMagnifier; + }, + ), + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: const Text(text), + ), + ), + ); + + final RenderParagraph paragraph = tester.renderObject( + find.descendant( + of: find.text(text), matching: find.byType(RichText))); + + // Show the selection handles. + final TestGesture activateSelectionGesture = await tester + .startGesture(textOffsetToPosition(paragraph, text.length ~/ 2)); + addTearDown(activateSelectionGesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await activateSelectionGesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + + // Drag the handle around so that the magnifier shows. + final TextBox selectionBox = + paragraph.getBoxesForSelection(paragraph.selections.first).first; + final Offset leftHandlePos = + globalize(selectionBox.toRect().bottomLeft, paragraph); + final TestGesture gesture = await tester.startGesture(leftHandlePos); + await gesture.moveTo(textOffsetToPosition(paragraph, text.length - 2)); + await tester.pump(); + + // Expect the magnifier to show and then store it's position. + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + final Offset firstDragGesturePosition = + infoBearer.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(paragraph, text.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, + isNot(infoBearer.value.globalGesturePosition)); + + // Lift the pointer and expect the magnifier to disappear. + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }); + }); }); testWidgets('toolbar is hidden on mobile when orientation changes', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index ba31a8974d7c8..a3646672a31cb 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -5152,6 +5152,79 @@ void main() { expect(find.byType(SelectableText, skipOffstage: false), findsOneWidget); }); + group('magnifier', () { + late ValueNotifier infoBearer; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + testWidgets( + 'Can drag handles to show, unshow, and update magnifier', + (WidgetTester tester) async { + const String testValue = 'abc def ghi'; + final SelectableText selectableText = SelectableText( + testValue, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: ( + _, + MagnifierController controller, + ValueNotifier localInfoBearer + ) { + infoBearer = localInfoBearer; + return fakeMagnifier; + }, + ) + ); + + await tester.pumpWidget( + overlay( + child: selectableText, + ), + ); + + await skipPastScrollingAnimation(tester); + + // Double tap the 'e' to select 'def'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + + final TextSelection selection = TextSelection( + baseOffset: testValue.indexOf('d'), + extentOffset: testValue.indexOf('f') + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + + // Drag the right handle 2 letters to the right. + final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + + Offset? firstDragGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2)); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + firstDragGesturePosition = infoBearer.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, + isNot(infoBearer.value.globalGesturePosition)); + + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }); + }); + testWidgets('SelectableText text span style is merged with default text style', (WidgetTester tester) async { // This is a regression test for https://github.com/flutter/flutter/issues/71389 diff --git a/packages/flutter/test_fixes/material.dart b/packages/flutter/test_fixes/material.dart index f60f8b1693d28..7811e86b205c7 100644 --- a/packages/flutter/test_fixes/material.dart +++ b/packages/flutter/test_fixes/material.dart @@ -569,4 +569,20 @@ void main() { primary: Colors.blue, onSurface: Colors.grey, ); + + // Changes made in https://github.com/flutter/flutter/pull/97972 + ThemeData themeData = ThemeData(); + themeData = ThemeData(toggleableActiveColor: Colors.black); + themeData = ThemeData( + toggleableActiveColor: Colors.black, + ); + themeData = ThemeData.raw(toggleableActiveColor: Colors.black); + themeData = ThemeData.raw( + toggleableActiveColor: Colors.black, + ); + themeData = themeData.copyWith(toggleableActiveColor: Colors.black); + themeData = themeData.copyWith( + toggleableActiveColor: Colors.black, + ); + themeData.toggleableActiveColor; // Removing field reference not supported. } diff --git a/packages/flutter/test_fixes/material.dart.expect b/packages/flutter/test_fixes/material.dart.expect index 985c843b5d011..b9fcf147db7d1 100644 --- a/packages/flutter/test_fixes/material.dart.expect +++ b/packages/flutter/test_fixes/material.dart.expect @@ -538,4 +538,254 @@ void main() { ButtonStyle textButtonStyle = TextButton.styleFrom( foregroundColor: Colors.blue, disabledForegroundColor: Colors.grey.withOpacity(0.38), ); + + // Changes made in https://github.com/flutter/flutter/pull/97972 + ThemeData themeData = ThemeData(); + themeData = ThemeData(checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + )); + themeData = ThemeData( + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), + ); + themeData = ThemeData.raw(checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + )); + themeData = ThemeData.raw( + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), + ); + themeData = themeData.copyWith(checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + )); + themeData = themeData.copyWith( + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return Colors.black; + } + return null; + }), + ), + ); + themeData.toggleableActiveColor; // Removing field reference not supported. } diff --git a/packages/flutter_tools/lib/src/commands/update_packages.dart b/packages/flutter_tools/lib/src/commands/update_packages.dart index 2ccfe6b8bcbcf..d8794b5ca4f8d 100644 --- a/packages/flutter_tools/lib/src/commands/update_packages.dart +++ b/packages/flutter_tools/lib/src/commands/update_packages.dart @@ -1439,7 +1439,7 @@ String generateFakePubspec( /// It ends up holding the full graph of dependencies, and the version number for /// each one. class PubDependencyTree { - final Map _versions = {}; + final Map _versions = {}; final Map> _dependencyTree = >{}; /// Handles the output from "pub deps --style=compact". diff --git a/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart b/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart index bc168e941fda6..af7013bb2d73c 100644 --- a/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart +++ b/packages/flutter_tools/lib/src/migrate/migrate_manifest.dart @@ -171,7 +171,7 @@ class MigrateManifest { deletedFileManifestContents.write(' - $localPath\n'); } - final String migrateManifestContents = 'merged_files:\n${mergedFileManifestContents.toString()}conflict_files:\n${conflictFilesManifestContents.toString()}added_files:\n${newFileManifestContents.toString()}deleted_files:\n${deletedFileManifestContents.toString()}'; + final String migrateManifestContents = 'merged_files:\n${mergedFileManifestContents}conflict_files:\n${conflictFilesManifestContents}added_files:\n${newFileManifestContents}deleted_files:\n$deletedFileManifestContents'; final File migrateManifest = getManifestFileFromDirectory(migrateRootDir); migrateManifest.createSync(recursive: true); migrateManifest.writeAsStringSync(migrateManifestContents, flush: true); diff --git a/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart index 8c4377d3d237d..43a06e515affc 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/config_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.8 - import 'dart:convert'; import 'package:args/command_runner.dart'; @@ -23,10 +21,10 @@ import '../../src/context.dart'; import '../../src/test_flutter_command_runner.dart'; void main() { - FakeAndroidStudio fakeAndroidStudio; - FakeAndroidSdk fakeAndroidSdk; - FakeFlutterVersion fakeFlutterVersion; - TestUsage testUsage; + late FakeAndroidStudio fakeAndroidStudio; + late FakeAndroidSdk fakeAndroidSdk; + late FakeFlutterVersion fakeFlutterVersion; + late TestUsage testUsage; setUpAll(() { Cache.disableLocking(); @@ -288,7 +286,7 @@ class FakeAndroidSdk extends Fake implements AndroidSdk { class FakeFlutterVersion extends Fake implements FlutterVersion { @override - String channel; + late String channel; @override void ensureVersionFile() {} diff --git a/packages/flutter_tools/test/commands.shard/hermetic/pub_get_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/pub_get_test.dart index 9a68bcec6f3a5..61a4010a47ac8 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/pub_get_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/pub_get_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.8 - import 'package:args/command_runner.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/file_system.dart'; @@ -12,15 +10,14 @@ import 'package:flutter_tools/src/commands/packages.dart'; import 'package:flutter_tools/src/dart/pub.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:flutter_tools/src/reporting/reporting.dart'; -import 'package:meta/meta.dart'; import 'package:test/fake.dart'; import '../../src/context.dart'; import '../../src/test_flutter_command_runner.dart'; void main() { - FileSystem fileSystem; - FakePub pub; + late FileSystem fileSystem; + late FakePub pub; setUp(() { Cache.disableLocking(); @@ -124,13 +121,13 @@ class FakePub extends Fake implements Pub { @override Future get({ - @required PubContext context, - String directory, + required PubContext context, + String? directory, bool skipIfAbsent = false, bool upgrade = false, bool offline = false, bool generateSyntheticPackage = false, - String flutterRootOverride, + String? flutterRootOverride, bool checkUpToDate = false, bool shouldSkipThirdPartyGenerator = true, bool printProgress = true, diff --git a/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart index 89e2316bcdfbc..2d947bc5c1e1a 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart @@ -2,15 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.8 - import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/commands/update_packages.dart'; import 'package:flutter_tools/src/dart/pub.dart'; -import 'package:meta/meta.dart'; import 'package:test/fake.dart'; import 'package:yaml/yaml.dart'; @@ -85,10 +82,10 @@ void main() { }); group('update-packages', () { - FileSystem fileSystem; - Directory flutterSdk; - Directory flutter; - FakePub pub; + late FileSystem fileSystem; + late Directory flutterSdk; + late Directory flutter; + late FakePub pub; setUpAll(() { Cache.disableLocking(); @@ -209,7 +206,6 @@ void main() { isTransitive: false, ), ], - doUpgrade: false, ); final YamlMap pubspec = loadYaml(pubspecSource) as YamlMap; expect((pubspec['dependencies'] as YamlMap)['foo'], prevVersion); @@ -226,18 +222,20 @@ class FakePub extends Fake implements Pub { @override Future get({ - @required PubContext context, - String directory, + required PubContext context, + String? directory, bool skipIfAbsent = false, bool upgrade = false, bool offline = false, bool generateSyntheticPackage = false, - String flutterRootOverride, + String? flutterRootOverride, bool checkUpToDate = false, bool shouldSkipThirdPartyGenerator = true, bool printProgress = true, }) async { - pubGetDirectories.add(directory); + if (directory != null) { + pubGetDirectories.add(directory); + } fileSystem.directory(directory).childFile('pubspec.lock') ..createSync(recursive: true) ..writeAsStringSync(''' @@ -264,14 +262,16 @@ sdks: @override Future batch( List arguments, { - @required PubContext context, - String directory, - MessageFilter filter, + required PubContext context, + String? directory, + MessageFilter? filter, String failureMessage = 'pub failed', - @required bool retry, - bool showTraceForErrors, + required bool retry, + bool? showTraceForErrors, }) async { - pubBatchDirectories.add(directory); + if (directory != null) { + pubBatchDirectories.add(directory); + } ''' Dart SDK 2.16.0-144.0.dev @@ -290,6 +290,6 @@ dev dependencies: transitive dependencies: - platform 3.1.0 - process 4.2.4 [file path platform] -'''.split('\n').forEach(filter); +'''.split('\n').forEach(filter!); } } diff --git a/packages/flutter_tools/test/integration.shard/build_ios_config_only_test.dart b/packages/flutter_tools/test/integration.shard/build_ios_config_only_test.dart index eb278939bec19..21d7141da860a 100644 --- a/packages/flutter_tools/test/integration.shard/build_ios_config_only_test.dart +++ b/packages/flutter_tools/test/integration.shard/build_ios_config_only_test.dart @@ -40,7 +40,7 @@ void main() { printOnFailure('Output of flutter build ios:'); final String firstRunStdout = firstRunResult.stdout.toString(); printOnFailure('First run stdout: $firstRunStdout'); - printOnFailure('First run stderr: ${firstRunResult.stderr.toString()}'); + printOnFailure('First run stderr: ${firstRunResult.stderr}'); expect(firstRunResult.exitCode, 0); expect(firstRunStdout, contains('Running pod install')); @@ -72,7 +72,7 @@ void main() { final ProcessResult secondRunResult = await processManager.run(buildCommand, workingDirectory: workingDirectory); final String secondRunStdout = secondRunResult.stdout.toString(); printOnFailure('Second run stdout: $secondRunStdout'); - printOnFailure('Second run stderr: ${secondRunResult.stderr.toString()}'); + printOnFailure('Second run stderr: ${secondRunResult.stderr}'); expect(secondRunResult.exitCode, 0); // Do not run "pod install" when nothing changes. diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart index 6cb284cfb0eca..70747602549d8 100644 --- a/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/flutter_adapter_test.dart @@ -99,6 +99,13 @@ void main() { '', startsWith('Exited'), ]); + + // If we're running with an out-of-process debug adapter, ensure that its + // own process shuts down after we terminated. + final DapTestServer server = dap.server; + if (server is OutOfProcessDapTestServer) { + await server.exitCode; + } }); testWithoutContext('outputs useful message on invalid DAP protocol messages', () async {