Skip to content
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

Closed
maheshmnj opened this issue Jan 28, 2023 · 13 comments
Closed

[Desktop]Tap event is not simulated in a overlay on Flutter 3.7 #119390

maheshmnj opened this issue Jan 28, 2023 · 13 comments
Labels
c: regression It was better in the past than it is now f: gestures flutter/packages/flutter/gestures repository. found in release: 3.7 Found to occur in 3.7 framework flutter/packages/flutter repository. See also f: labels. has reproducible steps The issue has been confirmed reproducible and is ready to work on r: invalid Issue is closed as not valid

Comments

@maheshmnj
Copy link
Member

maheshmnj commented Jan 28, 2023

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

  1. Run the code sample on the Web/Desktop
  2. Focus on the textfield and tap on the red area
  3. Notice that Tap events using any gesture-simulating widget (Inkwell, GestureDetector) is not triggered (line 243) on the Desktop but works on a mobile device

Expected Results: onTap is triggered

Actual Results: onTap event is not triggered

Logs

code sample
import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: ExampleList());
  }
}

class ExampleList extends StatelessWidget {
  ExampleList({Key? key}) : super(key: key);
  final List<String> _suggestions = [
    'United States',
    'Germany',
    'Washington',
    'Paris',
    'Jakarta',
    'Australia',
    'India',
    'Czech Republic',
    'Lorem Ipsum',
  ];
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('SearchField Issue')),
        body: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: SearchField(
                maxSuggestionsInViewPort: 5,
                itemHeight: 40,
                suggestions: _suggestions
                    .map((e) => SearchFieldListItem(
                          e,
                        ))
                    .toList(),
              ),
            ),
          ],
        ));
  }
}

enum Suggestion {
  expand,
  hidden,
}

class SearchFieldListItem<T> {
  Key? key;

  final String searchKey;

  final T? item;

  final Widget? child;

  SearchFieldListItem(this.searchKey, {this.child, this.item, this.key});

  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is SearchFieldListItem &&
            runtimeType == other.runtimeType &&
            searchKey == other.searchKey;
  }

  @override
  int get hashCode => searchKey.hashCode;
}

class SearchField<T> extends StatefulWidget {
  final FocusNode? focusNode;
  final List<SearchFieldListItem<T>> suggestions;
  final Function(SearchFieldListItem<T>)? onSuggestionTap;
  final Function(String)? onSubmit;

  final SearchFieldListItem<T>? initialValue;

  final Suggestion suggestionState;

  final double itemHeight;

  final int maxSuggestionsInViewPort;

  final TextEditingController? controller;

  final String? Function(String?)? validator;

  final bool hasOverlay;

  final Offset? offset;

  final Widget emptyWidget;

  SearchField({
    Key? key,
    required this.suggestions,
    this.controller,
    this.emptyWidget = const SizedBox.shrink(),
    this.focusNode,
    this.hasOverlay = true,
    this.initialValue,
    this.itemHeight = 35.0,
    this.maxSuggestionsInViewPort = 5,
    this.onSubmit,
    this.offset,
    this.onSuggestionTap,
    this.suggestionState = Suggestion.expand,
    this.validator,
  });

  @override
  _SearchFieldState<T> createState() => _SearchFieldState();
}

class _SearchFieldState<T> extends State<SearchField<T>> {
  final StreamController<List<SearchFieldListItem<T>?>?> suggestionStream =
      StreamController<List<SearchFieldListItem<T>?>?>.broadcast();
  FocusNode? _focus;
  TextEditingController? searchController;

