Skip to content

Commit

Permalink
Replace FocusTrap with TapRegionSurface (#107262)
Browse files Browse the repository at this point in the history
  • Loading branch information
gspencergoog authored Jul 29, 2022
1 parent 347de99 commit f5e4d2b
Show file tree
Hide file tree
Showing 21 changed files with 1,808 additions and 591 deletions.
12 changes: 5 additions & 7 deletions dev/manual_tests/lib/density.dart
Original file line number Diff line number Diff line change
Expand Up @@ -631,13 +631,11 @@ class _MyHomePageState extends State<MyHomePage> {
data: Theme.of(context).copyWith(visualDensity: _model.density),
child: Directionality(
textDirection: _model.rtl ? TextDirection.rtl : TextDirection.ltr,
child: Scrollbar(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size),
child: SizedBox.expand(
child: ListView(
children: tiles,
),
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size),
child: SizedBox.expand(
child: ListView(
children: tiles,
),
),
),
Expand Down
250 changes: 250 additions & 0 deletions examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// 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.

/// Flutter code sample for [TextFieldTapRegion].
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(const TapRegionApp());

class TapRegionApp extends StatelessWidget {
const TapRegionApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('TextFieldTapRegion Example')),
body: const TextFieldTapRegionExample(),
),
);
}
}

class TextFieldTapRegionExample extends StatefulWidget {
const TextFieldTapRegionExample({super.key});

@override
State<TextFieldTapRegionExample> createState() => _TextFieldTapRegionExampleState();
}

class _TextFieldTapRegionExampleState extends State<TextFieldTapRegionExample> {
int value = 0;

@override
Widget build(BuildContext context) {
return ListView(
children: <Widget>[
Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: SizedBox(
width: 150,
height: 80,
child: IntegerSpinnerField(
value: value,
autofocus: true,
onChanged: (int newValue) {
if (value == newValue) {
// Avoid unnecessary redraws.
return;
}
setState(() {
// Update the value and redraw.
value = newValue;
});
},
),
),
),
),
],
);
}
}

/// An integer example of the generic [SpinnerField] that validates input and
/// increments by a delta.
class IntegerSpinnerField extends StatelessWidget {
const IntegerSpinnerField({
super.key,
required this.value,
this.autofocus = false,
this.delta = 1,
this.onChanged,
});

final int value;
final bool autofocus;
final int delta;
final ValueChanged<int>? onChanged;

@override
Widget build(BuildContext context) {
return SpinnerField<int>(
value: value,
onChanged: onChanged,
autofocus: autofocus,
fromString: (String stringValue) => int.tryParse(stringValue) ?? value,
increment: (int i) => i + delta,
decrement: (int i) => i - delta,
// Add a text formatter that only allows integer values and a leading
// minus sign.
inputFormatters: <TextInputFormatter>[
TextInputFormatter.withFunction(
(TextEditingValue oldValue, TextEditingValue newValue) {
String newString;
if (newValue.text.startsWith('-')) {
newString = '-${newValue.text.replaceAll(RegExp(r'\D'), '')}';
} else {
newString = newValue.text.replaceAll(RegExp(r'\D'), '');
}
return newValue.copyWith(
text: newString,
selection: newValue.selection.copyWith(
baseOffset: newValue.selection.baseOffset.clamp(0, newString.length),
extentOffset: newValue.selection.extentOffset.clamp(0, newString.length),
),
);
},
)
],
);
}
}

/// A generic "spinner" field example which adds extra buttons next to a
/// [TextField] to increment and decrement the value.
///
/// This widget uses [TextFieldTapRegion] to indicate that tapping on the
/// spinner buttons should not cause the text field to lose focus.
class SpinnerField<T> extends StatefulWidget {
SpinnerField({
super.key,
required this.value,
required this.fromString,
this.autofocus = false,
String Function(T value)? asString,
this.increment,
this.decrement,
this.onChanged,
this.inputFormatters = const <TextInputFormatter>[],
}) : asString = asString ?? ((T value) => value.toString());

final T value;
final T Function(T value)? increment;
final T Function(T value)? decrement;
final String Function(T value) asString;
final T Function(String value) fromString;
final ValueChanged<T>? onChanged;
final List<TextInputFormatter> inputFormatters;
final bool autofocus;

@override
State<SpinnerField<T>> createState() => _SpinnerFieldState<T>();
}

