diff --git a/examples/api/lib/material/widget_state_input_border/widget_state_input_border.0.dart b/examples/api/lib/material/widget_state_input_border/widget_state_input_border.0.dart new file mode 100644 index 0000000000..3c9849ca39 --- /dev/null +++ b/examples/api/lib/material/widget_state_input_border/widget_state_input_border.0.dart @@ -0,0 +1,114 @@ +// 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 'package:flutter/material.dart'; + +/// Flutter code sample for [WidgetStateInputBorder]. + +void main() => runApp(const WidgetStateInputBorderExampleApp()); + +/// This extension isn't necessary when WidgetState properties are +/// configured using [WidgetStateMapper] objects. +/// +/// But sometimes it makes sense to use a resolveWith() callback, +/// and these getters make those callbacks a bit more readable! +extension WidgetStateHelpers on Set { + bool get focused => contains(WidgetState.focused); + bool get hovered => contains(WidgetState.hovered); + bool get disabled => contains(WidgetState.disabled); +} + +class WidgetStateInputBorderExampleApp extends StatelessWidget { + const WidgetStateInputBorderExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('WidgetStateInputBorder Example'), + ), + body: const Center(child: PageContent()), + ), + ); + } +} + +class PageContent extends StatefulWidget { + const PageContent({super.key}); + + @override + State createState() => _PageContentState(); +} + +class _PageContentState extends State { + bool enabled = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Spacer(flex: 8), + Focus( + child: WidgetStateInputBorderExample(enabled: enabled), + ), + const Spacer(), + FilterChip( + label: const Text('enable text field'), + selected: enabled, + onSelected: (bool selected) { + setState(() { + enabled = selected; + }); + }, + ), + const Spacer(flex: 8), + ], + ); + } +} + +class WidgetStateInputBorderExample extends StatelessWidget { + const WidgetStateInputBorderExample({super.key, required this.enabled}); + + final bool enabled; + + /// A global or static function can be referenced in a `const` constructor, + /// such as [WidgetStateInputBorder.resolveWith]. + /// + /// Constant values can be useful for promoting accurate equality checks, + /// such as when rebuilding a [Theme] widget. + static UnderlineInputBorder veryCoolBorder(Set states) { + if (states.disabled) { + return const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ); + } + + const Color dullViolet = Color(0xFF502080); + + return UnderlineInputBorder( + borderSide: BorderSide( + width: states.hovered ? 6 : (states.focused ? 3 : 1.5), + color: states.focused ? Colors.deepPurpleAccent : dullViolet, + ), + ); + } + + @override + Widget build(BuildContext context) { + final InputDecoration decoration = InputDecoration( + border: const WidgetStateInputBorder.resolveWith(veryCoolBorder), + labelText: enabled ? 'Type something awesome…' : '(click below to enable)', + ); + + return AnimatedFractionallySizedBox( + duration: Durations.medium1, + curve: Curves.ease, + widthFactor: Focus.of(context).hasFocus ? 0.9 : 0.6, + child: TextField(decoration: decoration, enabled: enabled), + ); + } +} diff --git a/examples/api/test/material/widget_state_input_border/widget_state_input_border.0_test.dart b/examples/api/test/material/widget_state_input_border/widget_state_input_border.0_test.dart new file mode 100644 index 0000000000..313f086bbd --- /dev/null +++ b/examples/api/test/material/widget_state_input_border/widget_state_input_border.0_test.dart @@ -0,0 +1,49 @@ +// 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 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/widget_state_input_border/widget_state_input_border.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('InputBorder appearance matches configuration', (WidgetTester tester) async { + const WidgetStateInputBorder inputBorder = WidgetStateInputBorder.resolveWith( + example.WidgetStateInputBorderExample.veryCoolBorder, + ); + + void expectBorderToMatch(Set states) { + final RenderBox renderBox = tester.renderObject( + find.descendant( + of: find.byType(TextField), + matching: find.byType(CustomPaint), + ), + ); + + final BorderSide side = inputBorder.resolve(states).borderSide; + expect( + renderBox, + paints..line(color: side.color, strokeWidth: side.width), + ); + } + + await tester.pumpWidget( + const example.WidgetStateInputBorderExampleApp(), + ); + expectBorderToMatch(const {WidgetState.disabled}); + + await tester.tap(find.byType(FilterChip)); + await tester.pumpAndSettle(); + expectBorderToMatch(const {}); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expectBorderToMatch(const {WidgetState.focused}); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(TextField))); + await tester.pumpAndSettle(); + expectBorderToMatch(const {WidgetState.focused, WidgetState.hovered}); + }); +} diff --git a/packages/flutter/lib/fix_data/fix_material/fix_widget_state.yaml b/packages/flutter/lib/fix_data/fix_material/fix_widget_state.yaml index 65343238b6..3a62acd6e1 100644 --- a/packages/flutter/lib/fix_data/fix_material/fix_widget_state.yaml +++ b/packages/flutter/lib/fix_data/fix_material/fix_widget_state.yaml @@ -26,6 +26,34 @@ # * ThemeDate: fix_theme_data.yaml version: 1 transforms: + # Changes made in https://github.com/flutter/flutter/pull/154972 + - title: "Replace with 'WidgetStateInputBorder'" + date: 2024-02-01 + element: + uris: [ 'material.dart' ] + constructor: 'resolveWith' + inClass: 'MaterialStateOutlineInputBorder' + changes: + - kind: 'replacedBy' + newElement: + uris: [ 'material.dart' ] + constructor: 'resolveWith' + inClass: 'WidgetStateInputBorder' + + # Changes made in https://github.com/flutter/flutter/pull/154972 + - title: "Replace with 'WidgetStateInputBorder'" + date: 2024-02-01 + element: + uris: [ 'material.dart' ] + constructor: 'resolveWith' + inClass: 'MaterialStateUnderlineInputBorder' + changes: + - kind: 'replacedBy' + newElement: + uris: [ 'material.dart' ] + constructor: 'resolveWith' + inClass: 'WidgetStateInputBorder' + # Changes made in https://github.com/flutter/flutter/pull/142151 - title: "Replace with 'WidgetState'" date: 2024-02-01 diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart index 7f7c06885e..3a54395115 100644 --- a/packages/flutter/lib/src/material/material_state.dart +++ b/packages/flutter/lib/src/material/material_state.dart @@ -12,6 +12,7 @@ /// @docImport 'list_tile.dart'; /// @docImport 'outlined_button.dart'; /// @docImport 'text_button.dart'; +/// @docImport 'text_field.dart'; /// @docImport 'time_picker_theme.dart'; library; @@ -301,9 +302,19 @@ typedef MaterialStateTextStyle = WidgetStateTextStyle; /// [MaterialStateOutlineInputBorder] and override its [resolve] method. You'll also need /// to provide a `defaultValue` to the super constructor, so that we can know /// at compile-time what its default color is. +@Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.' +) abstract class MaterialStateOutlineInputBorder extends OutlineInputBorder implements MaterialStateProperty { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. + @Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.' + ) const MaterialStateOutlineInputBorder(); /// Creates a [MaterialStateOutlineInputBorder] from a [MaterialPropertyResolver] @@ -314,6 +325,11 @@ abstract class MaterialStateOutlineInputBorder extends OutlineInputBorder implem /// /// The given callback parameter must return a non-null text style in the default /// state. + @Deprecated( + 'Use WidgetStateInputBorder.resolveWith() instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.' + ) const factory MaterialStateOutlineInputBorder.resolveWith(MaterialPropertyResolver callback) = _MaterialStateOutlineInputBorder; /// Returns a [InputBorder] that's to be used when a Material component is in the @@ -363,9 +379,19 @@ class _MaterialStateOutlineInputBorder extends MaterialStateOutlineInputBorder { /// [MaterialStateUnderlineInputBorder] and override its [resolve] method. You'll also need /// to provide a `defaultValue` to the super constructor, so that we can know /// at compile-time what its default color is. +@Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.' +) abstract class MaterialStateUnderlineInputBorder extends UnderlineInputBorder implements MaterialStateProperty { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. + @Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.' + ) const MaterialStateUnderlineInputBorder(); /// Creates a [MaterialStateUnderlineInputBorder] from a [MaterialPropertyResolver] @@ -376,6 +402,11 @@ abstract class MaterialStateUnderlineInputBorder extends UnderlineInputBorder im /// /// The given callback parameter must return a non-null text style in the default /// state. + @Deprecated( + 'Use WidgetStateInputBorder.resolveWith() instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.' + ) const factory MaterialStateUnderlineInputBorder.resolveWith(MaterialPropertyResolver callback) = _MaterialStateUnderlineInputBorder; /// Returns a [InputBorder] that's to be used when a Material component is in the @@ -400,6 +431,66 @@ class _MaterialStateUnderlineInputBorder extends MaterialStateUnderlineInputBord InputBorder resolve(Set states) => _resolve(states); } +/// Defines an [InputBorder] that is also a [WidgetStateProperty]. +/// +/// This class exists to enable widgets with [InputBorder] valued properties +/// to also accept [WidgetStateProperty] objects. +/// +/// [WidgetStateInputBorder] should only be used with widgets that document +/// their support, like [InputDecoration.border]. +/// +/// A [WidgetStateInputBorder] can be created by: +/// 1. Creating a class that extends [OutlineInputBorder] or [UnderlineInputBorder] +/// and implements [WidgetStateInputBorder]. The class would also need to +/// override the [resolve] method. +/// 2. Using [WidgetStateInputBorder.resolveWith] with a callback that +/// resolves the input border in the given states. +/// 3. Using [WidgetStateInputBorder.fromMap] to assign a border with a [WidgetStateMap]. +/// +/// {@tool dartpad} +/// This example shows how to use [WidgetStateInputBorder] to create +/// a [TextField] with an appearance that responds to user interaction. +/// +/// ** See code in examples/api/lib/material/widget_state_input_border/widget_state_input_border.0.dart ** +/// {@end-tool} +abstract interface class WidgetStateInputBorder implements InputBorder, WidgetStateProperty { + /// Creates a [WidgetStateInputBorder] using a [WidgetPropertyResolver] + /// callback. + /// + /// This constructor should only be used for fields that support + /// [WidgetStateInputBorder], such as [InputDecoration.border] + /// (if used as a regular [InputBorder], it acts the same as + /// an empty `OutlineInputBorder()` constructor). + const factory WidgetStateInputBorder.resolveWith( + WidgetPropertyResolver callback, + ) = _WidgetStateInputBorder; + + /// Creates a [WidgetStateOutlinedBorder] from a [WidgetStateMap]. + /// + /// {@macro flutter.widgets.WidgetStateProperty.fromMap} + /// It should only be used for fields that support [WidgetStateOutlinedBorder] + /// objects, such as [InputDecoration.border] + /// (throws an error if used as a regular [OutlinedBorder]). + /// + /// {@macro flutter.widgets.WidgetState.any} + const factory WidgetStateInputBorder.fromMap( + WidgetStateMap map, + ) = _WidgetInputBorderMapper; +} + +class _WidgetStateInputBorder extends OutlineInputBorder implements WidgetStateInputBorder { + const _WidgetStateInputBorder(this._resolve); + + final WidgetPropertyResolver _resolve; + + @override + InputBorder resolve(Set states) => _resolve(states); +} + +class _WidgetInputBorderMapper extends WidgetStateMapper implements WidgetStateInputBorder { + const _WidgetInputBorderMapper(super.map); +} + /// Interface for classes that [resolve] to a value of type `T` based /// on a widget's interactive "state", which is defined as a set /// of [MaterialState]s. diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index 6e8da7a2ce..7120416d26 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -7189,6 +7189,57 @@ void main() { expect(decoration.constraints, const BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40)); }); + testWidgets('InputDecoration with WidgetStateInputBorder', (WidgetTester tester) async { + const WidgetStateInputBorder outlineInputBorder = WidgetStateInputBorder.fromMap( + { + WidgetState.focused: OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue, width: 4.0), + ), + WidgetState.hovered: OutlineInputBorder( + borderSide: BorderSide(color: Colors.cyan, width: 8.0), + ), + WidgetState.any: OutlineInputBorder(), + }, + ); + + RenderObject getBorder() { + return tester.renderObject( + find.descendant( + of: find.byType(TextField), + matching: find.byType(CustomPaint), + ), + ); + } + + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + focusNode: focusNode, + decoration: const InputDecoration( + border: outlineInputBorder, + ), + ), + ), + ), + ); + expect(getBorder(), paints..rrect(strokeWidth: 1.0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getBorder(), paints..rrect(color: Colors.blue, strokeWidth: 4.0)); + + focusNode.unfocus(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(TextField))); + await tester.pumpAndSettle(); + expect(getBorder(), paints..rrect(color: Colors.cyan, strokeWidth: 8.0)); + + focusNode.dispose(); + }); + testWidgets('InputDecorator constrained to 0x0', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/17710 await tester.pumpWidget( diff --git a/packages/flutter/test_fixes/material/material.dart b/packages/flutter/test_fixes/material/material.dart index 41938f6c16..eca45b015e 100644 --- a/packages/flutter/test_fixes/material/material.dart +++ b/packages/flutter/test_fixes/material/material.dart @@ -306,4 +306,12 @@ void main() { final PlatformMenuBar platformMenuBar = PlatformMenuBar(menus: [], body: const SizedBox()); final Widget bodyValue = platformMenuBar.body; + + // Changes made in https://github.com/flutter/flutter/pull/154972 + final InputBorder outlineBorder = MaterialStateOutlineInputBorder.resolveWith( + (states) => const OutlineInputBorder(), + ); + final InputBorder underlineBorder = MaterialStateUnderlineInputBorder.resolveWith( + (states) => const UnderlineInputBorder(), + ); } diff --git a/packages/flutter/test_fixes/material/material.dart.expect b/packages/flutter/test_fixes/material/material.dart.expect index 512a8198bb..649ea9d9e6 100644 --- a/packages/flutter/test_fixes/material/material.dart.expect +++ b/packages/flutter/test_fixes/material/material.dart.expect @@ -302,4 +302,12 @@ void main() { final PlatformMenuBar platformMenuBar = PlatformMenuBar(menus: [], child: const SizedBox()); final Widget bodyValue = platformMenuBar.child; + + // Changes made in https://github.com/flutter/flutter/pull/154972 + final InputBorder outlineBorder = WidgetStateInputBorder.resolveWith( + (states) => const OutlineInputBorder(), + ); + final InputBorder underlineBorder = WidgetStateInputBorder.resolveWith( + (states) => const UnderlineInputBorder(), + ); }