  @override
  void dispose() {
    suggestionStream.close();
    if (widget.controller == null) {
      searchController!.dispose();
    }
    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();
    searchController = widget.controller ?? TextEditingController();
    initialize();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        _overlayEntry = _createOverlay();
        if (widget.initialValue == null ||
            widget.initialValue!.searchKey.isEmpty) {
          suggestionStream.sink.add(null);
        } else {
          searchController!.text = widget.initialValue!.searchKey;
          suggestionStream.sink.add([widget.initialValue]);
        }
      }
    });
  }

  @override
  void didUpdateWidget(covariant SearchField<T> oldWidget) {
    if (oldWidget.controller != widget.controller) {
      searchController = widget.controller ?? TextEditingController();
    }
    if (oldWidget.hasOverlay != widget.hasOverlay) {
      if (widget.hasOverlay) {
        _overlayEntry = _createOverlay();
        _focus!.removeListener(initialize);
        initialize();
      } else {
        if (_overlayEntry!.mounted) {
          _overlayEntry?.remove();
        }
      }
      if (mounted) {
        setState(() {});
      }
    }
    if (oldWidget.suggestions != widget.suggestions) {
      suggestionStream.sink.add(widget.suggestions);
    }
    super.didUpdateWidget(oldWidget);
  }

  Offset? getYOffset(
      Offset textFieldOffset, Size textFieldSize, int suggestionsCount) {
    if (mounted) {
      return Offset(0, textFieldSize.height);
    }
    return null;
  }

  OverlayEntry _createOverlay() {
    final textFieldRenderBox =
        key.currentContext!.findRenderObject() as RenderBox;
    final textFieldsize = textFieldRenderBox.size;
    final offset = textFieldRenderBox.localToGlobal(Offset.zero);
    var yOffset = Offset.zero;
    return OverlayEntry(
        builder: (context) => StreamBuilder<List<SearchFieldListItem?>?>(
            stream: suggestionStream.stream,
            builder: (BuildContext context,
                AsyncSnapshot<List<SearchFieldListItem?>?> snapshot) {
              late var count = widget.maxSuggestionsInViewPort;
              if (snapshot.data != null) {
                count = snapshot.data!.length;
              }
              yOffset = getYOffset(offset, textFieldsize, count) ?? Offset.zero;
              return Positioned(
                left: offset.dx,
                width: textFieldsize.width,
                child: CompositedTransformFollower(
                    offset: widget.offset ?? yOffset,
                    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,
            onFieldSubmitted: (x) {
              if (widget.onSubmit != null) widget.onSubmit!(x);
            },
            decoration: InputDecoration(
              hintText: 'Focus Textfield and tap on red area',
            ),
            onTap: () {
              if (!_focus!.hasFocus &&
                  widget.suggestionState == Suggestion.expand) {
                suggestionStream.sink.add(widget.suggestions);
              }
            },
            focusNode: _focus,
          ),
        ),
        InkWell(
          onTap: () {
            print('Container tapped');// This works
          },
          child:
              box(Colors.green, 'Notice that tapping on this green area works'),
        )
      ],
    );
  }
}
Logs
[✓] Flutter (Channel stable, 3.7.0, on macOS 13.1 22C65 darwin-arm64, locale en-IN)
    • Flutter version 3.7.0 on channel stable at /Users/mahesh/Development/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision b06b8b2710 (4 days ago), 2023-01-23 16:55:55 -0800
    • Engine revision b24591ed32
    • Dart version 2.19.0
    • DevTools version 2.20.1

[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0-rc4)
    • Android SDK at /Users/mahesh/Library/Android/sdk
    • Platform android-33, build-tools 33.0.0-rc4
    • ANDROID_HOME = /Users/mahesh/Library/Android/sdk
    • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.12+0-b1504.28-7817840)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 14.0.1)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 14A400
    • CocoaPods version 1.11.3

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2021.2)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.12+0-b1504.28-7817840)

[✓] IntelliJ IDEA Community Edition (version 2021.2.1)
    • IntelliJ at /Applications/IntelliJ IDEA CE.app
    • Flutter plugin version 61.2.4
    • Dart plugin version 212.5080.8

[✓] VS Code (version 1.74.2)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.58.0

[✓] Connected device (3 available)
    • iPhone 12 Pro (mobile) • 026D5789-9E78-4AD5-B1B2-3F8D4E7F65E4 • ios            •
      com.apple.CoreSimulator.SimRuntime.iOS-14-5 (simulator)
    • macOS (desktop)        • macos                                • darwin-arm64   • macOS 13.1 22C65
      darwin-arm64
    • Chrome (web)           • chrome                               • web-javascript • Google Chrome
      109.0.5414.119

[✓] HTTP Host Availability
    • All required HTTP hosts are available

• No issues found!
   • Flutter version 3.7.0-28.0.pre.13 on channel master at /Users/mahesh/Development/flutter_master
    ! Warning: `flutter` on your path resolves to /Users/mahesh/Development/flutter/bin/flutter, which
      is not inside your current Flutter SDK checkout at /Users/mahesh/Development/flutter_master.
      Consider adding /Users/mahesh/Development/flutter_master/bin to the front of your path.
    ! Warning: `dart` on your path resolves to /Users/mahesh/Development/flutter/bin/dart, which is not
      inside your current Flutter SDK checkout at /Users/mahesh/Development/flutter_master. Consider
      adding /Users/mahesh/Development/flutter_master/bin to the front of your path.
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision a04ab7129b (30 hours ago), 2023-01-26 12:01:12 -0800
    • Engine revision 04f22beebb
    • Dart version 3.0.0 (build 3.0.0-166.0.dev)
    • DevTools version 2.20.1
    • If those were intentional, you can disregard the above warnings; however it is recommended to use
      "git" directly to perform update checks and upgrades.

[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0-rc4)
    • Android SDK at /Users/mahesh/Library/Android/sdk
    • Platform android-33, build-tools 33.0.0-rc4
    • ANDROID_HOME = /Users/mahesh/Library/Android/sdk
    • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.12+0-b1504.28-7817840)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 14.0.1)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 14A400
    • CocoaPods version 1.11.3

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2021.2)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.12+0-b1504.28-7817840)

[✓] IntelliJ IDEA Community Edition (version 2021.2.1)
    • IntelliJ at /Applications/IntelliJ IDEA CE.app
    • Flutter plugin version 61.2.4
    • Dart plugin version 212.5080.8