class _SpinnerFieldState<T> extends State<SpinnerField<T>> {
TextEditingController controller = TextEditingController();

@override
void initState() {
super.initState();
_updateText(widget.asString(widget.value));
}

@override
void dispose() {
controller.dispose();
super.dispose();
}

@override
void didUpdateWidget(covariant SpinnerField<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.asString != widget.asString || oldWidget.value != widget.value) {
final String newText = widget.asString(widget.value);
_updateText(newText);
}
}

void _updateText(String text, {bool collapsed = true}) {
if (text != controller.text) {
controller.value = TextEditingValue(
text: text,
selection: collapsed
? TextSelection.collapsed(offset: text.length)
: TextSelection(baseOffset: 0, extentOffset: text.length),
);
}
}

void _spin(T Function(T value)? spinFunction) {
if (spinFunction == null) {
return;
}
final T newValue = spinFunction(widget.value);
widget.onChanged?.call(newValue);
_updateText(widget.asString(newValue), collapsed: false);
}

void _increment() {
_spin(widget.increment);
}

void _decrement() {
_spin(widget.decrement);
}

@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.arrowUp): _increment,
const SingleActivator(LogicalKeyboardKey.arrowDown): _decrement,
},
child: Row(
children: <Widget>[
Expanded(
child: TextField(
autofocus: widget.autofocus,
inputFormatters: widget.inputFormatters,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
onChanged: (String value) => widget.onChanged?.call(widget.fromString(value)),
controller: controller,
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
// Without this TextFieldTapRegion, tapping on the buttons below would
// increment the value, but it would cause the text field to be
// unfocused, since tapping outside of a text field should unfocus it
// on non-mobile platforms.
TextFieldTapRegion(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: _increment,
child: const Icon(Icons.add),
),
),
Expanded(
child: OutlinedButton(
onPressed: _decrement,
child: const Icon(Icons.remove),
),
),
],
),
)
],
),
);
}
}
100 changes: 100 additions & 0 deletions examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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';
import 'package:flutter_api_samples/widgets/tap_region/text_field_tap_region.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';

void main() {
testWidgets('shows a text field with a zero count, and the spinner buttons', (WidgetTester tester) async {
await tester.pumpWidget(
const example.TapRegionApp(),
);

expect(find.byType(TextField), findsOneWidget);
expect(getFieldValue(tester).text, equals('0'));
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.byIcon(Icons.remove), findsOneWidget);
});

testWidgets('tapping increment/decrement works', (WidgetTester tester) async {
await tester.pumpWidget(
const example.TapRegionApp(),
);
await tester.pump();

expect(getFieldValue(tester).text, equals('0'));
expect(
getFieldValue(tester).selection,
equals(const TextSelection.collapsed(offset: 1)),
);

await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();

expect(getFieldValue(tester).text, equals('1'));
expect(
getFieldValue(tester).selection,
equals(const TextSelection(baseOffset: 0, extentOffset: 1)),
);

await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();

expect(getFieldValue(tester).text, equals('-1'));
expect(
getFieldValue(tester).selection,
equals(const TextSelection(baseOffset: 0, extentOffset: 2)),
);
});

testWidgets('entering text and then incrementing/decrementing works', (WidgetTester tester) async {
await tester.pumpWidget(
const example.TapRegionApp(),
);
await tester.pump();

await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();

expect(getFieldValue(tester).text, equals('1'));
expect(
getFieldValue(tester).selection,
equals(const TextSelection(baseOffset: 0, extentOffset: 1)),
);

await tester.enterText(find.byType(TextField), '123');
await tester.pumpAndSettle();
expect(getFieldValue(tester).text, equals('123'));
expect(
getFieldValue(tester).selection,
equals(const TextSelection.collapsed(offset: 3)),
);

await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();

expect(getFieldValue(tester).text, equals('121'));
expect(
getFieldValue(tester).selection,
equals(const TextSelection(baseOffset: 0, extentOffset: 3)),
);
final FocusNode textFieldFocusNode = Focus.of(
tester.element(
find.byWidgetPredicate((Widget widget) {
return widget.runtimeType.toString() == '_Editable';
}),
),
);
expect(textFieldFocusNode.hasPrimaryFocus, isTrue);
});
}

TextEditingValue getFieldValue(WidgetTester tester) {
return (tester.widget(find.byType(TextField)) as TextField).controller!.value;
}
Loading

0 comments on commit f5e4d2b

Please sign in to comment.