WidgetStateInputBorder (#157190)

**Changes**
- Add `WidgetStateInputBorder` class, with `.resolveWith()` and `.fromMap()` constructors
- Deprecate `MaterialStateOutlineInputBorder` and `MaterialStateUnderlineInputBorder` and provide data-driven fixes

<br>

**Other changes** based on https://github.com/flutter/flutter/pull/154972#pullrequestreview-2344092821
- Fix documentation copy-paste typo ("OutlinedBorder" → "InputBorder")
- Add test to ensure borders are painted correctly
- Add DartPad sample & relevant test
This commit is contained in:
Nate Wilson 2024-10-22 17:45:41 -06:00 committed by GitHub
parent 4af4a9b282
commit ea0fda51ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 349 additions and 0 deletions

View File

@ -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<WidgetState> {
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<PageContent> createState() => _PageContentState();
}
class _PageContentState extends State<PageContent> {
bool enabled = false;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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<WidgetState> 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),
);
}
}

View File

@ -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<WidgetState> 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>{WidgetState.disabled});
await tester.tap(find.byType(FilterChip));
await tester.pumpAndSettle();
expectBorderToMatch(const <WidgetState>{});
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
expectBorderToMatch(const <WidgetState>{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>{WidgetState.focused, WidgetState.hovered});
});
}

View File

@ -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

View File

@ -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<InputBorder> {
/// 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<InputBorder>]
@ -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<InputBorder> 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<InputBorder> {
/// 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<InputBorder>]
@ -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<InputBorder> 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<MaterialState> 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<InputBorder> {
/// 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<InputBorder> 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<InputBorder> map,
) = _WidgetInputBorderMapper;
}
class _WidgetStateInputBorder extends OutlineInputBorder implements WidgetStateInputBorder {
const _WidgetStateInputBorder(this._resolve);
final WidgetPropertyResolver<InputBorder> _resolve;
@override
InputBorder resolve(Set<WidgetState> states) => _resolve(states);
}
class _WidgetInputBorderMapper extends WidgetStateMapper<InputBorder> 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.

View File

@ -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(
<WidgetStatesConstraint, InputBorder>{
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(

View File

@ -306,4 +306,12 @@ void main() {
final PlatformMenuBar platformMenuBar = PlatformMenuBar(menus: <PlatformMenuItem>[], 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(),
);
}

View File

@ -302,4 +302,12 @@ void main() {
final PlatformMenuBar platformMenuBar = PlatformMenuBar(menus: <PlatformMenuItem>[], 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(),
);
}