New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Desktop]Tap event is not simulated in a overlay on Flutter 3.7 #119390
Comments
I think this is working as intended (realized after reading docs on TexfieldTapRegion)
How the focus is now currently handled. The above code sample is supposed to close the overlay when the textfield is tapped. In the previous release 3.3: When the overlay was tapped the tap event was triggered and then the focus was lost But if this is intended, this seems inconsistent with mobile platforms. |
This happens to me when I pass a focusNode to the textfield, but not when Textfield focusNode is null. We do need the custom focusNode though to handle events such as closing the overlay or other stuff so I am intrigued to see the solution to this. |
Hello @maheshmnj. Is it possible to provide a more minimal example of this issue? The above code, while complete, is complex. |
@exaby73 Here is a more minimal example in my opinion. We have two files main.dart and country_search.dart code samplemain.dart import 'package:flutter/material.dart';
import 'package:overlay_bug/country_search.dart';
final RouteObserver<ModalRoute<void>> routeObserver =
RouteObserver<ModalRoute<void>>();
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
String? countryName = '';
late FocusNode focusNode;
bool focused = false;
@override
void initState() {
focusNode = FocusNode();
focusNode.addListener(() {
if (!focusNode.hasFocus && focused) {
// setState(() {
// focused = false;
// });
}
});
super.initState();
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: SizedBox(
height: 500,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 250,
child: TextSearch(
focusNode: focusNode,
label: 'Search for a country',
onTapFunction: (name) async {
print('you picked $name');
setState(() {
countryName = name;
});
},
),
),
Text(
'You have picked: $countryName',
),
],
),
),
));
}
} and country_search.dart import 'package:flutter/material.dart';
import 'main.dart';
class TextSearch extends StatefulWidget {
const TextSearch(
{Key? key,
required this.label,
required this.onTapFunction,
this.focusNode})
: super(key: key);
final String label;
final Future<void> Function(String) onTapFunction;
final FocusNode? focusNode;
@override
State<TextSearch> createState() => _TextSearchState();
}
class _TextSearchState extends State<TextSearch>
with WidgetsBindingObserver, RouteAware {
late TextEditingController textEditingController;
OverlayEntry? entry;
late LayerLink layerLink;
bool isLoading = false;
bool isSearchLoading = false;
List<String>? _searchResults;
String searchError = '';
bool hasDollarSigns = false;
bool shownSubscribe = false;
//for overlay reasons
bool isSearching = false;
final List<String> countries = [
'Albania',
'Brasil',
'Cambodia',
'Denmark',
'Greece',
'United Kingdom',
'Zimbabwe'
];
@override
void initState() {
textEditingController = TextEditingController();
//focusNode = widget.focusNode != null ? widget.focusNode! : FocusNode();
layerLink = LayerLink();
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
widget.focusNode!.addListener(() {
if (widget.focusNode!.hasFocus &&
textEditingController.text.isNotEmpty) {
showOverlay();
} else {
hideOverlay();
}
});
});
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
textEditingController.dispose();
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
super.dispose();
}
@override
void didChangeMetrics() {
hideOverlay();
}
@override
void didPush() {
hideOverlay();
}
@override
void didPopNext() {
hideOverlay();
}
void hideOverlay() {
if (entry != null && entry!.mounted) {
entry?.remove();
entry = null;
}
}
Future<void> searchStocks() async {
isSearching = true;
if (textEditingController.text.isNotEmpty &&
textEditingController.text != '') {
String query = textEditingController.text;
Future.delayed(const Duration(seconds: 1), () {
List<String>? searchResultsWhenResponseArrives = countries
.where((element) => element
.toUpperCase()
.contains(textEditingController.text.toUpperCase()))
.toList();
if (textEditingController.text == query) {
// until response arrives controller can have its value changed
_searchResults = searchResultsWhenResponseArrives;
showOverlay();
}
isSearching = false;
});
}
}
void showOverlay() {
hideOverlay();
if (textEditingController.text.isNotEmpty) {
final OverlayState overlay = Overlay.of(context)!;
overlay.build(context);
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final Size size = renderBox.size;
const double leftOffset = -35;
const double topOffset =
20; //this is basically the padding between label and border of textfield
final double width = size.width + 2 * leftOffset.abs();
entry = OverlayEntry(builder: (context) {
return Positioned(
width:
width, // somehow it appears more narrow than textfield so it is plus some width
height: 300,
child: CompositedTransformFollower(
link: layerLink,
offset: const Offset(leftOffset, topOffset + 20),
child: textEditingController.text.isEmpty
? null
: Material(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SearchBody(
callbackFunc: (countryName) async {
hideOverlay();
widget.onTapFunction(countryName).then(
(value) {
widget.focusNode!.unfocus();
textEditingController.clear();
},
);
},
isSearchLoading: isSearchLoading,
searchError: searchError,
searchResult: _searchResults,
),
],
),
),
));
});
overlay.insert(entry!);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
/// Important Info: Textfield height is always equal to fontSize - 5 when it does not have padding and isDense = true
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: layerLink,
child: TextField(
focusNode: widget.focusNode, //remove this in order for it to work
controller: textEditingController,
onChanged: (String value) async {
if (value.isNotEmpty) {
await searchStocks();
} else {
hideOverlay();
}
},
style: Theme.of(context).chipTheme.labelStyle,
textAlign: TextAlign.center,
textAlignVertical: TextAlignVertical.center,
decoration: const InputDecoration(
isDense: false,
border: OutlineInputBorder(
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.all(0),
filled: true,
),
),
);
}
}
class SearchBody extends StatelessWidget {
const SearchBody({
required this.callbackFunc,
required this.isSearchLoading,
required this.searchError,
required this.searchResult,
Key? key,
}) : super(key: key);
final Future<void> Function(String) callbackFunc;
final bool isSearchLoading;
final String searchError;
final List<String>? searchResult;
@override
Widget build(BuildContext context) {
if (isSearchLoading) {
return const CircularProgressIndicator();
} else if (searchError.isNotEmpty) {
return Text(searchError);
} else if (searchResult != null) {
return searchResult!.isEmpty
? const Text('No Countries with this name')
: Expanded(
child: SearchResults(
items: searchResult!,
callbackFunc: callbackFunc,
));
} else {
return const Text('');
}
}
}
class SearchResults extends StatelessWidget {
const SearchResults({
Key? key,
required this.items,
required this.callbackFunc,
}) : super(key: key);
final Future<void> Function(String) callbackFunc;
final List<String> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
return SearchResultTile(
item: items[index],
callbackFunc: callbackFunc,
);
},
);
}
}
class SearchResultTile extends StatelessWidget {
const SearchResultTile({
Key? key,
required this.item,
required this.callbackFunc,
}) : super(key: key);
final Future<void> Function(String) callbackFunc;
final String item;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(item),
onTap: () {
callbackFunc(item);
},
);
}
} country_search.dart is _with WidgetsBindingObserver, RouteAware because user can navigate out of this page so we need to close the overlay if open. Steps to reproduce:
If you run it on Flutter 3.7 it will fail and no country will be picked, if you remove the focusNode from textfield (comment) callback will work. If you run this on flutter 3.3.10 it will work regardless of focusNode. In my case I do need the focusNode, in order to handle other ui state in my app (like showing something above the textfield if it is not focused). |
@GeorgePagounis Is it possible to remove any logic here on country selection and just provide a bare minimum code to reproduce this issue? No fake async call. Just an overlay with for example a |
@exaby73 I have narrowed down the code sample to make this issue more obvious. Basically, This issue arises when you are closing the overlay in two ways
My main concern is the inconsistency across web/Desktop and mobile. code sampleimport 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({super.key});
final List<String> _suggestions = [
'United States',
'Germany',
'Washington',
'Paris',
'Jakarta',
'Australia',
'India',
'Czech Republic',
'Lorem Ipsum',
];
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: Scaffold(
appBar: AppBar(title: const Text('SearchField Issue')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: OverlayIssue(
maxSuggestionsInViewPort: 5,
itemHeight: 40,
suggestions: _suggestions,
),
),
],
)));
}
}
class OverlayIssue extends StatefulWidget {
final FocusNode? focusNode;
final List<String> suggestions;
final Function(String)? onSuggestionTap;
final double itemHeight;
final int maxSuggestionsInViewPort;
final String? Function(String?)? validator;
const OverlayIssue({
super.key,
required this.suggestions,
this.focusNode,
this.itemHeight = 35.0,
this.maxSuggestionsInViewPort = 5,
this.onSuggestionTap,
this.validator,
});
@override
_OverlayIssueState createState() => _OverlayIssueState();
}
class _OverlayIssueState<T> extends State<OverlayIssue> {
final StreamController<List<String>?> suggestionStream =
StreamController<List<String>?>.broadcast();
FocusNode? _focus;
@override
void dispose() {
suggestionStream.close();
if (widget.focusNode == null) {
_focus!.dispose();
}
super.dispose();
}
void initialize() {
if (widget.focusNode != null) {
_focus = widget.focusNode;
} else {
_focus = FocusNode();
}
_focus!.addListener(() {
if (_focus!.hasFocus) {
Overlay.of(context).insert(_overlayEntry!);
} else {
if (_overlayEntry != null && _overlayEntry!.mounted) {
_overlayEntry?.remove();
}
}
});
}
OverlayEntry? _overlayEntry;
@override
void initState() {
super.initState();
initialize();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_overlayEntry = _createOverlay();
}
});
}
OverlayEntry _createOverlay() {
final textFieldRenderBox =
key.currentContext!.findRenderObject() as RenderBox;
final textFieldsize = textFieldRenderBox.size;
final offset = textFieldRenderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => StreamBuilder<List<String>?>(
stream: suggestionStream.stream,
builder:
(BuildContext context, AsyncSnapshot<List<String>?> snapshot) {
return Positioned(
left: offset.dx,
width: textFieldsize.width,
child: CompositedTransformFollower(
offset: const Offset(200, 100),
link: _layerLink,
child: Material(
child: InkWell(
onTap: () {
print('Overlay tapped'); // this is not working
// suggestionStream.sink.add(null);
},
child: box(Colors.red,
"Notice that tapping on this red area doesn't log anything. regardless of any widget"),
),
)),
);
}));
}
Widget box(Color color, String message) {
return Container(
color: color,
height: 100,
width: 100,
child: Text(message),
);
}
final LayerLink _layerLink = LayerLink();
GlobalKey key = GlobalKey();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
key: key,
decoration: const InputDecoration(
hintText: 'Focus Textfield and tap on red area',
),
onTap: () {
if (!_focus!.hasFocus) {
suggestionStream.sink.add(widget.suggestions);
}
},
focusNode: _focus,
),
),
InkWell(
onTap: () {
print('Green Container tapped'); // This works
},
child:
box(Colors.green, 'Notice that tapping on this green area works'),
)
],
);
}
}
cc: @justinmc for insights |
Triage reportI can reproduce this issue on stable and master. I can confirm this is a regression. I have spent a bit of time bisecting it to f5e4d2b. cc: @gspencergoog Code Sampleimport 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({super.key});
final List<String> _suggestions = [
'United States',
'Germany',
'Washington',
'Paris',
'Jakarta',
'Australia',
'India',
'Czech Republic',
'Lorem Ipsum',
];
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: Scaffold(
appBar: AppBar(title: const Text('SearchField Issue')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: OverlayIssue(
maxSuggestionsInViewPort: 5,
itemHeight: 40,
suggestions: _suggestions,
),
),
],
)));
}
}
class OverlayIssue extends StatefulWidget {
final FocusNode? focusNode;
final List<String> suggestions;
final Function(String)? onSuggestionTap;
final double itemHeight;
final int maxSuggestionsInViewPort;
final String? Function(String?)? validator;
const OverlayIssue({
super.key,
required this.suggestions,
this.focusNode,
this.itemHeight = 35.0,
this.maxSuggestionsInViewPort = 5,
this.onSuggestionTap,
this.validator,
});
@override
State<OverlayIssue> createState() => _OverlayIssueState();
}
class _OverlayIssueState<T> extends State<OverlayIssue> {
final StreamController<List<String>?> suggestionStream =
StreamController<List<String>?>.broadcast();
FocusNode? _focus;
@override
void dispose() {
suggestionStream.close();
if (widget.focusNode == null) {
_focus!.dispose();
}
super.dispose();
}
void initialize() {
if (widget.focusNode != null) {
_focus = widget.focusNode;
} else {
_focus = FocusNode();
}
_focus!.addListener(() {
if (_focus!.hasFocus) {
Overlay.of(context)!.insert(_overlayEntry!);
} else {
if (_overlayEntry != null && _overlayEntry!.mounted) {
_overlayEntry?.remove();
}
}
});
}
OverlayEntry? _overlayEntry;
@override
void initState() {
super.initState();
initialize();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_overlayEntry = _createOverlay();
}
});
}
OverlayEntry _createOverlay() {
final textFieldRenderBox =
key.currentContext!.findRenderObject() as RenderBox;
final textFieldsize = textFieldRenderBox.size;
final offset = textFieldRenderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => StreamBuilder<List<String>?>(
stream: suggestionStream.stream,
builder: (BuildContext context, AsyncSnapshot<List<String>?> snapshot) {
return Positioned(
left: offset.dx,
width: textFieldsize.width,
child: CompositedTransformFollower(
offset: const Offset(200, 100),
link: _layerLink,
child: Material(
child: InkWell(
onTap: () {
print('Overlay tapped'); // this is not working
},
child: box(
Colors.red,
"Notice that tapping on this red area doesn't log anything. regardless of any widget",
),
),
),
),
);
},
),
);
}
Widget box(Color color, String message) {
return Container(
color: color,
height: 100,
width: 100,
child: Text(message),
);
}
final LayerLink _layerLink = LayerLink();
GlobalKey key = GlobalKey();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
key: key,
decoration: const InputDecoration(
hintText: 'Focus Textfield and tap on red area',
),
onTap: () {
if (!_focus!.hasFocus) {
suggestionStream.sink.add(widget.suggestions);
}
},
focusNode: _focus,
),
),
InkWell(
onTap: () {
print('Green Container tapped'); // This works
},
child:
box(Colors.green, 'Notice that tapping on this green area works'),
)
],
);
}
} |
Try wrapping a Code Sampleimport 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({super.key});
final List<String> _suggestions = [
'United States',
'Germany',
'Washington',
'Paris',
'Jakarta',
'Australia',
'India',
'Czech Republic',
'Lorem Ipsum',
];
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: Scaffold(
appBar: AppBar(title: const Text('SearchField Issue')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: OverlayIssue(
maxSuggestionsInViewPort: 5,
itemHeight: 40,
suggestions: _suggestions,
),
),
],
)));
}
}
class OverlayIssue extends StatefulWidget {
final FocusNode? focusNode;
final List<String> suggestions;
final Function(String)? onSuggestionTap;
final double itemHeight;
final int maxSuggestionsInViewPort;
final String? Function(String?)? validator;
const OverlayIssue({
super.key,
required this.suggestions,
this.focusNode,
this.itemHeight = 35.0,
this.maxSuggestionsInViewPort = 5,
this.onSuggestionTap,
this.validator,
});
@override
State<OverlayIssue> createState() => _OverlayIssueState();
}
class _OverlayIssueState<T> extends State<OverlayIssue> {
final StreamController<List<String>?> suggestionStream =
StreamController<List<String>?>.broadcast();
FocusNode? _focus;
@override
void dispose() {
suggestionStream.close();
if (widget.focusNode == null) {
_focus!.dispose();
}
super.dispose();
}
void initialize() {
if (widget.focusNode != null) {
_focus = widget.focusNode;
} else {
_focus = FocusNode();
}
_focus!.addListener(() {
if (_focus!.hasFocus) {
Overlay.of(context).insert(_overlayEntry!);
} else {
if (_overlayEntry != null && _overlayEntry!.mounted) {
_overlayEntry?.remove();
}
}
});
}
OverlayEntry? _overlayEntry;
@override
void initState() {
super.initState();
initialize();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_overlayEntry = _createOverlay();
}
});
}
OverlayEntry _createOverlay() {
final textFieldRenderBox =
key.currentContext!.findRenderObject() as RenderBox;
final textFieldsize = textFieldRenderBox.size;
final offset = textFieldRenderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => StreamBuilder<List<String>?>(
stream: suggestionStream.stream,
builder: (BuildContext context, AsyncSnapshot<List<String>?> snapshot) {
return Positioned(
left: offset.dx,
width: textFieldsize.width,
child: CompositedTransformFollower(
offset: const Offset(200, 100),
link: _layerLink,
child: TextFieldTapRegion(
child: Material(
child: InkWell(
onTap: () {
print('Overlay tapped'); // this is not working
// suggestionStream.sink.add(null);
},
child: box(Colors.red,
"Notice that tapping on this red area doesn't log anything. regardless of any widget"),
),
),
),
),
);
},
),
);
}
Widget box(Color color, String message) {
return Container(
color: color,
height: 100,
width: 100,
child: Text(message),
);
}
final LayerLink _layerLink = LayerLink();
GlobalKey key = GlobalKey();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
key: key,
decoration: const InputDecoration(
hintText: 'Focus Textfield and tap on red area',
),
onTap: () {
if (!_focus!.hasFocus) {
suggestionStream.sink.add(widget.suggestions);
}
},
focusNode: _focus,
),
),
InkWell(
onTap: () {
print('Green Container tapped'); // This works
},
child:
box(Colors.green, 'Notice that tapping on this green area works'),
)
],
);
}
} |
Yes, TextFieldTapRegion does work. @gspencergoog so the code sample I shared (Without TextFieldTapRegion) is incorrect for this use case? |
Yes, because the thing that the overlay is attached to is a |
Thank you @gspencergoog this works! Kind of sad because every tutorial out there ,up until 3.7, regarding overlay search will be outdated, but as the framework progresses, discrepancies are natural. |
Since this is working as expected as of #119390 (comment), I am closing this issue |
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of |
Tap events are not being simulated to overlay on the Web/Desktop but work fine on mobile devices. This worked fine until the previous release stable 3.3 .
Also note that if Inkwell is wrapped with
TextFieldTapRegion
onTap works fine.Steps to Reproduce
Expected Results: onTap is triggered
Actual Results: onTap event is not triggered
Logs
code sample
Logs
The text was updated successfully, but these errors were encountered: