diff --git a/.gitignore b/.gitignore index e87a1176..f4cd55eb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,11 @@ ios/Runner/GeneratedPluginRegistrant.* pubspec.lock flutter_typeahead -doc \ No newline at end of file +doc + +example/android/gradle/wrapper/gradle-wrapper.jar +example/android/gradlew +example/android/gradlew.bat +example/example.iml +example/ios/Flutter/flutter_export_environment.sh +example/pubspec.lock` \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 57601266..b0966c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 4.3.4 +Improved the main example to be able to read it in pub dev, + ## 4.3.3 - 1-Feburary-2023 -- Apply PR to fix onSelected issue introduced in Flutter 3.7.0 diff --git a/README.md b/README.md index 7b849366..ca6d4a53 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ builder function decoration, custom TextEditingController, text styling, etc. * Provides two versions, a normal version and a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) version that accepts validation, submitting, etc. -* Provides high customizability; you can customize the suggestion box decoration, +* Provides high customizable; you can customize the suggestion box decoration, the loading bar, the animation, the debounce duration, etc. ## Installation diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 9367d483..9625e105 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 11.0 diff --git a/example/ios/Flutter/flutter_export_environment.sh b/example/ios/Flutter/flutter_export_environment.sh index 87d2abd0..1d2bce06 100755 --- a/example/ios/Flutter/flutter_export_environment.sh +++ b/example/ios/Flutter/flutter_export_environment.sh @@ -1,13 +1,14 @@ #!/bin/sh # This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=C:\Flutter\flutter" -export "FLUTTER_APPLICATION_PATH=C:\Users\FRANZ\Documents\flutter_tests\flutter_typeahead\example" +export "FLUTTER_ROOT=/Users/ifernandes/development/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/ifernandes/Documents/GitHub/flutter_typeahead/example" export "COCOAPODS_PARALLEL_CODE_SIGN=true" -export "FLUTTER_TARGET=lib\main.dart" +export "FLUTTER_TARGET=/Users/ifernandes/Documents/GitHub/flutter_typeahead/example/lib/main.dart" export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_NAME=1.0.0" export "FLUTTER_BUILD_NUMBER=1" +export "DART_DEFINES=Zmx1dHRlci5pbnNwZWN0b3Iuc3RydWN0dXJlZEVycm9ycz10cnVl,RkxVVFRFUl9XRUJfQVVUT19ERVRFQ1Q9dHJ1ZQ==" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" -export "PACKAGE_CONFIG=.dart_tool/package_config.json" +export "PACKAGE_CONFIG=/Users/ifernandes/Documents/GitHub/flutter_typeahead/example/.dart_tool/package_config.json" diff --git a/example/ios/Podfile b/example/ios/Podfile index 1e8c3c90..88359b22 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +# platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock new file mode 100644 index 00000000..3091738c --- /dev/null +++ b/example/ios/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - Flutter (1.0.0) + - flutter_keyboard_visibility (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_keyboard_visibility: + :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.11.3 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index c6759a6e..a3f2352b 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,10 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 0AA0FD8F2BCB1D02A381FA2C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 070C64CE3E3829B4B69F6D65 /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -29,9 +30,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 05D429632D8FD2830C666F9D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 070C64CE3E3829B4B69F6D65 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5AF13D361E4357D2A725E8B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -42,6 +46,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D80F953CFD9EF627D227F3B1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,12 +54,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0AA0FD8F2BCB1D02A381FA2C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 78354FCE6CD6437F846D905F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 070C64CE3E3829B4B69F6D65 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -72,6 +86,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + DEA2152EEB1D8575D5DFDC3C /* Pods */, + 78354FCE6CD6437F846D905F /* Frameworks */, ); sourceTree = ""; }; @@ -98,6 +114,17 @@ path = Runner; sourceTree = ""; }; + DEA2152EEB1D8575D5DFDC3C /* Pods */ = { + isa = PBXGroup; + children = ( + D80F953CFD9EF627D227F3B1 /* Pods-Runner.debug.xcconfig */, + 5AF13D361E4357D2A725E8B9 /* Pods-Runner.release.xcconfig */, + 05D429632D8FD2830C666F9D /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -105,12 +132,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 23424392682879E6F903E036 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + B873A209970BF6D2B0E8E9D1 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -127,7 +156,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -169,8 +198,31 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 23424392682879E6F903E036 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -185,6 +237,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -197,6 +250,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + B873A209970BF6D2B0E8E9D1 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -272,7 +342,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -290,7 +360,10 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -346,7 +419,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -395,7 +468,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -414,7 +487,10 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -433,7 +509,10 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140cf..3db53b6e 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index a060db61..4f68a2ce 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -41,5 +41,9 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/example/lib/cupertino_app.dart b/example/lib/cupertino_app.dart deleted file mode 100644 index cae38a2b..00000000 --- a/example/lib/cupertino_app.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; - -import 'package:example/data.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; - -class MyCupertinoApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return CupertinoApp( - title: 'flutter_typeahead demo', - home: CupertinoPageScaffold( - child: FavoriteCitiesPage(), - ), //MyHomePage(), - ); - } -} - -class FavoriteCitiesPage extends StatefulWidget { - @override - _FavoriteCitiesPage createState() => _FavoriteCitiesPage(); -} - -class _FavoriteCitiesPage extends State { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _typeAheadController = TextEditingController(); - CupertinoSuggestionsBoxController _suggestionsBoxController = - CupertinoSuggestionsBoxController(); - String favoriteCity = 'Unavailable'; - - @override - Widget build(BuildContext context) { - return Form( - key: _formKey, - child: Padding( - padding: EdgeInsets.all(32.0), - child: Column( - children: [ - SizedBox( - height: 100.0, - ), - Text('What is your favorite city?'), - CupertinoTypeAheadFormField( - getImmediateSuggestions: true, - suggestionsBoxController: _suggestionsBoxController, - textFieldConfiguration: CupertinoTextFieldConfiguration( - controller: _typeAheadController, - ), - suggestionsCallback: (pattern) { - return Future.delayed( - Duration(seconds: 1), - () => CitiesService.getSuggestions(pattern), - ); - }, - itemBuilder: (context, String suggestion) { - return Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - suggestion, - ), - ); - }, - onSuggestionSelected: (String suggestion) { - _typeAheadController.text = suggestion; - }, - validator: (value) => - value!.isEmpty ? 'Please select a city' : null, - ), - SizedBox( - height: 10.0, - ), - CupertinoButton( - child: Text('Submit'), - onPressed: () { - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); - setState(() { - favoriteCity = _typeAheadController.text; - }); - } - }, - ), - SizedBox( - height: 10.0, - ), - Text( - 'Your favorite city is $favoriteCity!', - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } -} diff --git a/example/lib/data.dart b/example/lib/data.dart deleted file mode 100644 index e0218ddf..00000000 --- a/example/lib/data.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -class BackendService { - static Future>> getSuggestions(String query) async { - await Future.delayed(Duration(seconds: 1)); - - return List.generate(3, (index) { - return { - 'name': query + index.toString(), - 'price': Random().nextInt(100).toString() - }; - }); - } -} - -class CitiesService { - static final List cities = [ - 'Beirut', - 'Damascus', - 'San Fransisco', - 'Rome', - 'Los Angeles', - 'Madrid', - 'Bali', - 'Barcelona', - 'Paris', - 'Bucharest', - 'New York City', - 'Philadelphia', - 'Sydney', - ]; - - static List getSuggestions(String query) { - List matches = []; - matches.addAll(cities); - - matches.retainWhere((s) => s.toLowerCase().contains(query.toLowerCase())); - return matches; - } -} diff --git a/example/lib/main.dart b/example/lib/main.dart index 50a9a4f8..2f6fd6f4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,9 +1,358 @@ import 'dart:io' show Platform; +import 'dart:math'; +import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:example/material_app.dart'; -import 'package:example/cupertino_app.dart'; +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; + void main() => runApp(!kIsWeb && Platform.isIOS ? MyCupertinoApp() : MyMaterialApp()); + +class MyMaterialApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'flutter_typeahead demo', + scrollBehavior: MaterialScrollBehavior().copyWith( + dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch} + ), + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: TabBar(tabs: [ + Tab(text: 'Example 1: Navigation'), + Tab(text: 'Example 2: Form'), + Tab(text: 'Example 3: Scroll') + ]), + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: TabBarView(children: [ + NavigationExample(), + FormExample(), + ScrollExample(), + ]), + )), + ), + ); + } +} + +class NavigationExample extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(32.0), + child: Column( + children: [ + SizedBox( + height: 10.0, + ), + TypeAheadField( + textFieldConfiguration: TextFieldConfiguration( + autofocus: true, + style: DefaultTextStyle.of(context) + .style + .copyWith(fontStyle: FontStyle.italic), + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: 'What are you looking for?'), + ), + suggestionsCallback: (pattern) async { + return await BackendService.getSuggestions(pattern); + }, + itemBuilder: (context, Map suggestion) { + return ListTile( + leading: Icon(Icons.shopping_cart), + title: Text(suggestion['name']!), + subtitle: Text('\$${suggestion['price']}'), + ); + }, + onSuggestionSelected: (Map suggestion) { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ProductPage(product: suggestion))); + }, + ), + ], + ), + ); + } +} + +class FormExample extends StatefulWidget { + @override + _FormExampleState createState() => _FormExampleState(); +} + +class _FormExampleState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _typeAheadController = TextEditingController(); + + String? _selectedCity; + + @override + Widget build(BuildContext context) { + return Form( + key: this._formKey, + child: Padding( + padding: EdgeInsets.all(32.0), + child: Column( + children: [ + Text('What is your favorite city?'), + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + decoration: InputDecoration(labelText: 'City'), + controller: this._typeAheadController, + ), + suggestionsCallback: (pattern) { + return CitiesService.getSuggestions(pattern); + }, + itemBuilder: (context, String suggestion) { + return ListTile( + title: Text(suggestion), + ); + }, + transitionBuilder: (context, suggestionsBox, controller) { + return suggestionsBox; + }, + onSuggestionSelected: (String suggestion) { + this._typeAheadController.text = suggestion; + }, + validator: (value) => + value!.isEmpty ? 'Please select a city' : null, + onSaved: (value) => this._selectedCity = value, + ), + SizedBox( + height: 10.0, + ), + ElevatedButton( + child: Text('Submit'), + onPressed: () { + if (this._formKey.currentState!.validate()) { + this._formKey.currentState!.save(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Your Favorite City is ${this._selectedCity}'), + ), + ); + } + }, + ) + ], + ), + ), + ); + } +} + +class ProductPage extends StatelessWidget { + final Map product; + + ProductPage({required this.product}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(50.0), + child: Column( + children: [ + Text( + this.product['name']!, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + this.product['price']! + ' USD', + style: Theme.of(context).textTheme.titleMedium, + ) + ], + ), + ), + ); + } +} + +/// This example shows how to use the [TypeAheadField] in a [ListView] that +/// scrolls. The [TypeAheadField] will resize to fit the suggestions box when +/// scrolling. +class ScrollExample extends StatelessWidget { + final List items = List.generate(50, (index) => "Item $index"); + + @override + Widget build(BuildContext context) { + return ListView(children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text("Suggestion box will resize when scrolling"), + ), + ), + SizedBox(height: 200), + TypeAheadField( + getImmediateSuggestions: true, + textFieldConfiguration: TextFieldConfiguration( + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: 'What are you looking for?'), + ), + suggestionsCallback: (String pattern) async { + return items + .where((item) => + item.toLowerCase().startsWith(pattern.toLowerCase())) + .toList(); + }, + itemBuilder: (context, String suggestion) { + return ListTile( + title: Text(suggestion), + ); + }, + onSuggestionSelected: (String suggestion) { + print("Suggestion selected"); + }, + ), + SizedBox(height: 500), + ]); + } +} + +/// This is a fake service that mimics a backend service. +/// It returns a list of suggestions after a 1 second delay. +/// In a real app, this would be a service that makes a network request. +class BackendService { + static Future>> getSuggestions(String query) async { + await Future.delayed(Duration(seconds: 1)); + + return List.generate(3, (index) { + return { + 'name': query + index.toString(), + 'price': Random().nextInt(100).toString() + }; + }); + } +} + +/// A fake service to filter cities based on a query. +class CitiesService { + static final List cities = [ + 'Beirut', + 'Damascus', + 'San Fransisco', + 'Rome', + 'Los Angeles', + 'Madrid', + 'Bali', + 'Barcelona', + 'Paris', + 'Bucharest', + 'New York City', + 'Philadelphia', + 'Sydney', + ]; + + static List getSuggestions(String query) { + List matches = []; + matches.addAll(cities); + + matches.retainWhere((s) => s.toLowerCase().contains(query.toLowerCase())); + return matches; + } +} + + +/// Cupertino example +class MyCupertinoApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return CupertinoApp( + title: 'flutter_typeahead demo', + home: CupertinoPageScaffold( + child: FavoriteCitiesPage(), + ), //MyHomePage(), + ); + } +} + +class FavoriteCitiesPage extends StatefulWidget { + @override + _FavoriteCitiesPage createState() => _FavoriteCitiesPage(); +} + +class _FavoriteCitiesPage extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _typeAheadController = TextEditingController(); + CupertinoSuggestionsBoxController _suggestionsBoxController = + CupertinoSuggestionsBoxController(); + String favoriteCity = 'Unavailable'; + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Padding( + padding: EdgeInsets.all(32.0), + child: Column( + children: [ + SizedBox( + height: 100.0, + ), + Text('What is your favorite city?'), + CupertinoTypeAheadFormField( + getImmediateSuggestions: true, + suggestionsBoxController: _suggestionsBoxController, + textFieldConfiguration: CupertinoTextFieldConfiguration( + controller: _typeAheadController, + ), + suggestionsCallback: (pattern) { + return Future.delayed( + Duration(seconds: 1), + () => CitiesService.getSuggestions(pattern), + ); + }, + itemBuilder: (context, String suggestion) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + suggestion, + ), + ); + }, + onSuggestionSelected: (String suggestion) { + _typeAheadController.text = suggestion; + }, + validator: (value) => + value!.isEmpty ? 'Please select a city' : null, + ), + SizedBox( + height: 10.0, + ), + CupertinoButton( + child: Text('Submit'), + onPressed: () { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + setState(() { + favoriteCity = _typeAheadController.text; + }); + } + }, + ), + SizedBox( + height: 10.0, + ), + Text( + 'Your favorite city is $favoriteCity!', + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/material_app.dart b/example/lib/material_app.dart deleted file mode 100644 index ba897700..00000000 --- a/example/lib/material_app.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'dart:ui'; -import 'package:example/scroll_example.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; -import 'package:example/data.dart'; - -class MyMaterialApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'flutter_typeahead demo', - scrollBehavior: MaterialScrollBehavior().copyWith( - dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch} - ), - home: MyHomePage(), - ); - } -} - -class MyHomePage extends StatelessWidget { - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: Scaffold( - appBar: AppBar( - title: TabBar(tabs: [ - Tab(text: 'Example 1: Navigation'), - Tab(text: 'Example 2: Form'), - Tab(text: 'Example 3: Scroll') - ]), - ), - body: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: TabBarView(children: [ - NavigationExample(), - FormExample(), - ScrollExample(), - ]), - )), - ); - } -} - -class NavigationExample extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.all(32.0), - child: Column( - children: [ - SizedBox( - height: 10.0, - ), - TypeAheadField( - textFieldConfiguration: TextFieldConfiguration( - autofocus: true, - style: DefaultTextStyle.of(context) - .style - .copyWith(fontStyle: FontStyle.italic), - decoration: InputDecoration( - border: OutlineInputBorder(), - hintText: 'What are you looking for?'), - ), - suggestionsCallback: (pattern) async { - return await BackendService.getSuggestions(pattern); - }, - itemBuilder: (context, Map suggestion) { - return ListTile( - leading: Icon(Icons.shopping_cart), - title: Text(suggestion['name']!), - subtitle: Text('\$${suggestion['price']}'), - ); - }, - onSuggestionSelected: (Map suggestion) { - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => ProductPage(product: suggestion))); - }, - ), - ], - ), - ); - } -} - -class FormExample extends StatefulWidget { - @override - _FormExampleState createState() => _FormExampleState(); -} - -class _FormExampleState extends State { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _typeAheadController = TextEditingController(); - - String? _selectedCity; - - @override - Widget build(BuildContext context) { - return Form( - key: this._formKey, - child: Padding( - padding: EdgeInsets.all(32.0), - child: Column( - children: [ - Text('What is your favorite city?'), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - decoration: InputDecoration(labelText: 'City'), - controller: this._typeAheadController, - ), - suggestionsCallback: (pattern) { - return CitiesService.getSuggestions(pattern); - }, - itemBuilder: (context, String suggestion) { - return ListTile( - title: Text(suggestion), - ); - }, - transitionBuilder: (context, suggestionsBox, controller) { - return suggestionsBox; - }, - onSuggestionSelected: (String suggestion) { - this._typeAheadController.text = suggestion; - }, - validator: (value) => - value!.isEmpty ? 'Please select a city' : null, - onSaved: (value) => this._selectedCity = value, - ), - SizedBox( - height: 10.0, - ), - ElevatedButton( - child: Text('Submit'), - onPressed: () { - if (this._formKey.currentState!.validate()) { - this._formKey.currentState!.save(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Your Favorite City is ${this._selectedCity}'), - ), - ); - } - }, - ) - ], - ), - ), - ); - } -} - -class ProductPage extends StatelessWidget { - final Map product; - - ProductPage({required this.product}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(50.0), - child: Column( - children: [ - Text( - this.product['name']!, - style: Theme.of(context).textTheme.headline5, - ), - Text( - this.product['price']! + ' USD', - style: Theme.of(context).textTheme.subtitle1, - ) - ], - ), - ), - ); - } -} diff --git a/example/lib/scroll_example.dart b/example/lib/scroll_example.dart deleted file mode 100644 index 0d26b54e..00000000 --- a/example/lib/scroll_example.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; - -class ScrollExample extends StatelessWidget { - final List items = List.generate(50, (index) => "Item $index"); - - @override - Widget build(BuildContext context) { - return ListView(children: [ - Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text("Suggestion box will resize when scrolling"), - ), - ), - SizedBox(height: 200), - TypeAheadField( - getImmediateSuggestions: true, - textFieldConfiguration: TextFieldConfiguration( - decoration: InputDecoration( - border: OutlineInputBorder(), - hintText: 'What are you looking for?'), - ), - suggestionsCallback: (String pattern) async { - return items - .where((item) => - item.toLowerCase().startsWith(pattern.toLowerCase())) - .toList(); - }, - itemBuilder: (context, String suggestion) { - return ListTile( - title: Text(suggestion), - ); - }, - onSuggestionSelected: (String suggestion) { - print("Suggestion selected"); - }, - ), - SizedBox(height: 500), - ]); - } -} diff --git a/example/pubspec.lock b/example/pubspec.lock index 00073490..e71b68f5 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -121,7 +121,7 @@ packages: path: ".." relative: true source: path - version: "4.3.2" + version: "4.3.3" flutter_web_plugins: dependency: transitive description: flutter @@ -237,5 +237,5 @@ packages: source: hosted version: "2.1.4" sdks: - dart: ">=2.19.0 <4.0.0" + dart: ">=2.19.0 <3.0.0" flutter: ">=1.20.0" diff --git a/lib/flutter_typeahead.dart b/lib/flutter_typeahead.dart index c95d086c..0eb0e8a6 100644 --- a/lib/flutter_typeahead.dart +++ b/lib/flutter_typeahead.dart @@ -1,5 +1,11 @@ library flutter_typeahead; -export 'src/typedef.dart'; -export 'src/flutter_typeahead.dart'; -export 'src/cupertino_flutter_typeahead.dart'; +export 'package:flutter_typeahead/src/typedef.dart'; +export 'package:flutter_typeahead/src/material/field/typeahead_field.dart'; +export 'package:flutter_typeahead/src/material/field/text_field_configuration.dart'; +export 'package:flutter_typeahead/src/material/field/typeahead_form_field.dart'; +export 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box_controller.dart'; +export 'package:flutter_typeahead/src/cupertino/field/cupertino_typeahead_field.dart'; +export 'package:flutter_typeahead/src/cupertino/field/cupertino_text_field_configuration.dart'; +export 'package:flutter_typeahead/src/cupertino/field/cupertino_typeahead_form_field.dart'; +export 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box_controller.dart'; diff --git a/lib/src/cupertino/field/cupertino_text_field_configuration.dart b/lib/src/cupertino/field/cupertino_text_field_configuration.dart new file mode 100644 index 00000000..3cad3f16 --- /dev/null +++ b/lib/src/cupertino/field/cupertino_text_field_configuration.dart @@ -0,0 +1,185 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; + +// Cupertino BoxDecoration taken from flutter/lib/src/cupertino/text_field.dart +const BorderSide _kDefaultRoundedBorderSide = BorderSide( + color: CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), + ), + style: BorderStyle.solid, + width: 0.0, +); +const Border _kDefaultRoundedBorder = Border( + top: _kDefaultRoundedBorderSide, + bottom: _kDefaultRoundedBorderSide, + left: _kDefaultRoundedBorderSide, + right: _kDefaultRoundedBorderSide, +); + +const BoxDecoration _kDefaultRoundedBorderDecoration = BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: CupertinoColors.black, + ), + border: _kDefaultRoundedBorder, + borderRadius: BorderRadius.all(Radius.circular(5.0)), +); + +/// Supply an instance of this class to the [TypeAhead.textFieldConfiguration] +/// property to configure the displayed text field. See [documentation](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) +/// for more information on properties. +class CupertinoTextFieldConfiguration { + final TextEditingController? controller; + final FocusNode? focusNode; + final BoxDecoration decoration; + final EdgeInsetsGeometry padding; + final String? placeholder; + final Widget? prefix; + final OverlayVisibilityMode prefixMode; + final Widget? suffix; + final OverlayVisibilityMode suffixMode; + final OverlayVisibilityMode clearButtonMode; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final TextCapitalization textCapitalization; + final TextStyle? style; + final TextAlign textAlign; + final bool autofocus; + final bool obscureText; + final bool autocorrect; + final int maxLines; + final int? minLines; + final int? maxLength; + final MaxLengthEnforcement? maxLengthEnforcement; + final ValueChanged? onChanged; + final VoidCallback? onEditingComplete; + final GestureTapCallback? onTap; + final ValueChanged? onSubmitted; + final List? inputFormatters; + final bool enabled; + final bool enableSuggestions; + final double cursorWidth; + final Radius cursorRadius; + final Color? cursorColor; + final Brightness? keyboardAppearance; + final EdgeInsets scrollPadding; + final bool enableInteractiveSelection; + + /// Creates a CupertinoTextFieldConfiguration + const CupertinoTextFieldConfiguration({ + this.controller, + this.focusNode, + this.decoration = _kDefaultRoundedBorderDecoration, + this.padding = const EdgeInsets.all(6.0), + this.placeholder, + this.prefix, + this.prefixMode = OverlayVisibilityMode.always, + this.suffix, + this.suffixMode = OverlayVisibilityMode.always, + this.clearButtonMode = OverlayVisibilityMode.never, + this.keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.textAlign = TextAlign.start, + this.autofocus = false, + this.obscureText = false, + this.autocorrect = true, + this.maxLines = 1, + this.minLines, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onTap, + this.onSubmitted, + this.inputFormatters, + this.enabled = true, + this.enableSuggestions = true, + this.cursorWidth = 2.0, + this.cursorRadius = const Radius.circular(2.0), + this.cursorColor, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.enableInteractiveSelection = true, + }); + + /// Copies the [CupertinoTextFieldConfiguration] and only changes the specified properties + CupertinoTextFieldConfiguration copyWith({ + TextEditingController? controller, + FocusNode? focusNode, + BoxDecoration? decoration, + EdgeInsetsGeometry? padding, + String? placeholder, + Widget? prefix, + OverlayVisibilityMode? prefixMode, + Widget? suffix, + OverlayVisibilityMode? suffixMode, + OverlayVisibilityMode? clearButtonMode, + TextInputType? keyboardType, + TextInputAction? textInputAction, + TextCapitalization? textCapitalization, + TextStyle? style, + TextAlign? textAlign, + bool? autofocus, + bool? obscureText, + bool? autocorrect, + int? maxLines, + int? minLines, + int? maxLength, + MaxLengthEnforcement? maxLengthEnforcement, + ValueChanged? onChanged, + VoidCallback? onEditingComplete, + GestureTapCallback? onTap, + ValueChanged? onSubmitted, + List? inputFormatters, + bool? enabled, + bool? enableSuggestions, + double? cursorWidth, + Radius? cursorRadius, + Color? cursorColor, + Brightness? keyboardAppearance, + EdgeInsets? scrollPadding, + bool? enableInteractiveSelection, + }) { + return CupertinoTextFieldConfiguration( + controller: controller ?? this.controller, + focusNode: focusNode ?? this.focusNode, + decoration: decoration ?? this.decoration, + padding: padding ?? this.padding, + placeholder: placeholder ?? this.placeholder, + prefix: prefix ?? this.prefix, + prefixMode: prefixMode ?? this.prefixMode, + suffix: suffix ?? this.suffix, + suffixMode: suffixMode ?? this.suffixMode, + clearButtonMode: clearButtonMode ?? this.clearButtonMode, + keyboardType: keyboardType ?? this.keyboardType, + textInputAction: textInputAction ?? this.textInputAction, + textCapitalization: textCapitalization ?? this.textCapitalization, + style: style ?? this.style, + textAlign: textAlign ?? this.textAlign, + autofocus: autofocus ?? this.autofocus, + obscureText: obscureText ?? this.obscureText, + autocorrect: autocorrect ?? this.autocorrect, + maxLines: maxLines ?? this.maxLines, + minLines: minLines ?? this.minLines, + maxLength: maxLength ?? this.maxLength, + maxLengthEnforcement: maxLengthEnforcement ?? this.maxLengthEnforcement, + onChanged: onChanged ?? this.onChanged, + onEditingComplete: onEditingComplete ?? this.onEditingComplete, + onTap: onTap ?? this.onTap, + onSubmitted: onSubmitted ?? this.onSubmitted, + inputFormatters: inputFormatters ?? this.inputFormatters, + enabled: enabled ?? this.enabled, + enableSuggestions: enableSuggestions ?? this.enableSuggestions, + cursorWidth: cursorWidth ?? this.cursorWidth, + cursorRadius: cursorRadius ?? this.cursorRadius, + cursorColor: cursorColor ?? this.cursorColor, + keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance, + scrollPadding: scrollPadding ?? this.scrollPadding, + enableInteractiveSelection: + enableInteractiveSelection ?? this.enableInteractiveSelection, + ); + } +} \ No newline at end of file diff --git a/lib/src/cupertino/field/cupertino_typeahead_field.dart b/lib/src/cupertino/field/cupertino_typeahead_field.dart new file mode 100644 index 00000000..7d57bfd6 --- /dev/null +++ b/lib/src/cupertino/field/cupertino_typeahead_field.dart @@ -0,0 +1,576 @@ +import 'dart:async'; +import 'dart:core'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:flutter_typeahead/src/cupertino/field/cupertino_text_field_configuration.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box_controller.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box_decoration.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_list.dart'; +import 'package:flutter_typeahead/src/typedef.dart'; +import 'package:flutter_typeahead/src/utils.dart'; + + +/// A [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) +/// that displays a list of suggestions as the user types +/// +/// See also: +/// +/// * [TypeAheadFormField], a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// implementation of [TypeAheadField] that allows the value to be saved, +/// validated, etc. +class CupertinoTypeAheadField extends StatefulWidget { + /// Called with the search pattern to get the search suggestions. + /// + /// This callback must not be null. It is be called by the TypeAhead widget + /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) + /// of suggestions either synchronously, or asynchronously (as the result of a + /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). + /// Typically, the list of suggestions should not contain more than 4 or 5 + /// entries. These entries will then be provided to [itemBuilder] to display + /// the suggestions. + /// + /// Example: + /// ```dart + /// suggestionsCallback: (pattern) async { + /// return await _getSuggestions(pattern); + /// } + /// ``` + final SuggestionsCallback suggestionsCallback; + + /// Called when a suggestion is tapped. + /// + /// This callback must not be null. It is called by the TypeAhead widget and + /// provided with the value of the tapped suggestion. + /// + /// For example, you might want to navigate to a specific view when the user + /// tabs a suggestion: + /// ```dart + /// onSuggestionSelected: (suggestion) { + /// Navigator.of(context).push(MaterialPageRoute( + /// builder: (context) => SearchResult( + /// searchItem: suggestion + /// ) + /// )); + /// } + /// ``` + /// + /// Or to set the value of the text field: + /// ```dart + /// onSuggestionSelected: (suggestion) { + /// _controller.text = suggestion['name']; + /// } + /// ``` + final SuggestionSelectionCallback onSuggestionSelected; + + /// Called for each suggestion returned by [suggestionsCallback] to build the + /// corresponding widget. + /// + /// This callback must not be null. It is called by the TypeAhead widget for + /// each suggestion, and expected to build a widget to display this + /// suggestion's info. For example: + /// + /// ```dart + /// itemBuilder: (context, suggestion) { + /// return Padding( + /// padding: const EdgeInsets.all(4.0), + /// child: Text( + /// suggestion, + /// ), + /// ); + /// } + /// ``` + final ItemBuilder itemBuilder; + + /// The decoration of the material sheet that contains the suggestions. + final CupertinoSuggestionsBoxDecoration suggestionsBoxDecoration; + + /// Used to control the `_SuggestionsBox`. Allows manual control to + /// open, close, toggle, or resize the `_SuggestionsBox`. + final CupertinoSuggestionsBoxController? suggestionsBoxController; + + /// The duration to wait after the user stops typing before calling + /// [suggestionsCallback] + /// + /// This is useful, because, if not set, a request for suggestions will be + /// sent for every character that the user types. + /// + /// This duration is set by default to 300 milliseconds + final Duration debounceDuration; + + /// Called when waiting for [suggestionsCallback] to return. + /// + /// It is expected to return a widget to display while waiting. + /// For example: + /// ```dart + /// (BuildContext context) { + /// return Text('Loading...'); + /// } + /// ``` + /// + /// If not specified, a [CupertinoActivityIndicator](https://docs.flutter.io/flutter/cupertino/CupertinoActivityIndicator-class.html) is shown + final WidgetBuilder? loadingBuilder; + + /// Called when [suggestionsCallback] returns an empty array. + /// + /// It is expected to return a widget to display when no suggestions are + /// avaiable. + /// For example: + /// ```dart + /// (BuildContext context) { + /// return Text('No Items Found!'); + /// } + /// ``` + /// + /// If not specified, a simple text is shown + final WidgetBuilder? noItemsFoundBuilder; + + /// Called when [suggestionsCallback] throws an exception. + /// + /// It is called with the error object, and expected to return a widget to + /// display when an exception is thrown + /// For example: + /// ```dart + /// (BuildContext context, error) { + /// return Text('$error'); + /// } + /// ``` + final ErrorBuilder? errorBuilder; + + /// Called to display animations when [suggestionsCallback] returns suggestions + /// + /// It is provided with the suggestions box instance and the animation + /// controller, and expected to return some animation that uses the controller + /// to display the suggestion box. + /// + /// For example: + /// ```dart + /// transitionBuilder: (context, suggestionsBox, animationController) { + /// return FadeTransition( + /// child: suggestionsBox, + /// opacity: CurvedAnimation( + /// parent: animationController, + /// curve: Curves.fastOutSlowIn + /// ), + /// ); + /// } + /// ``` + /// This argument is best used with [animationDuration] and [animationStart] + /// to fully control the animation. + /// + /// To fully remove the animation, just return `suggestionsBox` + /// + /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. + final AnimationTransitionBuilder? transitionBuilder; + + /// The duration that [transitionBuilder] animation takes. + /// + /// This argument is best used with [transitionBuilder] and [animationStart] + /// to fully control the animation. + /// + /// Defaults to 500 milliseconds. + final Duration animationDuration; + + /// Determine the [SuggestionBox]'s direction. + /// + /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] + /// and the [_SuggestionsList] will grow **down**. + /// + /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] + /// and the [_SuggestionsList] will grow **up**. + /// + /// [AxisDirection.left] and [AxisDirection.right] are not allowed. + final AxisDirection direction; + + /// The value at which the [transitionBuilder] animation starts. + /// + /// This argument is best used with [transitionBuilder] and [animationDuration] + /// to fully control the animation. + /// + /// Defaults to 0.25. + final double animationStart; + + /// The configuration of the [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) + /// that the TypeAhead widget displays + final CupertinoTextFieldConfiguration textFieldConfiguration; + + /// How far below the text field should the suggestions box be + /// + /// Defaults to 5.0 + final double suggestionsBoxVerticalOffset; + + /// If set to true, suggestions will be fetched immediately when the field is + /// added to the view. + /// + /// But the suggestions box will only be shown when the field receives focus. + /// To make the field receive focus immediately, you can set the `autofocus` + /// property in the [textFieldConfiguration] to true + /// + /// Defaults to false + final bool getImmediateSuggestions; + + /// If set to true, no loading box will be shown while suggestions are + /// being fetched. [loadingBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnLoading; + + /// If set to true, nothing will be shown if there are no results. + /// [noItemsFoundBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnEmpty; + + /// If set to true, nothing will be shown if there is an error. + /// [errorBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnError; + + /// If set to false, the suggestions box will stay opened after + /// the keyboard is closed. + /// + /// Defaults to true. + final bool hideSuggestionsOnKeyboardHide; + + /// If set to false, the suggestions box will show a circular + /// progress indicator when retrieving suggestions. + /// + /// Defaults to true. + final bool keepSuggestionsOnLoading; + + /// If set to true, the suggestions box will remain opened even after + /// selecting a suggestion. + /// + /// Note that if this is enabled, the only way + /// to close the suggestions box is either manually via the + /// `SuggestionsBoxController` or when the user closes the software + /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users + /// with a physical keyboard will be unable to close the + /// box without a manual way via `SuggestionsBoxController`. + /// + /// Defaults to false. + final bool keepSuggestionsOnSuggestionSelected; + + /// If set to true, in the case where the suggestions box has less than + /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis + /// will be temporarily flipped if there's more room available in the opposite + /// direction. + /// + /// Defaults to false + final bool autoFlipDirection; + + /// If set to false, suggestion list will not be reversed according to the + /// [autoFlipDirection] property. + /// + /// Defaults to true. + final bool autoFlipListDirection; + + /// The minimum number of characters which must be entered before + /// [suggestionsCallback] is triggered. + /// + /// Defaults to 0. + final int minCharsForSuggestions; + + /// If set to true and if the user scrolls through the suggestion list, hide the keyboard automatically. + /// If set to false, the keyboard remains visible. + /// Throws an exception, if hideKeyboardOnDrag and hideSuggestionsOnKeyboardHide are both set to true as + /// they are mutual exclusive. + /// + /// Defaults to false + final bool hideKeyboardOnDrag; + + /// Creates a [CupertinoTypeAheadField] + CupertinoTypeAheadField({ + Key? key, + required this.suggestionsCallback, + required this.itemBuilder, + required this.onSuggestionSelected, + this.textFieldConfiguration = const CupertinoTextFieldConfiguration(), + this.suggestionsBoxDecoration = const CupertinoSuggestionsBoxDecoration(), + this.debounceDuration = const Duration(milliseconds: 300), + this.suggestionsBoxController, + this.loadingBuilder, + this.noItemsFoundBuilder, + this.errorBuilder, + this.transitionBuilder, + this.animationStart = 0.25, + this.animationDuration = const Duration(milliseconds: 500), + this.getImmediateSuggestions = false, + this.suggestionsBoxVerticalOffset = 5.0, + this.direction = AxisDirection.down, + this.hideOnLoading = false, + this.hideOnEmpty = false, + this.hideOnError = false, + this.hideSuggestionsOnKeyboardHide = true, + this.keepSuggestionsOnLoading = true, + this.keepSuggestionsOnSuggestionSelected = false, + this.autoFlipDirection = false, + this.autoFlipListDirection = true, + this.minCharsForSuggestions = 0, + this.hideKeyboardOnDrag = true, + }) : assert(animationStart >= 0.0 && animationStart <= 1.0), + assert( + direction == AxisDirection.down || direction == AxisDirection.up), + assert(minCharsForSuggestions >= 0), + assert(!hideKeyboardOnDrag || + hideKeyboardOnDrag && !hideSuggestionsOnKeyboardHide), + super(key: key); + + @override + _CupertinoTypeAheadFieldState createState() => + _CupertinoTypeAheadFieldState(); +} + + +class _CupertinoTypeAheadFieldState extends State> + with WidgetsBindingObserver { + FocusNode? _focusNode; + TextEditingController? _textEditingController; + CupertinoSuggestionsBox? _suggestionsBox; + + TextEditingController? get _effectiveController => + widget.textFieldConfiguration.controller ?? _textEditingController; + FocusNode? get _effectiveFocusNode => + widget.textFieldConfiguration.focusNode ?? _focusNode; + late VoidCallback _focusNodeListener; + + final LayerLink _layerLink = LayerLink(); + + // Timer that resizes the suggestion box on each tick. Only active when the user is scrolling. + Timer? _resizeOnScrollTimer; + // The rate at which the suggestion box will resize when the user is scrolling + final Duration _resizeOnScrollRefreshRate = const Duration(milliseconds: 500); + // Will have a value if the typeahead is inside a scrollable widget + ScrollPosition? _scrollPosition; + + // Keyboard detection + final Stream? _keyboardVisibility = + (supportedPlatform) ? KeyboardVisibilityController().onChange : null; + late StreamSubscription? _keyboardVisibilitySubscription; + + @override + void didChangeMetrics() { + // Catch keyboard event and orientation change; resize suggestions list + this._suggestionsBox!.onChangeMetrics(); + } + + @override + void dispose() { + this._suggestionsBox!.close(); + this._suggestionsBox!.widgetMounted = false; + WidgetsBinding.instance.removeObserver(this); + _keyboardVisibilitySubscription?.cancel(); + _effectiveFocusNode!.removeListener(_focusNodeListener); + _focusNode?.dispose(); + _resizeOnScrollTimer?.cancel(); + _scrollPosition?.removeListener(_scrollResizeListener); + _textEditingController?.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + if (widget.textFieldConfiguration.controller == null) { + this._textEditingController = TextEditingController(); + } + + if (widget.textFieldConfiguration.focusNode == null) { + this._focusNode = FocusNode(); + } + + this._suggestionsBox = CupertinoSuggestionsBox( + context, + widget.direction, + widget.autoFlipDirection, + widget.autoFlipListDirection, + ); + + widget.suggestionsBoxController?.suggestionsBox = this._suggestionsBox; + widget.suggestionsBoxController?.effectiveFocusNode = + this._effectiveFocusNode; + + this._focusNodeListener = () { + if (_effectiveFocusNode!.hasFocus) { + this._suggestionsBox!.open(); + } else { + this._suggestionsBox!.close(); + } + }; + + this._effectiveFocusNode!.addListener(_focusNodeListener); + + // hide suggestions box on keyboard closed + this._keyboardVisibilitySubscription = + _keyboardVisibility?.listen((bool isVisible) { + if (widget.hideSuggestionsOnKeyboardHide && !isVisible) { + _effectiveFocusNode!.unfocus(); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((duration) { + if (mounted) { + this._initOverlayEntry(); + // calculate initial suggestions list size + this._suggestionsBox!.resize(); + + // in case we already missed the focus event + if (this._effectiveFocusNode!.hasFocus) { + this._suggestionsBox!.open(); + } + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scrollableState = Scrollable.maybeOf(context); + if (scrollableState != null) { + // The TypeAheadField is inside a scrollable widget + _scrollPosition = scrollableState.position; + + _scrollPosition!.removeListener(_scrollResizeListener); + _scrollPosition!.isScrollingNotifier.addListener(_scrollResizeListener); + } + } + + void _scrollResizeListener() { + bool isScrolling = _scrollPosition!.isScrollingNotifier.value; + _resizeOnScrollTimer?.cancel(); + if (isScrolling) { + // Scroll started + _resizeOnScrollTimer = + Timer.periodic(_resizeOnScrollRefreshRate, (timer) { + _suggestionsBox!.resize(); + }); + } else { + // Scroll finished + _suggestionsBox!.resize(); + } + } + + void _initOverlayEntry() { + this._suggestionsBox!.overlayEntry = OverlayEntry(builder: (context) { + final suggestionsList = CupertinoSuggestionsList( + suggestionsBox: _suggestionsBox, + decoration: widget.suggestionsBoxDecoration, + debounceDuration: widget.debounceDuration, + controller: this._effectiveController, + loadingBuilder: widget.loadingBuilder, + noItemsFoundBuilder: widget.noItemsFoundBuilder, + errorBuilder: widget.errorBuilder, + transitionBuilder: widget.transitionBuilder, + suggestionsCallback: widget.suggestionsCallback, + animationDuration: widget.animationDuration, + animationStart: widget.animationStart, + getImmediateSuggestions: widget.getImmediateSuggestions, + onSuggestionSelected: (T selection) { + if (!widget.keepSuggestionsOnSuggestionSelected) { + this._effectiveFocusNode!.unfocus(); + this._suggestionsBox!.close(); + } + widget.onSuggestionSelected(selection); + }, + itemBuilder: widget.itemBuilder, + direction: _suggestionsBox!.direction, + hideOnLoading: widget.hideOnLoading, + hideOnEmpty: widget.hideOnEmpty, + hideOnError: widget.hideOnError, + keepSuggestionsOnLoading: widget.keepSuggestionsOnLoading, + minCharsForSuggestions: widget.minCharsForSuggestions, + hideKeyboardOnDrag: widget.hideKeyboardOnDrag, + ); + + double w = _suggestionsBox!.textBoxWidth; + if (widget.suggestionsBoxDecoration.constraints != null) { + if (widget.suggestionsBoxDecoration.constraints!.minWidth != 0.0 && + widget.suggestionsBoxDecoration.constraints!.maxWidth != + double.infinity) { + w = (widget.suggestionsBoxDecoration.constraints!.minWidth + + widget.suggestionsBoxDecoration.constraints!.maxWidth) / + 2; + } else if (widget.suggestionsBoxDecoration.constraints!.minWidth != + 0.0 && + widget.suggestionsBoxDecoration.constraints!.minWidth > w) { + w = widget.suggestionsBoxDecoration.constraints!.minWidth; + } else if (widget.suggestionsBoxDecoration.constraints!.maxWidth != + double.infinity && + widget.suggestionsBoxDecoration.constraints!.maxWidth < w) { + w = widget.suggestionsBoxDecoration.constraints!.maxWidth; + } + } + + return Positioned( + width: w, + child: CompositedTransformFollower( + link: this._layerLink, + showWhenUnlinked: false, + offset: Offset( + widget.suggestionsBoxDecoration.offsetX, + _suggestionsBox!.direction == AxisDirection.down + ? _suggestionsBox!.textBoxHeight + + widget.suggestionsBoxVerticalOffset + : _suggestionsBox!.directionUpOffset), + child: _suggestionsBox!.direction == AxisDirection.down + ? suggestionsList + : FractionalTranslation( + translation: + Offset(0.0, -1.0), // visually flips list to go up + child: suggestionsList, + ), + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: this._layerLink, + child: CupertinoTextField( + controller: this._effectiveController, + focusNode: this._effectiveFocusNode, + decoration: widget.textFieldConfiguration.decoration, + padding: widget.textFieldConfiguration.padding, + placeholder: widget.textFieldConfiguration.placeholder, + prefix: widget.textFieldConfiguration.prefix, + prefixMode: widget.textFieldConfiguration.prefixMode, + suffix: widget.textFieldConfiguration.suffix, + suffixMode: widget.textFieldConfiguration.suffixMode, + clearButtonMode: widget.textFieldConfiguration.clearButtonMode, + keyboardType: widget.textFieldConfiguration.keyboardType, + textInputAction: widget.textFieldConfiguration.textInputAction, + textCapitalization: widget.textFieldConfiguration.textCapitalization, + style: widget.textFieldConfiguration.style, + textAlign: widget.textFieldConfiguration.textAlign, + autofocus: widget.textFieldConfiguration.autofocus, + obscureText: widget.textFieldConfiguration.obscureText, + autocorrect: widget.textFieldConfiguration.autocorrect, + maxLines: widget.textFieldConfiguration.maxLines, + minLines: widget.textFieldConfiguration.minLines, + maxLength: widget.textFieldConfiguration.maxLength, + maxLengthEnforcement: + widget.textFieldConfiguration.maxLengthEnforcement, + onChanged: widget.textFieldConfiguration.onChanged, + onEditingComplete: widget.textFieldConfiguration.onEditingComplete, + onTap: widget.textFieldConfiguration.onTap, +// onTapOutside: (_){}, + onSubmitted: widget.textFieldConfiguration.onSubmitted, + inputFormatters: widget.textFieldConfiguration.inputFormatters, + enabled: widget.textFieldConfiguration.enabled, + cursorWidth: widget.textFieldConfiguration.cursorWidth, + cursorRadius: widget.textFieldConfiguration.cursorRadius, + cursorColor: widget.textFieldConfiguration.cursorColor, + keyboardAppearance: widget.textFieldConfiguration.keyboardAppearance, + scrollPadding: widget.textFieldConfiguration.scrollPadding, + enableInteractiveSelection: + widget.textFieldConfiguration.enableInteractiveSelection, + ), + ); + } +} \ No newline at end of file diff --git a/lib/src/cupertino/field/cupertino_typeahead_form_field.dart b/lib/src/cupertino/field/cupertino_typeahead_form_field.dart new file mode 100644 index 00000000..d0ac9295 --- /dev/null +++ b/lib/src/cupertino/field/cupertino_typeahead_form_field.dart @@ -0,0 +1,190 @@ +import 'dart:core'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_typeahead/src/cupertino/field/cupertino_text_field_configuration.dart'; +import 'package:flutter_typeahead/src/cupertino/field/cupertino_typeahead_field.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box_controller.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box_decoration.dart'; +import 'package:flutter_typeahead/src/typedef.dart'; + + +/// A [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// implementation of [TypeAheadField], that allows the value to be saved, +/// validated, etc. +/// +/// See also: +/// +/// * [TypeAheadField], A [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) +/// that displays a list of suggestions as the user types +class CupertinoTypeAheadFormField extends FormField { + /// The configuration of the [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) + /// that the TypeAhead widget displays + final CupertinoTextFieldConfiguration textFieldConfiguration; + + /// Creates a [CupertinoTypeAheadFormField] + CupertinoTypeAheadFormField( + {Key? key, + String? initialValue, + bool getImmediateSuggestions = false, + @Deprecated('Use autoValidateMode parameter which provides more specific ' + 'behavior related to auto validation. ' + 'This feature was deprecated after Flutter v1.19.0.') + bool autovalidate = false, + bool enabled = true, + AutovalidateMode? autovalidateMode, + FormFieldSetter? onSaved, + FormFieldValidator? validator, + ErrorBuilder? errorBuilder, + WidgetBuilder? noItemsFoundBuilder, + WidgetBuilder? loadingBuilder, + Duration debounceDuration = const Duration(milliseconds: 300), + CupertinoSuggestionsBoxDecoration suggestionsBoxDecoration = + const CupertinoSuggestionsBoxDecoration(), + CupertinoSuggestionsBoxController? suggestionsBoxController, + required SuggestionSelectionCallback onSuggestionSelected, + required ItemBuilder itemBuilder, + required SuggestionsCallback suggestionsCallback, + double suggestionsBoxVerticalOffset = 5.0, + this.textFieldConfiguration = const CupertinoTextFieldConfiguration(), + AnimationTransitionBuilder? transitionBuilder, + Duration animationDuration = const Duration(milliseconds: 500), + double animationStart = 0.25, + AxisDirection direction = AxisDirection.down, + bool hideOnLoading = false, + bool hideOnEmpty = false, + bool hideOnError = false, + bool hideSuggestionsOnKeyboardHide = true, + bool keepSuggestionsOnLoading = true, + bool keepSuggestionsOnSuggestionSelected = false, + bool autoFlipDirection = false, + bool autoFlipListDirection = true, + int minCharsForSuggestions = 0, + bool hideKeyboardOnDrag = false}) + : assert( + initialValue == null || textFieldConfiguration.controller == null), + assert(minCharsForSuggestions >= 0), + super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: textFieldConfiguration.controller != null + ? textFieldConfiguration.controller!.text + : (initialValue ?? ''), + enabled: enabled, + autovalidateMode: autovalidateMode, + builder: (FormFieldState field) { + final CupertinoTypeAheadFormFieldState state = + field as CupertinoTypeAheadFormFieldState; + + return CupertinoTypeAheadField( + getImmediateSuggestions: getImmediateSuggestions, + transitionBuilder: transitionBuilder, + errorBuilder: errorBuilder, + noItemsFoundBuilder: noItemsFoundBuilder, + loadingBuilder: loadingBuilder, + debounceDuration: debounceDuration, + suggestionsBoxDecoration: suggestionsBoxDecoration, + suggestionsBoxController: suggestionsBoxController, + textFieldConfiguration: textFieldConfiguration.copyWith( + onChanged: (text) { + state.didChange(text); + textFieldConfiguration.onChanged?.call(text); + }, + controller: state._effectiveController, + ), + suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, + onSuggestionSelected: onSuggestionSelected, + itemBuilder: itemBuilder, + suggestionsCallback: suggestionsCallback, + animationStart: animationStart, + animationDuration: animationDuration, + direction: direction, + hideOnLoading: hideOnLoading, + hideOnEmpty: hideOnEmpty, + hideOnError: hideOnError, + hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, + keepSuggestionsOnLoading: keepSuggestionsOnLoading, + keepSuggestionsOnSuggestionSelected: + keepSuggestionsOnSuggestionSelected, + autoFlipDirection: autoFlipDirection, + autoFlipListDirection: autoFlipListDirection, + minCharsForSuggestions: minCharsForSuggestions, + hideKeyboardOnDrag: hideKeyboardOnDrag, + ); + }); + + @override + CupertinoTypeAheadFormFieldState createState() => + CupertinoTypeAheadFormFieldState(); +} + +class CupertinoTypeAheadFormFieldState extends FormFieldState { + TextEditingController? _controller; + + TextEditingController? get _effectiveController => + widget.textFieldConfiguration.controller ?? _controller; + + @override + CupertinoTypeAheadFormField get widget => + super.widget as CupertinoTypeAheadFormField; + + @override + void initState() { + super.initState(); + if (widget.textFieldConfiguration.controller == null) { + _controller = TextEditingController(text: widget.initialValue); + } else { + widget.textFieldConfiguration.controller! + .addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(CupertinoTypeAheadFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.textFieldConfiguration.controller != + oldWidget.textFieldConfiguration.controller) { + oldWidget.textFieldConfiguration.controller + ?.removeListener(_handleControllerChanged); + widget.textFieldConfiguration.controller + ?.addListener(_handleControllerChanged); + + if (oldWidget.textFieldConfiguration.controller != null && + widget.textFieldConfiguration.controller == null) + _controller = TextEditingController.fromValue( + oldWidget.textFieldConfiguration.controller!.value); + if (widget.textFieldConfiguration.controller != null) { + setValue(widget.textFieldConfiguration.controller!.text); + if (oldWidget.textFieldConfiguration.controller == null) + _controller = null; + } + } + } + + @override + void dispose() { + widget.textFieldConfiguration.controller + ?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveController!.text = widget.initialValue!; + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController!.text != value) + didChange(_effectiveController!.text); + } +} \ No newline at end of file diff --git a/lib/src/cupertino/suggestions_box/cupertino_suggestions_box.dart b/lib/src/cupertino/suggestions_box/cupertino_suggestions_box.dart new file mode 100644 index 00000000..0ca2ce7f --- /dev/null +++ b/lib/src/cupertino/suggestions_box/cupertino_suggestions_box.dart @@ -0,0 +1,212 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_typeahead/src/cupertino/field/cupertino_typeahead_field.dart'; + +class CupertinoSuggestionsBox { + static const int waitMetricsTimeoutMillis = 1000; + static const double minOverlaySpace = 64.0; + + final BuildContext context; + final AxisDirection desiredDirection; + final bool autoFlipDirection; + final bool autoFlipListDirection; + + OverlayEntry? overlayEntry; + AxisDirection direction; + + bool isOpened = false; + bool widgetMounted = true; + double maxHeight = 300.0; + double textBoxWidth = 100.0; + double textBoxHeight = 100.0; + late double directionUpOffset; + + CupertinoSuggestionsBox( + this.context, + this.direction, + this.autoFlipDirection, + this.autoFlipListDirection, + ) : desiredDirection = direction; + + void open() { + if (this.isOpened) return; + assert(this.overlayEntry != null); + resize(); + Overlay.of(context).insert(this.overlayEntry!); + this.isOpened = true; + } + + void close() { + if (!this.isOpened) return; + assert(this.overlayEntry != null); + this.overlayEntry!.remove(); + this.isOpened = false; + } + + void toggle() { + if (this.isOpened) { + this.close(); + } else { + this.open(); + } + } + + MediaQuery? _findRootMediaQuery() { + MediaQuery? rootMediaQuery; + context.visitAncestorElements((element) { + if (element.widget is MediaQuery) { + rootMediaQuery = element.widget as MediaQuery; + } + return true; + }); + + return rootMediaQuery; + } + + /// Delays until the keyboard has toggled or the orientation has fully changed + Future _waitChangeMetrics() async { + if (widgetMounted) { + // initial viewInsets which are before the keyboard is toggled + EdgeInsets initial = MediaQuery.of(context).viewInsets; + // initial MediaQuery for orientation change + MediaQuery? initialRootMediaQuery = _findRootMediaQuery(); + + int timer = 0; + // viewInsets or MediaQuery have changed once keyboard has toggled or orientation has changed + while (widgetMounted && timer < waitMetricsTimeoutMillis) { + // TODO: reduce delay if showDialog ever exposes detection of animation end + await Future.delayed(const Duration(milliseconds: 170)); + timer += 170; + + if (widgetMounted && + (MediaQuery.of(context).viewInsets != initial || + _findRootMediaQuery() != initialRootMediaQuery)) { + return true; + } + } + } + + return false; + } + + void resize() { + // check to see if widget is still mounted + // user may have closed the widget with the keyboard still open + if (widgetMounted) { + _adjustMaxHeightAndOrientation(); + overlayEntry!.markNeedsBuild(); + } + } + + // See if there's enough room in the desired direction for the overlay to display + // correctly. If not, try the opposite direction if things look more roomy there + void _adjustMaxHeightAndOrientation() { + CupertinoTypeAheadField widget = context.widget as CupertinoTypeAheadField; + + RenderBox box = context.findRenderObject() as RenderBox; + textBoxWidth = box.size.width; + textBoxHeight = box.size.height; + + // top of text box + double textBoxAbsY = box.localToGlobal(Offset.zero).dy; + + // height of window + double windowHeight = MediaQuery.of(context).size.height; + + // we need to find the root MediaQuery for the unsafe area height + // we cannot use BuildContext.ancestorWidgetOfExactType because + // widgets like SafeArea creates a new MediaQuery with the padding removed + MediaQuery rootMediaQuery = _findRootMediaQuery()!; + + // height of keyboard + double keyboardHeight = rootMediaQuery.data.viewInsets.bottom; + + double maxHDesired = _calculateMaxHeight(desiredDirection, box, widget, + windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); + + // if there's enough room in the desired direction, update the direction and the max height + if (maxHDesired >= minOverlaySpace || !autoFlipDirection) { + direction = desiredDirection; + maxHeight = maxHDesired; + } else { + // There's not enough room in the desired direction so see how much room is in the opposite direction + AxisDirection flipped = flipAxisDirection(desiredDirection); + double maxHFlipped = _calculateMaxHeight(flipped, box, widget, + windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); + + // if there's more room in this opposite direction, update the direction and maxHeight + if (maxHFlipped > maxHDesired) { + direction = flipped; + maxHeight = maxHFlipped; + } + } + + if (maxHeight < 0) maxHeight = 0; + } + + double _calculateMaxHeight( + AxisDirection direction, + RenderBox box, + CupertinoTypeAheadField widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + return direction == AxisDirection.down + ? _calculateMaxHeightDown(box, widget, windowHeight, rootMediaQuery, + keyboardHeight, textBoxAbsY) + : _calculateMaxHeightUp(box, widget, windowHeight, rootMediaQuery, + keyboardHeight, textBoxAbsY); + } + + double _calculateMaxHeightDown( + RenderBox box, + CupertinoTypeAheadField widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + // unsafe area, ie: iPhone X 'home button' + // keyboardHeight includes unsafeAreaHeight, if keyboard is showing, set to 0 + double unsafeAreaHeight = + keyboardHeight == 0 ? rootMediaQuery.data.padding.bottom : 0; + + return windowHeight - + keyboardHeight - + unsafeAreaHeight - + textBoxHeight - + textBoxAbsY - + 2 * widget.suggestionsBoxVerticalOffset; + } + + double _calculateMaxHeightUp( + RenderBox box, + CupertinoTypeAheadField widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + // recalculate keyboard absolute y value + double keyboardAbsY = windowHeight - keyboardHeight; + + directionUpOffset = textBoxAbsY > keyboardAbsY + ? keyboardAbsY - textBoxAbsY - widget.suggestionsBoxVerticalOffset + : -widget.suggestionsBoxVerticalOffset; + + // unsafe area, ie: iPhone X notch + double unsafeAreaHeight = rootMediaQuery.data.padding.top; + + return textBoxAbsY > keyboardAbsY + ? keyboardAbsY - + unsafeAreaHeight - + 2 * widget.suggestionsBoxVerticalOffset + : textBoxAbsY - + unsafeAreaHeight - + 2 * widget.suggestionsBoxVerticalOffset; + } + + Future onChangeMetrics() async { + if (await _waitChangeMetrics()) { + resize(); + } + } +} \ No newline at end of file diff --git a/lib/src/cupertino/suggestions_box/cupertino_suggestions_box_controller.dart b/lib/src/cupertino/suggestions_box/cupertino_suggestions_box_controller.dart new file mode 100644 index 00000000..07ce7d57 --- /dev/null +++ b/lib/src/cupertino/suggestions_box/cupertino_suggestions_box_controller.dart @@ -0,0 +1,33 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box.dart'; + +/// Supply an instance of this class to the [TypeAhead.suggestionsBoxController] +/// property to manually control the suggestions box +class CupertinoSuggestionsBoxController { + CupertinoSuggestionsBox? suggestionsBox; + FocusNode? effectiveFocusNode; + + /// Opens the suggestions box + void open() { + effectiveFocusNode!.requestFocus(); + } + + /// Closes the suggestions box + void close() { + effectiveFocusNode!.unfocus(); + } + + /// Opens the suggestions box if closed and vice-versa + void toggle() { + if (suggestionsBox!.isOpened) { + close(); + } else { + open(); + } + } + + /// Recalculates the height of the suggestions box + void resize() { + suggestionsBox!.resize(); + } +} \ No newline at end of file diff --git a/lib/src/cupertino/suggestions_box/cupertino_suggestions_box_decoration.dart b/lib/src/cupertino/suggestions_box/cupertino_suggestions_box_decoration.dart new file mode 100644 index 00000000..9c0f223c --- /dev/null +++ b/lib/src/cupertino/suggestions_box/cupertino_suggestions_box_decoration.dart @@ -0,0 +1,26 @@ +import 'package:flutter/cupertino.dart'; + +/// Supply an instance of this class to the [TypeAhead.suggestionsBoxDecoration] +/// property to configure the suggestions box decoration +class CupertinoSuggestionsBoxDecoration { + /// Defines if a scrollbar will be displayed or not. + final bool hasScrollbar; + + /// The constraints to be applied to the suggestions box + final BoxConstraints? constraints; + final Color? color; + final BoxBorder? border; + final BorderRadiusGeometry? borderRadius; + + /// Adds an offset to the suggestions box + final double offsetX; + + /// Creates a [CupertinoSuggestionsBoxDecoration] + const CupertinoSuggestionsBoxDecoration( + {this.hasScrollbar = true, + this.constraints, + this.color, + this.border, + this.borderRadius, + this.offsetX = 0.0}); +} \ No newline at end of file diff --git a/lib/src/cupertino/suggestions_box/cupertino_suggestions_list.dart b/lib/src/cupertino/suggestions_box/cupertino_suggestions_list.dart new file mode 100644 index 00000000..c3fc96a2 --- /dev/null +++ b/lib/src/cupertino/suggestions_box/cupertino_suggestions_list.dart @@ -0,0 +1,376 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box.dart'; +import 'package:flutter_typeahead/src/cupertino/suggestions_box/cupertino_suggestions_box_decoration.dart'; +import 'package:flutter_typeahead/src/typedef.dart'; + +class CupertinoSuggestionsList extends StatefulWidget { + final CupertinoSuggestionsBox? suggestionsBox; + final TextEditingController? controller; + final bool getImmediateSuggestions; + final SuggestionSelectionCallback? onSuggestionSelected; + final SuggestionsCallback? suggestionsCallback; + final ItemBuilder? itemBuilder; + final CupertinoSuggestionsBoxDecoration? decoration; + final Duration? debounceDuration; + final WidgetBuilder? loadingBuilder; + final WidgetBuilder? noItemsFoundBuilder; + final ErrorBuilder? errorBuilder; + final AnimationTransitionBuilder? transitionBuilder; + final Duration? animationDuration; + final double? animationStart; + final AxisDirection? direction; + final bool? hideOnLoading; + final bool? hideOnEmpty; + final bool? hideOnError; + final bool? keepSuggestionsOnLoading; + final int? minCharsForSuggestions; + final bool hideKeyboardOnDrag; + + CupertinoSuggestionsList({ + required this.suggestionsBox, + this.controller, + this.getImmediateSuggestions = false, + this.onSuggestionSelected, + this.suggestionsCallback, + this.itemBuilder, + this.decoration, + this.debounceDuration, + this.loadingBuilder, + this.noItemsFoundBuilder, + this.errorBuilder, + this.transitionBuilder, + this.animationDuration, + this.animationStart, + this.direction, + this.hideOnLoading, + this.hideOnEmpty, + this.hideOnError, + this.keepSuggestionsOnLoading, + this.minCharsForSuggestions, + this.hideKeyboardOnDrag = false, + }); + + @override + _CupertinoSuggestionsListState createState() => _CupertinoSuggestionsListState(); +} + +class _CupertinoSuggestionsListState extends State> + with SingleTickerProviderStateMixin { + Iterable? _suggestions; + late bool _suggestionsValid; + late VoidCallback _controllerListener; + Timer? _debounceTimer; + bool? _isLoading, _isQueued; + Object? _error; + AnimationController? _animationController; + String? _lastTextValue; + + @override + void didUpdateWidget(CupertinoSuggestionsList oldWidget) { + super.didUpdateWidget(oldWidget); + _getSuggestions(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _getSuggestions(); + } + + @override + void initState() { + super.initState(); + + this._animationController = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + + this._suggestionsValid = widget.minCharsForSuggestions! > 0 ? true : false; + this._isLoading = false; + this._isQueued = false; + this._lastTextValue = widget.controller!.text; + + if (widget.getImmediateSuggestions) { + this._getSuggestions(); + } + + this._controllerListener = () { + // If we came here because of a change in selected text, not because of + // actual change in text + if (widget.controller!.text == this._lastTextValue) return; + + this._lastTextValue = widget.controller!.text; + + this._debounceTimer?.cancel(); + if (widget.controller!.text.length < widget.minCharsForSuggestions!) { + if (mounted) { + setState(() { + _isLoading = false; + _suggestions = null; + _suggestionsValid = true; + }); + } + return; + } else { + this._debounceTimer = Timer(widget.debounceDuration!, () async { + if (this._debounceTimer!.isActive) return; + if (_isLoading!) { + _isQueued = true; + return; + } + + await this.invalidateSuggestions(); + while (_isQueued!) { + _isQueued = false; + await this.invalidateSuggestions(); + } + }); + } + }; + + widget.controller!.addListener(this._controllerListener); + } + + Future invalidateSuggestions() async { + _suggestionsValid = false; + _getSuggestions(); + } + + Future _getSuggestions() async { + if (_suggestionsValid) return; + _suggestionsValid = true; + + if (mounted) { + setState(() { + this._animationController!.forward(from: 1.0); + + this._isLoading = true; + this._error = null; + }); + + Iterable? suggestions; + Object? error; + + try { + suggestions = + await widget.suggestionsCallback!(widget.controller!.text); + } catch (e) { + error = e; + } + + if (this.mounted) { + // if it wasn't removed in the meantime + setState(() { + double? animationStart = widget.animationStart; + // allow suggestionsCallback to return null and not throw error here + if (error != null || suggestions?.isEmpty == true) { + animationStart = 1.0; + } + this._animationController!.forward(from: animationStart); + + this._error = error; + this._isLoading = false; + this._suggestions = suggestions; + }); + } + } + } + + @override + void dispose() { + _animationController!.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bool isEmpty = + this._suggestions?.length == 0 && widget.controller!.text == ""; + if ((this._suggestions == null || isEmpty) && this._isLoading == false) + return Container(); + + Widget child; + if (this._isLoading!) { + if (widget.hideOnLoading!) { + child = Container(height: 0); + } else { + child = createLoadingWidget(); + } + } else if (this._error != null) { + if (widget.hideOnError!) { + child = Container(height: 0); + } else { + child = createErrorWidget(); + } + } else if (this._suggestions!.isEmpty) { + if (widget.hideOnEmpty!) { + child = Container(height: 0); + } else { + child = createNoItemsFoundWidget(); + } + } else { + child = createSuggestionsWidget(); + } + + var animationChild = widget.transitionBuilder != null + ? widget.transitionBuilder!(context, child, this._animationController) + : SizeTransition( + axisAlignment: -1.0, + sizeFactor: CurvedAnimation( + parent: this._animationController!, + curve: Curves.fastOutSlowIn), + child: child, + ); + + BoxConstraints constraints; + if (widget.decoration!.constraints == null) { + constraints = BoxConstraints( + maxHeight: widget.suggestionsBox!.maxHeight, + ); + } else { + double maxHeight = min(widget.decoration!.constraints!.maxHeight, + widget.suggestionsBox!.maxHeight); + constraints = widget.decoration!.constraints!.copyWith( + minHeight: min(widget.decoration!.constraints!.minHeight, maxHeight), + maxHeight: maxHeight, + ); + } + + return ConstrainedBox( + constraints: constraints, + child: animationChild, + ); + } + + Widget createLoadingWidget() { + Widget child; + + if (widget.keepSuggestionsOnLoading! && this._suggestions != null) { + if (this._suggestions!.isEmpty) { + child = createNoItemsFoundWidget(); + } else { + child = createSuggestionsWidget(); + } + } else { + child = widget.loadingBuilder != null + ? widget.loadingBuilder!(context) + : Container( + decoration: BoxDecoration( + color: CupertinoColors.white, + border: Border.all( + color: CupertinoColors.extraLightBackgroundGray, + width: 1.0, + ), + ), + child: Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: CupertinoActivityIndicator(), + ), + ), + ); + } + + return child; + } + + Widget createErrorWidget() { + return widget.errorBuilder != null + ? widget.errorBuilder!(context, this._error) + : Container( + decoration: BoxDecoration( + color: CupertinoColors.white, + border: Border.all( + color: CupertinoColors.extraLightBackgroundGray, + width: 1.0, + ), + ), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + 'Error: ${this._error}', + textAlign: TextAlign.start, + style: TextStyle( + color: CupertinoColors.destructiveRed, + fontSize: 18.0, + ), + ), + ), + ); + } + + Widget createNoItemsFoundWidget() { + return widget.noItemsFoundBuilder != null + ? widget.noItemsFoundBuilder!(context) + : Container( + decoration: BoxDecoration( + color: CupertinoColors.white, + border: Border.all( + color: CupertinoColors.extraLightBackgroundGray, + width: 1.0, + ), + ), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Text( + 'No Items Found!', + textAlign: TextAlign.start, + style: TextStyle( + color: CupertinoColors.inactiveGray, + fontSize: 18.0, + ), + ), + ), + ); + } + + Widget createSuggestionsWidget() { + Widget child = Container( + decoration: BoxDecoration( + color: widget.decoration!.color != null + ? widget.decoration!.color + : CupertinoColors.white, + border: widget.decoration!.border != null + ? widget.decoration!.border + : Border.all( + color: CupertinoColors.extraLightBackgroundGray, + width: 1.0, + ), + borderRadius: widget.decoration!.borderRadius != null + ? widget.decoration!.borderRadius + : null, + ), + child: ListView( + padding: EdgeInsets.zero, + primary: false, + shrinkWrap: true, + keyboardDismissBehavior: widget.hideKeyboardOnDrag + ? ScrollViewKeyboardDismissBehavior.onDrag + : ScrollViewKeyboardDismissBehavior.manual, + reverse: widget.suggestionsBox!.direction == AxisDirection.down + ? false + : widget.suggestionsBox!.autoFlipListDirection, + children: this._suggestions!.map((T suggestion) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + child: widget.itemBuilder!(context, suggestion), + onTap: () { + widget.onSuggestionSelected!(suggestion); + }, + ); + }).toList(), + ), + ); + + if (widget.decoration!.hasScrollbar) { + child = CupertinoScrollbar(child: child); + } + + return child; + } +} \ No newline at end of file diff --git a/lib/src/cupertino_flutter_typeahead.dart b/lib/src/cupertino_flutter_typeahead.dart deleted file mode 100644 index d9114206..00000000 --- a/lib/src/cupertino_flutter_typeahead.dart +++ /dev/null @@ -1,1586 +0,0 @@ -/// # Flutter TypeAhead -/// A TypeAhead widget for Flutter, where you can show suggestions to -/// users as they type -/// -/// ## Features -/// * Shows suggestions in an overlay that floats on top of other widgets -/// * Allows you to specify what the suggestions will look like through a -/// builder function -/// * Allows you to specify what happens when the user taps a suggestion -/// * Accepts all the parameters that traditional TextFields accept, like -/// decoration, custom TextEditingController, text styling, etc. -/// * Provides two versions, a normal version and a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) -/// version that accepts validation, submitting, etc. -/// * Provides high customizability; you can customize the suggestion box decoration, -/// the loading bar, the animation, the debounce duration, etc. -import 'dart:async'; -import 'dart:core'; -import 'dart:math'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; - -import 'typedef.dart'; -import 'utils.dart'; - -// Cupertino BoxDecoration taken from flutter/lib/src/cupertino/text_field.dart -const BorderSide _kDefaultRoundedBorderSide = BorderSide( - color: CupertinoDynamicColor.withBrightness( - color: Color(0x33000000), - darkColor: Color(0x33FFFFFF), - ), - style: BorderStyle.solid, - width: 0.0, -); -const Border _kDefaultRoundedBorder = Border( - top: _kDefaultRoundedBorderSide, - bottom: _kDefaultRoundedBorderSide, - left: _kDefaultRoundedBorderSide, - right: _kDefaultRoundedBorderSide, -); - -const BoxDecoration _kDefaultRoundedBorderDecoration = BoxDecoration( - color: CupertinoDynamicColor.withBrightness( - color: CupertinoColors.white, - darkColor: CupertinoColors.black, - ), - border: _kDefaultRoundedBorder, - borderRadius: BorderRadius.all(Radius.circular(5.0)), -); - -/// A [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) -/// implementation of [TypeAheadField], that allows the value to be saved, -/// validated, etc. -/// -/// See also: -/// -/// * [TypeAheadField], A [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) -/// that displays a list of suggestions as the user types -class CupertinoTypeAheadFormField extends FormField { - /// The configuration of the [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) - /// that the TypeAhead widget displays - final CupertinoTextFieldConfiguration textFieldConfiguration; - - /// Creates a [CupertinoTypeAheadFormField] - CupertinoTypeAheadFormField( - {Key? key, - String? initialValue, - bool getImmediateSuggestions: false, - @Deprecated('Use autoValidateMode parameter which provides more specific ' - 'behavior related to auto validation. ' - 'This feature was deprecated after Flutter v1.19.0.') - bool autovalidate: false, - bool enabled: true, - AutovalidateMode? autovalidateMode, - FormFieldSetter? onSaved, - FormFieldValidator? validator, - ErrorBuilder? errorBuilder, - WidgetBuilder? noItemsFoundBuilder, - WidgetBuilder? loadingBuilder, - Duration debounceDuration: const Duration(milliseconds: 300), - CupertinoSuggestionsBoxDecoration suggestionsBoxDecoration: - const CupertinoSuggestionsBoxDecoration(), - CupertinoSuggestionsBoxController? suggestionsBoxController, - required SuggestionSelectionCallback onSuggestionSelected, - required ItemBuilder itemBuilder, - required SuggestionsCallback suggestionsCallback, - double suggestionsBoxVerticalOffset: 5.0, - this.textFieldConfiguration: const CupertinoTextFieldConfiguration(), - AnimationTransitionBuilder? transitionBuilder, - Duration animationDuration: const Duration(milliseconds: 500), - double animationStart: 0.25, - AxisDirection direction: AxisDirection.down, - bool hideOnLoading: false, - bool hideOnEmpty: false, - bool hideOnError: false, - bool hideSuggestionsOnKeyboardHide: true, - bool keepSuggestionsOnLoading: true, - bool keepSuggestionsOnSuggestionSelected: false, - bool autoFlipDirection: false, - bool autoFlipListDirection: true, - int minCharsForSuggestions: 0, - bool hideKeyboardOnDrag: false}) - : assert( - initialValue == null || textFieldConfiguration.controller == null), - assert(minCharsForSuggestions >= 0), - super( - key: key, - onSaved: onSaved, - validator: validator, - initialValue: textFieldConfiguration.controller != null - ? textFieldConfiguration.controller!.text - : (initialValue ?? ''), - enabled: enabled, - autovalidateMode: autovalidateMode, - builder: (FormFieldState field) { - final CupertinoTypeAheadFormFieldState state = - field as CupertinoTypeAheadFormFieldState; - - return CupertinoTypeAheadField( - getImmediateSuggestions: getImmediateSuggestions, - transitionBuilder: transitionBuilder, - errorBuilder: errorBuilder, - noItemsFoundBuilder: noItemsFoundBuilder, - loadingBuilder: loadingBuilder, - debounceDuration: debounceDuration, - suggestionsBoxDecoration: suggestionsBoxDecoration, - suggestionsBoxController: suggestionsBoxController, - textFieldConfiguration: textFieldConfiguration.copyWith( - onChanged: (text) { - state.didChange(text); - textFieldConfiguration.onChanged?.call(text); - }, - controller: state._effectiveController, - ), - suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, - onSuggestionSelected: onSuggestionSelected, - itemBuilder: itemBuilder, - suggestionsCallback: suggestionsCallback, - animationStart: animationStart, - animationDuration: animationDuration, - direction: direction, - hideOnLoading: hideOnLoading, - hideOnEmpty: hideOnEmpty, - hideOnError: hideOnError, - hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, - keepSuggestionsOnLoading: keepSuggestionsOnLoading, - keepSuggestionsOnSuggestionSelected: - keepSuggestionsOnSuggestionSelected, - autoFlipDirection: autoFlipDirection, - autoFlipListDirection: autoFlipListDirection, - minCharsForSuggestions: minCharsForSuggestions, - hideKeyboardOnDrag: hideKeyboardOnDrag, - ); - }); - - @override - CupertinoTypeAheadFormFieldState createState() => - CupertinoTypeAheadFormFieldState(); -} - -class CupertinoTypeAheadFormFieldState extends FormFieldState { - TextEditingController? _controller; - - TextEditingController? get _effectiveController => - widget.textFieldConfiguration.controller ?? _controller; - - @override - CupertinoTypeAheadFormField get widget => - super.widget as CupertinoTypeAheadFormField; - - @override - void initState() { - super.initState(); - if (widget.textFieldConfiguration.controller == null) { - _controller = TextEditingController(text: widget.initialValue); - } else { - widget.textFieldConfiguration.controller! - .addListener(_handleControllerChanged); - } - } - - @override - void didUpdateWidget(CupertinoTypeAheadFormField oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.textFieldConfiguration.controller != - oldWidget.textFieldConfiguration.controller) { - oldWidget.textFieldConfiguration.controller - ?.removeListener(_handleControllerChanged); - widget.textFieldConfiguration.controller - ?.addListener(_handleControllerChanged); - - if (oldWidget.textFieldConfiguration.controller != null && - widget.textFieldConfiguration.controller == null) - _controller = TextEditingController.fromValue( - oldWidget.textFieldConfiguration.controller!.value); - if (widget.textFieldConfiguration.controller != null) { - setValue(widget.textFieldConfiguration.controller!.text); - if (oldWidget.textFieldConfiguration.controller == null) - _controller = null; - } - } - } - - @override - void dispose() { - widget.textFieldConfiguration.controller - ?.removeListener(_handleControllerChanged); - super.dispose(); - } - - @override - void reset() { - super.reset(); - setState(() { - _effectiveController!.text = widget.initialValue!; - }); - } - - void _handleControllerChanged() { - // Suppress changes that originated from within this class. - // - // In the case where a controller has been passed in to this widget, we - // register this change listener. In these cases, we'll also receive change - // notifications for changes originating from within this class -- for - // example, the reset() method. In such cases, the FormField value will - // already have been set. - if (_effectiveController!.text != value) - didChange(_effectiveController!.text); - } -} - -/// A [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) -/// that displays a list of suggestions as the user types -/// -/// See also: -/// -/// * [TypeAheadFormField], a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) -/// implementation of [TypeAheadField] that allows the value to be saved, -/// validated, etc. -class CupertinoTypeAheadField extends StatefulWidget { - /// Called with the search pattern to get the search suggestions. - /// - /// This callback must not be null. It is be called by the TypeAhead widget - /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) - /// of suggestions either synchronously, or asynchronously (as the result of a - /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). - /// Typically, the list of suggestions should not contain more than 4 or 5 - /// entries. These entries will then be provided to [itemBuilder] to display - /// the suggestions. - /// - /// Example: - /// ```dart - /// suggestionsCallback: (pattern) async { - /// return await _getSuggestions(pattern); - /// } - /// ``` - final SuggestionsCallback suggestionsCallback; - - /// Called when a suggestion is tapped. - /// - /// This callback must not be null. It is called by the TypeAhead widget and - /// provided with the value of the tapped suggestion. - /// - /// For example, you might want to navigate to a specific view when the user - /// tabs a suggestion: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// Navigator.of(context).push(MaterialPageRoute( - /// builder: (context) => SearchResult( - /// searchItem: suggestion - /// ) - /// )); - /// } - /// ``` - /// - /// Or to set the value of the text field: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// _controller.text = suggestion['name']; - /// } - /// ``` - final SuggestionSelectionCallback onSuggestionSelected; - - /// Called for each suggestion returned by [suggestionsCallback] to build the - /// corresponding widget. - /// - /// This callback must not be null. It is called by the TypeAhead widget for - /// each suggestion, and expected to build a widget to display this - /// suggestion's info. For example: - /// - /// ```dart - /// itemBuilder: (context, suggestion) { - /// return Padding( - /// padding: const EdgeInsets.all(4.0), - /// child: Text( - /// suggestion, - /// ), - /// ); - /// } - /// ``` - final ItemBuilder itemBuilder; - - /// The decoration of the material sheet that contains the suggestions. - final CupertinoSuggestionsBoxDecoration suggestionsBoxDecoration; - - /// Used to control the `_SuggestionsBox`. Allows manual control to - /// open, close, toggle, or resize the `_SuggestionsBox`. - final CupertinoSuggestionsBoxController? suggestionsBoxController; - - /// The duration to wait after the user stops typing before calling - /// [suggestionsCallback] - /// - /// This is useful, because, if not set, a request for suggestions will be - /// sent for every character that the user types. - /// - /// This duration is set by default to 300 milliseconds - final Duration debounceDuration; - - /// Called when waiting for [suggestionsCallback] to return. - /// - /// It is expected to return a widget to display while waiting. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('Loading...'); - /// } - /// ``` - /// - /// If not specified, a [CupertinoActivityIndicator](https://docs.flutter.io/flutter/cupertino/CupertinoActivityIndicator-class.html) is shown - final WidgetBuilder? loadingBuilder; - - /// Called when [suggestionsCallback] returns an empty array. - /// - /// It is expected to return a widget to display when no suggestions are - /// avaiable. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('No Items Found!'); - /// } - /// ``` - /// - /// If not specified, a simple text is shown - final WidgetBuilder? noItemsFoundBuilder; - - /// Called when [suggestionsCallback] throws an exception. - /// - /// It is called with the error object, and expected to return a widget to - /// display when an exception is thrown - /// For example: - /// ```dart - /// (BuildContext context, error) { - /// return Text('$error'); - /// } - /// ``` - final ErrorBuilder? errorBuilder; - - /// Called to display animations when [suggestionsCallback] returns suggestions - /// - /// It is provided with the suggestions box instance and the animation - /// controller, and expected to return some animation that uses the controller - /// to display the suggestion box. - /// - /// For example: - /// ```dart - /// transitionBuilder: (context, suggestionsBox, animationController) { - /// return FadeTransition( - /// child: suggestionsBox, - /// opacity: CurvedAnimation( - /// parent: animationController, - /// curve: Curves.fastOutSlowIn - /// ), - /// ); - /// } - /// ``` - /// This argument is best used with [animationDuration] and [animationStart] - /// to fully control the animation. - /// - /// To fully remove the animation, just return `suggestionsBox` - /// - /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. - final AnimationTransitionBuilder? transitionBuilder; - - /// The duration that [transitionBuilder] animation takes. - /// - /// This argument is best used with [transitionBuilder] and [animationStart] - /// to fully control the animation. - /// - /// Defaults to 500 milliseconds. - final Duration animationDuration; - - /// Determine the [SuggestionBox]'s direction. - /// - /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] - /// and the [_SuggestionsList] will grow **down**. - /// - /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] - /// and the [_SuggestionsList] will grow **up**. - /// - /// [AxisDirection.left] and [AxisDirection.right] are not allowed. - final AxisDirection direction; - - /// The value at which the [transitionBuilder] animation starts. - /// - /// This argument is best used with [transitionBuilder] and [animationDuration] - /// to fully control the animation. - /// - /// Defaults to 0.25. - final double animationStart; - - /// The configuration of the [CupertinoTextField](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) - /// that the TypeAhead widget displays - final CupertinoTextFieldConfiguration textFieldConfiguration; - - /// How far below the text field should the suggestions box be - /// - /// Defaults to 5.0 - final double suggestionsBoxVerticalOffset; - - /// If set to true, suggestions will be fetched immediately when the field is - /// added to the view. - /// - /// But the suggestions box will only be shown when the field receives focus. - /// To make the field receive focus immediately, you can set the `autofocus` - /// property in the [textFieldConfiguration] to true - /// - /// Defaults to false - final bool getImmediateSuggestions; - - /// If set to true, no loading box will be shown while suggestions are - /// being fetched. [loadingBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnLoading; - - /// If set to true, nothing will be shown if there are no results. - /// [noItemsFoundBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnEmpty; - - /// If set to true, nothing will be shown if there is an error. - /// [errorBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnError; - - /// If set to false, the suggestions box will stay opened after - /// the keyboard is closed. - /// - /// Defaults to true. - final bool hideSuggestionsOnKeyboardHide; - - /// If set to false, the suggestions box will show a circular - /// progress indicator when retrieving suggestions. - /// - /// Defaults to true. - final bool keepSuggestionsOnLoading; - - /// If set to true, the suggestions box will remain opened even after - /// selecting a suggestion. - /// - /// Note that if this is enabled, the only way - /// to close the suggestions box is either manually via the - /// `SuggestionsBoxController` or when the user closes the software - /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users - /// with a physical keyboard will be unable to close the - /// box without a manual way via `SuggestionsBoxController`. - /// - /// Defaults to false. - final bool keepSuggestionsOnSuggestionSelected; - - /// If set to true, in the case where the suggestions box has less than - /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis - /// will be temporarily flipped if there's more room available in the opposite - /// direction. - /// - /// Defaults to false - final bool autoFlipDirection; - - /// If set to false, suggestion list will not be reversed according to the - /// [autoFlipDirection] property. - /// - /// Defaults to true. - final bool autoFlipListDirection; - - /// The minimum number of characters which must be entered before - /// [suggestionsCallback] is triggered. - /// - /// Defaults to 0. - final int minCharsForSuggestions; - - /// If set to true and if the user scrolls through the suggestion list, hide the keyboard automatically. - /// If set to false, the keyboard remains visible. - /// Throws an exception, if hideKeyboardOnDrag and hideSuggestionsOnKeyboardHide are both set to true as - /// they are mutual exclusive. - /// - /// Defaults to false - final bool hideKeyboardOnDrag; - - /// Creates a [CupertinoTypeAheadField] - CupertinoTypeAheadField({ - Key? key, - required this.suggestionsCallback, - required this.itemBuilder, - required this.onSuggestionSelected, - this.textFieldConfiguration: const CupertinoTextFieldConfiguration(), - this.suggestionsBoxDecoration: const CupertinoSuggestionsBoxDecoration(), - this.debounceDuration: const Duration(milliseconds: 300), - this.suggestionsBoxController, - this.loadingBuilder, - this.noItemsFoundBuilder, - this.errorBuilder, - this.transitionBuilder, - this.animationStart: 0.25, - this.animationDuration: const Duration(milliseconds: 500), - this.getImmediateSuggestions: false, - this.suggestionsBoxVerticalOffset: 5.0, - this.direction: AxisDirection.down, - this.hideOnLoading: false, - this.hideOnEmpty: false, - this.hideOnError: false, - this.hideSuggestionsOnKeyboardHide: true, - this.keepSuggestionsOnLoading: true, - this.keepSuggestionsOnSuggestionSelected: false, - this.autoFlipDirection: false, - this.autoFlipListDirection: true, - this.minCharsForSuggestions: 0, - this.hideKeyboardOnDrag: true, - }) : assert(animationStart >= 0.0 && animationStart <= 1.0), - assert( - direction == AxisDirection.down || direction == AxisDirection.up), - assert(minCharsForSuggestions >= 0), - assert(!hideKeyboardOnDrag || - hideKeyboardOnDrag && !hideSuggestionsOnKeyboardHide), - super(key: key); - - @override - _CupertinoTypeAheadFieldState createState() => - _CupertinoTypeAheadFieldState(); -} - -class _CupertinoTypeAheadFieldState extends State> - with WidgetsBindingObserver { - FocusNode? _focusNode; - TextEditingController? _textEditingController; - _CupertinoSuggestionsBox? _suggestionsBox; - - TextEditingController? get _effectiveController => - widget.textFieldConfiguration.controller ?? _textEditingController; - FocusNode? get _effectiveFocusNode => - widget.textFieldConfiguration.focusNode ?? _focusNode; - late VoidCallback _focusNodeListener; - - final LayerLink _layerLink = LayerLink(); - - // Timer that resizes the suggestion box on each tick. Only active when the user is scrolling. - Timer? _resizeOnScrollTimer; - // The rate at which the suggestion box will resize when the user is scrolling - final Duration _resizeOnScrollRefreshRate = const Duration(milliseconds: 500); - // Will have a value if the typeahead is inside a scrollable widget - ScrollPosition? _scrollPosition; - - // Keyboard detection - final Stream? _keyboardVisibility = - (supportedPlatform) ? KeyboardVisibilityController().onChange : null; - late StreamSubscription? _keyboardVisibilitySubscription; - - @override - void didChangeMetrics() { - // Catch keyboard event and orientation change; resize suggestions list - this._suggestionsBox!.onChangeMetrics(); - } - - @override - void dispose() { - this._suggestionsBox!.close(); - this._suggestionsBox!.widgetMounted = false; - WidgetsBinding.instance.removeObserver(this); - _keyboardVisibilitySubscription?.cancel(); - _effectiveFocusNode!.removeListener(_focusNodeListener); - _focusNode?.dispose(); - _resizeOnScrollTimer?.cancel(); - _scrollPosition?.removeListener(_scrollResizeListener); - _textEditingController?.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - - if (widget.textFieldConfiguration.controller == null) { - this._textEditingController = TextEditingController(); - } - - if (widget.textFieldConfiguration.focusNode == null) { - this._focusNode = FocusNode(); - } - - this._suggestionsBox = _CupertinoSuggestionsBox( - context, - widget.direction, - widget.autoFlipDirection, - widget.autoFlipListDirection, - ); - - widget.suggestionsBoxController?._suggestionsBox = this._suggestionsBox; - widget.suggestionsBoxController?._effectiveFocusNode = - this._effectiveFocusNode; - - this._focusNodeListener = () { - if (_effectiveFocusNode!.hasFocus) { - this._suggestionsBox!.open(); - } else { - this._suggestionsBox!.close(); - } - }; - - this._effectiveFocusNode!.addListener(_focusNodeListener); - - // hide suggestions box on keyboard closed - this._keyboardVisibilitySubscription = - _keyboardVisibility?.listen((bool isVisible) { - if (widget.hideSuggestionsOnKeyboardHide && !isVisible) { - _effectiveFocusNode!.unfocus(); - } - }); - - WidgetsBinding.instance.addPostFrameCallback((duration) { - if (mounted) { - this._initOverlayEntry(); - // calculate initial suggestions list size - this._suggestionsBox!.resize(); - - // in case we already missed the focus event - if (this._effectiveFocusNode!.hasFocus) { - this._suggestionsBox!.open(); - } - } - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final scrollableState = Scrollable.maybeOf(context); - if (scrollableState != null) { - // The TypeAheadField is inside a scrollable widget - _scrollPosition = scrollableState.position; - - _scrollPosition!.removeListener(_scrollResizeListener); - _scrollPosition!.isScrollingNotifier.addListener(_scrollResizeListener); - } - } - - void _scrollResizeListener() { - bool isScrolling = _scrollPosition!.isScrollingNotifier.value; - _resizeOnScrollTimer?.cancel(); - if (isScrolling) { - // Scroll started - _resizeOnScrollTimer = - Timer.periodic(_resizeOnScrollRefreshRate, (timer) { - _suggestionsBox!.resize(); - }); - } else { - // Scroll finished - _suggestionsBox!.resize(); - } - } - - void _initOverlayEntry() { - this._suggestionsBox!._overlayEntry = OverlayEntry(builder: (context) { - final suggestionsList = _SuggestionsList( - suggestionsBox: _suggestionsBox, - decoration: widget.suggestionsBoxDecoration, - debounceDuration: widget.debounceDuration, - controller: this._effectiveController, - loadingBuilder: widget.loadingBuilder, - noItemsFoundBuilder: widget.noItemsFoundBuilder, - errorBuilder: widget.errorBuilder, - transitionBuilder: widget.transitionBuilder, - suggestionsCallback: widget.suggestionsCallback, - animationDuration: widget.animationDuration, - animationStart: widget.animationStart, - getImmediateSuggestions: widget.getImmediateSuggestions, - onSuggestionSelected: (T selection) { - if (!widget.keepSuggestionsOnSuggestionSelected) { - this._effectiveFocusNode!.unfocus(); - this._suggestionsBox!.close(); - } - widget.onSuggestionSelected(selection); - }, - itemBuilder: widget.itemBuilder, - direction: _suggestionsBox!.direction, - hideOnLoading: widget.hideOnLoading, - hideOnEmpty: widget.hideOnEmpty, - hideOnError: widget.hideOnError, - keepSuggestionsOnLoading: widget.keepSuggestionsOnLoading, - minCharsForSuggestions: widget.minCharsForSuggestions, - hideKeyboardOnDrag: widget.hideKeyboardOnDrag, - ); - - double w = _suggestionsBox!.textBoxWidth; - if (widget.suggestionsBoxDecoration.constraints != null) { - if (widget.suggestionsBoxDecoration.constraints!.minWidth != 0.0 && - widget.suggestionsBoxDecoration.constraints!.maxWidth != - double.infinity) { - w = (widget.suggestionsBoxDecoration.constraints!.minWidth + - widget.suggestionsBoxDecoration.constraints!.maxWidth) / - 2; - } else if (widget.suggestionsBoxDecoration.constraints!.minWidth != - 0.0 && - widget.suggestionsBoxDecoration.constraints!.minWidth > w) { - w = widget.suggestionsBoxDecoration.constraints!.minWidth; - } else if (widget.suggestionsBoxDecoration.constraints!.maxWidth != - double.infinity && - widget.suggestionsBoxDecoration.constraints!.maxWidth < w) { - w = widget.suggestionsBoxDecoration.constraints!.maxWidth; - } - } - - return Positioned( - width: w, - child: CompositedTransformFollower( - link: this._layerLink, - showWhenUnlinked: false, - offset: Offset( - widget.suggestionsBoxDecoration.offsetX, - _suggestionsBox!.direction == AxisDirection.down - ? _suggestionsBox!.textBoxHeight + - widget.suggestionsBoxVerticalOffset - : _suggestionsBox!.directionUpOffset), - child: _suggestionsBox!.direction == AxisDirection.down - ? suggestionsList - : FractionalTranslation( - translation: - Offset(0.0, -1.0), // visually flips list to go up - child: suggestionsList, - ), - ), - ); - }); - } - - @override - Widget build(BuildContext context) { - return CompositedTransformTarget( - link: this._layerLink, - child: CupertinoTextField( - controller: this._effectiveController, - focusNode: this._effectiveFocusNode, - decoration: widget.textFieldConfiguration.decoration, - padding: widget.textFieldConfiguration.padding, - placeholder: widget.textFieldConfiguration.placeholder, - prefix: widget.textFieldConfiguration.prefix, - prefixMode: widget.textFieldConfiguration.prefixMode, - suffix: widget.textFieldConfiguration.suffix, - suffixMode: widget.textFieldConfiguration.suffixMode, - clearButtonMode: widget.textFieldConfiguration.clearButtonMode, - keyboardType: widget.textFieldConfiguration.keyboardType, - textInputAction: widget.textFieldConfiguration.textInputAction, - textCapitalization: widget.textFieldConfiguration.textCapitalization, - style: widget.textFieldConfiguration.style, - textAlign: widget.textFieldConfiguration.textAlign, - autofocus: widget.textFieldConfiguration.autofocus, - obscureText: widget.textFieldConfiguration.obscureText, - autocorrect: widget.textFieldConfiguration.autocorrect, - maxLines: widget.textFieldConfiguration.maxLines, - minLines: widget.textFieldConfiguration.minLines, - maxLength: widget.textFieldConfiguration.maxLength, - maxLengthEnforcement: - widget.textFieldConfiguration.maxLengthEnforcement, - onChanged: widget.textFieldConfiguration.onChanged, - onEditingComplete: widget.textFieldConfiguration.onEditingComplete, - onTap: widget.textFieldConfiguration.onTap, -// onTapOutside: (_){}, - onSubmitted: widget.textFieldConfiguration.onSubmitted, - inputFormatters: widget.textFieldConfiguration.inputFormatters, - enabled: widget.textFieldConfiguration.enabled, - cursorWidth: widget.textFieldConfiguration.cursorWidth, - cursorRadius: widget.textFieldConfiguration.cursorRadius, - cursorColor: widget.textFieldConfiguration.cursorColor, - keyboardAppearance: widget.textFieldConfiguration.keyboardAppearance, - scrollPadding: widget.textFieldConfiguration.scrollPadding, - enableInteractiveSelection: - widget.textFieldConfiguration.enableInteractiveSelection, - ), - ); - } -} - -class _SuggestionsList extends StatefulWidget { - final _CupertinoSuggestionsBox? suggestionsBox; - final TextEditingController? controller; - final bool getImmediateSuggestions; - final SuggestionSelectionCallback? onSuggestionSelected; - final SuggestionsCallback? suggestionsCallback; - final ItemBuilder? itemBuilder; - final CupertinoSuggestionsBoxDecoration? decoration; - final Duration? debounceDuration; - final WidgetBuilder? loadingBuilder; - final WidgetBuilder? noItemsFoundBuilder; - final ErrorBuilder? errorBuilder; - final AnimationTransitionBuilder? transitionBuilder; - final Duration? animationDuration; - final double? animationStart; - final AxisDirection? direction; - final bool? hideOnLoading; - final bool? hideOnEmpty; - final bool? hideOnError; - final bool? keepSuggestionsOnLoading; - final int? minCharsForSuggestions; - final bool hideKeyboardOnDrag; - - _SuggestionsList({ - required this.suggestionsBox, - this.controller, - this.getImmediateSuggestions: false, - this.onSuggestionSelected, - this.suggestionsCallback, - this.itemBuilder, - this.decoration, - this.debounceDuration, - this.loadingBuilder, - this.noItemsFoundBuilder, - this.errorBuilder, - this.transitionBuilder, - this.animationDuration, - this.animationStart, - this.direction, - this.hideOnLoading, - this.hideOnEmpty, - this.hideOnError, - this.keepSuggestionsOnLoading, - this.minCharsForSuggestions, - this.hideKeyboardOnDrag: false, - }); - - @override - _SuggestionsListState createState() => _SuggestionsListState(); -} - -class _SuggestionsListState extends State<_SuggestionsList> - with SingleTickerProviderStateMixin { - Iterable? _suggestions; - late bool _suggestionsValid; - late VoidCallback _controllerListener; - Timer? _debounceTimer; - bool? _isLoading, _isQueued; - Object? _error; - AnimationController? _animationController; - String? _lastTextValue; - - @override - void didUpdateWidget(_SuggestionsList oldWidget) { - super.didUpdateWidget(oldWidget); - _getSuggestions(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _getSuggestions(); - } - - @override - void initState() { - super.initState(); - - this._animationController = AnimationController( - vsync: this, - duration: widget.animationDuration, - ); - - this._suggestionsValid = widget.minCharsForSuggestions! > 0 ? true : false; - this._isLoading = false; - this._isQueued = false; - this._lastTextValue = widget.controller!.text; - - if (widget.getImmediateSuggestions) { - this._getSuggestions(); - } - - this._controllerListener = () { - // If we came here because of a change in selected text, not because of - // actual change in text - if (widget.controller!.text == this._lastTextValue) return; - - this._lastTextValue = widget.controller!.text; - - this._debounceTimer?.cancel(); - if (widget.controller!.text.length < widget.minCharsForSuggestions!) { - if (mounted) { - setState(() { - _isLoading = false; - _suggestions = null; - _suggestionsValid = true; - }); - } - return; - } else { - this._debounceTimer = Timer(widget.debounceDuration!, () async { - if (this._debounceTimer!.isActive) return; - if (_isLoading!) { - _isQueued = true; - return; - } - - await this.invalidateSuggestions(); - while (_isQueued!) { - _isQueued = false; - await this.invalidateSuggestions(); - } - }); - } - }; - - widget.controller!.addListener(this._controllerListener); - } - - Future invalidateSuggestions() async { - _suggestionsValid = false; - _getSuggestions(); - } - - Future _getSuggestions() async { - if (_suggestionsValid) return; - _suggestionsValid = true; - - if (mounted) { - setState(() { - this._animationController!.forward(from: 1.0); - - this._isLoading = true; - this._error = null; - }); - - Iterable? suggestions; - Object? error; - - try { - suggestions = - await widget.suggestionsCallback!(widget.controller!.text); - } catch (e) { - error = e; - } - - if (this.mounted) { - // if it wasn't removed in the meantime - setState(() { - double? animationStart = widget.animationStart; - // allow suggestionsCallback to return null and not throw error here - if (error != null || suggestions?.isEmpty == true) { - animationStart = 1.0; - } - this._animationController!.forward(from: animationStart); - - this._error = error; - this._isLoading = false; - this._suggestions = suggestions; - }); - } - } - } - - @override - void dispose() { - _animationController!.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - bool isEmpty = - this._suggestions?.length == 0 && widget.controller!.text == ""; - if ((this._suggestions == null || isEmpty) && this._isLoading == false) - return Container(); - - Widget child; - if (this._isLoading!) { - if (widget.hideOnLoading!) { - child = Container(height: 0); - } else { - child = createLoadingWidget(); - } - } else if (this._error != null) { - if (widget.hideOnError!) { - child = Container(height: 0); - } else { - child = createErrorWidget(); - } - } else if (this._suggestions!.isEmpty) { - if (widget.hideOnEmpty!) { - child = Container(height: 0); - } else { - child = createNoItemsFoundWidget(); - } - } else { - child = createSuggestionsWidget(); - } - - var animationChild = widget.transitionBuilder != null - ? widget.transitionBuilder!(context, child, this._animationController) - : SizeTransition( - axisAlignment: -1.0, - sizeFactor: CurvedAnimation( - parent: this._animationController!, - curve: Curves.fastOutSlowIn), - child: child, - ); - - BoxConstraints constraints; - if (widget.decoration!.constraints == null) { - constraints = BoxConstraints( - maxHeight: widget.suggestionsBox!.maxHeight, - ); - } else { - double maxHeight = min(widget.decoration!.constraints!.maxHeight, - widget.suggestionsBox!.maxHeight); - constraints = widget.decoration!.constraints!.copyWith( - minHeight: min(widget.decoration!.constraints!.minHeight, maxHeight), - maxHeight: maxHeight, - ); - } - - return ConstrainedBox( - constraints: constraints, - child: animationChild, - ); - } - - Widget createLoadingWidget() { - Widget child; - - if (widget.keepSuggestionsOnLoading! && this._suggestions != null) { - if (this._suggestions!.isEmpty) { - child = createNoItemsFoundWidget(); - } else { - child = createSuggestionsWidget(); - } - } else { - child = widget.loadingBuilder != null - ? widget.loadingBuilder!(context) - : Container( - decoration: BoxDecoration( - color: CupertinoColors.white, - border: Border.all( - color: CupertinoColors.extraLightBackgroundGray, - width: 1.0, - ), - ), - child: Align( - alignment: Alignment.center, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: CupertinoActivityIndicator(), - ), - ), - ); - } - - return child; - } - - Widget createErrorWidget() { - return widget.errorBuilder != null - ? widget.errorBuilder!(context, this._error) - : Container( - decoration: BoxDecoration( - color: CupertinoColors.white, - border: Border.all( - color: CupertinoColors.extraLightBackgroundGray, - width: 1.0, - ), - ), - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - 'Error: ${this._error}', - textAlign: TextAlign.start, - style: TextStyle( - color: CupertinoColors.destructiveRed, - fontSize: 18.0, - ), - ), - ), - ); - } - - Widget createNoItemsFoundWidget() { - return widget.noItemsFoundBuilder != null - ? widget.noItemsFoundBuilder!(context) - : Container( - decoration: BoxDecoration( - color: CupertinoColors.white, - border: Border.all( - color: CupertinoColors.extraLightBackgroundGray, - width: 1.0, - ), - ), - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Text( - 'No Items Found!', - textAlign: TextAlign.start, - style: TextStyle( - color: CupertinoColors.inactiveGray, - fontSize: 18.0, - ), - ), - ), - ); - } - - Widget createSuggestionsWidget() { - Widget child = Container( - decoration: BoxDecoration( - color: widget.decoration!.color != null - ? widget.decoration!.color - : CupertinoColors.white, - border: widget.decoration!.border != null - ? widget.decoration!.border - : Border.all( - color: CupertinoColors.extraLightBackgroundGray, - width: 1.0, - ), - borderRadius: widget.decoration!.borderRadius != null - ? widget.decoration!.borderRadius - : null, - ), - child: ListView( - padding: EdgeInsets.zero, - primary: false, - shrinkWrap: true, - keyboardDismissBehavior: widget.hideKeyboardOnDrag - ? ScrollViewKeyboardDismissBehavior.onDrag - : ScrollViewKeyboardDismissBehavior.manual, - reverse: widget.suggestionsBox!.direction == AxisDirection.down - ? false - : widget.suggestionsBox!.autoFlipListDirection, - children: this._suggestions!.map((T suggestion) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - child: widget.itemBuilder!(context, suggestion), - onTap: () { - widget.onSuggestionSelected!(suggestion); - }, - ); - }).toList(), - ), - ); - - if (widget.decoration!.hasScrollbar) { - child = CupertinoScrollbar(child: child); - } - - return child; - } -} - -/// Supply an instance of this class to the [TypeAhead.suggestionsBoxDecoration] -/// property to configure the suggestions box decoration -class CupertinoSuggestionsBoxDecoration { - /// Defines if a scrollbar will be displayed or not. - final bool hasScrollbar; - - /// The constraints to be applied to the suggestions box - final BoxConstraints? constraints; - final Color? color; - final BoxBorder? border; - final BorderRadiusGeometry? borderRadius; - - /// Adds an offset to the suggestions box - final double offsetX; - - /// Creates a [CupertinoSuggestionsBoxDecoration] - const CupertinoSuggestionsBoxDecoration( - {this.hasScrollbar: true, - this.constraints, - this.color, - this.border, - this.borderRadius, - this.offsetX: 0.0}); -} - -/// Supply an instance of this class to the [TypeAhead.textFieldConfiguration] -/// property to configure the displayed text field. See [documentation](https://docs.flutter.io/flutter/cupertino/CupertinoTextField-class.html) -/// for more information on properties. -class CupertinoTextFieldConfiguration { - final TextEditingController? controller; - final FocusNode? focusNode; - final BoxDecoration decoration; - final EdgeInsetsGeometry padding; - final String? placeholder; - final Widget? prefix; - final OverlayVisibilityMode prefixMode; - final Widget? suffix; - final OverlayVisibilityMode suffixMode; - final OverlayVisibilityMode clearButtonMode; - final TextInputType? keyboardType; - final TextInputAction? textInputAction; - final TextCapitalization textCapitalization; - final TextStyle? style; - final TextAlign textAlign; - final bool autofocus; - final bool obscureText; - final bool autocorrect; - final int maxLines; - final int? minLines; - final int? maxLength; - final MaxLengthEnforcement? maxLengthEnforcement; - final ValueChanged? onChanged; - final VoidCallback? onEditingComplete; - final GestureTapCallback? onTap; - final ValueChanged? onSubmitted; - final List? inputFormatters; - final bool enabled; - final bool enableSuggestions; - final double cursorWidth; - final Radius cursorRadius; - final Color? cursorColor; - final Brightness? keyboardAppearance; - final EdgeInsets scrollPadding; - final bool enableInteractiveSelection; - - /// Creates a CupertinoTextFieldConfiguration - const CupertinoTextFieldConfiguration({ - this.controller, - this.focusNode, - this.decoration = _kDefaultRoundedBorderDecoration, - this.padding = const EdgeInsets.all(6.0), - this.placeholder, - this.prefix, - this.prefixMode = OverlayVisibilityMode.always, - this.suffix, - this.suffixMode = OverlayVisibilityMode.always, - this.clearButtonMode = OverlayVisibilityMode.never, - this.keyboardType, - this.textInputAction, - this.textCapitalization = TextCapitalization.none, - this.style, - this.textAlign = TextAlign.start, - this.autofocus = false, - this.obscureText = false, - this.autocorrect = true, - this.maxLines = 1, - this.minLines, - this.maxLength, - this.maxLengthEnforcement, - this.onChanged, - this.onEditingComplete, - this.onTap, - this.onSubmitted, - this.inputFormatters, - this.enabled: true, - this.enableSuggestions: true, - this.cursorWidth = 2.0, - this.cursorRadius = const Radius.circular(2.0), - this.cursorColor, - this.keyboardAppearance, - this.scrollPadding = const EdgeInsets.all(20.0), - this.enableInteractiveSelection = true, - }); - - /// Copies the [CupertinoTextFieldConfiguration] and only changes the specified properties - CupertinoTextFieldConfiguration copyWith({ - TextEditingController? controller, - FocusNode? focusNode, - BoxDecoration? decoration, - EdgeInsetsGeometry? padding, - String? placeholder, - Widget? prefix, - OverlayVisibilityMode? prefixMode, - Widget? suffix, - OverlayVisibilityMode? suffixMode, - OverlayVisibilityMode? clearButtonMode, - TextInputType? keyboardType, - TextInputAction? textInputAction, - TextCapitalization? textCapitalization, - TextStyle? style, - TextAlign? textAlign, - bool? autofocus, - bool? obscureText, - bool? autocorrect, - int? maxLines, - int? minLines, - int? maxLength, - MaxLengthEnforcement? maxLengthEnforcement, - ValueChanged? onChanged, - VoidCallback? onEditingComplete, - GestureTapCallback? onTap, - ValueChanged? onSubmitted, - List? inputFormatters, - bool? enabled, - bool? enableSuggestions, - double? cursorWidth, - Radius? cursorRadius, - Color? cursorColor, - Brightness? keyboardAppearance, - EdgeInsets? scrollPadding, - bool? enableInteractiveSelection, - }) { - return CupertinoTextFieldConfiguration( - controller: controller ?? this.controller, - focusNode: focusNode ?? this.focusNode, - decoration: decoration ?? this.decoration, - padding: padding ?? this.padding, - placeholder: placeholder ?? this.placeholder, - prefix: prefix ?? this.prefix, - prefixMode: prefixMode ?? this.prefixMode, - suffix: suffix ?? this.suffix, - suffixMode: suffixMode ?? this.suffixMode, - clearButtonMode: clearButtonMode ?? this.clearButtonMode, - keyboardType: keyboardType ?? this.keyboardType, - textInputAction: textInputAction ?? this.textInputAction, - textCapitalization: textCapitalization ?? this.textCapitalization, - style: style ?? this.style, - textAlign: textAlign ?? this.textAlign, - autofocus: autofocus ?? this.autofocus, - obscureText: obscureText ?? this.obscureText, - autocorrect: autocorrect ?? this.autocorrect, - maxLines: maxLines ?? this.maxLines, - minLines: minLines ?? this.minLines, - maxLength: maxLength ?? this.maxLength, - maxLengthEnforcement: maxLengthEnforcement ?? this.maxLengthEnforcement, - onChanged: onChanged ?? this.onChanged, - onEditingComplete: onEditingComplete ?? this.onEditingComplete, - onTap: onTap ?? this.onTap, - onSubmitted: onSubmitted ?? this.onSubmitted, - inputFormatters: inputFormatters ?? this.inputFormatters, - enabled: enabled ?? this.enabled, - enableSuggestions: enableSuggestions ?? this.enableSuggestions, - cursorWidth: cursorWidth ?? this.cursorWidth, - cursorRadius: cursorRadius ?? this.cursorRadius, - cursorColor: cursorColor ?? this.cursorColor, - keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance, - scrollPadding: scrollPadding ?? this.scrollPadding, - enableInteractiveSelection: - enableInteractiveSelection ?? this.enableInteractiveSelection, - ); - } -} - -class _CupertinoSuggestionsBox { - static const int waitMetricsTimeoutMillis = 1000; - static const double minOverlaySpace = 64.0; - - final BuildContext context; - final AxisDirection desiredDirection; - final bool autoFlipDirection; - final bool autoFlipListDirection; - - OverlayEntry? _overlayEntry; - AxisDirection direction; - - bool isOpened = false; - bool widgetMounted = true; - double maxHeight = 300.0; - double textBoxWidth = 100.0; - double textBoxHeight = 100.0; - late double directionUpOffset; - - _CupertinoSuggestionsBox( - this.context, - this.direction, - this.autoFlipDirection, - this.autoFlipListDirection, - ) : desiredDirection = direction; - - void open() { - if (this.isOpened) return; - assert(this._overlayEntry != null); - resize(); - Overlay.of(context)!.insert(this._overlayEntry!); - this.isOpened = true; - } - - void close() { - if (!this.isOpened) return; - assert(this._overlayEntry != null); - this._overlayEntry!.remove(); - this.isOpened = false; - } - - void toggle() { - if (this.isOpened) { - this.close(); - } else { - this.open(); - } - } - - MediaQuery? _findRootMediaQuery() { - MediaQuery? rootMediaQuery; - context.visitAncestorElements((element) { - if (element.widget is MediaQuery) { - rootMediaQuery = element.widget as MediaQuery; - } - return true; - }); - - return rootMediaQuery; - } - - /// Delays until the keyboard has toggled or the orientation has fully changed - Future _waitChangeMetrics() async { - if (widgetMounted) { - // initial viewInsets which are before the keyboard is toggled - EdgeInsets initial = MediaQuery.of(context).viewInsets; - // initial MediaQuery for orientation change - MediaQuery? initialRootMediaQuery = _findRootMediaQuery(); - - int timer = 0; - // viewInsets or MediaQuery have changed once keyboard has toggled or orientation has changed - while (widgetMounted && timer < waitMetricsTimeoutMillis) { - // TODO: reduce delay if showDialog ever exposes detection of animation end - await Future.delayed(const Duration(milliseconds: 170)); - timer += 170; - - if (widgetMounted && - (MediaQuery.of(context).viewInsets != initial || - _findRootMediaQuery() != initialRootMediaQuery)) { - return true; - } - } - } - - return false; - } - - void resize() { - // check to see if widget is still mounted - // user may have closed the widget with the keyboard still open - if (widgetMounted) { - _adjustMaxHeightAndOrientation(); - _overlayEntry!.markNeedsBuild(); - } - } - - // See if there's enough room in the desired direction for the overlay to display - // correctly. If not, try the opposite direction if things look more roomy there - void _adjustMaxHeightAndOrientation() { - CupertinoTypeAheadField widget = context.widget as CupertinoTypeAheadField; - - RenderBox box = context.findRenderObject() as RenderBox; - textBoxWidth = box.size.width; - textBoxHeight = box.size.height; - - // top of text box - double textBoxAbsY = box.localToGlobal(Offset.zero).dy; - - // height of window - double windowHeight = MediaQuery.of(context).size.height; - - // we need to find the root MediaQuery for the unsafe area height - // we cannot use BuildContext.ancestorWidgetOfExactType because - // widgets like SafeArea creates a new MediaQuery with the padding removed - MediaQuery rootMediaQuery = _findRootMediaQuery()!; - - // height of keyboard - double keyboardHeight = rootMediaQuery.data.viewInsets.bottom; - - double maxHDesired = _calculateMaxHeight(desiredDirection, box, widget, - windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); - - // if there's enough room in the desired direction, update the direction and the max height - if (maxHDesired >= minOverlaySpace || !autoFlipDirection) { - direction = desiredDirection; - maxHeight = maxHDesired; - } else { - // There's not enough room in the desired direction so see how much room is in the opposite direction - AxisDirection flipped = flipAxisDirection(desiredDirection); - double maxHFlipped = _calculateMaxHeight(flipped, box, widget, - windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); - - // if there's more room in this opposite direction, update the direction and maxHeight - if (maxHFlipped > maxHDesired) { - direction = flipped; - maxHeight = maxHFlipped; - } - } - - if (maxHeight < 0) maxHeight = 0; - } - - double _calculateMaxHeight( - AxisDirection direction, - RenderBox box, - CupertinoTypeAheadField widget, - double windowHeight, - MediaQuery rootMediaQuery, - double keyboardHeight, - double textBoxAbsY) { - return direction == AxisDirection.down - ? _calculateMaxHeightDown(box, widget, windowHeight, rootMediaQuery, - keyboardHeight, textBoxAbsY) - : _calculateMaxHeightUp(box, widget, windowHeight, rootMediaQuery, - keyboardHeight, textBoxAbsY); - } - - double _calculateMaxHeightDown( - RenderBox box, - CupertinoTypeAheadField widget, - double windowHeight, - MediaQuery rootMediaQuery, - double keyboardHeight, - double textBoxAbsY) { - // unsafe area, ie: iPhone X 'home button' - // keyboardHeight includes unsafeAreaHeight, if keyboard is showing, set to 0 - double unsafeAreaHeight = - keyboardHeight == 0 ? rootMediaQuery.data.padding.bottom : 0; - - return windowHeight - - keyboardHeight - - unsafeAreaHeight - - textBoxHeight - - textBoxAbsY - - 2 * widget.suggestionsBoxVerticalOffset; - } - - double _calculateMaxHeightUp( - RenderBox box, - CupertinoTypeAheadField widget, - double windowHeight, - MediaQuery rootMediaQuery, - double keyboardHeight, - double textBoxAbsY) { - // recalculate keyboard absolute y value - double keyboardAbsY = windowHeight - keyboardHeight; - - directionUpOffset = textBoxAbsY > keyboardAbsY - ? keyboardAbsY - textBoxAbsY - widget.suggestionsBoxVerticalOffset - : -widget.suggestionsBoxVerticalOffset; - - // unsafe area, ie: iPhone X notch - double unsafeAreaHeight = rootMediaQuery.data.padding.top; - - return textBoxAbsY > keyboardAbsY - ? keyboardAbsY - - unsafeAreaHeight - - 2 * widget.suggestionsBoxVerticalOffset - : textBoxAbsY - - unsafeAreaHeight - - 2 * widget.suggestionsBoxVerticalOffset; - } - - Future onChangeMetrics() async { - if (await _waitChangeMetrics()) { - resize(); - } - } -} - -/// Supply an instance of this class to the [TypeAhead.suggestionsBoxController] -/// property to manually control the suggestions box -class CupertinoSuggestionsBoxController { - _CupertinoSuggestionsBox? _suggestionsBox; - FocusNode? _effectiveFocusNode; - - /// Opens the suggestions box - void open() { - _effectiveFocusNode!.requestFocus(); - } - - /// Closes the suggestions box - void close() { - _effectiveFocusNode!.unfocus(); - } - - /// Opens the suggestions box if closed and vice-versa - void toggle() { - if (_suggestionsBox!.isOpened) { - close(); - } else { - open(); - } - } - - /// Recalculates the height of the suggestions box - void resize() { - _suggestionsBox!.resize(); - } -} diff --git a/lib/src/flutter_typeahead.dart b/lib/src/flutter_typeahead.dart deleted file mode 100644 index 7485b50e..00000000 --- a/lib/src/flutter_typeahead.dart +++ /dev/null @@ -1,2064 +0,0 @@ -/// # Flutter TypeAhead -/// A TypeAhead widget for Flutter, where you can show suggestions to -/// users as they type -/// -/// ## Features -/// * Shows suggestions in an overlay that floats on top of other widgets -/// * Allows you to specify what the suggestions will look like through a -/// builder function -/// * Allows you to specify what happens when the user taps a suggestion -/// * Accepts all the parameters that traditional TextFields accept, like -/// decoration, custom TextEditingController, text styling, etc. -/// * Provides two versions, a normal version and a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) -/// version that accepts validation, submitting, etc. -/// * Provides high customizability; you can customize the suggestion box decoration, -/// the loading bar, the animation, the debounce duration, etc. -/// -/// ## Installation -/// See the [installation instructions on pub](https://pub.dartlang.org/packages/flutter_typeahead#-installing-tab-). -/// -/// ## Usage examples -/// You can import the package with: -/// ```dart -/// import 'package:flutter_typeahead/flutter_typeahead.dart'; -/// ``` -/// -/// and then use it as follows: -/// -/// ### Example 1: -/// ```dart -/// TypeAheadField( -/// textFieldConfiguration: TextFieldConfiguration( -/// autofocus: true, -/// style: DefaultTextStyle.of(context).style.copyWith( -/// fontStyle: FontStyle.italic -/// ), -/// decoration: InputDecoration( -/// border: OutlineInputBorder() -/// ) -/// ), -/// suggestionsCallback: (pattern) async { -/// return await BackendService.getSuggestions(pattern); -/// }, -/// itemBuilder: (context, suggestion) { -/// return ListTile( -/// leading: Icon(Icons.shopping_cart), -/// title: Text(suggestion['name']), -/// subtitle: Text('\$${suggestion['price']}'), -/// ); -/// }, -/// onSuggestionSelected: (suggestion) { -/// Navigator.of(context).push(MaterialPageRoute( -/// builder: (context) => ProductPage(product: suggestion) -/// )); -/// }, -/// ) -/// ``` -/// In the code above, the `textFieldConfiguration` property allows us to -/// configure the displayed `TextField` as we want. In this example, we are -/// configuring the `autofocus`, `style` and `decoration` properties. -/// -/// The `suggestionsCallback` is called with the search string that the user -/// types, and is expected to return a `List` of data either synchronously or -/// asynchronously. In this example, we are calling an asynchronous function -/// called `BackendService.getSuggestions` which fetches the list of -/// suggestions. -/// -/// The `itemBuilder` is called to build a widget for each suggestion. -/// In this example, we build a simple `ListTile` that shows the name and the -/// price of the item. Please note that you shouldn't provide an `onTap` -/// callback here. The TypeAhead widget takes care of that. -/// -/// The `onSuggestionSelected` is a callback called when the user taps a -/// suggestion. In this example, when the user taps a -/// suggestion, we navigate to a page that shows us the information of the -/// tapped product. -/// -/// ### Example 2: -/// Here's another example, where we use the TypeAheadFormField inside a `Form`: -/// ```dart -/// final GlobalKey _formKey = GlobalKey(); -/// final TextEditingController _typeAheadController = TextEditingController(); -/// String _selectedCity; -/// ... -/// Form( -/// key: this._formKey, -/// child: Padding( -/// padding: EdgeInsets.all(32.0), -/// child: Column( -/// children: [ -/// Text( -/// 'What is your favorite city?' -/// ), -/// TypeAheadFormField( -/// textFieldConfiguration: TextFieldConfiguration( -/// controller: this._typeAheadController, -/// decoration: InputDecoration( -/// labelText: 'City' -/// ) -/// ), -/// suggestionsCallback: (pattern) { -/// return CitiesService.getSuggestions(pattern); -/// }, -/// itemBuilder: (context, suggestion) { -/// return ListTile( -/// title: Text(suggestion), -/// ); -/// }, -/// transitionBuilder: (context, suggestionsBox, controller) { -/// return suggestionsBox; -/// }, -/// onSuggestionSelected: (suggestion) { -/// this._typeAheadController.text = suggestion; -/// }, -/// validator: (value) { -/// if (value.isEmpty) { -/// return 'Please select a city'; -/// } -/// }, -/// onSaved: (value) => this._selectedCity = value, -/// ), -/// SizedBox(height: 10.0,), -/// RaisedButton( -/// child: Text('Submit'), -/// onPressed: () { -/// if (this._formKey.currentState.validate()) { -/// this._formKey.currentState.save(); -/// Scaffold.of(context).showSnackBar(SnackBar( -/// content: Text('Your Favorite City is ${this._selectedCity}') -/// )); -/// } -/// }, -/// ) -/// ], -/// ), -/// ), -/// ) -/// ``` -/// Here, we assign to the `controller` property of the `textFieldConfiguration` -/// a `TextEditingController` that we call `_typeAheadController`. -/// We use this controller in the `onSuggestionSelected` callback to set the -/// value of the `TextField` to the selected suggestion. -/// -/// The `validator` callback can be used like any `FormField.validator` -/// function. In our example, it checks whether a value has been entered, -/// and displays an error message if not. The `onSaved` callback is used to -/// save the value of the field to the `_selectedCity` member variable. -/// -/// The `transitionBuilder` allows us to customize the animation of the -/// suggestion box. In this example, we are returning the suggestionsBox -/// immediately, meaning that we don't want any animation. -/// -/// ## Customizations -/// TypeAhead widgets consist of a TextField and a suggestion box that shows -/// as the user types. Both are highly customizable -/// -/// ### Customizing the TextField -/// You can customize the text field using the `textFieldConfiguration` property. -/// You provide this property with an instance of `TextFieldConfiguration`, -/// which allows you to configure all the usual properties of `TextField`, like -/// `decoration`, `style`, `controller`, `focusNode`, `autofocus`, `enabled`, -/// etc. -/// -/// ### Customizing the Suggestions Box -/// TypeAhead provides default configurations for the suggestions box. You can, -/// however, override most of them. -/// -/// #### Customizing the loader, the error and the "no items found" message -/// You can use the [loadingBuilder], [errorBuilder] and [noItemsFoundBuilder] to -/// customize their corresponding widgets. For example, to show a custom error -/// widget: -/// ```dart -/// errorBuilder: (BuildContext context, Object error) => -/// Text( -/// '$error', -/// style: TextStyle( -/// color: Theme.of(context).errorColor -/// ) -/// ) -/// ``` -/// #### Customizing the animation -/// You can customize the suggestion box animation through 3 parameters: the -/// `animationDuration`, the `animationStart`, and the `transitionBuilder`. -/// -/// The `animationDuration` specifies how long the animation should take, while the -/// `animationStart` specified what point (between 0.0 and 1.0) the animation -/// should start from. The `transitionBuilder` accepts the `suggestionsBox` and -/// `animationController` as parameters, and should return a widget that uses -/// the `animationController` to animate the display of the `suggestionsBox`. -/// For example: -/// ```dart -/// transitionBuilder: (context, suggestionsBox, animationController) => -/// FadeTransition( -/// child: suggestionsBox, -/// opacity: CurvedAnimation( -/// parent: animationController, -/// curve: Curves.fastOutSlowIn -/// ), -/// ) -/// ``` -/// This uses [FadeTransition](https://docs.flutter.io/flutter/widgets/FadeTransition-class.html) -/// to fade the `suggestionsBox` into the view. Note how the -/// `animationController` was provided as the parent of the animation. -/// -/// In order to fully remove the animation, `transitionBuilder` should simply -/// return the `suggestionsBox`. This callback could also be used to wrap the -/// `suggestionsBox` with any desired widgets, not necessarily for animation. -/// -/// #### Customizing the debounce duration -/// The suggestions box does not fire for each character the user types. Instead, -/// we wait until the user is idle for a duration of time, and then call the -/// `suggestionsCallback`. The duration defaults to 300 milliseconds, but can be -/// configured using the `debounceDuration` parameter. -/// -/// #### Customizing the offset of the suggestions box -/// By default, the suggestions box is displayed 5 pixels below the `TextField`. -/// You can change this by changing the `suggestionsBoxVerticalOffset` property. -/// -/// #### Customizing the decoration of the suggestions box -/// You can also customize the decoration of the suggestions box using the -/// `suggestionsBoxDecoration` property. For example, to remove the elevation -/// of the suggestions box, you can write: -/// ```dart -/// suggestionsBoxDecoration: SuggestionsBoxDecoration( -/// elevation: 0.0 -/// ) -/// ``` -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; -import 'package:flutter_typeahead/src/keyboard_suggestion_selection_notifier.dart'; -import 'package:flutter_typeahead/src/should_refresh_suggestion_focus_index_notifier.dart'; - -import 'typedef.dart'; -import 'utils.dart'; - -/// A [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) -/// implementation of [TypeAheadField], that allows the value to be saved, -/// validated, etc. -/// -/// See also: -/// -/// * [TypeAheadField], A [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) -/// that displays a list of suggestions as the user types -class TypeAheadFormField extends FormField { - /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) - /// that the TypeAhead widget displays - final TextFieldConfiguration textFieldConfiguration; - - // Adds a callback for resetting the form field - void Function()? onReset; - - /// Creates a [TypeAheadFormField] - TypeAheadFormField( - {Key? key, - String? initialValue, - bool getImmediateSuggestions: false, - @Deprecated('Use autovalidateMode parameter which provides more specific ' - 'behavior related to auto validation. ' - 'This feature was deprecated after Flutter v1.19.0.') - bool autovalidate: false, - bool enabled: true, - AutovalidateMode autovalidateMode: AutovalidateMode.disabled, - FormFieldSetter? onSaved, - this.onReset, - FormFieldValidator? validator, - ErrorBuilder? errorBuilder, - WidgetBuilder? noItemsFoundBuilder, - WidgetBuilder? loadingBuilder, - void Function(bool)? onSuggestionsBoxToggle, - Duration debounceDuration: const Duration(milliseconds: 300), - SuggestionsBoxDecoration suggestionsBoxDecoration: - const SuggestionsBoxDecoration(), - SuggestionsBoxController? suggestionsBoxController, - required SuggestionSelectionCallback onSuggestionSelected, - required ItemBuilder itemBuilder, - required SuggestionsCallback suggestionsCallback, - double suggestionsBoxVerticalOffset: 5.0, - this.textFieldConfiguration: const TextFieldConfiguration(), - AnimationTransitionBuilder? transitionBuilder, - Duration animationDuration: const Duration(milliseconds: 500), - double animationStart: 0.25, - AxisDirection direction: AxisDirection.down, - bool hideOnLoading: false, - bool hideOnEmpty: false, - bool hideOnError: false, - bool hideSuggestionsOnKeyboardHide: true, - bool keepSuggestionsOnLoading: true, - bool keepSuggestionsOnSuggestionSelected: false, - bool autoFlipDirection: false, - bool autoFlipListDirection: true, - bool hideKeyboard: false, - int minCharsForSuggestions: 0, - bool hideKeyboardOnDrag: false}) - : assert( - initialValue == null || textFieldConfiguration.controller == null), - assert(minCharsForSuggestions >= 0), - super( - key: key, - onSaved: onSaved, - validator: validator, - initialValue: textFieldConfiguration.controller != null - ? textFieldConfiguration.controller!.text - : (initialValue ?? ''), - enabled: enabled, - autovalidateMode: autovalidateMode, - builder: (FormFieldState field) { - final _TypeAheadFormFieldState state = - field as _TypeAheadFormFieldState; - - return TypeAheadField( - getImmediateSuggestions: getImmediateSuggestions, - transitionBuilder: transitionBuilder, - errorBuilder: errorBuilder, - noItemsFoundBuilder: noItemsFoundBuilder, - loadingBuilder: loadingBuilder, - debounceDuration: debounceDuration, - suggestionsBoxDecoration: suggestionsBoxDecoration, - suggestionsBoxController: suggestionsBoxController, - textFieldConfiguration: textFieldConfiguration.copyWith( - decoration: textFieldConfiguration.decoration - .copyWith(errorText: state.errorText), - onChanged: (text) { - state.didChange(text); - textFieldConfiguration.onChanged?.call(text); - }, - controller: state._effectiveController, - ), - suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, - onSuggestionSelected: onSuggestionSelected, - onSuggestionsBoxToggle: onSuggestionsBoxToggle, - itemBuilder: itemBuilder, - suggestionsCallback: suggestionsCallback, - animationStart: animationStart, - animationDuration: animationDuration, - direction: direction, - hideOnLoading: hideOnLoading, - hideOnEmpty: hideOnEmpty, - hideOnError: hideOnError, - hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, - keepSuggestionsOnLoading: keepSuggestionsOnLoading, - keepSuggestionsOnSuggestionSelected: - keepSuggestionsOnSuggestionSelected, - autoFlipDirection: autoFlipDirection, - autoFlipListDirection: autoFlipListDirection, - hideKeyboard: hideKeyboard, - minCharsForSuggestions: minCharsForSuggestions, - hideKeyboardOnDrag: hideKeyboardOnDrag, - ); - }); - - @override - _TypeAheadFormFieldState createState() => _TypeAheadFormFieldState(); -} - -class _TypeAheadFormFieldState extends FormFieldState { - TextEditingController? _controller; - - TextEditingController? get _effectiveController => - widget.textFieldConfiguration.controller ?? _controller; - - @override - TypeAheadFormField get widget => super.widget as TypeAheadFormField; - - @override - void initState() { - super.initState(); - if (widget.textFieldConfiguration.controller == null) { - _controller = TextEditingController(text: widget.initialValue); - } else { - widget.textFieldConfiguration.controller! - .addListener(_handleControllerChanged); - } - } - - @override - void didUpdateWidget(TypeAheadFormField oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.textFieldConfiguration.controller != - oldWidget.textFieldConfiguration.controller) { - oldWidget.textFieldConfiguration.controller - ?.removeListener(_handleControllerChanged); - widget.textFieldConfiguration.controller - ?.addListener(_handleControllerChanged); - - if (oldWidget.textFieldConfiguration.controller != null && - widget.textFieldConfiguration.controller == null) - _controller = TextEditingController.fromValue( - oldWidget.textFieldConfiguration.controller!.value); - if (widget.textFieldConfiguration.controller != null) { - setValue(widget.textFieldConfiguration.controller!.text); - if (oldWidget.textFieldConfiguration.controller == null) - _controller = null; - } - } - } - - @override - void dispose() { - widget.textFieldConfiguration.controller - ?.removeListener(_handleControllerChanged); - super.dispose(); - } - - @override - void reset() { - super.reset(); - setState(() { - _effectiveController!.text = widget.initialValue!; - if (widget.onReset != null) { - widget.onReset!(); - } - }); - } - - void _handleControllerChanged() { - // Suppress changes that originated from within this class. - // - // In the case where a controller has been passed in to this widget, we - // register this change listener. In these cases, we'll also receive change - // notifications for changes originating from within this class -- for - // example, the reset() method. In such cases, the FormField value will - // already have been set. - if (_effectiveController!.text != value) - didChange(_effectiveController!.text); - } -} - -/// A [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) -/// that displays a list of suggestions as the user types -/// -/// See also: -/// -/// * [TypeAheadFormField], a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) -/// implementation of [TypeAheadField] that allows the value to be saved, -/// validated, etc. -class TypeAheadField extends StatefulWidget { - /// Called with the search pattern to get the search suggestions. - /// - /// This callback must not be null. It is be called by the TypeAhead widget - /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) - /// of suggestions either synchronously, or asynchronously (as the result of a - /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). - /// Typically, the list of suggestions should not contain more than 4 or 5 - /// entries. These entries will then be provided to [itemBuilder] to display - /// the suggestions. - /// - /// Example: - /// ```dart - /// suggestionsCallback: (pattern) async { - /// return await _getSuggestions(pattern); - /// } - /// ``` - final SuggestionsCallback suggestionsCallback; - - /// Called when a suggestion is tapped. - /// - /// This callback must not be null. It is called by the TypeAhead widget and - /// provided with the value of the tapped suggestion. - /// - /// For example, you might want to navigate to a specific view when the user - /// tabs a suggestion: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// Navigator.of(context).push(MaterialPageRoute( - /// builder: (context) => SearchResult( - /// searchItem: suggestion - /// ) - /// )); - /// } - /// ``` - /// - /// Or to set the value of the text field: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// _controller.text = suggestion['name']; - /// } - /// ``` - final SuggestionSelectionCallback onSuggestionSelected; - - /// Called for each suggestion returned by [suggestionsCallback] to build the - /// corresponding widget. - /// - /// This callback must not be null. It is called by the TypeAhead widget for - /// each suggestion, and expected to build a widget to display this - /// suggestion's info. For example: - /// - /// ```dart - /// itemBuilder: (context, suggestion) { - /// return ListTile( - /// title: Text(suggestion['name']), - /// subtitle: Text('USD' + suggestion['price'].toString()) - /// ); - /// } - /// ``` - final ItemBuilder itemBuilder; - - /// used to control the scroll behavior of item-builder list - final ScrollController? scrollController; - - /// The decoration of the material sheet that contains the suggestions. - /// - /// If null, default decoration with an elevation of 4.0 is used - /// - - final SuggestionsBoxDecoration suggestionsBoxDecoration; - - /// Used to control the `_SuggestionsBox`. Allows manual control to - /// open, close, toggle, or resize the `_SuggestionsBox`. - final SuggestionsBoxController? suggestionsBoxController; - - /// The duration to wait after the user stops typing before calling - /// [suggestionsCallback] - /// - /// This is useful, because, if not set, a request for suggestions will be - /// sent for every character that the user types. - /// - /// This duration is set by default to 300 milliseconds - final Duration debounceDuration; - - /// Called when waiting for [suggestionsCallback] to return. - /// - /// It is expected to return a widget to display while waiting. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('Loading...'); - /// } - /// ``` - /// - /// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown - final WidgetBuilder? loadingBuilder; - - /// Called when [suggestionsCallback] returns an empty array. - /// - /// It is expected to return a widget to display when no suggestions are - /// avaiable. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('No Items Found!'); - /// } - /// ``` - /// - /// If not specified, a simple text is shown - final WidgetBuilder? noItemsFoundBuilder; - - /// Called when [suggestionsCallback] throws an exception. - /// - /// It is called with the error object, and expected to return a widget to - /// display when an exception is thrown - /// For example: - /// ```dart - /// (BuildContext context, error) { - /// return Text('$error'); - /// } - /// ``` - /// - /// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html) - final ErrorBuilder? errorBuilder; - - /// Called to display animations when [suggestionsCallback] returns suggestions - /// - /// It is provided with the suggestions box instance and the animation - /// controller, and expected to return some animation that uses the controller - /// to display the suggestion box. - /// - /// For example: - /// ```dart - /// transitionBuilder: (context, suggestionsBox, animationController) { - /// return FadeTransition( - /// child: suggestionsBox, - /// opacity: CurvedAnimation( - /// parent: animationController, - /// curve: Curves.fastOutSlowIn - /// ), - /// ); - /// } - /// ``` - /// This argument is best used with [animationDuration] and [animationStart] - /// to fully control the animation. - /// - /// To fully remove the animation, just return `suggestionsBox` - /// - /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. - final AnimationTransitionBuilder? transitionBuilder; - - /// The duration that [transitionBuilder] animation takes. - /// - /// This argument is best used with [transitionBuilder] and [animationStart] - /// to fully control the animation. - /// - /// Defaults to 500 milliseconds. - final Duration animationDuration; - - /// Determine the [SuggestionBox]'s direction. - /// - /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] - /// and the [_SuggestionsList] will grow **down**. - /// - /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] - /// and the [_SuggestionsList] will grow **up**. - /// - /// [AxisDirection.left] and [AxisDirection.right] are not allowed. - final AxisDirection direction; - - /// The value at which the [transitionBuilder] animation starts. - /// - /// This argument is best used with [transitionBuilder] and [animationDuration] - /// to fully control the animation. - /// - /// Defaults to 0.25. - final double animationStart; - - /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) - /// that the TypeAhead widget displays - final TextFieldConfiguration textFieldConfiguration; - - /// How far below the text field should the suggestions box be - /// - /// Defaults to 5.0 - final double suggestionsBoxVerticalOffset; - - /// If set to true, suggestions will be fetched immediately when the field is - /// added to the view. - /// - /// But the suggestions box will only be shown when the field receives focus. - /// To make the field receive focus immediately, you can set the `autofocus` - /// property in the [textFieldConfiguration] to true - /// - /// Defaults to false - final bool getImmediateSuggestions; - - /// If set to true, no loading box will be shown while suggestions are - /// being fetched. [loadingBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnLoading; - - /// If set to true, nothing will be shown if there are no results. - /// [noItemsFoundBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnEmpty; - - /// If set to true, nothing will be shown if there is an error. - /// [errorBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnError; - - /// If set to false, the suggestions box will stay opened after - /// the keyboard is closed. - /// - /// Defaults to true. - final bool hideSuggestionsOnKeyboardHide; - - /// If set to false, the suggestions box will show a circular - /// progress indicator when retrieving suggestions. - /// - /// Defaults to true. - final bool keepSuggestionsOnLoading; - - /// If set to true, the suggestions box will remain opened even after - /// selecting a suggestion. - /// - /// Note that if this is enabled, the only way - /// to close the suggestions box is either manually via the - /// `SuggestionsBoxController` or when the user closes the software - /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users - /// with a physical keyboard will be unable to close the - /// box without a manual way via `SuggestionsBoxController`. - /// - /// Defaults to false. - final bool keepSuggestionsOnSuggestionSelected; - - /// If set to true, in the case where the suggestions box has less than - /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis - /// will be temporarily flipped if there's more room available in the opposite - /// direction. - /// - /// Defaults to false - final bool autoFlipDirection; - - /// If set to false, suggestion list will not be reversed according to the - /// [autoFlipDirection] property. - /// - /// Defaults to true. - final bool autoFlipListDirection; - - final bool hideKeyboard; - - /// The minimum number of characters which must be entered before - /// [suggestionsCallback] is triggered. - /// - /// Defaults to 0. - final int minCharsForSuggestions; - - /// If set to true and if the user scrolls through the suggestion list, hide the keyboard automatically. - /// If set to false, the keyboard remains visible. - /// Throws an exception, if hideKeyboardOnDrag and hideSuggestionsOnKeyboardHide are both set to true as - /// they are mutual exclusive. - /// - /// Defaults to false - final bool hideKeyboardOnDrag; - - // Adds a callback for the suggestion box opening or closing - final void Function(bool)? onSuggestionsBoxToggle; - - /// Creates a [TypeAheadField] - TypeAheadField({ - Key? key, - required this.suggestionsCallback, - required this.itemBuilder, - required this.onSuggestionSelected, - this.textFieldConfiguration: const TextFieldConfiguration(), - this.suggestionsBoxDecoration: const SuggestionsBoxDecoration(), - this.debounceDuration: const Duration(milliseconds: 300), - this.suggestionsBoxController, - this.scrollController, - this.loadingBuilder, - this.noItemsFoundBuilder, - this.errorBuilder, - this.transitionBuilder, - this.animationStart: 0.25, - this.animationDuration: const Duration(milliseconds: 500), - this.getImmediateSuggestions: false, - this.suggestionsBoxVerticalOffset: 5.0, - this.direction: AxisDirection.down, - this.hideOnLoading: false, - this.hideOnEmpty: false, - this.hideOnError: false, - this.hideSuggestionsOnKeyboardHide: true, - this.keepSuggestionsOnLoading: true, - this.keepSuggestionsOnSuggestionSelected: false, - this.autoFlipDirection: false, - this.autoFlipListDirection: true, - this.hideKeyboard: false, - this.minCharsForSuggestions: 0, - this.onSuggestionsBoxToggle, - this.hideKeyboardOnDrag: false, - }) : assert(animationStart >= 0.0 && animationStart <= 1.0), - assert( - direction == AxisDirection.down || direction == AxisDirection.up), - assert(minCharsForSuggestions >= 0), - assert(!hideKeyboardOnDrag || - hideKeyboardOnDrag && !hideSuggestionsOnKeyboardHide), - super(key: key); - - @override - _TypeAheadFieldState createState() => _TypeAheadFieldState(); -} - -class _TypeAheadFieldState extends State> - with WidgetsBindingObserver { - FocusNode? _focusNode; - final KeyboardSuggestionSelectionNotifier - _keyboardSuggestionSelectionNotifier = - KeyboardSuggestionSelectionNotifier(); - TextEditingController? _textEditingController; - _SuggestionsBox? _suggestionsBox; - - TextEditingController? get _effectiveController => - widget.textFieldConfiguration.controller ?? _textEditingController; - FocusNode? get _effectiveFocusNode => - widget.textFieldConfiguration.focusNode ?? _focusNode; - late VoidCallback _focusNodeListener; - - final LayerLink _layerLink = LayerLink(); - - // Timer that resizes the suggestion box on each tick. Only active when the user is scrolling. - Timer? _resizeOnScrollTimer; - // The rate at which the suggestion box will resize when the user is scrolling - final Duration _resizeOnScrollRefreshRate = const Duration(milliseconds: 500); - // Will have a value if the typeahead is inside a scrollable widget - ScrollPosition? _scrollPosition; - - // Keyboard detection - final Stream? _keyboardVisibility = - (supportedPlatform) ? KeyboardVisibilityController().onChange : null; - late StreamSubscription? _keyboardVisibilitySubscription; - - bool _areSuggestionsFocused = false; - late final _shouldRefreshSuggestionsFocusIndex = - ShouldRefreshSuggestionFocusIndexNotifier( - textFieldFocusNode: _effectiveFocusNode); - - @override - void didChangeMetrics() { - // Catch keyboard event and orientation change; resize suggestions list - this._suggestionsBox!.onChangeMetrics(); - } - - @override - void dispose() { - this._suggestionsBox!.close(); - this._suggestionsBox!.widgetMounted = false; - WidgetsBinding.instance.removeObserver(this); - _keyboardVisibilitySubscription?.cancel(); - _effectiveFocusNode!.removeListener(_focusNodeListener); - _focusNode?.dispose(); - _resizeOnScrollTimer?.cancel(); - _scrollPosition?.removeListener(_scrollResizeListener); - _textEditingController?.dispose(); - _keyboardSuggestionSelectionNotifier.dispose(); - super.dispose(); - } - - KeyEventResult _onKeyEvent(FocusNode _, RawKeyEvent event) { - if (event.isKeyPressed(LogicalKeyboardKey.arrowUp) || - event.isKeyPressed(LogicalKeyboardKey.arrowDown)) { - // do nothing to avoid puzzling users until keyboard arrow nav is implemented - } else { - _keyboardSuggestionSelectionNotifier.onKeyboardEvent(event); - } - return KeyEventResult.ignored; - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - - if (widget.textFieldConfiguration.controller == null) { - this._textEditingController = TextEditingController(); - } - - final textFieldConfigurationFocusNode = - widget.textFieldConfiguration.focusNode; - if (textFieldConfigurationFocusNode == null) { - this._focusNode = FocusNode(onKey: _onKeyEvent); - } else if (textFieldConfigurationFocusNode.onKey == null) { - // * we add the _onKeyEvent callback to the textFieldConfiguration focusNode - textFieldConfigurationFocusNode.onKey = ((node, event) { - final keyEventResult = _onKeyEvent(node, event); - return keyEventResult; - }); - } else { - final onKeyCopy = textFieldConfigurationFocusNode.onKey!; - textFieldConfigurationFocusNode.onKey = ((node, event) { - _onKeyEvent(node, event); - return onKeyCopy(node, event); - }); - } - - this._suggestionsBox = _SuggestionsBox( - context, - widget.direction, - widget.autoFlipDirection, - widget.autoFlipListDirection, - ); - - widget.suggestionsBoxController?._suggestionsBox = this._suggestionsBox; - widget.suggestionsBoxController?._effectiveFocusNode = - this._effectiveFocusNode; - - this._focusNodeListener = () { - if (_effectiveFocusNode!.hasFocus) { - this._suggestionsBox!.open(); - } else if (!_areSuggestionsFocused) { - if (widget.hideSuggestionsOnKeyboardHide) { - this._suggestionsBox!.close(); - } - } - - widget.onSuggestionsBoxToggle?.call(this._suggestionsBox!.isOpened); - }; - - this._effectiveFocusNode!.addListener(_focusNodeListener); - - // hide suggestions box on keyboard closed - this._keyboardVisibilitySubscription = - _keyboardVisibility?.listen((bool isVisible) { - if (widget.hideSuggestionsOnKeyboardHide && !isVisible) { - _effectiveFocusNode!.unfocus(); - } - }); - - WidgetsBinding.instance.addPostFrameCallback((duration) { - if (mounted) { - this._initOverlayEntry(); - // calculate initial suggestions list size - this._suggestionsBox!.resize(); - - // in case we already missed the focus event - if (this._effectiveFocusNode!.hasFocus) { - this._suggestionsBox!.open(); - } - } - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final scrollableState = Scrollable.maybeOf(context); - if (scrollableState != null) { - // The TypeAheadField is inside a scrollable widget - _scrollPosition = scrollableState.position; - - _scrollPosition!.removeListener(_scrollResizeListener); - _scrollPosition!.isScrollingNotifier.addListener(_scrollResizeListener); - } - } - - void _scrollResizeListener() { - bool isScrolling = _scrollPosition!.isScrollingNotifier.value; - _resizeOnScrollTimer?.cancel(); - if (isScrolling) { - // Scroll started - _resizeOnScrollTimer = - Timer.periodic(_resizeOnScrollRefreshRate, (timer) { - _suggestionsBox!.resize(); - }); - } else { - // Scroll finished - _suggestionsBox!.resize(); - } - } - - void _initOverlayEntry() { - this._suggestionsBox!._overlayEntry = OverlayEntry(builder: (context) { - void giveTextFieldFocus() { - _effectiveFocusNode?.requestFocus(); - _areSuggestionsFocused = false; - } - - void onSuggestionFocus() { - if (!_areSuggestionsFocused) { - _areSuggestionsFocused = true; - } - } - - final suggestionsList = _SuggestionsList( - suggestionsBox: _suggestionsBox, - decoration: widget.suggestionsBoxDecoration, - debounceDuration: widget.debounceDuration, - controller: this._effectiveController, - loadingBuilder: widget.loadingBuilder, - scrollController: widget.scrollController, - noItemsFoundBuilder: widget.noItemsFoundBuilder, - errorBuilder: widget.errorBuilder, - transitionBuilder: widget.transitionBuilder, - suggestionsCallback: widget.suggestionsCallback, - animationDuration: widget.animationDuration, - animationStart: widget.animationStart, - getImmediateSuggestions: widget.getImmediateSuggestions, - onSuggestionSelected: (T selection) { - if (!widget.keepSuggestionsOnSuggestionSelected) { - this._effectiveFocusNode!.unfocus(); - this._suggestionsBox!.close(); - } - widget.onSuggestionSelected(selection); - }, - itemBuilder: widget.itemBuilder, - direction: _suggestionsBox!.direction, - hideOnLoading: widget.hideOnLoading, - hideOnEmpty: widget.hideOnEmpty, - hideOnError: widget.hideOnError, - keepSuggestionsOnLoading: widget.keepSuggestionsOnLoading, - minCharsForSuggestions: widget.minCharsForSuggestions, - keyboardSuggestionSelectionNotifier: - _keyboardSuggestionSelectionNotifier, - shouldRefreshSuggestionFocusIndexNotifier: - _shouldRefreshSuggestionsFocusIndex, - giveTextFieldFocus: giveTextFieldFocus, - onSuggestionFocus: onSuggestionFocus, - onKeyEvent: _onKeyEvent, - hideKeyboardOnDrag: widget.hideKeyboardOnDrag); - - double w = _suggestionsBox!.textBoxWidth; - if (widget.suggestionsBoxDecoration.constraints != null) { - if (widget.suggestionsBoxDecoration.constraints!.minWidth != 0.0 && - widget.suggestionsBoxDecoration.constraints!.maxWidth != - double.infinity) { - w = (widget.suggestionsBoxDecoration.constraints!.minWidth + - widget.suggestionsBoxDecoration.constraints!.maxWidth) / - 2; - } else if (widget.suggestionsBoxDecoration.constraints!.minWidth != - 0.0 && - widget.suggestionsBoxDecoration.constraints!.minWidth > w) { - w = widget.suggestionsBoxDecoration.constraints!.minWidth; - } else if (widget.suggestionsBoxDecoration.constraints!.maxWidth != - double.infinity && - widget.suggestionsBoxDecoration.constraints!.maxWidth < w) { - w = widget.suggestionsBoxDecoration.constraints!.maxWidth; - } - } - - final Widget compositedFollower = CompositedTransformFollower( - link: this._layerLink, - showWhenUnlinked: false, - offset: Offset( - widget.suggestionsBoxDecoration.offsetX, - _suggestionsBox!.direction == AxisDirection.down - ? _suggestionsBox!.textBoxHeight + - widget.suggestionsBoxVerticalOffset - : _suggestionsBox!.directionUpOffset), - child: _suggestionsBox!.direction == AxisDirection.down - ? suggestionsList - : FractionalTranslation( - translation: Offset(0.0, -1.0), // visually flips list to go up - child: suggestionsList, - ), - ); - - // When wrapped in the Positioned widget, the suggestions box widget - // is placed before the Scaffold semantically. In order to have the - // suggestions box navigable from the search input or keyboard, - // Semantics > Align > ConstrainedBox are needed. This does not change - // the style visually. However, when VO/TB are not enabled it is - // necessary to use the Positioned widget to allow the elements to be - // properly tappable. - return MediaQuery.of(context).accessibleNavigation - ? Semantics( - container: true, - child: Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: w), - child: compositedFollower, - ), - ), - ) - : Positioned( - width: w, - child: compositedFollower, - ); - }); - } - - @override - Widget build(BuildContext context) { - return CompositedTransformTarget( - link: this._layerLink, - child: TextField( - key: TestKeys.textFieldKey, - focusNode: this._effectiveFocusNode, - controller: this._effectiveController, - decoration: widget.textFieldConfiguration.decoration, - style: widget.textFieldConfiguration.style, - textAlign: widget.textFieldConfiguration.textAlign, - enabled: widget.textFieldConfiguration.enabled, - keyboardType: widget.textFieldConfiguration.keyboardType, - autofocus: widget.textFieldConfiguration.autofocus, - inputFormatters: widget.textFieldConfiguration.inputFormatters, - autocorrect: widget.textFieldConfiguration.autocorrect, - maxLines: widget.textFieldConfiguration.maxLines, - textAlignVertical: widget.textFieldConfiguration.textAlignVertical, - minLines: widget.textFieldConfiguration.minLines, - maxLength: widget.textFieldConfiguration.maxLength, - maxLengthEnforcement: - widget.textFieldConfiguration.maxLengthEnforcement, - obscureText: widget.textFieldConfiguration.obscureText, - onChanged: widget.textFieldConfiguration.onChanged, - onSubmitted: widget.textFieldConfiguration.onSubmitted, - onEditingComplete: widget.textFieldConfiguration.onEditingComplete, - onTap: widget.textFieldConfiguration.onTap, -// onTapOutside: (_) {}, - scrollPadding: widget.textFieldConfiguration.scrollPadding, - textInputAction: widget.textFieldConfiguration.textInputAction, - textCapitalization: widget.textFieldConfiguration.textCapitalization, - keyboardAppearance: widget.textFieldConfiguration.keyboardAppearance, - cursorWidth: widget.textFieldConfiguration.cursorWidth, - cursorRadius: widget.textFieldConfiguration.cursorRadius, - cursorColor: widget.textFieldConfiguration.cursorColor, - textDirection: widget.textFieldConfiguration.textDirection, - enableInteractiveSelection: - widget.textFieldConfiguration.enableInteractiveSelection, - readOnly: widget.hideKeyboard, - ), - ); - } -} - -class _SuggestionsList extends StatefulWidget { - final _SuggestionsBox? suggestionsBox; - final TextEditingController? controller; - final bool getImmediateSuggestions; - final SuggestionSelectionCallback? onSuggestionSelected; - final SuggestionsCallback? suggestionsCallback; - final ItemBuilder? itemBuilder; - final ScrollController? scrollController; - final SuggestionsBoxDecoration? decoration; - final Duration? debounceDuration; - final WidgetBuilder? loadingBuilder; - final WidgetBuilder? noItemsFoundBuilder; - final ErrorBuilder? errorBuilder; - final AnimationTransitionBuilder? transitionBuilder; - final Duration? animationDuration; - final double? animationStart; - final AxisDirection? direction; - final bool? hideOnLoading; - final bool? hideOnEmpty; - final bool? hideOnError; - final bool? keepSuggestionsOnLoading; - final int? minCharsForSuggestions; - final KeyboardSuggestionSelectionNotifier keyboardSuggestionSelectionNotifier; - final ShouldRefreshSuggestionFocusIndexNotifier - shouldRefreshSuggestionFocusIndexNotifier; - final VoidCallback giveTextFieldFocus; - final VoidCallback onSuggestionFocus; - final KeyEventResult Function(FocusNode _, RawKeyEvent event) onKeyEvent; - final bool hideKeyboardOnDrag; - - _SuggestionsList({ - required this.suggestionsBox, - this.controller, - this.getImmediateSuggestions: false, - this.onSuggestionSelected, - this.suggestionsCallback, - this.itemBuilder, - this.scrollController, - this.decoration, - this.debounceDuration, - this.loadingBuilder, - this.noItemsFoundBuilder, - this.errorBuilder, - this.transitionBuilder, - this.animationDuration, - this.animationStart, - this.direction, - this.hideOnLoading, - this.hideOnEmpty, - this.hideOnError, - this.keepSuggestionsOnLoading, - this.minCharsForSuggestions, - required this.keyboardSuggestionSelectionNotifier, - required this.shouldRefreshSuggestionFocusIndexNotifier, - required this.giveTextFieldFocus, - required this.onSuggestionFocus, - required this.onKeyEvent, - required this.hideKeyboardOnDrag, - }); - - @override - _SuggestionsListState createState() => _SuggestionsListState(); -} - -class _SuggestionsListState extends State<_SuggestionsList> - with SingleTickerProviderStateMixin { - Iterable? _suggestions; - late bool _suggestionsValid; - late VoidCallback _controllerListener; - Timer? _debounceTimer; - bool? _isLoading, _isQueued; - Object? _error; - AnimationController? _animationController; - String? _lastTextValue; - late final ScrollController _scrollController = - widget.scrollController ?? ScrollController(); - List _focusNodes = []; - int _suggestionIndex = -1; - - _SuggestionsListState() { - this._controllerListener = () { - // If we came here because of a change in selected text, not because of - // actual change in text - if (widget.controller!.text == this._lastTextValue) return; - - this._lastTextValue = widget.controller!.text; - - this._debounceTimer?.cancel(); - if (widget.controller!.text.length < widget.minCharsForSuggestions!) { - if (mounted) { - setState(() { - _isLoading = false; - _suggestions = null; - _suggestionsValid = true; - }); - } - return; - } else { - this._debounceTimer = Timer(widget.debounceDuration!, () async { - if (this._debounceTimer!.isActive) return; - if (_isLoading!) { - _isQueued = true; - return; - } - - await this.invalidateSuggestions(); - while (_isQueued!) { - _isQueued = false; - await this.invalidateSuggestions(); - } - }); - } - }; - } - - @override - void didUpdateWidget(_SuggestionsList oldWidget) { - super.didUpdateWidget(oldWidget); - widget.controller!.addListener(this._controllerListener); - _getSuggestions(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _getSuggestions(); - } - - @override - void initState() { - super.initState(); - - this._animationController = AnimationController( - vsync: this, - duration: widget.animationDuration, - ); - - this._suggestionsValid = widget.minCharsForSuggestions! > 0 ? true : false; - this._isLoading = false; - this._isQueued = false; - this._lastTextValue = widget.controller!.text; - - if (widget.getImmediateSuggestions) { - this._getSuggestions(); - } - - widget.controller!.addListener(this._controllerListener); - - widget.keyboardSuggestionSelectionNotifier.addListener(() { - final suggestionsLength = _suggestions?.length; - final event = widget.keyboardSuggestionSelectionNotifier.value; - if (event == null || suggestionsLength == null) return; - - if (event == LogicalKeyboardKey.arrowDown && - _suggestionIndex < suggestionsLength - 1) { - _suggestionIndex++; - } else if (event == LogicalKeyboardKey.arrowUp && _suggestionIndex > -1) { - _suggestionIndex--; - } - - if (_suggestionIndex > -1 && _suggestionIndex < _focusNodes.length) { - final focusNode = _focusNodes[_suggestionIndex]; - focusNode.requestFocus(); - widget.onSuggestionFocus(); - } else { - widget.giveTextFieldFocus(); - } - }); - - widget.shouldRefreshSuggestionFocusIndexNotifier.addListener(() { - if (_suggestionIndex != -1) { - _suggestionIndex = -1; - } - }); - } - - Future invalidateSuggestions() async { - _suggestionsValid = false; - await _getSuggestions(); - } - - Future _getSuggestions() async { - if (_suggestionsValid) return; - _suggestionsValid = true; - - if (mounted) { - setState(() { - this._animationController!.forward(from: 1.0); - - this._isLoading = true; - this._error = null; - }); - - Iterable? suggestions; - Object? error; - - try { - suggestions = - await widget.suggestionsCallback!(widget.controller!.text); - } catch (e) { - error = e; - } - - if (this.mounted) { - // if it wasn't removed in the meantime - setState(() { - double? animationStart = widget.animationStart; - // allow suggestionsCallback to return null and not throw error here - if (error != null || suggestions?.isEmpty == true) { - animationStart = 1.0; - } - this._animationController!.forward(from: animationStart); - - this._error = error; - this._isLoading = false; - this._suggestions = suggestions; - _focusNodes = List.generate( - _suggestions?.length ?? 0, - (index) => FocusNode(onKey: (_, event) { - return widget.onKeyEvent(_, event); - }), - ); - }); - } - } - } - - @override - void dispose() { - _animationController!.dispose(); - _debounceTimer?.cancel(); - for (final focusNode in _focusNodes) { - focusNode.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - bool isEmpty = - this._suggestions?.length == 0 && widget.controller!.text == ""; - if ((this._suggestions == null || isEmpty) && - this._isLoading == false && - this._error == null) return Container(); - - Widget child; - if (this._isLoading!) { - if (widget.hideOnLoading!) { - child = Container(height: 0); - } else { - child = createLoadingWidget(); - } - } else if (this._error != null) { - if (widget.hideOnError!) { - child = Container(height: 0); - } else { - child = createErrorWidget(); - } - } else if (this._suggestions!.isEmpty) { - if (widget.hideOnEmpty!) { - child = Container(height: 0); - } else { - child = createNoItemsFoundWidget(); - } - } else { - child = createSuggestionsWidget(); - } - - final animationChild = widget.transitionBuilder != null - ? widget.transitionBuilder!(context, child, this._animationController) - : SizeTransition( - axisAlignment: -1.0, - sizeFactor: CurvedAnimation( - parent: this._animationController!, - curve: Curves.fastOutSlowIn), - child: child, - ); - - BoxConstraints constraints; - if (widget.decoration!.constraints == null) { - constraints = BoxConstraints( - maxHeight: widget.suggestionsBox!.maxHeight, - ); - } else { - double maxHeight = min(widget.decoration!.constraints!.maxHeight, - widget.suggestionsBox!.maxHeight); - constraints = widget.decoration!.constraints!.copyWith( - minHeight: min(widget.decoration!.constraints!.minHeight, maxHeight), - maxHeight: maxHeight, - ); - } - - var container = Material( - elevation: widget.decoration!.elevation, - color: widget.decoration!.color, - shape: widget.decoration!.shape, - borderRadius: widget.decoration!.borderRadius, - shadowColor: widget.decoration!.shadowColor, - clipBehavior: widget.decoration!.clipBehavior, - child: ConstrainedBox( - constraints: constraints, - child: animationChild, - ), - ); - - return container; - } - - Widget createLoadingWidget() { - Widget child; - - if (widget.keepSuggestionsOnLoading! && this._suggestions != null) { - if (this._suggestions!.isEmpty) { - child = createNoItemsFoundWidget(); - } else { - child = createSuggestionsWidget(); - } - } else { - child = widget.loadingBuilder != null - ? widget.loadingBuilder!(context) - : Align( - alignment: Alignment.center, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: CircularProgressIndicator(), - ), - ); - } - - return child; - } - - Widget createErrorWidget() { - return widget.errorBuilder != null - ? widget.errorBuilder!(context, this._error) - : Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Error: ${this._error}', - style: TextStyle(color: Theme.of(context).errorColor), - ), - ); - } - - Widget createNoItemsFoundWidget() { - return widget.noItemsFoundBuilder != null - ? widget.noItemsFoundBuilder!(context) - : Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - 'No Items Found!', - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).disabledColor, fontSize: 18.0), - ), - ); - } - - Widget createSuggestionsWidget() { - Widget child = ListView( - padding: EdgeInsets.zero, - primary: false, - shrinkWrap: true, - keyboardDismissBehavior: widget.hideKeyboardOnDrag - ? ScrollViewKeyboardDismissBehavior.onDrag - : ScrollViewKeyboardDismissBehavior.manual, - controller: _scrollController, - reverse: widget.suggestionsBox!.direction == AxisDirection.down - ? false - : widget.suggestionsBox!.autoFlipListDirection, - children: List.generate(this._suggestions!.length, (index) { - final suggestion = _suggestions!.elementAt(index); - final focusNode = _focusNodes[index]; - - return TextFieldTapRegion( - child: InkWell( - key: TestKeys.getSuggestionKey(index), - focusColor: Theme.of(context).hoverColor, - focusNode: focusNode, - child: widget.itemBuilder!(context, suggestion), - onTap: () { - // * we give the focus back to the text field - widget.giveTextFieldFocus(); - - widget.onSuggestionSelected!(suggestion); - }, - ), - ); - }), - ); - - if (widget.decoration!.hasScrollbar) { - child = Scrollbar( - controller: _scrollController, - child: child, - ); - } - - return child; - } -} - -/// Supply an instance of this class to the [TypeAhead.suggestionsBoxDecoration] -/// property to configure the suggestions box decoration -class SuggestionsBoxDecoration { - /// The z-coordinate at which to place the suggestions box. This controls the size - /// of the shadow below the box. - /// - /// Same as [Material.elevation](https://docs.flutter.io/flutter/material/Material/elevation.html) - final double elevation; - - /// The color to paint the suggestions box. - /// - /// Same as [Material.color](https://docs.flutter.io/flutter/material/Material/color.html) - final Color? color; - - /// Defines the material's shape as well its shadow. - /// - /// Same as [Material.shape](https://docs.flutter.io/flutter/material/Material/shape.html) - final ShapeBorder? shape; - - /// Defines if a scrollbar will be displayed or not. - final bool hasScrollbar; - - /// If non-null, the corners of this box are rounded by this [BorderRadius](https://docs.flutter.io/flutter/painting/BorderRadius-class.html). - /// - /// Same as [Material.borderRadius](https://docs.flutter.io/flutter/material/Material/borderRadius.html) - final BorderRadius? borderRadius; - - /// The color to paint the shadow below the material. - /// - /// Same as [Material.shadowColor](https://docs.flutter.io/flutter/material/Material/shadowColor.html) - final Color shadowColor; - - /// The constraints to be applied to the suggestions box - final BoxConstraints? constraints; - - /// Adds an offset to the suggestions box - final double offsetX; - - /// The content will be clipped (or not) according to this option. - /// - /// Same as [Material.clipBehavior](https://api.flutter.dev/flutter/material/Material/clipBehavior.html) - final Clip clipBehavior; - - /// Creates a SuggestionsBoxDecoration - const SuggestionsBoxDecoration( - {this.elevation: 4.0, - this.color, - this.shape, - this.hasScrollbar: true, - this.borderRadius, - this.shadowColor: const Color(0xFF000000), - this.constraints, - this.clipBehavior: Clip.none, - this.offsetX: 0.0}); -} - -/// Supply an instance of this class to the [TypeAhead.textFieldConfiguration] -/// property to configure the displayed text field -class TextFieldConfiguration { - /// The decoration to show around the text field. - /// - /// Same as [TextField.decoration](https://docs.flutter.io/flutter/material/TextField/decoration.html) - final InputDecoration decoration; - - /// Controls the text being edited. - /// - /// If null, this widget will create its own [TextEditingController](https://docs.flutter.io/flutter/widgets/TextEditingController-class.html). - /// A typical use case for this field in the TypeAhead widget is to set the - /// text of the widget when a suggestion is selected. For example: - /// - /// ```dart - /// final _controller = TextEditingController(); - /// ... - /// ... - /// TypeAheadField( - /// controller: _controller, - /// ... - /// ... - /// onSuggestionSelected: (suggestion) { - /// _controller.text = suggestion['city_name']; - /// } - /// ) - /// ``` - final TextEditingController? controller; - - /// Controls whether this widget has keyboard focus. - /// - /// Same as [TextField.focusNode](https://docs.flutter.io/flutter/material/TextField/focusNode.html) - final FocusNode? focusNode; - - /// The style to use for the text being edited. - /// - /// Same as [TextField.style](https://docs.flutter.io/flutter/material/TextField/style.html) - final TextStyle? style; - - /// How the text being edited should be aligned horizontally. - /// - /// Same as [TextField.textAlign](https://docs.flutter.io/flutter/material/TextField/textAlign.html) - final TextAlign textAlign; - - /// Same as [TextField.textDirection](https://docs.flutter.io/flutter/material/TextField/textDirection.html) - /// - /// Defaults to null - final TextDirection? textDirection; - - /// Same as [TextField.textAlignVertical](https://api.flutter.dev/flutter/material/TextField/textAlignVertical.html) - final TextAlignVertical? textAlignVertical; - - /// If false the textfield is "disabled": it ignores taps and its - /// [decoration] is rendered in grey. - /// - /// Same as [TextField.enabled](https://docs.flutter.io/flutter/material/TextField/enabled.html) - final bool enabled; - - /// Whether to show input suggestions as the user types. - /// - /// Same as [TextField.enableSuggestions](https://api.flutter.dev/flutter/material/TextField/enableSuggestions.html) - final bool enableSuggestions; - - /// The type of keyboard to use for editing the text. - /// - /// Same as [TextField.keyboardType](https://docs.flutter.io/flutter/material/TextField/keyboardType.html) - final TextInputType keyboardType; - - /// Whether this text field should focus itself if nothing else is already - /// focused. - /// - /// Same as [TextField.autofocus](https://docs.flutter.io/flutter/material/TextField/autofocus.html) - final bool autofocus; - - /// Optional input validation and formatting overrides. - /// - /// Same as [TextField.inputFormatters](https://docs.flutter.io/flutter/material/TextField/inputFormatters.html) - final List? inputFormatters; - - /// Whether to enable autocorrection. - /// - /// Same as [TextField.autocorrect](https://docs.flutter.io/flutter/material/TextField/autocorrect.html) - final bool autocorrect; - - /// The maximum number of lines for the text to span, wrapping if necessary. - /// - /// Same as [TextField.maxLines](https://docs.flutter.io/flutter/material/TextField/maxLines.html) - final int? maxLines; - - /// The minimum number of lines to occupy when the content spans fewer lines. - /// - /// Same as [TextField.minLines](https://docs.flutter.io/flutter/material/TextField/minLines.html) - final int? minLines; - - /// The maximum number of characters (Unicode scalar values) to allow in the - /// text field. - /// - /// Same as [TextField.maxLength](https://docs.flutter.io/flutter/material/TextField/maxLength.html) - final int? maxLength; - - /// If true, prevents the field from allowing more than [maxLength] - /// characters. - /// - /// Same as [TextField.maxLengthEnforcement](https://api.flutter.dev/flutter/material/TextField/maxLengthEnforcement.html) - final MaxLengthEnforcement? maxLengthEnforcement; - - /// Whether to hide the text being edited (e.g., for passwords). - /// - /// Same as [TextField.obscureText](https://docs.flutter.io/flutter/material/TextField/obscureText.html) - final bool obscureText; - - /// Called when the text being edited changes. - /// - /// Same as [TextField.onChanged](https://docs.flutter.io/flutter/material/TextField/onChanged.html) - final ValueChanged? onChanged; - - /// Called when the user indicates that they are done editing the text in the - /// field. - /// - /// Same as [TextField.onSubmitted](https://docs.flutter.io/flutter/material/TextField/onSubmitted.html) - final ValueChanged? onSubmitted; - - /// The color to use when painting the cursor. - /// - /// Same as [TextField.cursorColor](https://docs.flutter.io/flutter/material/TextField/cursorColor.html) - final Color? cursorColor; - - /// How rounded the corners of the cursor should be. By default, the cursor has a null Radius - /// - /// Same as [TextField.cursorRadius](https://docs.flutter.io/flutter/material/TextField/cursorRadius.html) - final Radius? cursorRadius; - - /// How thick the cursor will be. - /// - /// Same as [TextField.cursorWidth](https://docs.flutter.io/flutter/material/TextField/cursorWidth.html) - final double cursorWidth; - - /// The appearance of the keyboard. - /// - /// Same as [TextField.keyboardAppearance](https://docs.flutter.io/flutter/material/TextField/keyboardAppearance.html) - final Brightness? keyboardAppearance; - - /// Called when the user submits editable content (e.g., user presses the "done" button on the keyboard). - /// - /// Same as [TextField.onEditingComplete](https://docs.flutter.io/flutter/material/TextField/onEditingComplete.html) - final VoidCallback? onEditingComplete; - - /// Called for each distinct tap except for every second tap of a double tap. - /// - /// Same as [TextField.onTap](https://docs.flutter.io/flutter/material/TextField/onTap.html) - final GestureTapCallback? onTap; - - /// Configures padding to edges surrounding a Scrollable when the Textfield scrolls into view. - /// - /// Same as [TextField.scrollPadding](https://docs.flutter.io/flutter/material/TextField/scrollPadding.html) - final EdgeInsets scrollPadding; - - /// Configures how the platform keyboard will select an uppercase or lowercase keyboard. - /// - /// Same as [TextField.TextCapitalization](https://docs.flutter.io/flutter/material/TextField/textCapitalization.html) - final TextCapitalization textCapitalization; - - /// The type of action button to use for the keyboard. - /// - /// Same as [TextField.textInputAction](https://docs.flutter.io/flutter/material/TextField/textInputAction.html) - final TextInputAction? textInputAction; - - final bool enableInteractiveSelection; - - /// Creates a TextFieldConfiguration - const TextFieldConfiguration({ - this.decoration: const InputDecoration(), - this.style, - this.controller, - this.onChanged, - this.onSubmitted, - this.obscureText: false, - this.maxLengthEnforcement, - this.maxLength, - this.maxLines: 1, - this.minLines, - this.textAlignVertical, - this.autocorrect: true, - this.inputFormatters, - this.autofocus: false, - this.keyboardType: TextInputType.text, - this.enabled: true, - this.enableSuggestions: true, - this.textAlign: TextAlign.start, - this.focusNode, - this.cursorColor, - this.cursorRadius, - this.textInputAction, - this.textCapitalization: TextCapitalization.none, - this.cursorWidth: 2.0, - this.keyboardAppearance, - this.onEditingComplete, - this.onTap, - this.textDirection, - this.scrollPadding: const EdgeInsets.all(20.0), - this.enableInteractiveSelection: true, - }); - - /// Copies the [TextFieldConfiguration] and only changes the specified - /// properties - TextFieldConfiguration copyWith( - {InputDecoration? decoration, - TextStyle? style, - TextEditingController? controller, - ValueChanged? onChanged, - ValueChanged? onSubmitted, - bool? obscureText, - MaxLengthEnforcement? maxLengthEnforcement, - int? maxLength, - int? maxLines, - int? minLines, - bool? autocorrect, - List? inputFormatters, - bool? autofocus, - TextInputType? keyboardType, - bool? enabled, - bool? enableSuggestions, - TextAlign? textAlign, - FocusNode? focusNode, - Color? cursorColor, - TextAlignVertical? textAlignVertical, - Radius? cursorRadius, - double? cursorWidth, - Brightness? keyboardAppearance, - VoidCallback? onEditingComplete, - GestureTapCallback? onTap, - EdgeInsets? scrollPadding, - TextCapitalization? textCapitalization, - TextDirection? textDirection, - TextInputAction? textInputAction, - bool? enableInteractiveSelection}) { - return TextFieldConfiguration( - decoration: decoration ?? this.decoration, - style: style ?? this.style, - controller: controller ?? this.controller, - onChanged: onChanged ?? this.onChanged, - onSubmitted: onSubmitted ?? this.onSubmitted, - obscureText: obscureText ?? this.obscureText, - maxLengthEnforcement: maxLengthEnforcement ?? this.maxLengthEnforcement, - maxLength: maxLength ?? this.maxLength, - maxLines: maxLines ?? this.maxLines, - minLines: minLines ?? this.minLines, - autocorrect: autocorrect ?? this.autocorrect, - inputFormatters: inputFormatters ?? this.inputFormatters, - autofocus: autofocus ?? this.autofocus, - keyboardType: keyboardType ?? this.keyboardType, - enabled: enabled ?? this.enabled, - enableSuggestions: enableSuggestions ?? this.enableSuggestions, - textAlign: textAlign ?? this.textAlign, - textAlignVertical: textAlignVertical ?? this.textAlignVertical, - focusNode: focusNode ?? this.focusNode, - cursorColor: cursorColor ?? this.cursorColor, - cursorRadius: cursorRadius ?? this.cursorRadius, - cursorWidth: cursorWidth ?? this.cursorWidth, - keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance, - onEditingComplete: onEditingComplete ?? this.onEditingComplete, - onTap: onTap ?? this.onTap, - scrollPadding: scrollPadding ?? this.scrollPadding, - textCapitalization: textCapitalization ?? this.textCapitalization, - textInputAction: textInputAction ?? this.textInputAction, - textDirection: textDirection ?? this.textDirection, - enableInteractiveSelection: - enableInteractiveSelection ?? this.enableInteractiveSelection, - ); - } -} - -class _SuggestionsBox { - static const int waitMetricsTimeoutMillis = 1000; - static const double minOverlaySpace = 64.0; - - final BuildContext context; - final AxisDirection desiredDirection; - final bool autoFlipDirection; - final bool autoFlipListDirection; - - OverlayEntry? _overlayEntry; - AxisDirection direction; - - bool isOpened = false; - bool widgetMounted = true; - double maxHeight = 300.0; - double textBoxWidth = 100.0; - double textBoxHeight = 100.0; - late double directionUpOffset; - - _SuggestionsBox( - this.context, - this.direction, - this.autoFlipDirection, - this.autoFlipListDirection, - ) : desiredDirection = direction; - - void open() { - if (this.isOpened) return; - assert(this._overlayEntry != null); - resize(); - Overlay.of(context)!.insert(this._overlayEntry!); - this.isOpened = true; - } - - void close() { - if (!this.isOpened) return; - assert(this._overlayEntry != null); - this._overlayEntry!.remove(); - this.isOpened = false; - } - - void toggle() { - if (this.isOpened) { - this.close(); - } else { - this.open(); - } - } - - MediaQuery? _findRootMediaQuery() { - MediaQuery? rootMediaQuery; - context.visitAncestorElements((element) { - if (element.widget is MediaQuery) { - rootMediaQuery = element.widget as MediaQuery; - } - return true; - }); - - return rootMediaQuery; - } - - /// Delays until the keyboard has toggled or the orientation has fully changed - Future _waitChangeMetrics() async { - if (widgetMounted) { - // initial viewInsets which are before the keyboard is toggled - EdgeInsets initial = MediaQuery.of(context).viewInsets; - // initial MediaQuery for orientation change - MediaQuery? initialRootMediaQuery = _findRootMediaQuery(); - - int timer = 0; - // viewInsets or MediaQuery have changed once keyboard has toggled or orientation has changed - while (widgetMounted && timer < waitMetricsTimeoutMillis) { - // TODO: reduce delay if showDialog ever exposes detection of animation end - await Future.delayed(const Duration(milliseconds: 170)); - timer += 170; - - if (widgetMounted && - (MediaQuery.of(context).viewInsets != initial || - _findRootMediaQuery() != initialRootMediaQuery)) { - return true; - } - } - } - - return false; - } - - void resize() { - // check to see if widget is still mounted - // user may have closed the widget with the keyboard still open - if (widgetMounted) { - _adjustMaxHeightAndOrientation(); - _overlayEntry!.markNeedsBuild(); - } - } - - // See if there's enough room in the desired direction for the overlay to display - // correctly. If not, try the opposite direction if things look more roomy there - void _adjustMaxHeightAndOrientation() { - TypeAheadField widget = context.widget as TypeAheadField; - - RenderBox? box = context.findRenderObject() as RenderBox?; - if (box == null || box.hasSize == false) { - return; - } - - textBoxWidth = box.size.width; - textBoxHeight = box.size.height; - - // top of text box - double textBoxAbsY = box.localToGlobal(Offset.zero).dy; - - // height of window - double windowHeight = MediaQuery.of(context).size.height; - - // we need to find the root MediaQuery for the unsafe area height - // we cannot use BuildContext.ancestorWidgetOfExactType because - // widgets like SafeArea creates a new MediaQuery with the padding removed - MediaQuery rootMediaQuery = _findRootMediaQuery()!; - - // height of keyboard - double keyboardHeight = rootMediaQuery.data.viewInsets.bottom; - - double maxHDesired = _calculateMaxHeight(desiredDirection, box, widget, - windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); - - // if there's enough room in the desired direction, update the direction and the max height - if (maxHDesired >= minOverlaySpace || !autoFlipDirection) { - direction = desiredDirection; - maxHeight = maxHDesired; - } else { - // There's not enough room in the desired direction so see how much room is in the opposite direction - AxisDirection flipped = flipAxisDirection(desiredDirection); - double maxHFlipped = _calculateMaxHeight(flipped, box, widget, - windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); - - // if there's more room in this opposite direction, update the direction and maxHeight - if (maxHFlipped > maxHDesired) { - direction = flipped; - maxHeight = maxHFlipped; - } - } - - if (maxHeight < 0) maxHeight = 0; - } - - double _calculateMaxHeight( - AxisDirection direction, - RenderBox box, - TypeAheadField widget, - double windowHeight, - MediaQuery rootMediaQuery, - double keyboardHeight, - double textBoxAbsY) { - return direction == AxisDirection.down - ? _calculateMaxHeightDown(box, widget, windowHeight, rootMediaQuery, - keyboardHeight, textBoxAbsY) - : _calculateMaxHeightUp(box, widget, windowHeight, rootMediaQuery, - keyboardHeight, textBoxAbsY); - } - - double _calculateMaxHeightDown( - RenderBox box, - TypeAheadField widget, - double windowHeight, - MediaQuery rootMediaQuery, - double keyboardHeight, - double textBoxAbsY) { - // unsafe area, ie: iPhone X 'home button' - // keyboardHeight includes unsafeAreaHeight, if keyboard is showing, set to 0 - double unsafeAreaHeight = - keyboardHeight == 0 ? rootMediaQuery.data.padding.bottom : 0; - - return windowHeight - - keyboardHeight - - unsafeAreaHeight - - textBoxHeight - - textBoxAbsY - - 2 * widget.suggestionsBoxVerticalOffset; - } - - double _calculateMaxHeightUp( - RenderBox box, - TypeAheadField widget, - double windowHeight, - MediaQuery rootMediaQuery, - double keyboardHeight, - double textBoxAbsY) { - // recalculate keyboard absolute y value - double keyboardAbsY = windowHeight - keyboardHeight; - - directionUpOffset = textBoxAbsY > keyboardAbsY - ? keyboardAbsY - textBoxAbsY - widget.suggestionsBoxVerticalOffset - : -widget.suggestionsBoxVerticalOffset; - - // unsafe area, ie: iPhone X notch - double unsafeAreaHeight = rootMediaQuery.data.padding.top; - - return textBoxAbsY > keyboardAbsY - ? keyboardAbsY - - unsafeAreaHeight - - 2 * widget.suggestionsBoxVerticalOffset - : textBoxAbsY - - unsafeAreaHeight - - 2 * widget.suggestionsBoxVerticalOffset; - } - - Future onChangeMetrics() async { - if (await _waitChangeMetrics()) { - resize(); - } - } -} - -/// Supply an instance of this class to the [TypeAhead.suggestionsBoxController] -/// property to manually control the suggestions box -class SuggestionsBoxController { - _SuggestionsBox? _suggestionsBox; - FocusNode? _effectiveFocusNode; - - /// Opens the suggestions box - void open() { - _effectiveFocusNode?.requestFocus(); - } - - bool isOpened() { - return _suggestionsBox?.isOpened ?? false; - } - - /// Closes the suggestions box - void close() { - _effectiveFocusNode?.unfocus(); - } - - /// Opens the suggestions box if closed and vice-versa - void toggle() { - if (_suggestionsBox?.isOpened ?? false) { - close(); - } else { - open(); - } - } - - /// Recalculates the height of the suggestions box - void resize() { - _suggestionsBox!.resize(); - } -} - -@visibleForTesting -class TestKeys { - TestKeys._(); - - static const textFieldKey = ValueKey("text-field"); - static ValueKey getSuggestionKey(int index) => - ValueKey("suggestion-$index"); -} diff --git a/lib/src/material/field/test_keys.dart b/lib/src/material/field/test_keys.dart new file mode 100644 index 00000000..922dbffa --- /dev/null +++ b/lib/src/material/field/test_keys.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +@visibleForTesting +class TestKeys { + TestKeys._(); + + static const textFieldKey = ValueKey("text-field"); + static ValueKey getSuggestionKey(int index) => + ValueKey("suggestion-$index"); +} \ No newline at end of file diff --git a/lib/src/material/field/text_field_configuration.dart b/lib/src/material/field/text_field_configuration.dart new file mode 100644 index 00000000..0bbde852 --- /dev/null +++ b/lib/src/material/field/text_field_configuration.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Supply an instance of this class to the [TypeAhead.textFieldConfiguration] +/// property to configure the displayed text field +class TextFieldConfiguration { + /// The decoration to show around the text field. + /// + /// Same as [TextField.decoration](https://docs.flutter.io/flutter/material/TextField/decoration.html) + final InputDecoration decoration; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController](https://docs.flutter.io/flutter/widgets/TextEditingController-class.html). + /// A typical use case for this field in the TypeAhead widget is to set the + /// text of the widget when a suggestion is selected. For example: + /// + /// ```dart + /// final _controller = TextEditingController(); + /// ... + /// ... + /// TypeAheadField( + /// controller: _controller, + /// ... + /// ... + /// onSuggestionSelected: (suggestion) { + /// _controller.text = suggestion['city_name']; + /// } + /// ) + /// ``` + final TextEditingController? controller; + + /// Controls whether this widget has keyboard focus. + /// + /// Same as [TextField.focusNode](https://docs.flutter.io/flutter/material/TextField/focusNode.html) + final FocusNode? focusNode; + + /// The style to use for the text being edited. + /// + /// Same as [TextField.style](https://docs.flutter.io/flutter/material/TextField/style.html) + final TextStyle? style; + + /// How the text being edited should be aligned horizontally. + /// + /// Same as [TextField.textAlign](https://docs.flutter.io/flutter/material/TextField/textAlign.html) + final TextAlign textAlign; + + /// Same as [TextField.textDirection](https://docs.flutter.io/flutter/material/TextField/textDirection.html) + /// + /// Defaults to null + final TextDirection? textDirection; + + /// Same as [TextField.textAlignVertical](https://api.flutter.dev/flutter/material/TextField/textAlignVertical.html) + final TextAlignVertical? textAlignVertical; + + /// If false the textfield is "disabled": it ignores taps and its + /// [decoration] is rendered in grey. + /// + /// Same as [TextField.enabled](https://docs.flutter.io/flutter/material/TextField/enabled.html) + final bool enabled; + + /// Whether to show input suggestions as the user types. + /// + /// Same as [TextField.enableSuggestions](https://api.flutter.dev/flutter/material/TextField/enableSuggestions.html) + final bool enableSuggestions; + + /// The type of keyboard to use for editing the text. + /// + /// Same as [TextField.keyboardType](https://docs.flutter.io/flutter/material/TextField/keyboardType.html) + final TextInputType keyboardType; + + /// Whether this text field should focus itself if nothing else is already + /// focused. + /// + /// Same as [TextField.autofocus](https://docs.flutter.io/flutter/material/TextField/autofocus.html) + final bool autofocus; + + /// Optional input validation and formatting overrides. + /// + /// Same as [TextField.inputFormatters](https://docs.flutter.io/flutter/material/TextField/inputFormatters.html) + final List? inputFormatters; + + /// Whether to enable autocorrection. + /// + /// Same as [TextField.autocorrect](https://docs.flutter.io/flutter/material/TextField/autocorrect.html) + final bool autocorrect; + + /// The maximum number of lines for the text to span, wrapping if necessary. + /// + /// Same as [TextField.maxLines](https://docs.flutter.io/flutter/material/TextField/maxLines.html) + final int? maxLines; + + /// The minimum number of lines to occupy when the content spans fewer lines. + /// + /// Same as [TextField.minLines](https://docs.flutter.io/flutter/material/TextField/minLines.html) + final int? minLines; + + /// The maximum number of characters (Unicode scalar values) to allow in the + /// text field. + /// + /// Same as [TextField.maxLength](https://docs.flutter.io/flutter/material/TextField/maxLength.html) + final int? maxLength; + + /// If true, prevents the field from allowing more than [maxLength] + /// characters. + /// + /// Same as [TextField.maxLengthEnforcement](https://api.flutter.dev/flutter/material/TextField/maxLengthEnforcement.html) + final MaxLengthEnforcement? maxLengthEnforcement; + + /// Whether to hide the text being edited (e.g., for passwords). + /// + /// Same as [TextField.obscureText](https://docs.flutter.io/flutter/material/TextField/obscureText.html) + final bool obscureText; + + /// Called when the text being edited changes. + /// + /// Same as [TextField.onChanged](https://docs.flutter.io/flutter/material/TextField/onChanged.html) + final ValueChanged? onChanged; + + /// Called when the user indicates that they are done editing the text in the + /// field. + /// + /// Same as [TextField.onSubmitted](https://docs.flutter.io/flutter/material/TextField/onSubmitted.html) + final ValueChanged? onSubmitted; + + /// The color to use when painting the cursor. + /// + /// Same as [TextField.cursorColor](https://docs.flutter.io/flutter/material/TextField/cursorColor.html) + final Color? cursorColor; + + /// How rounded the corners of the cursor should be. By default, the cursor has a null Radius + /// + /// Same as [TextField.cursorRadius](https://docs.flutter.io/flutter/material/TextField/cursorRadius.html) + final Radius? cursorRadius; + + /// How thick the cursor will be. + /// + /// Same as [TextField.cursorWidth](https://docs.flutter.io/flutter/material/TextField/cursorWidth.html) + final double cursorWidth; + + /// The appearance of the keyboard. + /// + /// Same as [TextField.keyboardAppearance](https://docs.flutter.io/flutter/material/TextField/keyboardAppearance.html) + final Brightness? keyboardAppearance; + + /// Called when the user submits editable content (e.g., user presses the "done" button on the keyboard). + /// + /// Same as [TextField.onEditingComplete](https://docs.flutter.io/flutter/material/TextField/onEditingComplete.html) + final VoidCallback? onEditingComplete; + + /// Called for each distinct tap except for every second tap of a double tap. + /// + /// Same as [TextField.onTap](https://docs.flutter.io/flutter/material/TextField/onTap.html) + final GestureTapCallback? onTap; + + /// Configures padding to edges surrounding a Scrollable when the Textfield scrolls into view. + /// + /// Same as [TextField.scrollPadding](https://docs.flutter.io/flutter/material/TextField/scrollPadding.html) + final EdgeInsets scrollPadding; + + /// Configures how the platform keyboard will select an uppercase or lowercase keyboard. + /// + /// Same as [TextField.TextCapitalization](https://docs.flutter.io/flutter/material/TextField/textCapitalization.html) + final TextCapitalization textCapitalization; + + /// The type of action button to use for the keyboard. + /// + /// Same as [TextField.textInputAction](https://docs.flutter.io/flutter/material/TextField/textInputAction.html) + final TextInputAction? textInputAction; + + final bool enableInteractiveSelection; + + /// Creates a TextFieldConfiguration + const TextFieldConfiguration({ + this.decoration = const InputDecoration(), + this.style, + this.controller, + this.onChanged, + this.onSubmitted, + this.obscureText = false, + this.maxLengthEnforcement, + this.maxLength, + this.maxLines = 1, + this.minLines, + this.textAlignVertical, + this.autocorrect = true, + this.inputFormatters, + this.autofocus = false, + this.keyboardType = TextInputType.text, + this.enabled = true, + this.enableSuggestions = true, + this.textAlign = TextAlign.start, + this.focusNode, + this.cursorColor, + this.cursorRadius, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.cursorWidth = 2.0, + this.keyboardAppearance, + this.onEditingComplete, + this.onTap, + this.textDirection, + this.scrollPadding = const EdgeInsets.all(20.0), + this.enableInteractiveSelection = true, + }); + + /// Copies the [TextFieldConfiguration] and only changes the specified + /// properties + TextFieldConfiguration copyWith( + {InputDecoration? decoration, + TextStyle? style, + TextEditingController? controller, + ValueChanged? onChanged, + ValueChanged? onSubmitted, + bool? obscureText, + MaxLengthEnforcement? maxLengthEnforcement, + int? maxLength, + int? maxLines, + int? minLines, + bool? autocorrect, + List? inputFormatters, + bool? autofocus, + TextInputType? keyboardType, + bool? enabled, + bool? enableSuggestions, + TextAlign? textAlign, + FocusNode? focusNode, + Color? cursorColor, + TextAlignVertical? textAlignVertical, + Radius? cursorRadius, + double? cursorWidth, + Brightness? keyboardAppearance, + VoidCallback? onEditingComplete, + GestureTapCallback? onTap, + EdgeInsets? scrollPadding, + TextCapitalization? textCapitalization, + TextDirection? textDirection, + TextInputAction? textInputAction, + bool? enableInteractiveSelection}) { + return TextFieldConfiguration( + decoration: decoration ?? this.decoration, + style: style ?? this.style, + controller: controller ?? this.controller, + onChanged: onChanged ?? this.onChanged, + onSubmitted: onSubmitted ?? this.onSubmitted, + obscureText: obscureText ?? this.obscureText, + maxLengthEnforcement: maxLengthEnforcement ?? this.maxLengthEnforcement, + maxLength: maxLength ?? this.maxLength, + maxLines: maxLines ?? this.maxLines, + minLines: minLines ?? this.minLines, + autocorrect: autocorrect ?? this.autocorrect, + inputFormatters: inputFormatters ?? this.inputFormatters, + autofocus: autofocus ?? this.autofocus, + keyboardType: keyboardType ?? this.keyboardType, + enabled: enabled ?? this.enabled, + enableSuggestions: enableSuggestions ?? this.enableSuggestions, + textAlign: textAlign ?? this.textAlign, + textAlignVertical: textAlignVertical ?? this.textAlignVertical, + focusNode: focusNode ?? this.focusNode, + cursorColor: cursorColor ?? this.cursorColor, + cursorRadius: cursorRadius ?? this.cursorRadius, + cursorWidth: cursorWidth ?? this.cursorWidth, + keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance, + onEditingComplete: onEditingComplete ?? this.onEditingComplete, + onTap: onTap ?? this.onTap, + scrollPadding: scrollPadding ?? this.scrollPadding, + textCapitalization: textCapitalization ?? this.textCapitalization, + textInputAction: textInputAction ?? this.textInputAction, + textDirection: textDirection ?? this.textDirection, + enableInteractiveSelection: + enableInteractiveSelection ?? this.enableInteractiveSelection, + ); + } +} \ No newline at end of file diff --git a/lib/src/material/field/typeahead_field.dart b/lib/src/material/field/typeahead_field.dart new file mode 100644 index 00000000..c3256c57 --- /dev/null +++ b/lib/src/material/field/typeahead_field.dart @@ -0,0 +1,888 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:flutter_typeahead/src/material/field/test_keys.dart'; +import 'package:flutter_typeahead/src/material/field/text_field_configuration.dart'; +import 'package:flutter_typeahead/src/keyboard_suggestion_selection_notifier.dart'; +import 'package:flutter_typeahead/src/should_refresh_suggestion_focus_index_notifier.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box_controller.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box_decoration.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_list.dart'; +import 'package:flutter_typeahead/src/typedef.dart'; +import 'package:flutter_typeahead/src/utils.dart'; + +/// # Flutter TypeAhead +/// A TypeAhead widget for Flutter, where you can show suggestions to +/// users as they type +/// +/// ## Features +/// * Shows suggestions in an overlay that floats on top of other widgets +/// * Allows you to specify what the suggestions will look like through a +/// builder function +/// * Allows you to specify what happens when the user taps a suggestion +/// * Accepts all the parameters that traditional TextFields accept, like +/// decoration, custom TextEditingController, text styling, etc. +/// * Provides two versions, a normal version and a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// version that accepts validation, submitting, etc. +/// * Provides high customizability; you can customize the suggestion box decoration, +/// the loading bar, the animation, the debounce duration, etc. +/// +/// ## Installation +/// See the [installation instructions on pub](https://pub.dartlang.org/packages/flutter_typeahead#-installing-tab-). +/// +/// ## Usage examples +/// You can import the package with: +/// ```dart +/// import 'package:flutter_typeahead/flutter_typeahead.dart'; +/// ``` +/// +/// and then use it as follows: +/// +/// ### Example 1: +/// ```dart +/// TypeAheadField( +/// textFieldConfiguration: TextFieldConfiguration( +/// autofocus: true, +/// style: DefaultTextStyle.of(context).style.copyWith( +/// fontStyle: FontStyle.italic +/// ), +/// decoration: InputDecoration( +/// border: OutlineInputBorder() +/// ) +/// ), +/// suggestionsCallback: (pattern) async { +/// return await BackendService.getSuggestions(pattern); +/// }, +/// itemBuilder: (context, suggestion) { +/// return ListTile( +/// leading: Icon(Icons.shopping_cart), +/// title: Text(suggestion['name']), +/// subtitle: Text('\$${suggestion['price']}'), +/// ); +/// }, +/// onSuggestionSelected: (suggestion) { +/// Navigator.of(context).push(MaterialPageRoute( +/// builder: (context) => ProductPage(product: suggestion) +/// )); +/// }, +/// ) +/// ``` +/// In the code above, the `textFieldConfiguration` property allows us to +/// configure the displayed `TextField` as we want. In this example, we are +/// configuring the `autofocus`, `style` and `decoration` properties. +/// +/// The `suggestionsCallback` is called with the search string that the user +/// types, and is expected to return a `List` of data either synchronously or +/// asynchronously. In this example, we are calling an asynchronous function +/// called `BackendService.getSuggestions` which fetches the list of +/// suggestions. +/// +/// The `itemBuilder` is called to build a widget for each suggestion. +/// In this example, we build a simple `ListTile` that shows the name and the +/// price of the item. Please note that you shouldn't provide an `onTap` +/// callback here. The TypeAhead widget takes care of that. +/// +/// The `onSuggestionSelected` is a callback called when the user taps a +/// suggestion. In this example, when the user taps a +/// suggestion, we navigate to a page that shows us the information of the +/// tapped product. +/// +/// ### Example 2: +/// Here's another example, where we use the TypeAheadFormField inside a `Form`: +/// ```dart +/// final GlobalKey _formKey = GlobalKey(); +/// final TextEditingController _typeAheadController = TextEditingController(); +/// String _selectedCity; +/// ... +/// Form( +/// key: this._formKey, +/// child: Padding( +/// padding: EdgeInsets.all(32.0), +/// child: Column( +/// children: [ +/// Text( +/// 'What is your favorite city?' +/// ), +/// TypeAheadFormField( +/// textFieldConfiguration: TextFieldConfiguration( +/// controller: this._typeAheadController, +/// decoration: InputDecoration( +/// labelText: 'City' +/// ) +/// ), +/// suggestionsCallback: (pattern) { +/// return CitiesService.getSuggestions(pattern); +/// }, +/// itemBuilder: (context, suggestion) { +/// return ListTile( +/// title: Text(suggestion), +/// ); +/// }, +/// transitionBuilder: (context, suggestionsBox, controller) { +/// return suggestionsBox; +/// }, +/// onSuggestionSelected: (suggestion) { +/// this._typeAheadController.text = suggestion; +/// }, +/// validator: (value) { +/// if (value.isEmpty) { +/// return 'Please select a city'; +/// } +/// }, +/// onSaved: (value) => this._selectedCity = value, +/// ), +/// SizedBox(height: 10.0,), +/// RaisedButton( +/// child: Text('Submit'), +/// onPressed: () { +/// if (this._formKey.currentState.validate()) { +/// this._formKey.currentState.save(); +/// Scaffold.of(context).showSnackBar(SnackBar( +/// content: Text('Your Favorite City is ${this._selectedCity}') +/// )); +/// } +/// }, +/// ) +/// ], +/// ), +/// ), +/// ) +/// ``` +/// Here, we assign to the `controller` property of the `textFieldConfiguration` +/// a `TextEditingController` that we call `_typeAheadController`. +/// We use this controller in the `onSuggestionSelected` callback to set the +/// value of the `TextField` to the selected suggestion. +/// +/// The `validator` callback can be used like any `FormField.validator` +/// function. In our example, it checks whether a value has been entered, +/// and displays an error message if not. The `onSaved` callback is used to +/// save the value of the field to the `_selectedCity` member variable. +/// +/// The `transitionBuilder` allows us to customize the animation of the +/// suggestion box. In this example, we are returning the suggestionsBox +/// immediately, meaning that we don't want any animation. +/// +/// ## Customizations +/// TypeAhead widgets consist of a TextField and a suggestion box that shows +/// as the user types. Both are highly customizable +/// +/// ### Customizing the TextField +/// You can customize the text field using the `textFieldConfiguration` property. +/// You provide this property with an instance of `TextFieldConfiguration`, +/// which allows you to configure all the usual properties of `TextField`, like +/// `decoration`, `style`, `controller`, `focusNode`, `autofocus`, `enabled`, +/// etc. +/// +/// ### Customizing the Suggestions Box +/// TypeAhead provides default configurations for the suggestions box. You can, +/// however, override most of them. +/// +/// #### Customizing the loader, the error and the "no items found" message +/// You can use the [loadingBuilder], [errorBuilder] and [noItemsFoundBuilder] to +/// customize their corresponding widgets. For example, to show a custom error +/// widget: +/// ```dart +/// errorBuilder: (BuildContext context, Object error) => +/// Text( +/// '$error', +/// style: TextStyle( +/// color: Theme.of(context).errorColor +/// ) +/// ) +/// ``` +/// #### Customizing the animation +/// You can customize the suggestion box animation through 3 parameters: the +/// `animationDuration`, the `animationStart`, and the `transitionBuilder`. +/// +/// The `animationDuration` specifies how long the animation should take, while the +/// `animationStart` specified what point (between 0.0 and 1.0) the animation +/// should start from. The `transitionBuilder` accepts the `suggestionsBox` and +/// `animationController` as parameters, and should return a widget that uses +/// the `animationController` to animate the display of the `suggestionsBox`. +/// For example: +/// ```dart +/// transitionBuilder: (context, suggestionsBox, animationController) => +/// FadeTransition( +/// child: suggestionsBox, +/// opacity: CurvedAnimation( +/// parent: animationController, +/// curve: Curves.fastOutSlowIn +/// ), +/// ) +/// ``` +/// This uses [FadeTransition](https://docs.flutter.io/flutter/widgets/FadeTransition-class.html) +/// to fade the `suggestionsBox` into the view. Note how the +/// `animationController` was provided as the parent of the animation. +/// +/// In order to fully remove the animation, `transitionBuilder` should simply +/// return the `suggestionsBox`. This callback could also be used to wrap the +/// `suggestionsBox` with any desired widgets, not necessarily for animation. +/// +/// #### Customizing the debounce duration +/// The suggestions box does not fire for each character the user types. Instead, +/// we wait until the user is idle for a duration of time, and then call the +/// `suggestionsCallback`. The duration defaults to 300 milliseconds, but can be +/// configured using the `debounceDuration` parameter. +/// +/// #### Customizing the offset of the suggestions box +/// By default, the suggestions box is displayed 5 pixels below the `TextField`. +/// You can change this by changing the `suggestionsBoxVerticalOffset` property. +/// +/// #### Customizing the decoration of the suggestions box +/// You can also customize the decoration of the suggestions box using the +/// `suggestionsBoxDecoration` property. For example, to remove the elevation +/// of the suggestions box, you can write: +/// ```dart +/// suggestionsBoxDecoration: SuggestionsBoxDecoration( +/// elevation: 0.0 +/// ) +/// ``` +/// A [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// implementation of [TypeAheadField], that allows the value to be saved, +/// validated, etc. +/// +/// See also: +/// +/// * [TypeAheadField], A [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) +/// that displays a list of suggestions as the user types +class TypeAheadField extends StatefulWidget { + /// Called with the search pattern to get the search suggestions. + /// + /// This callback must not be null. It is be called by the TypeAhead widget + /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) + /// of suggestions either synchronously, or asynchronously (as the result of a + /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). + /// Typically, the list of suggestions should not contain more than 4 or 5 + /// entries. These entries will then be provided to [itemBuilder] to display + /// the suggestions. + /// + /// Example: + /// ```dart + /// suggestionsCallback: (pattern) async { + /// return await _getSuggestions(pattern); + /// } + /// ``` + final SuggestionsCallback suggestionsCallback; + + /// Called when a suggestion is tapped. + /// + /// This callback must not be null. It is called by the TypeAhead widget and + /// provided with the value of the tapped suggestion. + /// + /// For example, you might want to navigate to a specific view when the user + /// tabs a suggestion: + /// ```dart + /// onSuggestionSelected: (suggestion) { + /// Navigator.of(context).push(MaterialPageRoute( + /// builder: (context) => SearchResult( + /// searchItem: suggestion + /// ) + /// )); + /// } + /// ``` + /// + /// Or to set the value of the text field: + /// ```dart + /// onSuggestionSelected: (suggestion) { + /// _controller.text = suggestion['name']; + /// } + /// ``` + final SuggestionSelectionCallback onSuggestionSelected; + + /// Called for each suggestion returned by [suggestionsCallback] to build the + /// corresponding widget. + /// + /// This callback must not be null. It is called by the TypeAhead widget for + /// each suggestion, and expected to build a widget to display this + /// suggestion's info. For example: + /// + /// ```dart + /// itemBuilder: (context, suggestion) { + /// return ListTile( + /// title: Text(suggestion['name']), + /// subtitle: Text('USD' + suggestion['price'].toString()) + /// ); + /// } + /// ``` + final ItemBuilder itemBuilder; + + /// used to control the scroll behavior of item-builder list + final ScrollController? scrollController; + + /// The decoration of the material sheet that contains the suggestions. + /// + /// If null, default decoration with an elevation of 4.0 is used + /// + + final SuggestionsBoxDecoration suggestionsBoxDecoration; + + /// Used to control the `_SuggestionsBox`. Allows manual control to + /// open, close, toggle, or resize the `_SuggestionsBox`. + final SuggestionsBoxController? suggestionsBoxController; + + /// The duration to wait after the user stops typing before calling + /// [suggestionsCallback] + /// + /// This is useful, because, if not set, a request for suggestions will be + /// sent for every character that the user types. + /// + /// This duration is set by default to 300 milliseconds + final Duration debounceDuration; + + /// Called when waiting for [suggestionsCallback] to return. + /// + /// It is expected to return a widget to display while waiting. + /// For example: + /// ```dart + /// (BuildContext context) { + /// return Text('Loading...'); + /// } + /// ``` + /// + /// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown + final WidgetBuilder? loadingBuilder; + + /// Called when [suggestionsCallback] returns an empty array. + /// + /// It is expected to return a widget to display when no suggestions are + /// avaiable. + /// For example: + /// ```dart + /// (BuildContext context) { + /// return Text('No Items Found!'); + /// } + /// ``` + /// + /// If not specified, a simple text is shown + final WidgetBuilder? noItemsFoundBuilder; + + /// Called when [suggestionsCallback] throws an exception. + /// + /// It is called with the error object, and expected to return a widget to + /// display when an exception is thrown + /// For example: + /// ```dart + /// (BuildContext context, error) { + /// return Text('$error'); + /// } + /// ``` + /// + /// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html) + final ErrorBuilder? errorBuilder; + + /// Called to display animations when [suggestionsCallback] returns suggestions + /// + /// It is provided with the suggestions box instance and the animation + /// controller, and expected to return some animation that uses the controller + /// to display the suggestion box. + /// + /// For example: + /// ```dart + /// transitionBuilder: (context, suggestionsBox, animationController) { + /// return FadeTransition( + /// child: suggestionsBox, + /// opacity: CurvedAnimation( + /// parent: animationController, + /// curve: Curves.fastOutSlowIn + /// ), + /// ); + /// } + /// ``` + /// This argument is best used with [animationDuration] and [animationStart] + /// to fully control the animation. + /// + /// To fully remove the animation, just return `suggestionsBox` + /// + /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. + final AnimationTransitionBuilder? transitionBuilder; + + /// The duration that [transitionBuilder] animation takes. + /// + /// This argument is best used with [transitionBuilder] and [animationStart] + /// to fully control the animation. + /// + /// Defaults to 500 milliseconds. + final Duration animationDuration; + + /// Determine the [SuggestionBox]'s direction. + /// + /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] + /// and the [_SuggestionsList] will grow **down**. + /// + /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] + /// and the [_SuggestionsList] will grow **up**. + /// + /// [AxisDirection.left] and [AxisDirection.right] are not allowed. + final AxisDirection direction; + + /// The value at which the [transitionBuilder] animation starts. + /// + /// This argument is best used with [transitionBuilder] and [animationDuration] + /// to fully control the animation. + /// + /// Defaults to 0.25. + final double animationStart; + + /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) + /// that the TypeAhead widget displays + final TextFieldConfiguration textFieldConfiguration; + + /// How far below the text field should the suggestions box be + /// + /// Defaults to 5.0 + final double suggestionsBoxVerticalOffset; + + /// If set to true, suggestions will be fetched immediately when the field is + /// added to the view. + /// + /// But the suggestions box will only be shown when the field receives focus. + /// To make the field receive focus immediately, you can set the `autofocus` + /// property in the [textFieldConfiguration] to true + /// + /// Defaults to false + final bool getImmediateSuggestions; + + /// If set to true, no loading box will be shown while suggestions are + /// being fetched. [loadingBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnLoading; + + /// If set to true, nothing will be shown if there are no results. + /// [noItemsFoundBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnEmpty; + + /// If set to true, nothing will be shown if there is an error. + /// [errorBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnError; + + /// If set to false, the suggestions box will stay opened after + /// the keyboard is closed. + /// + /// Defaults to true. + final bool hideSuggestionsOnKeyboardHide; + + /// If set to false, the suggestions box will show a circular + /// progress indicator when retrieving suggestions. + /// + /// Defaults to true. + final bool keepSuggestionsOnLoading; + + /// If set to true, the suggestions box will remain opened even after + /// selecting a suggestion. + /// + /// Note that if this is enabled, the only way + /// to close the suggestions box is either manually via the + /// `SuggestionsBoxController` or when the user closes the software + /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users + /// with a physical keyboard will be unable to close the + /// box without a manual way via `SuggestionsBoxController`. + /// + /// Defaults to false. + final bool keepSuggestionsOnSuggestionSelected; + + /// If set to true, in the case where the suggestions box has less than + /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis + /// will be temporarily flipped if there's more room available in the opposite + /// direction. + /// + /// Defaults to false + final bool autoFlipDirection; + + /// If set to false, suggestion list will not be reversed according to the + /// [autoFlipDirection] property. + /// + /// Defaults to true. + final bool autoFlipListDirection; + + final bool hideKeyboard; + + /// The minimum number of characters which must be entered before + /// [suggestionsCallback] is triggered. + /// + /// Defaults to 0. + final int minCharsForSuggestions; + + /// If set to true and if the user scrolls through the suggestion list, hide the keyboard automatically. + /// If set to false, the keyboard remains visible. + /// Throws an exception, if hideKeyboardOnDrag and hideSuggestionsOnKeyboardHide are both set to true as + /// they are mutual exclusive. + /// + /// Defaults to false + final bool hideKeyboardOnDrag; + + // Adds a callback for the suggestion box opening or closing + final void Function(bool)? onSuggestionsBoxToggle; + + /// Creates a [TypeAheadField] + TypeAheadField({ + Key? key, + required this.suggestionsCallback, + required this.itemBuilder, + required this.onSuggestionSelected, + this.textFieldConfiguration = const TextFieldConfiguration(), + this.suggestionsBoxDecoration = const SuggestionsBoxDecoration(), + this.debounceDuration = const Duration(milliseconds: 300), + this.suggestionsBoxController, + this.scrollController, + this.loadingBuilder, + this.noItemsFoundBuilder, + this.errorBuilder, + this.transitionBuilder, + this.animationStart = 0.25, + this.animationDuration = const Duration(milliseconds: 500), + this.getImmediateSuggestions = false, + this.suggestionsBoxVerticalOffset = 5.0, + this.direction = AxisDirection.down, + this.hideOnLoading = false, + this.hideOnEmpty = false, + this.hideOnError = false, + this.hideSuggestionsOnKeyboardHide = true, + this.keepSuggestionsOnLoading = true, + this.keepSuggestionsOnSuggestionSelected = false, + this.autoFlipDirection = false, + this.autoFlipListDirection = true, + this.hideKeyboard = false, + this.minCharsForSuggestions = 0, + this.onSuggestionsBoxToggle, + this.hideKeyboardOnDrag = false, + }) : assert(animationStart >= 0.0 && animationStart <= 1.0), + assert( + direction == AxisDirection.down || direction == AxisDirection.up), + assert(minCharsForSuggestions >= 0), + assert(!hideKeyboardOnDrag || + hideKeyboardOnDrag && !hideSuggestionsOnKeyboardHide), + super(key: key); + + @override + _TypeAheadFieldState createState() => _TypeAheadFieldState(); +} + +class _TypeAheadFieldState extends State> + with WidgetsBindingObserver { + FocusNode? _focusNode; + final KeyboardSuggestionSelectionNotifier + _keyboardSuggestionSelectionNotifier = + KeyboardSuggestionSelectionNotifier(); + TextEditingController? _textEditingController; + SuggestionsBox? _suggestionsBox; + + TextEditingController? get _effectiveController => + widget.textFieldConfiguration.controller ?? _textEditingController; + FocusNode? get _effectiveFocusNode => + widget.textFieldConfiguration.focusNode ?? _focusNode; + late VoidCallback _focusNodeListener; + + final LayerLink _layerLink = LayerLink(); + + // Timer that resizes the suggestion box on each tick. Only active when the user is scrolling. + Timer? _resizeOnScrollTimer; + // The rate at which the suggestion box will resize when the user is scrolling + final Duration _resizeOnScrollRefreshRate = const Duration(milliseconds: 500); + // Will have a value if the typeahead is inside a scrollable widget + ScrollPosition? _scrollPosition; + + // Keyboard detection + final Stream? _keyboardVisibility = + (supportedPlatform) ? KeyboardVisibilityController().onChange : null; + late StreamSubscription? _keyboardVisibilitySubscription; + + bool _areSuggestionsFocused = false; + late final _shouldRefreshSuggestionsFocusIndex = + ShouldRefreshSuggestionFocusIndexNotifier( + textFieldFocusNode: _effectiveFocusNode); + + @override + void didChangeMetrics() { + // Catch keyboard event and orientation change; resize suggestions list + this._suggestionsBox!.onChangeMetrics(); + } + + @override + void dispose() { + this._suggestionsBox!.close(); + this._suggestionsBox!.widgetMounted = false; + WidgetsBinding.instance.removeObserver(this); + _keyboardVisibilitySubscription?.cancel(); + _effectiveFocusNode!.removeListener(_focusNodeListener); + _focusNode?.dispose(); + _resizeOnScrollTimer?.cancel(); + _scrollPosition?.removeListener(_scrollResizeListener); + _textEditingController?.dispose(); + _keyboardSuggestionSelectionNotifier.dispose(); + super.dispose(); + } + + KeyEventResult _onKeyEvent(FocusNode _, RawKeyEvent event) { + if (event.isKeyPressed(LogicalKeyboardKey.arrowUp) || + event.isKeyPressed(LogicalKeyboardKey.arrowDown)) { + // do nothing to avoid puzzling users until keyboard arrow nav is implemented + } else { + _keyboardSuggestionSelectionNotifier.onKeyboardEvent(event); + } + return KeyEventResult.ignored; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + if (widget.textFieldConfiguration.controller == null) { + this._textEditingController = TextEditingController(); + } + + final textFieldConfigurationFocusNode = + widget.textFieldConfiguration.focusNode; + if (textFieldConfigurationFocusNode == null) { + this._focusNode = FocusNode(onKey: _onKeyEvent); + } else if (textFieldConfigurationFocusNode.onKey == null) { + // * we add the _onKeyEvent callback to the textFieldConfiguration focusNode + textFieldConfigurationFocusNode.onKey = ((node, event) { + final keyEventResult = _onKeyEvent(node, event); + return keyEventResult; + }); + } else { + final onKeyCopy = textFieldConfigurationFocusNode.onKey!; + textFieldConfigurationFocusNode.onKey = ((node, event) { + _onKeyEvent(node, event); + return onKeyCopy(node, event); + }); + } + + this._suggestionsBox = SuggestionsBox( + context, + widget.direction, + widget.autoFlipDirection, + widget.autoFlipListDirection, + ); + + widget.suggestionsBoxController?.suggestionsBox = this._suggestionsBox; + widget.suggestionsBoxController?.effectiveFocusNode = + this._effectiveFocusNode; + + this._focusNodeListener = () { + if (_effectiveFocusNode!.hasFocus) { + this._suggestionsBox!.open(); + } else if (!_areSuggestionsFocused) { + if (widget.hideSuggestionsOnKeyboardHide) { + this._suggestionsBox!.close(); + } + } + + widget.onSuggestionsBoxToggle?.call(this._suggestionsBox!.isOpened); + }; + + this._effectiveFocusNode!.addListener(_focusNodeListener); + + // hide suggestions box on keyboard closed + this._keyboardVisibilitySubscription = + _keyboardVisibility?.listen((bool isVisible) { + if (widget.hideSuggestionsOnKeyboardHide && !isVisible) { + _effectiveFocusNode!.unfocus(); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((duration) { + if (mounted) { + this._initOverlayEntry(); + // calculate initial suggestions list size + this._suggestionsBox!.resize(); + + // in case we already missed the focus event + if (this._effectiveFocusNode!.hasFocus) { + this._suggestionsBox!.open(); + } + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scrollableState = Scrollable.maybeOf(context); + if (scrollableState != null) { + // The TypeAheadField is inside a scrollable widget + _scrollPosition = scrollableState.position; + + _scrollPosition!.removeListener(_scrollResizeListener); + _scrollPosition!.isScrollingNotifier.addListener(_scrollResizeListener); + } + } + + void _scrollResizeListener() { + bool isScrolling = _scrollPosition!.isScrollingNotifier.value; + _resizeOnScrollTimer?.cancel(); + if (isScrolling) { + // Scroll started + _resizeOnScrollTimer = + Timer.periodic(_resizeOnScrollRefreshRate, (timer) { + _suggestionsBox!.resize(); + }); + } else { + // Scroll finished + _suggestionsBox!.resize(); + } + } + + void _initOverlayEntry() { + this._suggestionsBox!.overlayEntry = OverlayEntry(builder: (context) { + void giveTextFieldFocus() { + _effectiveFocusNode?.requestFocus(); + _areSuggestionsFocused = false; + } + + void onSuggestionFocus() { + if (!_areSuggestionsFocused) { + _areSuggestionsFocused = true; + } + } + + final suggestionsList = SuggestionsList( + suggestionsBox: _suggestionsBox, + decoration: widget.suggestionsBoxDecoration, + debounceDuration: widget.debounceDuration, + controller: this._effectiveController, + loadingBuilder: widget.loadingBuilder, + scrollController: widget.scrollController, + noItemsFoundBuilder: widget.noItemsFoundBuilder, + errorBuilder: widget.errorBuilder, + transitionBuilder: widget.transitionBuilder, + suggestionsCallback: widget.suggestionsCallback, + animationDuration: widget.animationDuration, + animationStart: widget.animationStart, + getImmediateSuggestions: widget.getImmediateSuggestions, + onSuggestionSelected: (T selection) { + if (!widget.keepSuggestionsOnSuggestionSelected) { + this._effectiveFocusNode!.unfocus(); + this._suggestionsBox!.close(); + } + widget.onSuggestionSelected(selection); + }, + itemBuilder: widget.itemBuilder, + direction: _suggestionsBox!.direction, + hideOnLoading: widget.hideOnLoading, + hideOnEmpty: widget.hideOnEmpty, + hideOnError: widget.hideOnError, + keepSuggestionsOnLoading: widget.keepSuggestionsOnLoading, + minCharsForSuggestions: widget.minCharsForSuggestions, + keyboardSuggestionSelectionNotifier: + _keyboardSuggestionSelectionNotifier, + shouldRefreshSuggestionFocusIndexNotifier: + _shouldRefreshSuggestionsFocusIndex, + giveTextFieldFocus: giveTextFieldFocus, + onSuggestionFocus: onSuggestionFocus, + onKeyEvent: _onKeyEvent, + hideKeyboardOnDrag: widget.hideKeyboardOnDrag); + + double w = _suggestionsBox!.textBoxWidth; + if (widget.suggestionsBoxDecoration.constraints != null) { + if (widget.suggestionsBoxDecoration.constraints!.minWidth != 0.0 && + widget.suggestionsBoxDecoration.constraints!.maxWidth != + double.infinity) { + w = (widget.suggestionsBoxDecoration.constraints!.minWidth + + widget.suggestionsBoxDecoration.constraints!.maxWidth) / + 2; + } else if (widget.suggestionsBoxDecoration.constraints!.minWidth != + 0.0 && + widget.suggestionsBoxDecoration.constraints!.minWidth > w) { + w = widget.suggestionsBoxDecoration.constraints!.minWidth; + } else if (widget.suggestionsBoxDecoration.constraints!.maxWidth != + double.infinity && + widget.suggestionsBoxDecoration.constraints!.maxWidth < w) { + w = widget.suggestionsBoxDecoration.constraints!.maxWidth; + } + } + + final Widget compositedFollower = CompositedTransformFollower( + link: this._layerLink, + showWhenUnlinked: false, + offset: Offset( + widget.suggestionsBoxDecoration.offsetX, + _suggestionsBox!.direction == AxisDirection.down + ? _suggestionsBox!.textBoxHeight + + widget.suggestionsBoxVerticalOffset + : _suggestionsBox!.directionUpOffset), + child: _suggestionsBox!.direction == AxisDirection.down + ? suggestionsList + : FractionalTranslation( + translation: Offset(0.0, -1.0), // visually flips list to go up + child: suggestionsList, + ), + ); + + // When wrapped in the Positioned widget, the suggestions box widget + // is placed before the Scaffold semantically. In order to have the + // suggestions box navigable from the search input or keyboard, + // Semantics > Align > ConstrainedBox are needed. This does not change + // the style visually. However, when VO/TB are not enabled it is + // necessary to use the Positioned widget to allow the elements to be + // properly tappable. + return MediaQuery.of(context).accessibleNavigation + ? Semantics( + container: true, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: w), + child: compositedFollower, + ), + ), + ) + : Positioned( + width: w, + child: compositedFollower, + ); + }); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: this._layerLink, + child: TextField( + key: TestKeys.textFieldKey, + focusNode: this._effectiveFocusNode, + controller: this._effectiveController, + decoration: widget.textFieldConfiguration.decoration, + style: widget.textFieldConfiguration.style, + textAlign: widget.textFieldConfiguration.textAlign, + enabled: widget.textFieldConfiguration.enabled, + keyboardType: widget.textFieldConfiguration.keyboardType, + autofocus: widget.textFieldConfiguration.autofocus, + inputFormatters: widget.textFieldConfiguration.inputFormatters, + autocorrect: widget.textFieldConfiguration.autocorrect, + maxLines: widget.textFieldConfiguration.maxLines, + textAlignVertical: widget.textFieldConfiguration.textAlignVertical, + minLines: widget.textFieldConfiguration.minLines, + maxLength: widget.textFieldConfiguration.maxLength, + maxLengthEnforcement: + widget.textFieldConfiguration.maxLengthEnforcement, + obscureText: widget.textFieldConfiguration.obscureText, + onChanged: widget.textFieldConfiguration.onChanged, + onSubmitted: widget.textFieldConfiguration.onSubmitted, + onEditingComplete: widget.textFieldConfiguration.onEditingComplete, + onTap: widget.textFieldConfiguration.onTap, +// onTapOutside: (_) {}, + scrollPadding: widget.textFieldConfiguration.scrollPadding, + textInputAction: widget.textFieldConfiguration.textInputAction, + textCapitalization: widget.textFieldConfiguration.textCapitalization, + keyboardAppearance: widget.textFieldConfiguration.keyboardAppearance, + cursorWidth: widget.textFieldConfiguration.cursorWidth, + cursorRadius: widget.textFieldConfiguration.cursorRadius, + cursorColor: widget.textFieldConfiguration.cursorColor, + textDirection: widget.textFieldConfiguration.textDirection, + enableInteractiveSelection: + widget.textFieldConfiguration.enableInteractiveSelection, + readOnly: widget.hideKeyboard, + ), + ); + } +} diff --git a/lib/src/material/field/typeahead_form_field.dart b/lib/src/material/field/typeahead_form_field.dart new file mode 100644 index 00000000..311bbe44 --- /dev/null +++ b/lib/src/material/field/typeahead_form_field.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/src/material/field/text_field_configuration.dart'; +import 'package:flutter_typeahead/src/material/field/typeahead_field.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box_controller.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box_decoration.dart'; +import 'package:flutter_typeahead/src/typedef.dart'; + + +/// A [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// implementation of [TypeAheadField], that allows the value to be saved, +/// validated, etc. +/// +/// See also: +/// +/// * [TypeAheadField], A [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) +/// that displays a list of suggestions as the user types +class TypeAheadFormField extends FormField { + /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) + /// that the TypeAhead widget displays + final TextFieldConfiguration textFieldConfiguration; + + // Adds a callback for resetting the form field + void Function()? onReset; + + /// Creates a [TypeAheadFormField] + TypeAheadFormField( + {Key? key, + String? initialValue, + bool getImmediateSuggestions = false, + @Deprecated('Use autovalidateMode parameter which provides more specific ' + 'behavior related to auto validation. ' + 'This feature was deprecated after Flutter v1.19.0.') + bool autovalidate = false, + bool enabled = true, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + FormFieldSetter? onSaved, + this.onReset, + FormFieldValidator? validator, + ErrorBuilder? errorBuilder, + WidgetBuilder? noItemsFoundBuilder, + WidgetBuilder? loadingBuilder, + void Function(bool)? onSuggestionsBoxToggle, + Duration debounceDuration = const Duration(milliseconds: 300), + SuggestionsBoxDecoration suggestionsBoxDecoration = + const SuggestionsBoxDecoration(), + SuggestionsBoxController? suggestionsBoxController, + required SuggestionSelectionCallback onSuggestionSelected, + required ItemBuilder itemBuilder, + required SuggestionsCallback suggestionsCallback, + double suggestionsBoxVerticalOffset = 5.0, + this.textFieldConfiguration = const TextFieldConfiguration(), + AnimationTransitionBuilder? transitionBuilder, + Duration animationDuration = const Duration(milliseconds: 500), + double animationStart = 0.25, + AxisDirection direction = AxisDirection.down, + bool hideOnLoading = false, + bool hideOnEmpty = false, + bool hideOnError = false, + bool hideSuggestionsOnKeyboardHide = true, + bool keepSuggestionsOnLoading = true, + bool keepSuggestionsOnSuggestionSelected = false, + bool autoFlipDirection = false, + bool autoFlipListDirection = true, + bool hideKeyboard = false, + int minCharsForSuggestions = 0, + bool hideKeyboardOnDrag = false}) + : assert( + initialValue == null || textFieldConfiguration.controller == null), + assert(minCharsForSuggestions >= 0), + super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: textFieldConfiguration.controller != null + ? textFieldConfiguration.controller!.text + : (initialValue ?? ''), + enabled: enabled, + autovalidateMode: autovalidateMode, + builder: (FormFieldState field) { + final _TypeAheadFormFieldState state = + field as _TypeAheadFormFieldState; + + return TypeAheadField( + getImmediateSuggestions: getImmediateSuggestions, + transitionBuilder: transitionBuilder, + errorBuilder: errorBuilder, + noItemsFoundBuilder: noItemsFoundBuilder, + loadingBuilder: loadingBuilder, + debounceDuration: debounceDuration, + suggestionsBoxDecoration: suggestionsBoxDecoration, + suggestionsBoxController: suggestionsBoxController, + textFieldConfiguration: textFieldConfiguration.copyWith( + decoration: textFieldConfiguration.decoration + .copyWith(errorText: state.errorText), + onChanged: (text) { + state.didChange(text); + textFieldConfiguration.onChanged?.call(text); + }, + controller: state._effectiveController, + ), + suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, + onSuggestionSelected: onSuggestionSelected, + onSuggestionsBoxToggle: onSuggestionsBoxToggle, + itemBuilder: itemBuilder, + suggestionsCallback: suggestionsCallback, + animationStart: animationStart, + animationDuration: animationDuration, + direction: direction, + hideOnLoading: hideOnLoading, + hideOnEmpty: hideOnEmpty, + hideOnError: hideOnError, + hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, + keepSuggestionsOnLoading: keepSuggestionsOnLoading, + keepSuggestionsOnSuggestionSelected: + keepSuggestionsOnSuggestionSelected, + autoFlipDirection: autoFlipDirection, + autoFlipListDirection: autoFlipListDirection, + hideKeyboard: hideKeyboard, + minCharsForSuggestions: minCharsForSuggestions, + hideKeyboardOnDrag: hideKeyboardOnDrag, + ); + }); + + @override + _TypeAheadFormFieldState createState() => _TypeAheadFormFieldState(); +} + +class _TypeAheadFormFieldState extends FormFieldState { + TextEditingController? _controller; + + TextEditingController? get _effectiveController => + widget.textFieldConfiguration.controller ?? _controller; + + @override + TypeAheadFormField get widget => super.widget as TypeAheadFormField; + + @override + void initState() { + super.initState(); + if (widget.textFieldConfiguration.controller == null) { + _controller = TextEditingController(text: widget.initialValue); + } else { + widget.textFieldConfiguration.controller! + .addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(TypeAheadFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.textFieldConfiguration.controller != + oldWidget.textFieldConfiguration.controller) { + oldWidget.textFieldConfiguration.controller + ?.removeListener(_handleControllerChanged); + widget.textFieldConfiguration.controller + ?.addListener(_handleControllerChanged); + + if (oldWidget.textFieldConfiguration.controller != null && + widget.textFieldConfiguration.controller == null) + _controller = TextEditingController.fromValue( + oldWidget.textFieldConfiguration.controller!.value); + if (widget.textFieldConfiguration.controller != null) { + setValue(widget.textFieldConfiguration.controller!.text); + if (oldWidget.textFieldConfiguration.controller == null) + _controller = null; + } + } + } + + @override + void dispose() { + widget.textFieldConfiguration.controller + ?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveController!.text = widget.initialValue!; + if (widget.onReset != null) { + widget.onReset!(); + } + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController!.text != value) + didChange(_effectiveController!.text); + } +} \ No newline at end of file diff --git a/lib/src/material/suggestions_box/suggestions_box.dart b/lib/src/material/suggestions_box/suggestions_box.dart new file mode 100644 index 00000000..176abecf --- /dev/null +++ b/lib/src/material/suggestions_box/suggestions_box.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/src/material/field/typeahead_field.dart'; + +class SuggestionsBox { + static const int waitMetricsTimeoutMillis = 1000; + static const double minOverlaySpace = 64.0; + + final BuildContext context; + final AxisDirection desiredDirection; + final bool autoFlipDirection; + final bool autoFlipListDirection; + + OverlayEntry? overlayEntry; + AxisDirection direction; + + bool isOpened = false; + bool widgetMounted = true; + double maxHeight = 300.0; + double textBoxWidth = 100.0; + double textBoxHeight = 100.0; + late double directionUpOffset; + + SuggestionsBox( + this.context, + this.direction, + this.autoFlipDirection, + this.autoFlipListDirection, + ) : desiredDirection = direction; + + void open() { + if (this.isOpened) return; + assert(this.overlayEntry != null); + resize(); + Overlay.of(context).insert(this.overlayEntry!); + this.isOpened = true; + } + + void close() { + if (!this.isOpened) return; + assert(this.overlayEntry != null); + this.overlayEntry!.remove(); + this.isOpened = false; + } + + void toggle() { + if (this.isOpened) { + this.close(); + } else { + this.open(); + } + } + + MediaQuery? _findRootMediaQuery() { + MediaQuery? rootMediaQuery; + context.visitAncestorElements((element) { + if (element.widget is MediaQuery) { + rootMediaQuery = element.widget as MediaQuery; + } + return true; + }); + + return rootMediaQuery; + } + + /// Delays until the keyboard has toggled or the orientation has fully changed + Future _waitChangeMetrics() async { + if (widgetMounted) { + // initial viewInsets which are before the keyboard is toggled + EdgeInsets initial = MediaQuery.of(context).viewInsets; + // initial MediaQuery for orientation change + MediaQuery? initialRootMediaQuery = _findRootMediaQuery(); + + int timer = 0; + // viewInsets or MediaQuery have changed once keyboard has toggled or orientation has changed + while (widgetMounted && timer < waitMetricsTimeoutMillis) { + // TODO: reduce delay if showDialog ever exposes detection of animation end + await Future.delayed(const Duration(milliseconds: 170)); + timer += 170; + + if (widgetMounted && + (MediaQuery.of(context).viewInsets != initial || + _findRootMediaQuery() != initialRootMediaQuery)) { + return true; + } + } + } + + return false; + } + + void resize() { + // check to see if widget is still mounted + // user may have closed the widget with the keyboard still open + if (widgetMounted) { + _adjustMaxHeightAndOrientation(); + overlayEntry!.markNeedsBuild(); + } + } + + // See if there's enough room in the desired direction for the overlay to display + // correctly. If not, try the opposite direction if things look more roomy there + void _adjustMaxHeightAndOrientation() { + TypeAheadField widget = context.widget as TypeAheadField; + + RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null || box.hasSize == false) { + return; + } + + textBoxWidth = box.size.width; + textBoxHeight = box.size.height; + + // top of text box + double textBoxAbsY = box.localToGlobal(Offset.zero).dy; + + // height of window + double windowHeight = MediaQuery.of(context).size.height; + + // we need to find the root MediaQuery for the unsafe area height + // we cannot use BuildContext.ancestorWidgetOfExactType because + // widgets like SafeArea creates a new MediaQuery with the padding removed + MediaQuery rootMediaQuery = _findRootMediaQuery()!; + + // height of keyboard + double keyboardHeight = rootMediaQuery.data.viewInsets.bottom; + + double maxHDesired = _calculateMaxHeight(desiredDirection, box, widget, + windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); + + // if there's enough room in the desired direction, update the direction and the max height + if (maxHDesired >= minOverlaySpace || !autoFlipDirection) { + direction = desiredDirection; + // Sometimes textBoxAbsY is NaN, so we need to check for that + if(!maxHDesired.isNaN) { + maxHeight = maxHDesired; + } + } else { + // There's not enough room in the desired direction so see how much room is in the opposite direction + AxisDirection flipped = flipAxisDirection(desiredDirection); + double maxHFlipped = _calculateMaxHeight(flipped, box, widget, + windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); + + // if there's more room in this opposite direction, update the direction and maxHeight + if (maxHFlipped > maxHDesired) { + direction = flipped; + + // Not sure if this is needed, but it's here just in case + if(!maxHFlipped.isNaN) { + maxHeight = maxHFlipped; + } + } + } + + if (maxHeight < 0) maxHeight = 0; + } + + double _calculateMaxHeight( + AxisDirection direction, + RenderBox box, + TypeAheadField widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + return direction == AxisDirection.down + ? _calculateMaxHeightDown(box, widget, windowHeight, rootMediaQuery, + keyboardHeight, textBoxAbsY) + : _calculateMaxHeightUp(box, widget, windowHeight, rootMediaQuery, + keyboardHeight, textBoxAbsY); + } + + double _calculateMaxHeightDown( + RenderBox box, + TypeAheadField widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + // unsafe area, ie: iPhone X 'home button' + // keyboardHeight includes unsafeAreaHeight, if keyboard is showing, set to 0 + double unsafeAreaHeight = + keyboardHeight == 0 ? rootMediaQuery.data.padding.bottom : 0; + + return windowHeight - + keyboardHeight - + unsafeAreaHeight - + textBoxHeight - + textBoxAbsY - + 2 * widget.suggestionsBoxVerticalOffset; + } + + double _calculateMaxHeightUp( + RenderBox box, + TypeAheadField widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + // recalculate keyboard absolute y value + double keyboardAbsY = windowHeight - keyboardHeight; + + directionUpOffset = textBoxAbsY > keyboardAbsY + ? keyboardAbsY - textBoxAbsY - widget.suggestionsBoxVerticalOffset + : -widget.suggestionsBoxVerticalOffset; + + // unsafe area, ie: iPhone X notch + double unsafeAreaHeight = rootMediaQuery.data.padding.top; + + return textBoxAbsY > keyboardAbsY + ? keyboardAbsY - + unsafeAreaHeight - + 2 * widget.suggestionsBoxVerticalOffset + : textBoxAbsY - + unsafeAreaHeight - + 2 * widget.suggestionsBoxVerticalOffset; + } + + Future onChangeMetrics() async { + if (await _waitChangeMetrics()) { + resize(); + } + } +} \ No newline at end of file diff --git a/lib/src/material/suggestions_box/suggestions_box_controller.dart b/lib/src/material/suggestions_box/suggestions_box_controller.dart new file mode 100644 index 00000000..853328d6 --- /dev/null +++ b/lib/src/material/suggestions_box/suggestions_box_controller.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box.dart'; + +/// Supply an instance of this class to the [TypeAhead.suggestionsBoxController] +/// property to manually control the suggestions box +class SuggestionsBoxController { + SuggestionsBox? suggestionsBox; + FocusNode? effectiveFocusNode; + + /// Opens the suggestions box + void open() { + effectiveFocusNode?.requestFocus(); + } + + bool isOpened() { + return suggestionsBox?.isOpened ?? false; + } + + /// Closes the suggestions box + void close() { + effectiveFocusNode?.unfocus(); + } + + /// Opens the suggestions box if closed and vice-versa + void toggle() { + if (suggestionsBox?.isOpened ?? false) { + close(); + } else { + open(); + } + } + + /// Recalculates the height of the suggestions box + void resize() { + suggestionsBox!.resize(); + } +} \ No newline at end of file diff --git a/lib/src/material/suggestions_box/suggestions_box_decoration.dart b/lib/src/material/suggestions_box/suggestions_box_decoration.dart new file mode 100644 index 00000000..4f618ff9 --- /dev/null +++ b/lib/src/material/suggestions_box/suggestions_box_decoration.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Supply an instance of this class to the [TypeAhead.suggestionsBoxDecoration] +/// property to configure the suggestions box decoration +class SuggestionsBoxDecoration { + /// The z-coordinate at which to place the suggestions box. This controls the size + /// of the shadow below the box. + /// + /// Same as [Material.elevation](https://docs.flutter.io/flutter/material/Material/elevation.html) + final double elevation; + + /// The color to paint the suggestions box. + /// + /// Same as [Material.color](https://docs.flutter.io/flutter/material/Material/color.html) + final Color? color; + + /// Defines the material's shape as well its shadow. + /// + /// Same as [Material.shape](https://docs.flutter.io/flutter/material/Material/shape.html) + final ShapeBorder? shape; + + /// Defines if a scrollbar will be displayed or not. + final bool hasScrollbar; + + /// If non-null, the corners of this box are rounded by this [BorderRadius](https://docs.flutter.io/flutter/painting/BorderRadius-class.html). + /// + /// Same as [Material.borderRadius](https://docs.flutter.io/flutter/material/Material/borderRadius.html) + final BorderRadius? borderRadius; + + /// The color to paint the shadow below the material. + /// + /// Same as [Material.shadowColor](https://docs.flutter.io/flutter/material/Material/shadowColor.html) + final Color shadowColor; + + /// The constraints to be applied to the suggestions box + final BoxConstraints? constraints; + + /// Adds an offset to the suggestions box + final double offsetX; + + /// The content will be clipped (or not) according to this option. + /// + /// Same as [Material.clipBehavior](https://api.flutter.dev/flutter/material/Material/clipBehavior.html) + final Clip clipBehavior; + + /// Creates a SuggestionsBoxDecoration + const SuggestionsBoxDecoration( + {this.elevation = 4.0, + this.color, + this.shape, + this.hasScrollbar = true, + this.borderRadius, + this.shadowColor = const Color(0xFF000000), + this.constraints, + this.clipBehavior = Clip.none, + this.offsetX = 0.0}); +} \ No newline at end of file diff --git a/lib/src/material/suggestions_box/suggestions_list.dart b/lib/src/material/suggestions_box/suggestions_list.dart new file mode 100644 index 00000000..01fb76fa --- /dev/null +++ b/lib/src/material/suggestions_box/suggestions_list.dart @@ -0,0 +1,413 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_typeahead/src/material/field/test_keys.dart'; +import 'package:flutter_typeahead/src/keyboard_suggestion_selection_notifier.dart'; +import 'package:flutter_typeahead/src/should_refresh_suggestion_focus_index_notifier.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box.dart'; +import 'package:flutter_typeahead/src/material/suggestions_box/suggestions_box_decoration.dart'; +import 'package:flutter_typeahead/src/typedef.dart'; + +class SuggestionsList extends StatefulWidget { + final SuggestionsBox? suggestionsBox; + final TextEditingController? controller; + final bool getImmediateSuggestions; + final SuggestionSelectionCallback? onSuggestionSelected; + final SuggestionsCallback? suggestionsCallback; + final ItemBuilder? itemBuilder; + final ScrollController? scrollController; + final SuggestionsBoxDecoration? decoration; + final Duration? debounceDuration; + final WidgetBuilder? loadingBuilder; + final WidgetBuilder? noItemsFoundBuilder; + final ErrorBuilder? errorBuilder; + final AnimationTransitionBuilder? transitionBuilder; + final Duration? animationDuration; + final double? animationStart; + final AxisDirection? direction; + final bool? hideOnLoading; + final bool? hideOnEmpty; + final bool? hideOnError; + final bool? keepSuggestionsOnLoading; + final int? minCharsForSuggestions; + final KeyboardSuggestionSelectionNotifier keyboardSuggestionSelectionNotifier; + final ShouldRefreshSuggestionFocusIndexNotifier + shouldRefreshSuggestionFocusIndexNotifier; + final VoidCallback giveTextFieldFocus; + final VoidCallback onSuggestionFocus; + final KeyEventResult Function(FocusNode _, RawKeyEvent event) onKeyEvent; + final bool hideKeyboardOnDrag; + + SuggestionsList({ + required this.suggestionsBox, + this.controller, + this.getImmediateSuggestions = false, + this.onSuggestionSelected, + this.suggestionsCallback, + this.itemBuilder, + this.scrollController, + this.decoration, + this.debounceDuration, + this.loadingBuilder, + this.noItemsFoundBuilder, + this.errorBuilder, + this.transitionBuilder, + this.animationDuration, + this.animationStart, + this.direction, + this.hideOnLoading, + this.hideOnEmpty, + this.hideOnError, + this.keepSuggestionsOnLoading, + this.minCharsForSuggestions, + required this.keyboardSuggestionSelectionNotifier, + required this.shouldRefreshSuggestionFocusIndexNotifier, + required this.giveTextFieldFocus, + required this.onSuggestionFocus, + required this.onKeyEvent, + required this.hideKeyboardOnDrag, + }); + + @override + _SuggestionsListState createState() => _SuggestionsListState(); +} + +class _SuggestionsListState extends State> + with SingleTickerProviderStateMixin { + Iterable? _suggestions; + late bool _suggestionsValid; + late VoidCallback _controllerListener; + Timer? _debounceTimer; + bool? _isLoading, _isQueued; + Object? _error; + AnimationController? _animationController; + String? _lastTextValue; + late final ScrollController _scrollController = + widget.scrollController ?? ScrollController(); + List _focusNodes = []; + int _suggestionIndex = -1; + + _SuggestionsListState() { + this._controllerListener = () { + // If we came here because of a change in selected text, not because of + // actual change in text + if (widget.controller!.text == this._lastTextValue) return; + + this._lastTextValue = widget.controller!.text; + + this._debounceTimer?.cancel(); + if (widget.controller!.text.length < widget.minCharsForSuggestions!) { + if (mounted) { + setState(() { + _isLoading = false; + _suggestions = null; + _suggestionsValid = true; + }); + } + return; + } else { + this._debounceTimer = Timer(widget.debounceDuration!, () async { + if (this._debounceTimer!.isActive) return; + if (_isLoading!) { + _isQueued = true; + return; + } + + await this.invalidateSuggestions(); + while (_isQueued!) { + _isQueued = false; + await this.invalidateSuggestions(); + } + }); + } + }; + } + + @override + void didUpdateWidget(SuggestionsList oldWidget) { + super.didUpdateWidget(oldWidget); + widget.controller!.addListener(this._controllerListener); + _getSuggestions(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _getSuggestions(); + } + + @override + void initState() { + super.initState(); + + this._animationController = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + + this._suggestionsValid = widget.minCharsForSuggestions! > 0 ? true : false; + this._isLoading = false; + this._isQueued = false; + this._lastTextValue = widget.controller!.text; + + if (widget.getImmediateSuggestions) { + this._getSuggestions(); + } + + widget.controller!.addListener(this._controllerListener); + + widget.keyboardSuggestionSelectionNotifier.addListener(() { + final suggestionsLength = _suggestions?.length; + final event = widget.keyboardSuggestionSelectionNotifier.value; + if (event == null || suggestionsLength == null) return; + + if (event == LogicalKeyboardKey.arrowDown && + _suggestionIndex < suggestionsLength - 1) { + _suggestionIndex++; + } else if (event == LogicalKeyboardKey.arrowUp && _suggestionIndex > -1) { + _suggestionIndex--; + } + + if (_suggestionIndex > -1 && _suggestionIndex < _focusNodes.length) { + final focusNode = _focusNodes[_suggestionIndex]; + focusNode.requestFocus(); + widget.onSuggestionFocus(); + } else { + widget.giveTextFieldFocus(); + } + }); + + widget.shouldRefreshSuggestionFocusIndexNotifier.addListener(() { + if (_suggestionIndex != -1) { + _suggestionIndex = -1; + } + }); + } + + Future invalidateSuggestions() async { + _suggestionsValid = false; + await _getSuggestions(); + } + + Future _getSuggestions() async { + if (_suggestionsValid) return; + _suggestionsValid = true; + + if (mounted) { + setState(() { + this._animationController!.forward(from: 1.0); + + this._isLoading = true; + this._error = null; + }); + + Iterable? suggestions; + Object? error; + + try { + suggestions = + await widget.suggestionsCallback!(widget.controller!.text); + } catch (e) { + error = e; + } + + if (this.mounted) { + // if it wasn't removed in the meantime + setState(() { + double? animationStart = widget.animationStart; + // allow suggestionsCallback to return null and not throw error here + if (error != null || suggestions?.isEmpty == true) { + animationStart = 1.0; + } + this._animationController!.forward(from: animationStart); + + this._error = error; + this._isLoading = false; + this._suggestions = suggestions; + _focusNodes = List.generate( + _suggestions?.length ?? 0, + (index) => FocusNode(onKey: (_, event) { + return widget.onKeyEvent(_, event); + }), + ); + }); + } + } + } + + @override + void dispose() { + _animationController!.dispose(); + _debounceTimer?.cancel(); + for (final focusNode in _focusNodes) { + focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bool isEmpty = + this._suggestions?.length == 0 && widget.controller!.text == ""; + if ((this._suggestions == null || isEmpty) && + this._isLoading == false && + this._error == null) return Container(); + + Widget child; + if (this._isLoading!) { + if (widget.hideOnLoading!) { + child = Container(height: 0); + } else { + child = createLoadingWidget(); + } + } else if (this._error != null) { + if (widget.hideOnError!) { + child = Container(height: 0); + } else { + child = createErrorWidget(); + } + } else if (this._suggestions!.isEmpty) { + if (widget.hideOnEmpty!) { + child = Container(height: 0); + } else { + child = createNoItemsFoundWidget(); + } + } else { + child = createSuggestionsWidget(); + } + + final animationChild = widget.transitionBuilder != null + ? widget.transitionBuilder!(context, child, this._animationController) + : SizeTransition( + axisAlignment: -1.0, + sizeFactor: CurvedAnimation( + parent: this._animationController!, + curve: Curves.fastOutSlowIn), + child: child, + ); + + BoxConstraints constraints; + if (widget.decoration!.constraints == null) { + constraints = BoxConstraints( + maxHeight: widget.suggestionsBox!.maxHeight, + ); + } else { + double maxHeight = min(widget.decoration!.constraints!.maxHeight, + widget.suggestionsBox!.maxHeight); + constraints = widget.decoration!.constraints!.copyWith( + minHeight: min(widget.decoration!.constraints!.minHeight, maxHeight), + maxHeight: maxHeight, + ); + } + + var container = Material( + elevation: widget.decoration!.elevation, + color: widget.decoration!.color, + shape: widget.decoration!.shape, + borderRadius: widget.decoration!.borderRadius, + shadowColor: widget.decoration!.shadowColor, + clipBehavior: widget.decoration!.clipBehavior, + child: ConstrainedBox( + constraints: constraints, + child: animationChild, + ), + ); + + return container; + } + + Widget createLoadingWidget() { + Widget child; + + if (widget.keepSuggestionsOnLoading! && this._suggestions != null) { + if (this._suggestions!.isEmpty) { + child = createNoItemsFoundWidget(); + } else { + child = createSuggestionsWidget(); + } + } else { + child = widget.loadingBuilder != null + ? widget.loadingBuilder!(context) + : Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: CircularProgressIndicator(), + ), + ); + } + + return child; + } + + Widget createErrorWidget() { + return widget.errorBuilder != null + ? widget.errorBuilder!(context, this._error) + : Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Error: ${this._error}', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ); + } + + Widget createNoItemsFoundWidget() { + return widget.noItemsFoundBuilder != null + ? widget.noItemsFoundBuilder!(context) + : Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'No Items Found!', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).disabledColor, fontSize: 18.0), + ), + ); + } + + Widget createSuggestionsWidget() { + Widget child = ListView( + padding: EdgeInsets.zero, + primary: false, + shrinkWrap: true, + keyboardDismissBehavior: widget.hideKeyboardOnDrag + ? ScrollViewKeyboardDismissBehavior.onDrag + : ScrollViewKeyboardDismissBehavior.manual, + controller: _scrollController, + reverse: widget.suggestionsBox!.direction == AxisDirection.down + ? false + : widget.suggestionsBox!.autoFlipListDirection, + children: List.generate(this._suggestions!.length, (index) { + final suggestion = _suggestions!.elementAt(index); + final focusNode = _focusNodes[index]; + + return TextFieldTapRegion( + child: InkWell( + key: TestKeys.getSuggestionKey(index), + focusColor: Theme.of(context).hoverColor, + focusNode: focusNode, + child: widget.itemBuilder!(context, suggestion), + onTap: () { + // * we give the focus back to the text field + widget.giveTextFieldFocus(); + + widget.onSuggestionSelected!(suggestion); + }, + ), + ); + }), + ); + + if (widget.decoration!.hasScrollbar) { + child = Scrollbar( + controller: _scrollController, + child: child, + ); + } + + return child; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 07de1d52..c248566c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ homepage: https://github.com/AbdulRahmanAlHamali/flutter_typeahead dependencies: flutter: sdk: flutter - flutter_keyboard_visibility: ^5.0.0 + flutter_keyboard_visibility: ^5.4.0 dev_dependencies: flutter_test: diff --git a/scripts/code_analysis.sh b/scripts/code_analysis.sh new file mode 100644 index 00000000..b7d6362e --- /dev/null +++ b/scripts/code_analysis.sh @@ -0,0 +1,9 @@ +cd .. +echo "> # Library code # lib:" +find ./lib -name "*.dart" -type f|xargs wc -l | grep total +echo"> How the library code is distributed between files:" +find ./lib -name "*.dart" -type f|xargs wc -l +echo "> # Example:" +find ./example/lib -name "*.dart" -type f|xargs wc -l | grep main +echo "> # Should have nothing after this line, as examples are single files in pub.dev" +find ./example/lib -name "*.dart" -type f|xargs wc -l | grep total diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100644 index 00000000..307ea39a --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,2 @@ +cd .. +dart pub publish \ No newline at end of file diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100644 index 00000000..e8d808bd --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,2 @@ +cd .. +flutter test \ No newline at end of file diff --git a/test/flutter_typeahead_test.dart b/test/flutter_typeahead_test.dart index 0cace2ee..4cc88cb2 100644 --- a/test/flutter_typeahead_test.dart +++ b/test/flutter_typeahead_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_typeahead/flutter_typeahead.dart'; class TestPage extends StatefulWidget { final int minCharsForSuggestions; - TestPage({Key? key, this.minCharsForSuggestions: 0}) : super(key: key); + TestPage({Key? key, this.minCharsForSuggestions = 0}) : super(key: key); @override State createState() => TestPageState(); @@ -69,7 +69,7 @@ class TestPageState extends State { class CupertinoTestPage extends StatefulWidget { final int minCharsForSuggestions; - CupertinoTestPage({Key? key, this.minCharsForSuggestions: 0}) + CupertinoTestPage({Key? key, this.minCharsForSuggestions = 0}) : super(key: key); @override