[✓] VS Code (version 1.74.2)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.58.0

[✓] Connected device (3 available)
    • iPhone 12 Pro (mobile) • 026D5789-9E78-4AD5-B1B2-3F8D4E7F65E4 • ios            •
      com.apple.CoreSimulator.SimRuntime.iOS-14-5 (simulator)
    • macOS (desktop)        • macos                                • darwin-arm64   • macOS 13.1 22C65
      darwin-arm64
    • Chrome (web)           • chrome                               • web-javascript • Google Chrome
      109.0.5414.119

[✓] HTTP Host Availability
    • All required HTTP hosts are available

! Doctor found issues in 1 category.
@maheshmnj
Copy link
Member Author

maheshmnj commented Jan 28, 2023

I think this is working as intended (realized after reading docs on TexfieldTapRegion)

Widgets that are wrapped with a TextFieldTapRegion are considered to be part of a text field for purposes of unfocus behavior. So, when the user taps on them, the currently focused text field won't be unfocused by default. This allows controls like spinners, copy buttons, and formatting buttons to be associated with a text field without causing the text field to lose focus when they are interacted with.

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
In the current release 3.7: The focus is lost first so by the time the tap event is triggered overlay is removed.

But if this is intended, this seems inconsistent with mobile platforms.

@GeorgePagounis
Copy link

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.

@exaby73 exaby73 added the in triage Presently being triaged by the triage team label Jan 30, 2023
@exaby73
Copy link
Member

exaby73 commented Jan 30, 2023

Hello @maheshmnj. Is it possible to provide a more minimal example of this issue? The above code, while complete, is complex.

@exaby73 exaby73 added the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Jan 30, 2023
@GeorgePagounis
Copy link

GeorgePagounis commented Jan 30, 2023

@exaby73 Here is a more minimal example in my opinion. We have two files main.dart and country_search.dart

code sample

main.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:

  1. Copy paste above code to two files
  2. Run and search for any string on the list of countries, wait one second, since I want to replicate an async api search call
  3. Pick a country from the overlay

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).

@exaby73
Copy link
Member

exaby73 commented Jan 30, 2023

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

@maheshmnj
Copy link
Member Author

maheshmnj commented Jan 31, 2023

Hello @maheshmnj. Is it possible to provide a more minimal example of this issue? The above code, while complete, is complex.

@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

  1. By removing the focus from the textfield
  2. and by tapping it.
    In the second case, the tap gesture is ignored (or never executed) since the overlay is wiped off on losing the focus as I mentioned here [Desktop]Tap event is not simulated in a overlay on Flutter 3.7 #119390 (comment)

My main concern is the inconsistency across web/Desktop and mobile.

code sample
import '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

@github-actions github-actions bot removed the waiting for customer response The Flutter team cannot make further progress on this issue until the original reporter responds label Jan 31, 2023
@exaby73
Copy link
Member

exaby73 commented Jan 31, 2023

Triage report

I 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 Sample
import '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'),
        )
      ],
    );
  }
}

@exaby73 exaby73 added c: regression It was better in the past than it is now framework flutter/packages/flutter repository. See also f: labels. f: gestures flutter/packages/flutter/gestures repository. has reproducible steps The issue has been confirmed reproducible and is ready to work on found in release: 3.7 Found to occur in 3.7 and removed in triage Presently being triaged by the triage team labels Jan 31, 2023
@gspencergoog
Copy link
Contributor

Try wrapping a TextFieldTapRegion around the Material in the overlay. That's it's intended purpose.

Code Sample
import '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'),
        )
      ],
    );
  }
}

@maheshmnj
Copy link
Member Author

Yes, TextFieldTapRegion does work. @gspencergoog so the code sample I shared (Without TextFieldTapRegion) is incorrect for this use case?

@gspencergoog
Copy link
Contributor

Yes, because the thing that the overlay is attached to is a TextField, so in order to keep from unfocusing the text field when tapping outside of it, you need to tell the overlay widget that it's part of the TextField for purposes of the "tap outside" behavior by adding the TextFieldTapRegion around it, so that when the tap arrives, it's considered "inside" of the text field.

@GeorgePagounis
Copy link

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.

@exaby73
Copy link
Member

exaby73 commented Feb 1, 2023

Since this is working as expected as of #119390 (comment), I am closing this issue

@exaby73 exaby73 closed this as not planned Won't fix, can't repro, duplicate, stale Feb 1, 2023
@exaby73 exaby73 added the r: invalid Issue is closed as not valid label Feb 1, 2023
@github-actions
Copy link

github-actions bot commented Mar 3, 2023

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 flutter doctor -v and a minimal reproduction of the issue.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Mar 3, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
c: regression It was better in the past than it is now f: gestures flutter/packages/flutter/gestures repository. found in release: 3.7 Found to occur in 3.7 framework flutter/packages/flutter repository. See also f: labels. has reproducible steps The issue has been confirmed reproducible and is ready to work on r: invalid Issue is closed as not valid
Projects
None yet
Development

No branches or pull requests

4 participants