diff --git a/CHANGELOG.md b/CHANGELOG.md index c3749b0..be30421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,74 @@ -## 7.0.0-non-null-safety +# 11.0.1 + +* fix issue on ios after flutter version 3.7.0. #191 #198 + +# 11.0.0 + +* Migrate to 3.7.0 + +# 10.2.0 + +* Add TextInputBindingMixin to prevent system keyboard show. +* Add No SystemKeyboard demo + +# 10.1.1 + +* Fix issue selection not right #172 + +# 10.1.0 + +* Migrate to 3.0.0 +* Support Scribble Handwriting for iPads + +# 10.0.1 + +* Public ExtendedTextFieldState and add bringIntoView method to support jump to caret when insert text with TextEditingController + +# 10.0.0 + +* Migrate to 2.10.0. +* Add shouldShowSelectionHandles and textSelectionGestureDetectorBuilder call back to define the behavior of handles and toolbar. +* Shortcut support for web and desktop. + +# 9.0.3 + +* Fix hittest is not right #131 + +# 9.0.2 + +* Fix selectionWidthStyle and selectionHeightStyle are not working. + +## 9.0.1 + +* Support to use keyboard move cursor for SpecialInlineSpan. #135 +* Fix issue that backspace delete two chars. #141 + +## 9.0.0 + +* Migrate to 2.8 + +## 8.0.0 + +* Add [SpecialTextSpan.mouseCursor], [SpecialTextSpan.onEnter] and [SpecialTextSpan.onExit]. +* merge code from 2.2.0 + +## 7.0.1 + +* Fix issue that composing is not updated.#122 + +## 7.0.0 * Breaking change: [SpecialText.getContent] is not include endflag now.(please check if you call getContent and your endflag length is more than 1) * Fix demo manualDelete error #120 -## 6.0.0-non-null-safety -* non-null-safety +## 6.0.1 + +* Fix issue that toolbar is not shown when double tap +* Fix throw exception when selectWordAtOffset + +## 6.0.0 + +* Support null-safety ## 5.0.4 diff --git a/README-ZH.md b/README-ZH.md index 55b4eba..dfa81da 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -2,9 +2,12 @@ [![pub package](https://img.shields.io/pub/v/extended_text_field.svg)](https://pub.dartlang.org/packages/extended_text_field) [![GitHub stars](https://img.shields.io/github/stars/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/stargazers) [![GitHub forks](https://img.shields.io/github/forks/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/network) [![GitHub license](https://img.shields.io/github/license/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/blob/master/LICENSE) [![GitHub issues](https://img.shields.io/github/issues/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/issues) flutter-candies +文档语言: [English](README.md) | 中文简体 + 官方输入框的扩展组件,支持图片,@某人,自定义文字背景。也支持自定义菜单和选择器。 -文档语言: [English](README.md) | [中文简体](README-ZH.md) +[ExtendedTextField 在线 Demo](https://fluttercandies.github.io/extended_text_field/) + - [extended_text_field](#extended_text_field) - [限制](#限制) @@ -16,6 +19,10 @@ - [缓存图片](#缓存图片) - [文本选择控制器](#文本选择控制器) - [WidgetSpan](#widgetspan) + - [阻止系统键盘](#阻止系统键盘) + - [TextInputBindingMixin](#textinputbindingmixin) + - [TextInputFocusNode](#textinputfocusnode) + - [CustomKeyboard](#customkeyboard) - [☕️Buy me a coffee](#️buy-me-a-coffee) ## 限制 @@ -508,6 +515,140 @@ class EmailText extends SpecialText { } ``` +## 阻止系统键盘 + +我们不需要代码侵入到 [ExtendedTextField] 或者 [TextField] 当中, 就可以阻止系统键盘弹出, + +### TextInputBindingMixin + +我们通过阻止 Flutter Framework 发送 `TextInput.show` 到 Flutter 引擎来阻止系统键盘弹出 + +你可以直接使用 [TextInputBinding]. + +``` dart +void main() { + TextInputBinding(); + runApp(const MyApp()); +} +``` + +或者你如果有其他的 `binding`,你可以这样。 + +``` dart + class YourBinding extends WidgetsFlutterBinding with TextInputBindingMixin,YourBindingMixin { + } + + void main() { + YourBinding(); + runApp(const MyApp()); + } +``` + +或者你需要重载 `ignoreTextInputShow` 方法,你可以这样。 + +``` dart + class YourBinding extends TextInputBinding { + @override + // ignore: unnecessary_overrides + bool ignoreTextInputShow() { + // you can override it base on your case + // if NoKeyboardFocusNode is not enough + return super.ignoreTextInputShow(); + } + } + + void main() { + YourBinding(); + runApp(const MyApp()); + } +``` + +### TextInputFocusNode + +把 [TextInputFocusNode] 传递给 [ExtendedTextField] 或者 [TextField]。 + + +``` dart +final TextInputFocusNode _focusNode = TextInputFocusNode(); + + @override + Widget build(BuildContext context) { + return ExtendedTextField( + // request keyboard if need + focusNode: _focusNode..debugLabel = 'ExtendedTextField', + ); + } + + @override + Widget build(BuildContext context) { + return TextField( + // request keyboard if need + focusNode: _focusNode..debugLabel = 'CustomTextField', + ); + } +``` + +我们通过当前的 `FocusNode` 是否是 [TextInputFocusNode],来决定是否阻止系统键盘弹出的。 + +``` dart + final FocusNode? focus = FocusManager.instance.primaryFocus; + if (focus != null && + focus is TextInputFocusNode && + focus.ignoreSystemKeyboardShow) { + return true; + } +``` +### CustomKeyboard + +你可以通过当前焦点的变化的时候,来显示或者隐藏自定义的键盘。 + +当你的自定义键盘可以关闭而不让焦点失去,你应该在 [ExtendedTextField] 或者 [TextField] +的 `onTap` 事件中,再次判断键盘是否显示。 + +``` dart + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChanged); + } + + void _onTextFiledTap() { + if (_bottomSheetController == null) { + _handleFocusChanged(); + } + } + + void _handleFocusChanged() { + if (_focusNode.hasFocus) { + // just demo, you can define your custom keyboard as you want + _bottomSheetController = showBottomSheet( + context: FocusManager.instance.primaryFocus!.context!, + // set false, if don't want to drag to close custom keyboard + enableDrag: true, + builder: (BuildContext b) { + // your custom keyboard + return Container(); + }); + // maybe drag close + _bottomSheetController?.closed.whenComplete(() { + _bottomSheetController = null; + }); + } else { + _bottomSheetController?.close(); + _bottomSheetController = null; + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChanged); + super.dispose(); + } +``` + + +查看 [完整的例子](https://github.com/fluttercandies/extended_text_field/tree/master/example/lib/pages/simple/no_keyboard.dart) + ## ☕️Buy me a coffee ![img](http://zmtzawqlp.gitee.io/my_images/images/qrcode.png) diff --git a/README.md b/README.md index 70e1544..e236754 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ [![pub package](https://img.shields.io/pub/v/extended_text_field.svg)](https://pub.dartlang.org/packages/extended_text_field) [![GitHub stars](https://img.shields.io/github/stars/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/stargazers) [![GitHub forks](https://img.shields.io/github/forks/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/network) [![GitHub license](https://img.shields.io/github/license/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/blob/master/LICENSE) [![GitHub issues](https://img.shields.io/github/issues/fluttercandies/extended_text_field)](https://github.com/fluttercandies/extended_text_field/issues) flutter-candies +Language: English | [中文简体](README-ZH.md) + Extended official text field to build special text like inline image, @somebody, custom background etc quickly.It also support to build custom seleciton toolbar and handles. -Language: [English](README.md) | [中文简体](README-ZH.md) +[Web demo for ExtendedTextField](https://fluttercandies.github.io/extended_text_field/) - [extended_text_field](#extended_text_field) - [Limitation](#limitation) @@ -16,6 +18,9 @@ Language: [English](README.md) | [中文简体](README-ZH.md) - [Cache Image](#cache-image) - [TextSelectionControls](#textselectioncontrols) - [WidgetSpan](#widgetspan) + - [NoSystemKeyboard](#nosystemkeyboard) + - [TextInputBindingMixin](#textinputbindingmixin) + - [TextInputFocusNode](#textinputfocusnode) ## Limitation @@ -502,5 +507,135 @@ class EmailText extends SpecialText { } ``` +## NoSystemKeyboard + +support to prevent system keyboard show without any code intrusion for [ExtendedTextField] or [TextField]. + +### TextInputBindingMixin + +we prevent system keyboard show by stop Flutter Framework send `TextInput.show` message to Flutter Engine. + +you can use [TextInputBinding] directly. + +``` dart +void main() { + TextInputBinding(); + runApp(const MyApp()); +} +``` + +or if you have other `binding` you can do as following. + +``` dart + class YourBinding extends WidgetsFlutterBinding with TextInputBindingMixin,YourBindingMixin { + } + + void main() { + YourBinding(); + runApp(const MyApp()); + } +``` + +or you need to override `ignoreTextInputShow`, you can do as following. + +``` dart + class YourBinding extends TextInputBinding { + @override + // ignore: unnecessary_overrides + bool ignoreTextInputShow() { + // you can override it base on your case + // if NoKeyboardFocusNode is not enough + return super.ignoreTextInputShow(); + } + } + + void main() { + YourBinding(); + runApp(const MyApp()); + } +``` + +### TextInputFocusNode + +you should pass the [TextInputFocusNode] into [ExtendedTextField] or [TextField]. + +``` dart +final TextInputFocusNode _focusNode = TextInputFocusNode(); + + @override + Widget build(BuildContext context) { + return ExtendedTextField( + // request keyboard if need + focusNode: _focusNode..debugLabel = 'ExtendedTextField', + ); + } + + @override + Widget build(BuildContext context) { + return TextField( + // request keyboard if need + focusNode: _focusNode..debugLabel = 'CustomTextField', + ); + } +``` + +we prevent system keyboard show base on current focus is [TextInputFocusNode] and `ignoreSystemKeyboardShow` is true。 + +``` dart + final FocusNode? focus = FocusManager.instance.primaryFocus; + if (focus != null && + focus is TextInputFocusNode && + focus.ignoreSystemKeyboardShow) { + return true; + } + +### CustomKeyboard + +show/hide your custom keyboard on [TextInputFocusNode] focus is changed. + +if your custom keyboard can be close without unFocus, you need also handle +show custom keyboard when [ExtendedTextField] or [TextField] `onTap`. + +``` dart + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChanged); + } + + void _onTextFiledTap() { + if (_bottomSheetController == null) { + _handleFocusChanged(); + } + } + + void _handleFocusChanged() { + if (_focusNode.hasFocus) { + // just demo, you can define your custom keyboard as you want + _bottomSheetController = showBottomSheet( + context: FocusManager.instance.primaryFocus!.context!, + // set false, if don't want to drag to close custom keyboard + enableDrag: true, + builder: (BuildContext b) { + // your custom keyboard + return Container(); + }); + // maybe drag close + _bottomSheetController?.closed.whenComplete(() { + _bottomSheetController = null; + }); + } else { + _bottomSheetController?.close(); + _bottomSheetController = null; + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChanged); + super.dispose(); + } +``` +see [Full Demo](https://github.com/fluttercandies/extended_text_field/tree/master/example/lib/pages/simple/no_keyboard.dart) \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 4b0da0b..a995197 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -173,7 +173,7 @@ linter: - slash_for_doc_comments # - sort_child_properties_last # not yet tested - sort_constructors_first - - sort_pub_dependencies + # - sort_pub_dependencies - sort_unnamed_constructors_first - test_types_in_equals - throw_in_finally diff --git a/example/.flutter-plugins-dependencies b/example/.flutter-plugins-dependencies deleted file mode 100644 index 930679c..0000000 --- a/example/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"url_launcher","path":"E:\\\\Flutter\\\\flutter_source\\\\1.22.6\\\\.pub-cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher-5.3.0\\\\","dependencies":[]}],"android":[{"name":"url_launcher","path":"E:\\\\Flutter\\\\flutter_source\\\\1.22.6\\\\.pub-cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher-5.3.0\\\\","dependencies":[]}],"macos":[],"linux":[],"windows":[],"web":[{"name":"url_launcher_web","path":"E:\\\\Flutter\\\\flutter_source\\\\1.22.6\\\\.pub-cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_web-0.1.5+3\\\\","dependencies":[]}]},"dependencyGraph":[{"name":"url_launcher","dependencies":["url_launcher_web"]},{"name":"url_launcher_web","dependencies":[]}],"date_created":"2021-04-24 11:52:26.206055","version":"1.22.6"} \ No newline at end of file diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..4656aa0 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,207 @@ +# Specify analysis options. +# +# Until there are meta linter rules, each desired lint must be explicitly enabled. +# See: https://github.com/dart-lang/linter/issues/288 +# +# For a list of lints, see: http://dart-lang.github.io/linter/lints/ +# See the configuration guide for more +# https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer +# +# There are other similar analysis options files in the flutter repos, +# which should be kept in sync with this file: +# +# - analysis_options.yaml (this file) +# - packages/flutter/lib/analysis_options_user.yaml +# - https://github.com/flutter/plugins/blob/master/analysis_options.yaml +# - https://github.com/flutter/engine/blob/master/analysis_options.yaml +# +# This file contains the analysis options used by Flutter tools, such as IntelliJ, +# Android Studio, and the `flutter analyze` command. + +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: false + errors: + # treat missing required parameters as a warning (not a hint) + missing_required_param: warning + # treat missing returns as a warning (not a hint) + missing_return: warning + # allow having TODOs in the code + todo: ignore + # Ignore analyzer hints for updating pubspecs when using Future or + # Stream and not importing dart:async + # Please see https://github.com/flutter/flutter/pull/24528 for details. + sdk_version_async_exported_from_core: ignore + # exclude: + # - "bin/cache/**" + # # the following two are relative to the stocks example and the flutter package respectively + # # see https://github.com/dart-lang/sdk/issues/28463 + # - "lib/i18n/messages_*.dart" + # - "lib/src/http/**" + +linter: + rules: + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml + - always_declare_return_types + - always_put_control_body_on_new_line + # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 + - always_require_non_null_named_parameters + - always_specify_types + - annotate_overrides + # - avoid_annotating_with_dynamic # conflicts with always_specify_types + # - avoid_as # required for implicit-casts: true + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # we do this commonly + # - avoid_catching_errors # we do this commonly + - avoid_classes_with_only_static_members + # - avoid_double_and_int_checks # only useful when targeting JS runtime + - avoid_empty_else + # - avoid_equals_and_hash_code_on_mutable_classes # not yet tested + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + # - avoid_implementing_value_types # not yet tested + - avoid_init_to_null + # - avoid_js_rounded_ints # only useful when targeting JS runtime + - avoid_null_checks_in_equality_operators + # - avoid_positional_boolean_parameters # not yet tested + # - avoid_print # not yet tested + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + # - avoid_redundant_argument_values # not yet tested + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + # - avoid_returning_null # there are plenty of valid reasons to return null + # - avoid_returning_null_for_future # not yet tested + - avoid_returning_null_for_void + # - avoid_returning_this # there are plenty of valid reasons to return this + # - avoid_setters_without_getters # not yet tested + # - avoid_shadowing_type_parameters # not yet tested + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # conflicts with always_specify_types + # - avoid_unnecessary_containers # not yet tested + - avoid_unused_constructor_parameters + - avoid_void_async + # - avoid_web_libraries_in_flutter # not yet tested + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # not yet tested + # - close_sinks # not reliable enough + # - comment_references # blocked on https://github.com/flutter/flutter/issues/20765 + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + # - curly_braces_in_flow_control_structures # not yet tested + # - diagnostic_describe_all_properties # not yet tested + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + # - file_names # not yet tested + - flutter_style_todos + - hash_and_equals + - implementation_imports + # - invariant_booleans # too many false positives: https://github.com/dart-lang/linter/issues/811 + - iterable_contains_unrelated_type + # - join_return_with_assignment # not yet tested + - library_names + - library_prefixes + # - lines_longer_than_80_chars # not yet tested + - list_remove_unrelated_type + # - literal_only_boolean_expressions # too many false positives: https://github.com/dart-lang/sdk/issues/34181 + # - missing_whitespace_between_adjacent_strings # not yet tested + - no_adjacent_strings_in_list + - no_duplicate_case_values + # - no_logic_in_create_state # not yet tested + # - no_runtimeType_toString # not yet tested + - non_constant_identifier_names + # - null_closures # not yet tested + # - omit_local_variable_types # opposite of always_specify_types + # - one_member_abstracts # too many false positives + # - only_throw_errors # https://github.com/flutter/flutter/issues/5792 + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not yet tested + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # not yet tested + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + - prefer_equal_for_default_values + # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + # - prefer_function_declarations_over_variables # not yet tested + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # not yet tested + # - prefer_interpolation_to_compose_strings # not yet tested + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + # - prefer_mixin # https://github.com/dart-lang/language/issues/32 + # - prefer_null_aware_operators # disable until NNBD, see https://github.com/flutter/flutter/pull/32711#issuecomment-492930932 + # - prefer_relative_imports # not yet tested + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + # - provide_deprecation_message # not yet tested + # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + - slash_for_doc_comments + #- sort_child_properties_last # not yet tested + - sort_constructors_first + #- sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + # - unawaited_futures # too many false positives + # - unnecessary_await_in_return # not yet tested + - unnecessary_brace_in_string_interps + - unnecessary_const + # - unnecessary_final # conflicts with prefer_final_locals + - unnecessary_getters_setters + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_string_interpolations + - unnecessary_this + - unrelated_type_equality_checks + # - unsafe_html # not yet tested + - use_full_hex_values_for_flutter_colors + # - use_function_type_syntax_for_parameters # not yet tested + # - use_key_in_widget_constructors # not yet tested + - use_rethrow_when_possible + # - use_setters_to_change_properties # not yet tested + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - valid_regexps + - void_checks diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 3932aa9..5fe3c92 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -26,21 +26,26 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 29 + compileSdkVersion flutter.compileSdkVersion - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' } - lintOptions { - disable 'InvalidPackage' + sourceSets { + main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" - minSdkVersion 16 - targetSdkVersion 29 + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 55ca830..3f41384 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,16 +1,12 @@ - - - - diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml index 449a9f9..3db14bb 100644 --- a/example/android/app/src/main/res/values-night/styles.xml +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -10,7 +10,7 @@ This theme determines the color of the Android Window while your Flutter UI initializes, as well as behind your Flutter UI while its running. - + This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/example/android/build.gradle b/example/android/build.gradle index 3100ad2..4256f91 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.6.10' repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:4.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -14,7 +14,7 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index a673820..94adc3a 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true -android.enableR8=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 296b146..bc6a58a 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/example/ff_annotation_route_commands b/example/ff_annotation_route_commands index e1ed45b..ebf8ab2 100644 --- a/example/ff_annotation_route_commands +++ b/example/ff_annotation_route_commands @@ -1 +1 @@ ---route-constants --route-names --route-helper --git flutter_candies_demo_library --no-is-initial-route \ No newline at end of file +-s \ No newline at end of file diff --git a/example/ios/Flutter/.last_build_id b/example/ios/Flutter/.last_build_id deleted file mode 100644 index d1054b8..0000000 --- a/example/ios/Flutter/.last_build_id +++ /dev/null @@ -1 +0,0 @@ -afab667b626c6581508496d02f18db69 \ No newline at end of file diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index f2872cf..4f8d4d2 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 11.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index 1e8c3c9..88359b2 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 index 0cace26..377a985 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,22 +1,22 @@ PODS: - Flutter (1.0.0) - - url_launcher (0.0.1): + - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter - url_launcher: - :path: ".symlinks/plugins/url_launcher/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - Flutter: 0e3d915762c693b495b44d77113d4970485de6ec - url_launcher: a1c0cc845906122c4784c542523d8cacbded5626 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2 -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 -COCOAPODS: 1.10.0 +COCOAPODS: 1.11.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index eee1362..9184c9e 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -156,7 +156,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -222,6 +222,7 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -236,6 +237,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -340,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; @@ -422,7 +424,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; @@ -471,7 +473,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; diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a1..919434a 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140c..3db53b6 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/example/lib/common/toggle_button.dart b/example/lib/common/toggle_button.dart index 6fe5e9c..f96fd63 100644 --- a/example/lib/common/toggle_button.dart +++ b/example/lib/common/toggle_button.dart @@ -6,10 +6,10 @@ class ToggleButton extends StatefulWidget { this.unActiveWidget, this.activeChanged, this.active = false}); - final Widget activeWidget; - final Widget unActiveWidget; + final Widget? activeWidget; + final Widget? unActiveWidget; final bool active; - final ValueChanged activeChanged; + final ValueChanged? activeChanged; @override _ToggleButtonState createState() => _ToggleButtonState(); } diff --git a/example/lib/example_route.dart b/example/lib/example_route.dart index 67735fa..f95adee 100644 --- a/example/lib/example_route.dart +++ b/example/lib/example_route.dart @@ -2,92 +2,103 @@ // ************************************************************************** // Auto generated by https://github.com/fluttercandies/ff_annotation_route // ************************************************************************** - -import 'package:ff_annotation_route/ff_annotation_route.dart'; +// fast mode: true +// version: 10.0.6 +// ************************************************************************** +// ignore_for_file: prefer_const_literals_to_create_immutables,unused_local_variable,unused_import,unnecessary_import,unused_shown_name,implementation_imports,duplicate_import +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; import 'package:flutter/widgets.dart'; + import 'pages/complex/text_demo.dart'; import 'pages/main_page.dart'; import 'pages/simple/custom_toolbar.dart'; +import 'pages/simple/no_keyboard.dart'; import 'pages/simple/widget_span.dart'; -RouteResult getRouteResult({String name, Map arguments}) { - arguments = arguments ?? const {}; +FFRouteSettings getRouteSettings({ + required String name, + Map? arguments, + PageBuilder? notFoundPageBuilder, +}) { + final Map safeArguments = + arguments ?? const {}; switch (name) { case 'fluttercandies://CustomToolBar': - return RouteResult( + return FFRouteSettings( name: name, - widget: CustomToolBar(), + arguments: arguments, + builder: () => CustomToolBar(), routeName: 'custom toolbar', description: 'custom selection toolbar and handles for text field', - exts: {'group': 'Simple', 'order': 0}, + exts: { + 'group': 'Simple', + 'order': 0, + }, + ); + case 'fluttercandies://NoKeyboard': + return FFRouteSettings( + name: name, + arguments: arguments, + builder: () => NoSystemKeyboardDemo( + key: asT( + safeArguments['key'], + ), + ), + routeName: 'no system Keyboard', + description: + 'show how to ignore system keyboard and show custom keyboard', + exts: { + 'group': 'Simple', + 'order': 2, + }, ); case 'fluttercandies://TextDemo': - return RouteResult( + return FFRouteSettings( name: name, - widget: TextDemo(), + arguments: arguments, + builder: () => TextDemo(), routeName: 'text', description: 'build special text and inline image in text field', - exts: {'group': 'Complex', 'order': 0}, + exts: { + 'group': 'Complex', + 'order': 0, + }, ); case 'fluttercandies://WidgetSpanDemo': - return RouteResult( + return FFRouteSettings( name: name, - widget: WidgetSpanDemo(), + arguments: arguments, + builder: () => WidgetSpanDemo(), routeName: 'widget span', description: 'mailbox demo with widgetSpan', - exts: {'group': 'Simple', 'order': 1}, + exts: { + 'group': 'Simple', + 'order': 1, + }, ); case 'fluttercandies://demogrouppage': - return RouteResult( + return FFRouteSettings( name: name, - widget: DemoGroupPage( - keyValue: - arguments['keyValue'] as MapEntry>, + arguments: arguments, + builder: () => DemoGroupPage( + keyValue: asT>>( + safeArguments['keyValue'], + )!, ), routeName: 'DemoGroupPage', ); case 'fluttercandies://mainpage': - return RouteResult( + return FFRouteSettings( name: name, - widget: MainPage(), + arguments: arguments, + builder: () => MainPage(), routeName: 'MainPage', ); default: - return const RouteResult(name: 'flutterCandies://notfound'); + return FFRouteSettings( + name: FFRoute.notFoundName, + routeName: FFRoute.notFoundRouteName, + builder: notFoundPageBuilder ?? () => Container(), + ); } } - -class RouteResult { - const RouteResult({ - @required this.name, - this.widget, - this.showStatusBar = true, - this.routeName = '', - this.pageRouteType, - this.description = '', - this.exts, - }); - - /// The name of the route (e.g., "/settings"). - /// - /// If null, the route is anonymous. - final String name; - - /// The Widget return base on route - final Widget widget; - - /// Whether show this route with status bar. - final bool showStatusBar; - - /// The route name to track page - final String routeName; - - /// The type of page route - final PageRouteType pageRouteType; - - /// The description of route - final String description; - - /// The extend arguments - final Map exts; -} diff --git a/example/lib/example_route_helper.dart b/example/lib/example_route_helper.dart deleted file mode 100644 index 2ef518e..0000000 --- a/example/lib/example_route_helper.dart +++ /dev/null @@ -1,188 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY MANUALLY -// ************************************************************************** -// Auto generated by https://github.com/fluttercandies/ff_annotation_route -// ************************************************************************** - -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:ff_annotation_route/ff_annotation_route.dart'; - -import 'example_route.dart'; - -class FFNavigatorObserver extends NavigatorObserver { - FFNavigatorObserver({this.routeChange}); - - final RouteChange routeChange; - - @override - void didPop(Route route, Route previousRoute) { - super.didPop(route, previousRoute); - _didRouteChange(previousRoute, route); - } - - @override - void didPush(Route route, Route previousRoute) { - super.didPush(route, previousRoute); - _didRouteChange(route, previousRoute); - } - - @override - void didRemove(Route route, Route previousRoute) { - super.didRemove(route, previousRoute); - _didRouteChange(previousRoute, route); - } - - @override - void didReplace({Route newRoute, Route oldRoute}) { - super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - _didRouteChange(newRoute, oldRoute); - } - - void _didRouteChange(Route newRoute, Route oldRoute) { - // oldRoute may be null when route first time enter. - routeChange?.call(newRoute, oldRoute); - } -} - -typedef RouteChange = void Function( - Route newRoute, Route oldRoute); - -class FFTransparentPageRoute extends PageRouteBuilder { - FFTransparentPageRoute({ - RouteSettings settings, - @required RoutePageBuilder pageBuilder, - RouteTransitionsBuilder transitionsBuilder = _defaultTransitionsBuilder, - Duration transitionDuration = const Duration(milliseconds: 150), - bool barrierDismissible = false, - Color barrierColor, - String barrierLabel, - bool maintainState = true, - }) : assert(pageBuilder != null), - assert(transitionsBuilder != null), - assert(barrierDismissible != null), - assert(maintainState != null), - super( - settings: settings, - opaque: false, - pageBuilder: pageBuilder, - transitionsBuilder: transitionsBuilder, - transitionDuration: transitionDuration, - barrierDismissible: barrierDismissible, - barrierColor: barrierColor, - barrierLabel: barrierLabel, - maintainState: maintainState, - ); -} - -Widget _defaultTransitionsBuilder( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, -) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOut, - ), - child: child, - ); -} - -Route onGenerateRouteHelper( - RouteSettings settings, { - Widget notFoundFallback, - Object arguments, - WidgetBuilder builder, -}) { - arguments ??= settings.arguments; - - final RouteResult routeResult = getRouteResult( - name: settings.name, - arguments: arguments as Map, - ); - if (routeResult.showStatusBar != null || routeResult.routeName != null) { - settings = FFRouteSettings( - name: settings.name, - routeName: routeResult.routeName, - arguments: arguments as Map, - showStatusBar: routeResult.showStatusBar, - ); - } - Widget page = routeResult.widget ?? notFoundFallback; - if (page == null) { - throw Exception( - '''Route "${settings.name}" returned null. Route Widget must never return null, - maybe the reason is that route name did not match with right path. - You can use parameter[notFoundFallback] to avoid this ugly error.''', - ); - } - - if (arguments is Map) { - final RouteBuilder builder = arguments['routeBuilder'] as RouteBuilder; - if (builder != null) { - return builder(page); - } - } - - if (builder != null) { - page = builder(page, routeResult); - } - - switch (routeResult.pageRouteType) { - case PageRouteType.material: - return MaterialPageRoute( - settings: settings, - builder: (BuildContext _) => page, - ); - case PageRouteType.cupertino: - return CupertinoPageRoute( - settings: settings, - builder: (BuildContext _) => page, - ); - case PageRouteType.transparent: - return FFTransparentPageRoute( - settings: settings, - pageBuilder: ( - BuildContext _, - Animation __, - Animation ___, - ) => - page, - ); - default: - return kIsWeb || !Platform.isIOS - ? MaterialPageRoute( - settings: settings, - builder: (BuildContext _) => page, - ) - : CupertinoPageRoute( - settings: settings, - builder: (BuildContext _) => page, - ); - } -} - -typedef RouteBuilder = PageRoute Function(Widget page); - -class FFRouteSettings extends RouteSettings { - const FFRouteSettings({ - this.routeName, - this.showStatusBar, - String name, - Object arguments, - }) : super( - name: name, - arguments: arguments, - ); - - final String routeName; - final bool showStatusBar; -} - -/// Signature for a function that creates a widget, e.g. -typedef WidgetBuilder = Widget Function(Widget child, RouteResult routeResult); diff --git a/example/lib/example_routes.dart b/example/lib/example_routes.dart index 64a30b9..ac72491 100644 --- a/example/lib/example_routes.dart +++ b/example/lib/example_routes.dart @@ -2,8 +2,13 @@ // ************************************************************************** // Auto generated by https://github.com/fluttercandies/ff_annotation_route // ************************************************************************** +// fast mode: true +// version: 10.0.6 +// ************************************************************************** +// ignore_for_file: prefer_const_literals_to_create_immutables,unused_local_variable,unused_import,unnecessary_import,unused_shown_name,implementation_imports,duplicate_import const List routeNames = [ 'fluttercandies://CustomToolBar', + 'fluttercandies://NoKeyboard', 'fluttercandies://TextDemo', 'fluttercandies://WidgetSpanDemo', 'fluttercandies://demogrouppage', @@ -21,10 +26,25 @@ class Routes { /// /// [description] : 'custom selection toolbar and handles for text field' /// - /// [exts] : {group: Simple, order: 0} + /// [exts] : {'group': 'Simple', 'order': 0} static const String fluttercandiesCustomToolBar = 'fluttercandies://CustomToolBar'; + /// 'show how to ignore system keyboard and show custom keyboard' + /// + /// [name] : 'fluttercandies://NoKeyboard' + /// + /// [routeName] : 'no system Keyboard' + /// + /// [description] : 'show how to ignore system keyboard and show custom keyboard' + /// + /// [constructors] : + /// + /// NoSystemKeyboardDemo : [Key? key] + /// + /// [exts] : {'group': 'Simple', 'order': 2} + static const String fluttercandiesNoKeyboard = 'fluttercandies://NoKeyboard'; + /// 'build special text and inline image in text field' /// /// [name] : 'fluttercandies://TextDemo' @@ -33,7 +53,7 @@ class Routes { /// /// [description] : 'build special text and inline image in text field' /// - /// [exts] : {group: Complex, order: 0} + /// [exts] : {'group': 'Complex', 'order': 0} static const String fluttercandiesTextDemo = 'fluttercandies://TextDemo'; /// 'mailbox demo with widgetSpan' @@ -44,7 +64,7 @@ class Routes { /// /// [description] : 'mailbox demo with widgetSpan' /// - /// [exts] : {group: Simple, order: 1} + /// [exts] : {'group': 'Simple', 'order': 1} static const String fluttercandiesWidgetSpanDemo = 'fluttercandies://WidgetSpanDemo'; @@ -56,7 +76,7 @@ class Routes { /// /// [constructors] : /// - /// DemoGroupPage : [MapEntry> keyValue] + /// DemoGroupPage : [MapEntry>(required) keyValue] static const String fluttercandiesDemogrouppage = 'fluttercandies://demogrouppage'; diff --git a/example/lib/generated_plugin_registrant.dart b/example/lib/generated_plugin_registrant.dart index 2973833..40a0d00 100644 --- a/example/lib/generated_plugin_registrant.dart +++ b/example/lib/generated_plugin_registrant.dart @@ -2,15 +2,16 @@ // Generated file. Do not edit. // -// ignore: unused_import -import 'dart:ui'; +// ignore_for_file: directives_ordering +// ignore_for_file: lines_longer_than_80_chars +// ignore_for_file: depend_on_referenced_packages import 'package:url_launcher_web/url_launcher_web.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; // ignore: public_member_api_docs -void registerPlugins(PluginRegistry registry) { - UrlLauncherPlugin.registerWith(registry.registrarFor(UrlLauncherPlugin)); - registry.registerMessageHandler(); +void registerPlugins(Registrar registrar) { + UrlLauncherPlugin.registerWith(registrar); + registrar.registerMessageHandler(); } diff --git a/example/lib/main.dart b/example/lib/main.dart index e790a6b..4b943b0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,70 +1,36 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; +import 'package:example/pages/simple/no_keyboard.dart'; +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; import 'package:flutter/material.dart'; +import 'package:oktoast/oktoast.dart'; import 'example_route.dart'; -import 'example_route_helper.dart'; import 'example_routes.dart'; -void main() => runApp(MyApp()); +void main() { + CustomKeyboarBinding(); + runApp(MyApp()); +} class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'extended_text_field demo', - debugShowCheckedModeBanner: false, - theme: ThemeData( - primarySwatch: Colors.blue, - ), - initialRoute: Routes.fluttercandiesMainpage, - onGenerateRoute: (RouteSettings settings) { - //when refresh web, route will as following - // / - // /fluttercandies: - // /fluttercandies:/ - // /fluttercandies://mainpage - if (kIsWeb && settings.name.startsWith('/')) { - return onGenerateRouteHelper( - settings.copyWith(name: settings.name.replaceFirst('/', '')), - notFoundFallback: - getRouteResult(name: Routes.fluttercandiesMainpage).widget, - ); - } - return onGenerateRouteHelper(settings, - builder: (Widget child, RouteResult result) { - return child; - // if (settings.name == Routes.fluttercandiesMainpage || - // settings.name == Routes.fluttercandiesDemogrouppage) { - // return child; - // } - // return CommonWidget( - // child: child, - // result: result, - // ); - }); - }, - ); - } -} - -class CommonWidget extends StatelessWidget { - const CommonWidget({ - this.child, - this.result, - }); - final Widget child; - final RouteResult result; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - result.routeName, + //EditableText + //TextField + return OKToast( + child: MaterialApp( + title: 'extended_text_field demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, ), + initialRoute: Routes.fluttercandiesMainpage, + onGenerateRoute: (RouteSettings settings) { + return onGenerateRoute( + settings: settings, + getRouteSettings: getRouteSettings, + ); + }, ), - body: child, ); } } diff --git a/example/lib/pages/complex/text_demo.dart b/example/lib/pages/complex/text_demo.dart index c4f8c22..fee79e1 100644 --- a/example/lib/pages/complex/text_demo.dart +++ b/example/lib/pages/complex/text_demo.dart @@ -1,17 +1,20 @@ +import 'dart:async'; +import 'dart:io'; import 'dart:math'; + import 'package:example/common/toggle_button.dart'; import 'package:example/special_text/at_text.dart'; import 'package:example/special_text/dollar_text.dart'; +import 'package:example/special_text/emoji_text.dart' as emoji; import 'package:example/special_text/my_extended_text_selection_controls.dart'; import 'package:example/special_text/my_special_text_span_builder.dart'; import 'package:extended_list/extended_list.dart'; import 'package:extended_text/extended_text.dart'; -import 'package:flutter/material.dart'; import 'package:extended_text_field/extended_text_field.dart'; +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:ff_annotation_route/ff_annotation_route.dart'; -import 'package:loading_more_list/loading_more_list.dart'; -import 'package:example/special_text/emoji_text.dart' as emoji; import 'package:url_launcher/url_launcher.dart'; @FFRoute( @@ -30,15 +33,18 @@ class TextDemo extends StatefulWidget { class _TextDemoState extends State { final TextEditingController _textEditingController = TextEditingController(); - final MyExtendedMaterialTextSelectionControls - _myExtendedMaterialTextSelectionControls = - MyExtendedMaterialTextSelectionControls(); - final GlobalKey _key = GlobalKey(); + final MyTextSelectionControls _myExtendedMaterialTextSelectionControls = + MyTextSelectionControls(); + final GlobalKey _key = + GlobalKey(); final MySpecialTextSpanBuilder _mySpecialTextSpanBuilder = MySpecialTextSpanBuilder(); + final StreamController _gridBuilderController = + StreamController.broadcast(); final FocusNode _focusNode = FocusNode(); - double _keyboardHeight = 267.0; + double _keyboardHeight = 0; + double _preKeyboardHeight = 0; bool get showCustomKeyBoard => activeEmojiGird || activeAtGrid || activeDollarGrid; bool activeEmojiGird = false; @@ -55,215 +61,247 @@ class _TextDemoState extends State { @override Widget build(BuildContext context) { - FocusScope.of(context).autofocus(_focusNode); + //FocusScope.of(context).autofocus(_focusNode); + final MediaQueryData mediaQueryData = MediaQuery.of(context); final double keyboardHeight = MediaQuery.of(context).viewInsets.bottom; - if (keyboardHeight > 0) { + + final bool showingKeyboard = keyboardHeight > _preKeyboardHeight; + _preKeyboardHeight = keyboardHeight; + if ((keyboardHeight > 0 && keyboardHeight >= _keyboardHeight) || + showingKeyboard) { activeEmojiGird = activeAtGrid = activeDollarGrid = false; + _gridBuilderController.add(null); } _keyboardHeight = max(_keyboardHeight, keyboardHeight); - return Scaffold( - appBar: AppBar( - title: const Text('special text'), - actions: [ - FlatButton( - child: const Icon(Icons.backspace), - onPressed: manualDelete, - ) - ], - ), - body: Column( - children: [ - Expanded( - child: ExtendedListView.builder( - extendedListDelegate: - const ExtendedListDelegate(closeToTrailing: true), - itemBuilder: (BuildContext context, int index) { - final bool left = index % 2 == 0; - final Image logo = Image.asset( - 'assets/flutter_candies_logo.png', - width: 30.0, - height: 30.0, - ); - //print(sessions[index]); - final Widget text = ExtendedText( - sessions[index], - textAlign: left ? TextAlign.left : TextAlign.right, - specialTextSpanBuilder: _mySpecialTextSpanBuilder, - onSpecialTextTap: (dynamic value) { - if (value.toString().startsWith('\$')) { - launch('https://github.com/fluttercandies'); - } else if (value.toString().startsWith('@')) { - launch('mailto:zmtzawqlp@live.com'); - } - }, - ); - List list = [ - logo, - Expanded(child: text), - Container( + return SafeArea( + bottom: true, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: const Text('special text'), + actions: [ + TextButton( + child: const Icon( + Icons.backspace, + color: Colors.white, + ), + onPressed: manualDelete, + ) + ], + ), + body: Column( + children: [ + Expanded( + child: ExtendedListView.builder( + extendedListDelegate: + const ExtendedListDelegate(closeToTrailing: true), + itemBuilder: (BuildContext context, int index) { + final bool left = index % 2 == 0; + final Image logo = Image.asset( + 'assets/flutter_candies_logo.png', width: 30.0, - ) - ]; - if (!left) { - list = list.reversed.toList(); - } - return Row( - children: list, - ); - }, - padding: const EdgeInsets.only(bottom: 10.0), - reverse: true, - itemCount: sessions.length, - )), - // TextField() - Container( - height: 2.0, - color: Colors.blue, - ), - ExtendedTextField( - key: _key, - specialTextSpanBuilder: MySpecialTextSpanBuilder( - showAtBackground: true, - ), - controller: _textEditingController, - textSelectionControls: _myExtendedMaterialTextSelectionControls, - maxLines: null, - focusNode: _focusNode, - decoration: InputDecoration( - suffixIcon: GestureDetector( - onTap: () { - setState(() { - sessions.insert(0, _textEditingController.text); - _textEditingController.value = - _textEditingController.value.copyWith( - text: '', - selection: - const TextSelection.collapsed(offset: 0), - composing: TextRange.empty); - }); + height: 30.0, + ); + //print(sessions[index]); + final Widget text = ExtendedText( + sessions[index], + textAlign: left ? TextAlign.left : TextAlign.right, + specialTextSpanBuilder: _mySpecialTextSpanBuilder, + onSpecialTextTap: (dynamic value) { + if (value.toString().startsWith('\$')) { + launchUrl(Uri.parse('https://github.com/fluttercandies')); + } else if (value.toString().startsWith('@')) { + launchUrl(Uri.parse('mailto:zmtzawqlp@live.com')); + } }, - child: const Icon(Icons.send), - ), - contentPadding: const EdgeInsets.all(12.0)), - //textDirection: TextDirection.rtl, - ), - Container( - color: Colors.grey.withOpacity(0.3), - child: Column( - children: [ - Row( - children: [ - ToggleButton( - activeWidget: const Icon( - Icons.sentiment_very_satisfied, - color: Colors.orange, - ), - unActiveWidget: - const Icon(Icons.sentiment_very_satisfied), - activeChanged: (bool active) { - final Function change = () { - setState(() { - if (active) { - activeAtGrid = activeDollarGrid = false; - FocusScope.of(context).requestFocus(_focusNode); - } - activeEmojiGird = active; - }); - }; - update(change); - }, - active: activeEmojiGird, - ), - ToggleButton( - activeWidget: const Padding( - padding: EdgeInsets.only(bottom: 5.0), - child: Text( - '@', - style: TextStyle( + ); + List list = [ + logo, + Expanded(child: text), + Container( + width: 30.0, + ) + ]; + if (!left) { + list = list.reversed.toList(); + } + return Row( + children: list, + ); + }, + padding: const EdgeInsets.only(bottom: 10.0), + reverse: true, + itemCount: sessions.length, + )), + // TextField() + Container( + height: 2.0, + color: Colors.blue, + ), + //EditableText(controller: controller, focusNode: focusNode, style: style, cursorColor: cursorColor, backgroundCursorColor: backgroundCursorColor) + ExtendedTextField( + key: _key, + minLines: 1, + maxLines: 2, + // StrutStyle get strutStyle { + // if (_strutStyle == null) { + // return StrutStyle.fromTextStyle(style, forceStrutHeight: true); + // } + // return _strutStyle!.inheritFromTextStyle(style); + // } + // default strutStyle is not good for WidgetSpan + strutStyle: const StrutStyle(), + specialTextSpanBuilder: MySpecialTextSpanBuilder( + showAtBackground: true, + ), + controller: _textEditingController, + selectionControls: _myExtendedMaterialTextSelectionControls, + + focusNode: _focusNode, + decoration: InputDecoration( + suffixIcon: GestureDetector( + onTap: () { + setState(() { + sessions.insert(0, _textEditingController.text); + _textEditingController.value = + _textEditingController.value.copyWith( + text: '', + selection: + const TextSelection.collapsed(offset: 0), + composing: TextRange.empty); + }); + }, + child: const Icon(Icons.send), + ), + contentPadding: const EdgeInsets.all(12.0)), + //textDirection: TextDirection.rtl, + ), + StreamBuilder( + stream: _gridBuilderController.stream, + builder: (BuildContext b, AsyncSnapshot d) { + return Container( + color: Colors.grey.withOpacity(0.3), + child: Column( + children: [ + Row( + children: [ + ToggleButton( + activeWidget: const Icon( + Icons.sentiment_very_satisfied, color: Colors.orange, - fontWeight: FontWeight.bold, - fontSize: 20.0, ), + unActiveWidget: + const Icon(Icons.sentiment_very_satisfied), + activeChanged: (bool active) { + onToolbarButtonActiveChanged( + keyboardHeight, active, () { + activeEmojiGird = active; + }); + }, + active: activeEmojiGird, ), - ), - unActiveWidget: const Padding( - padding: EdgeInsets.only(bottom: 5.0), - child: Text( - '@', - style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 20.0), - ), - ), - activeChanged: (bool active) { - final Function change = () { - setState(() { - if (active) { - activeEmojiGird = activeDollarGrid = false; - FocusScope.of(context).requestFocus(_focusNode); - } - activeAtGrid = active; - }); - }; - update(change); - }, - active: activeAtGrid), - ToggleButton( - activeWidget: const Icon( - Icons.attach_money, - color: Colors.orange, - ), - unActiveWidget: const Icon(Icons.attach_money), - activeChanged: (bool active) { - final Function change = () { - setState(() { - if (active) { - activeEmojiGird = activeAtGrid = false; - FocusScope.of(context).requestFocus(_focusNode); - } - activeDollarGrid = active; - }); - }; - update(change); - }, - active: activeDollarGrid), - Container( - width: 20.0, - ) - ], - mainAxisAlignment: MainAxisAlignment.end, - ), - Container(), - ], + ToggleButton( + activeWidget: const Padding( + padding: EdgeInsets.only(bottom: 5.0), + child: Text( + '@', + style: TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + ), + unActiveWidget: const Padding( + padding: EdgeInsets.only(bottom: 5.0), + child: Text( + '@', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0), + ), + ), + activeChanged: (bool active) { + onToolbarButtonActiveChanged( + keyboardHeight, active, () { + activeAtGrid = active; + }); + }, + active: activeAtGrid), + ToggleButton( + activeWidget: const Icon( + Icons.attach_money, + color: Colors.orange, + ), + unActiveWidget: const Icon(Icons.attach_money), + activeChanged: (bool active) { + onToolbarButtonActiveChanged( + keyboardHeight, active, () { + activeDollarGrid = active; + }); + }, + active: activeDollarGrid), + Container( + width: 20.0, + ) + ], + mainAxisAlignment: MainAxisAlignment.end, + ), + Container(), + ], + ), + ); + }, ), - ), - Container( - height: 2.0, - color: Colors.blue, - ), - Container( - height: showCustomKeyBoard ? _keyboardHeight : 0.0, - child: buildCustomKeyBoard(), - ) - ], + + Container( + height: 2.0, + color: Colors.blue, + ), + StreamBuilder( + stream: _gridBuilderController.stream, + builder: (BuildContext b, AsyncSnapshot d) { + return SizedBox( + height: showCustomKeyBoard + ? _keyboardHeight - + (Platform.isIOS ? mediaQueryData.padding.bottom : 0) + : 0, + child: buildCustomKeyBoard()); + }, + ), + + StreamBuilder( + stream: _gridBuilderController.stream, + builder: (BuildContext b, AsyncSnapshot d) { + return Container( + height: showCustomKeyBoard ? 0 : keyboardHeight, + ); + }, + ), + ], + ), ), ); } - void update(Function change) { - if (showCustomKeyBoard) { - change(); - } else { - SystemChannels.textInput - .invokeMethod('TextInput.hide') - .whenComplete(() { - Future.delayed(const Duration(milliseconds: 200)) - .whenComplete(() { - change(); - }); - }); + void onToolbarButtonActiveChanged( + double keyboardHeight, bool active, Function activeOne) { + if (keyboardHeight > 0) { + // make sure grid height = keyboardHeight + _keyboardHeight = keyboardHeight; + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + + if (active) { + activeDollarGrid = activeEmojiGird = activeAtGrid = false; } + + activeOne(); + //activeDollarGrid = active; + + _gridBuilderController.add(null); } Widget buildCustomKeyBoard() { @@ -289,7 +327,7 @@ class _TextDemoState extends State { itemBuilder: (BuildContext context, int index) { return GestureDetector( child: - Image.asset(emoji.EmojiUitl.instance.emojiMap['[${index + 1}]']), + Image.asset(emoji.EmojiUitl.instance.emojiMap['[${index + 1}]']!), behavior: HitTestBehavior.translucent, onTap: () { insertText('[${index + 1}]'); @@ -372,6 +410,10 @@ class _TextDemoState extends State { selection: TextSelection.fromPosition(TextPosition(offset: text.length))); } + + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + _key.currentState?.bringIntoView(_textEditingController.selection.base); + }); } void manualDelete() { diff --git a/example/lib/pages/main_page.dart b/example/lib/pages/main_page.dart index 36fa184..eb077b4 100644 --- a/example/lib/pages/main_page.dart +++ b/example/lib/pages/main_page.dart @@ -1,8 +1,10 @@ +import 'package:collection/collection.dart'; import 'package:example/example_routes.dart'; +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:ff_annotation_route/ff_annotation_route.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:collection/collection.dart'; + import '../example_route.dart'; import '../example_routes.dart' as example_routes; @@ -16,18 +18,18 @@ class MainPage extends StatelessWidget { routeNames.addAll(example_routes.routeNames); routeNames.remove(Routes.fluttercandiesMainpage); routeNames.remove(Routes.fluttercandiesDemogrouppage); - routesGroup.addAll(groupBy( + routesGroup.addAll(groupBy( routeNames - .map((String name) => getRouteResult(name: name)) - .where((RouteResult element) => element.exts != null) - .map((RouteResult e) => DemoRouteResult(e)) + .map((String name) => getRouteSettings(name: name)) + .where((FFRouteSettings element) => element.exts != null) + .map((FFRouteSettings e) => DemoRouteResult(e)) .toList() - ..sort((DemoRouteResult a, DemoRouteResult b) => - b.group.compareTo(a.group)), + ..sort((DemoRouteResult a, DemoRouteResult b) => + b.group!.compareTo(a.group!)), (DemoRouteResult x) => x.group)); } - final Map> routesGroup = - >{}; + final Map> routesGroup = + >{}; @override Widget build(BuildContext context) { @@ -40,7 +42,7 @@ class MainPage extends StatelessWidget { ButtonTheme( minWidth: 0.0, padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: FlatButton( + child: TextButton( child: const Text( 'Github', style: TextStyle( @@ -50,27 +52,29 @@ class MainPage extends StatelessWidget { ), ), onPressed: () { - launch('https://github.com/fluttercandies/extended_text_field'); + launchUrl(Uri.parse( + 'https://github.com/fluttercandies/extended_text_field')); }, ), ), - ButtonTheme( - padding: const EdgeInsets.only(right: 10.0), - minWidth: 0.0, - child: FlatButton( - child: - Image.network('https://pub.idqqimg.com/wpa/images/group.png'), - onPressed: () { - launch('https://jq.qq.com/?_wv=1027&k=5bcc0gy'); - }, - ), - ) + if (!kIsWeb) + ButtonTheme( + padding: const EdgeInsets.only(right: 10.0), + minWidth: 0.0, + child: TextButton( + child: Image.network( + 'https://pub.idqqimg.com/wpa/images/group.png'), + onPressed: () { + launchUrl(Uri.parse('https://jq.qq.com/?_wv=1027&k=5bcc0gy')); + }, + ), + ) ], ), body: ListView.builder( itemBuilder: (BuildContext c, int index) { // final RouteResult page = routes[index]; - final String type = routesGroup.keys.toList()[index]; + final String type = routesGroup.keys.toList()[index]!; return Container( margin: const EdgeInsets.all(20.0), child: GestureDetector( @@ -91,10 +95,12 @@ class MainPage extends StatelessWidget { ), onTap: () { Navigator.pushNamed( - context, Routes.fluttercandiesDemogrouppage, - arguments: { - 'keyValue': routesGroup.entries.toList()[index], - }); + context, + Routes.fluttercandiesDemogrouppage, + arguments: { + 'keyValue': routesGroup.entries.toList()[index], + }, + ); }, )); }, @@ -109,11 +115,11 @@ class MainPage extends StatelessWidget { routeName: 'DemoGroupPage', ) class DemoGroupPage extends StatelessWidget { - DemoGroupPage({MapEntry> keyValue}) + DemoGroupPage({required MapEntry> keyValue}) : routes = keyValue.value ..sort((DemoRouteResult a, DemoRouteResult b) => - a.order.compareTo(b.order)), - group = keyValue.key; + a.order!.compareTo(b.order!)), + group = keyValue.key!; final List routes; final String group; @override @@ -133,17 +139,17 @@ class DemoGroupPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - (index + 1).toString() + '.' + page.routeResult.routeName, + (index + 1).toString() + '.' + page.routeResult.routeName!, //style: TextStyle(inherit: false), ), Text( - page.routeResult.description, + page.routeResult.description!, style: const TextStyle(color: Colors.grey), ) ], ), onTap: () { - Navigator.pushNamed(context, page.routeResult.name); + Navigator.pushNamed(context, page.routeResult.name!); }, ), ); @@ -157,10 +163,10 @@ class DemoGroupPage extends StatelessWidget { class DemoRouteResult { DemoRouteResult( this.routeResult, - ) : order = routeResult.exts['order'] as int, - group = routeResult.exts['group'] as String; + ) : order = routeResult.exts!['order'] as int?, + group = routeResult.exts!['group'] as String?; - final int order; - final String group; - final RouteResult routeResult; + final int? order; + final String? group; + final FFRouteSettings routeResult; } diff --git a/example/lib/pages/simple/custom_toolbar.dart b/example/lib/pages/simple/custom_toolbar.dart index aecd892..c57daf0 100644 --- a/example/lib/pages/simple/custom_toolbar.dart +++ b/example/lib/pages/simple/custom_toolbar.dart @@ -1,8 +1,10 @@ +// ignore_for_file: always_put_control_body_on_new_line + import 'package:example/special_text/my_extended_text_selection_controls.dart'; import 'package:example/special_text/my_special_text_span_builder.dart'; import 'package:extended_text_field/extended_text_field.dart'; +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; import 'package:flutter/material.dart'; -import 'package:ff_annotation_route/ff_annotation_route.dart'; /// /// create by zmtzawqlp on 2019/7/31 @@ -23,9 +25,8 @@ class CustomToolBar extends StatefulWidget { } class _CustomToolBarState extends State { - final MyExtendedMaterialTextSelectionControls - _myExtendedMaterialTextSelectionControls = - MyExtendedMaterialTextSelectionControls(); + final MyTextSelectionControls _myExtendedMaterialTextSelectionControls = + MyTextSelectionControls(); final MySpecialTextSpanBuilder _mySpecialTextSpanBuilder = MySpecialTextSpanBuilder(); TextEditingController controller = TextEditingController() @@ -44,13 +45,95 @@ class _CustomToolBarState extends State { padding: const EdgeInsets.symmetric(horizontal: 30.0), child: Center( child: ExtendedTextField( - textSelectionControls: _myExtendedMaterialTextSelectionControls, + selectionControls: _myExtendedMaterialTextSelectionControls, specialTextSpanBuilder: _mySpecialTextSpanBuilder, controller: controller, maxLines: null, + // StrutStyle get strutStyle { + // if (_strutStyle == null) { + // return StrutStyle.fromTextStyle(style, forceStrutHeight: true); + // } + // return _strutStyle!.inheritFromTextStyle(style); + // } + // default strutStyle is not good for WidgetSpan + strutStyle: const StrutStyle(), + shouldShowSelectionHandles: _shouldShowSelectionHandles, + textSelectionGestureDetectorBuilder: ({ + required ExtendedTextSelectionGestureDetectorBuilderDelegate + delegate, + required Function showToolbar, + required Function hideToolbar, + required Function? onTap, + required BuildContext context, + required Function? requestKeyboard, + }) { + return MyCommonTextSelectionGestureDetectorBuilder( + delegate: delegate, + showToolbar: showToolbar, + hideToolbar: hideToolbar, + onTap: onTap, + context: context, + requestKeyboard: requestKeyboard, + ); + }, ), ), ), ); } + + bool _shouldShowSelectionHandles( + SelectionChangedCause? cause, + CommonTextSelectionGestureDetectorBuilder selectionGestureDetectorBuilder, + TextEditingValue editingValue, + ) { + // When the text field is activated by something that doesn't trigger the + // selection overlay, we shouldn't show the handles either. + + // + // if (!selectionGestureDetectorBuilder.shouldShowSelectionToolbar) + // return false; + + if (cause == SelectionChangedCause.keyboard) return false; + + // if (widget.readOnly && _effectiveController.selection.isCollapsed) + // return false; + + // if (!_isEnabled) return false; + + if (cause == SelectionChangedCause.longPress) return true; + + if (editingValue.text.isNotEmpty) return true; + + return false; + } +} + +class MyCommonTextSelectionGestureDetectorBuilder + extends CommonTextSelectionGestureDetectorBuilder { + MyCommonTextSelectionGestureDetectorBuilder( + {required ExtendedTextSelectionGestureDetectorBuilderDelegate delegate, + required Function showToolbar, + required Function hideToolbar, + required Function? onTap, + required BuildContext context, + required Function? requestKeyboard}) + : super( + delegate: delegate, + showToolbar: showToolbar, + hideToolbar: hideToolbar, + onTap: onTap, + context: context, + requestKeyboard: requestKeyboard, + ); + @override + void onTapDown(TapDownDetails details) { + super.onTapDown(details); + + /// always show toolbar + shouldShowSelectionToolbar = true; + } + + @override + bool get showToolbarInWeb => true; } diff --git a/example/lib/pages/simple/no_keyboard.dart b/example/lib/pages/simple/no_keyboard.dart new file mode 100644 index 0000000..52d7fb2 --- /dev/null +++ b/example/lib/pages/simple/no_keyboard.dart @@ -0,0 +1,428 @@ +import 'dart:ui' as ui; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:oktoast/oktoast.dart'; + +class CustomKeyboarBinding extends TextInputBinding { + @override + // ignore: unnecessary_overrides + bool ignoreTextInputShow() { + // you can override it base on your case + // if NoKeyboardFocusNode is not enough + return super.ignoreTextInputShow(); + } +} + +@FFRoute( + name: 'fluttercandies://NoKeyboard', + routeName: 'no system Keyboard', + description: 'show how to ignore system keyboard and show custom keyboard', + exts: { + 'group': 'Simple', + 'order': 2, + }, +) +class NoSystemKeyboardDemo extends StatelessWidget { + const NoSystemKeyboardDemo({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('no system Keyboard'), + ), + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column(children: const [ + Text('ExtendedTextField'), + ExtendedTextFieldCase(), + Text('CustomTextField'), + TextFieldCase(), + ]), + ), + ), + ); + } +} + +class ExtendedTextFieldCase extends StatefulWidget { + const ExtendedTextFieldCase({Key? key}) : super(key: key); + + @override + State createState() => _ExtendedTextFieldCaseState(); +} + +class _ExtendedTextFieldCaseState extends State + with CustomKeyboardShowStateMixin { + @override + Widget build(BuildContext context) { + return ExtendedTextField( + // you must use TextInputFocusNode + focusNode: _focusNode..debugLabel = 'ExtendedTextField', + // if your custom keyboard can be close without unfocus + // you can show custom keyboard when TextField onTap + controller: _controller, + maxLines: null, + inputFormatters: _inputFormatters, + ); + } +} + +class TextFieldCase extends StatefulWidget { + const TextFieldCase({Key? key}) : super(key: key); + @override + State createState() => TextFieldCaseState(); +} + +class TextFieldCaseState extends State + with CustomKeyboardShowStateMixin { + @override + Widget build(BuildContext context) { + return TextField( + // you must use TextInputFocusNode + focusNode: _focusNode..debugLabel = 'CustomTextField', + // if your custom keyboard can be close without unfocus + // you can show custom keyboard when TextField onTap + onTap: _onTextFiledTap, + controller: _controller, + inputFormatters: _inputFormatters, + maxLines: null, + ); + } +} + +@optionalTypeArgs +mixin CustomKeyboardShowStateMixin on State { + final TextInputFocusNode _focusNode = TextInputFocusNode(); + final TextEditingController _controller = TextEditingController(); + PersistentBottomSheetController? _bottomSheetController; + + final List _inputFormatters = [ + // digit or decimal + FilteringTextInputFormatter.allow(RegExp(r'[1-9]{1}[0-9.]*')), + // only one decimal + TextInputFormatter.withFunction( + (TextEditingValue oldValue, TextEditingValue newValue) => + newValue.text.indexOf('.') != newValue.text.lastIndexOf('.') + ? oldValue + : newValue), + ]; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChanged); + } + + void _onTextFiledTap() { + if (_bottomSheetController == null) { + _handleFocusChanged(); + } + } + + void _handleFocusChanged() { + if (_focusNode.hasFocus) { + // just demo, you can define your custom keyboard as you want + _bottomSheetController = showBottomSheet( + context: FocusManager.instance.primaryFocus!.context!, + // set false, if don't want to drag to close custom keyboard + enableDrag: true, + builder: (BuildContext b) { + return Material( + //shadowColor: Colors.grey, + color: Colors.grey.withOpacity(0.3), + //elevation: 8, + child: Padding( + padding: EdgeInsets.only( + left: 10, + right: 10, + top: 20, + bottom: + ui.window.viewPadding.bottom / ui.window.devicePixelRatio, + ), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + flex: 15, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + flex: 5, + child: NumberButton( + number: 1, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 2, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 3, + insertText: insertText, + ), + ), + ], + ), + Row( + children: [ + Expanded( + flex: 5, + child: NumberButton( + number: 4, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 5, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 6, + insertText: insertText, + ), + ), + ], + ), + Row( + children: [ + Expanded( + flex: 5, + child: NumberButton( + number: 7, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 8, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 9, + insertText: insertText, + ), + ), + ], + ), + Row( + children: [ + Expanded( + flex: 5, + child: CustomButton( + child: const Text('.'), + onTap: () { + insertText('.'); + }, + ), + ), + Expanded( + flex: 5, + child: NumberButton( + number: 0, + insertText: insertText, + ), + ), + Expanded( + flex: 5, + child: CustomButton( + child: const Icon(Icons.arrow_downward), + onTap: () { + _focusNode.unfocus(); + }, + ), + ), + ], + ), + ], + ), + ), + Expanded( + flex: 7, + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: CustomButton( + child: const Icon(Icons.backspace), + onTap: () { + manualDelete(); + }, + )), + Expanded( + child: CustomButton( + child: const Icon(Icons.keyboard_return), + onTap: () { + showToast('onSubmitted: ${_controller.text}'); + }, + )) + ], + ), + ), + ], + ), + ), + ), + ); + }); + // maybe drag close + _bottomSheetController?.closed.whenComplete(() { + _bottomSheetController = null; + }); + } else { + _bottomSheetController?.close(); + _bottomSheetController = null; + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChanged); + super.dispose(); + } + + void insertText(String text) { + final TextEditingValue oldValue = _controller.value; + TextEditingValue newValue = oldValue; + final int start = oldValue.selection.baseOffset; + int end = oldValue.selection.extentOffset; + if (oldValue.selection.isValid) { + String newText = ''; + if (oldValue.selection.isCollapsed) { + if (end > 0) { + newText += oldValue.text.substring(0, end); + } + newText += text; + if (oldValue.text.length > end) { + newText += oldValue.text.substring(end, oldValue.text.length); + } + } else { + newText = oldValue.text.replaceRange(start, end, text); + end = start; + } + + newValue = oldValue.copyWith( + text: newText, + selection: oldValue.selection.copyWith( + baseOffset: end + text.length, extentOffset: end + text.length)); + } else { + newValue = TextEditingValue( + text: text, + selection: + TextSelection.fromPosition(TextPosition(offset: text.length))); + } + for (final TextInputFormatter inputFormatter in _inputFormatters) { + newValue = inputFormatter.formatEditUpdate(oldValue, newValue); + } + + _controller.value = newValue; + } + + void manualDelete() { + //delete by code + final TextEditingValue _value = _controller.value; + final TextSelection selection = _value.selection; + if (!selection.isValid) { + return; + } + + TextEditingValue value; + final String actualText = _value.text; + if (selection.isCollapsed && selection.start == 0) { + return; + } + final int start = + selection.isCollapsed ? selection.start - 1 : selection.start; + final int end = selection.end; + + value = TextEditingValue( + text: actualText.replaceRange(start, end, ''), + selection: TextSelection.collapsed(offset: start), + ); + + _controller.value = value; + } +} + +class NumberButton extends StatelessWidget { + const NumberButton({ + Key? key, + required this.number, + required this.insertText, + }) : super(key: key); + final int number; + final Function(String text) insertText; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + insertText('$number'); + }, + child: Container( + margin: const EdgeInsets.all(5), + alignment: Alignment.center, + height: 50, + child: Text( + '$number', + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + ), + ); + } +} + +class CustomButton extends StatelessWidget { + const CustomButton({ + Key? key, + required this.child, + required this.onTap, + }) : super(key: key); + final Widget child; + final GestureTapCallback onTap; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(5), + alignment: Alignment.center, + height: 50, + child: child, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + ), + ); + } +} diff --git a/example/lib/pages/simple/widget_span.dart b/example/lib/pages/simple/widget_span.dart index 7bd7afb..b60b5c4 100644 --- a/example/lib/pages/simple/widget_span.dart +++ b/example/lib/pages/simple/widget_span.dart @@ -1,8 +1,8 @@ import 'package:example/special_text/email_span_builder.dart'; import 'package:example/special_text/my_special_text_span_builder.dart'; import 'package:extended_text_field/extended_text_field.dart'; +import 'package:ff_annotation_route_library/ff_annotation_route_library.dart'; import 'package:flutter/material.dart'; -import 'package:ff_annotation_route/ff_annotation_route.dart'; /// /// create by zmtzawqlp on 2019/8/4 @@ -29,7 +29,7 @@ class _WidgetSpanDemoState extends State { '[33]Extended text field help you to build rich text quickly. any special text you will have with extended text field. this is demo to show how to create special text with widget span.' '\n\nIt\'s my pleasure to invite you to join \$FlutterCandies\$ if you want to improve flutter .[36]' '\n\nif you meet any problem, please let me konw @zmtzawqlp .[44]'; - EmailSpanBuilder _emailSpanBuilder; + EmailSpanBuilder? _emailSpanBuilder; @override void initState() { _emailSpanBuilder = EmailSpanBuilder(controller, context); @@ -64,6 +64,14 @@ class _WidgetSpanDemoState extends State { controller: controller, specialTextSpanBuilder: _emailSpanBuilder, maxLines: null, + // StrutStyle get strutStyle { + // if (_strutStyle == null) { + // return StrutStyle.fromTextStyle(style, forceStrutHeight: true); + // } + // return _strutStyle!.inheritFromTextStyle(style); + // } + // default strutStyle is not good for WidgetSpan + strutStyle: const StrutStyle(), decoration: InputDecoration( suffixIcon: IconButton( icon: const Icon(Icons.add), @@ -85,7 +93,7 @@ class _WidgetSpanDemoState extends State { padding: const EdgeInsets.all(10.0), child: Column( children: [ - FlatButton( + TextButton( onPressed: () { insertEmail( 'zmtzawqlp@live.com ', @@ -94,7 +102,7 @@ class _WidgetSpanDemoState extends State { }, child: const Text( 'zmtzawqlp@live.com')), - FlatButton( + TextButton( onPressed: () { insertEmail( '410496936@qq.com ', diff --git a/example/lib/special_text/at_text.dart b/example/lib/special_text/at_text.dart index ff9dc3c..d80ab3e 100644 --- a/example/lib/special_text/at_text.dart +++ b/example/lib/special_text/at_text.dart @@ -3,18 +3,18 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class AtText extends SpecialText { - AtText(TextStyle textStyle, SpecialTextGestureTapCallback onTap, + AtText(TextStyle? textStyle, SpecialTextGestureTapCallback? onTap, {this.showAtBackground = false, this.start}) : super(flag, ' ', textStyle, onTap: onTap); static const String flag = '@'; - final int start; + final int? start; /// whether show background for @somebody final bool showAtBackground; @override InlineSpan finishText() { - final TextStyle textStyle = + final TextStyle? textStyle = this.textStyle?.copyWith(color: Colors.blue, fontSize: 16.0); final String atText = toString(); @@ -24,7 +24,7 @@ class AtText extends SpecialText { background: Paint()..color = Colors.blue.withOpacity(0.15), text: atText, actualText: atText, - start: start, + start: start!, ///caret can move into special text deleteAll: true, @@ -32,18 +32,18 @@ class AtText extends SpecialText { recognizer: (TapGestureRecognizer() ..onTap = () { if (onTap != null) { - onTap(atText); + onTap!(atText); } })) : SpecialTextSpan( text: atText, actualText: atText, - start: start, + start: start!, style: textStyle, recognizer: (TapGestureRecognizer() ..onTap = () { if (onTap != null) { - onTap(atText); + onTap!(atText); } })); } diff --git a/example/lib/special_text/dollar_text.dart b/example/lib/special_text/dollar_text.dart index 8698ed5..989feae 100644 --- a/example/lib/special_text/dollar_text.dart +++ b/example/lib/special_text/dollar_text.dart @@ -3,11 +3,11 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class DollarText extends SpecialText { - DollarText(TextStyle textStyle, SpecialTextGestureTapCallback onTap, + DollarText(TextStyle? textStyle, SpecialTextGestureTapCallback? onTap, {this.start}) : super(flag, flag, textStyle, onTap: onTap); static const String flag = '\$'; - final int start; + final int? start; @override InlineSpan finishText() { final String text = getContent(); @@ -15,7 +15,7 @@ class DollarText extends SpecialText { return SpecialTextSpan( text: text, actualText: toString(), - start: start, + start: start!, ///caret can move into special text deleteAll: true, @@ -23,7 +23,7 @@ class DollarText extends SpecialText { recognizer: TapGestureRecognizer() ..onTap = () { if (onTap != null) { - onTap(toString()); + onTap!(toString()); } }); } diff --git a/example/lib/special_text/email_span_builder.dart b/example/lib/special_text/email_span_builder.dart index 88b2092..06aab1c 100644 --- a/example/lib/special_text/email_span_builder.dart +++ b/example/lib/special_text/email_span_builder.dart @@ -12,14 +12,16 @@ class EmailSpanBuilder extends SpecialTextSpanBuilder { final TextEditingController controller; final BuildContext context; @override - SpecialText createSpecialText(String flag, - {TextStyle textStyle, SpecialTextGestureTapCallback onTap, int index}) { - if (flag == null || flag == '') { + SpecialText? createSpecialText(String flag, + {TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + int? index}) { + if (flag == '') { return null; } if (!flag.startsWith(' ') && !flag.startsWith('@')) { - return EmailText(textStyle, onTap, + return EmailText(textStyle!, onTap, start: index, context: context, controller: controller, diff --git a/example/lib/special_text/email_text.dart b/example/lib/special_text/email_text.dart index fa9e718..47c47cb 100644 --- a/example/lib/special_text/email_text.dart +++ b/example/lib/special_text/email_text.dart @@ -3,12 +3,12 @@ import 'package:extended_text_library/extended_text_library.dart'; import 'package:flutter/material.dart'; class EmailText extends SpecialText { - EmailText(TextStyle textStyle, SpecialTextGestureTapCallback onTap, - {this.start, this.controller, this.context, String startFlag}) + EmailText(TextStyle textStyle, SpecialTextGestureTapCallback? onTap, + {this.start, this.controller, this.context, required String startFlag}) : super(startFlag, ' ', textStyle, onTap: onTap); - final TextEditingController controller; - final int start; - final BuildContext context; + final TextEditingController? controller; + final int? start; + final BuildContext? context; @override bool isEnd(String value) { final int index = value.indexOf('@'); @@ -26,7 +26,7 @@ class EmailText extends SpecialText { return ExtendedWidgetSpan( actualText: text, - start: start, + start: start!, alignment: ui.PlaceholderAlignment.middle, child: GestureDetector( child: Padding( @@ -53,11 +53,11 @@ class EmailText extends SpecialText { size: 15.0, ), onTap: () { - controller.value = controller.value.copyWith( - text: controller.text - .replaceRange(start, start + text.length, ''), + controller!.value = controller!.value.copyWith( + text: controller!.text + .replaceRange(start!, start! + text.length, ''), selection: TextSelection.fromPosition( - TextPosition(offset: start))); + TextPosition(offset: start!))); }, ) ], @@ -66,7 +66,7 @@ class EmailText extends SpecialText { ), onTap: () { showDialog( - context: context, + context: context!, barrierDismissible: true, builder: (BuildContext c) { final TextEditingController textEditingController = @@ -82,21 +82,21 @@ class EmailText extends SpecialText { child: TextField( controller: textEditingController, decoration: InputDecoration( - suffixIcon: FlatButton( + suffixIcon: TextButton( child: const Text('OK'), onPressed: () { - controller.value = controller.value.copyWith( - text: controller.text.replaceRange( - start, - start + text.length, + controller!.value = controller!.value.copyWith( + text: controller!.text.replaceRange( + start!, + start! + text.length, textEditingController.text + ' '), selection: TextSelection.fromPosition( TextPosition( - offset: start + + offset: start! + (textEditingController.text + ' ') .length))); - Navigator.pop(context); + Navigator.pop(context!); }, )), ), diff --git a/example/lib/special_text/emoji_text.dart b/example/lib/special_text/emoji_text.dart index 057d66d..6fc483b 100644 --- a/example/lib/special_text/emoji_text.dart +++ b/example/lib/special_text/emoji_text.dart @@ -1,36 +1,34 @@ import 'package:extended_text_library/extended_text_library.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; ///emoji/image text class EmojiText extends SpecialText { - EmojiText(TextStyle textStyle, {this.start}) + EmojiText(TextStyle? textStyle, {this.start}) : super(EmojiText.flag, ']', textStyle); static const String flag = '['; - final int start; + final int? start; @override InlineSpan finishText() { final String key = toString(); - ///https://github.com/flutter/flutter/issues/42086 - /// widget span is not working on web - if (EmojiUitl.instance.emojiMap.containsKey(key) && !kIsWeb) { - //fontsize id define image height - //size = 30.0/26.0 * fontSize - const double size = 20.0; + if (EmojiUitl.instance.emojiMap.containsKey(key)) { + double size = 18; + + final TextStyle ts = textStyle!; + if (ts.fontSize != null) { + size = ts.fontSize! * 1.15; + } - ///fontSize 26 and text height =30.0 - //final double fontSize = 26.0; return ImageSpan( AssetImage( - EmojiUitl.instance.emojiMap[key], + EmojiUitl.instance.emojiMap[key]!, ), actualText: key, imageWidth: size, imageHeight: size, - start: start, - fit: BoxFit.fill, - margin: const EdgeInsets.only(left: 2.0, top: 2.0, right: 2.0)); + start: start!, + //fit: BoxFit.fill, + margin: const EdgeInsets.all(2)); } return TextSpan(text: toString(), style: textStyle); @@ -50,6 +48,6 @@ class EmojiUitl { final String _emojiFilePath = 'assets'; - static EmojiUitl _instance; + static EmojiUitl? _instance; static EmojiUitl get instance => _instance ??= EmojiUitl._(); } diff --git a/example/lib/special_text/image_text.dart b/example/lib/special_text/image_text.dart index 0a1f0cd..dd2355a 100644 --- a/example/lib/special_text/image_text.dart +++ b/example/lib/special_text/image_text.dart @@ -6,8 +6,8 @@ import 'package:html/dom.dart' hide Text; import 'package:html/parser.dart'; class ImageText extends SpecialText { - ImageText(TextStyle textStyle, - {this.start, SpecialTextGestureTapCallback onTap}) + ImageText(TextStyle? textStyle, + {this.start, SpecialTextGestureTapCallback? onTap}) : super( ImageText.flag, '/>', @@ -16,9 +16,9 @@ class ImageText extends SpecialText { ); static const String flag = ' _imageUrl; + final int? start; + String? _imageUrl; + String? get imageUrl => _imageUrl; @override InlineSpan finishText() { ///content already has endflag '/' @@ -34,13 +34,13 @@ class ImageText extends SpecialText { final Document html = parse(text); final Element img = html.getElementsByTagName('img').first; - final String url = img.attributes['src']; + final String url = img.attributes['src']!; _imageUrl = url; //fontsize id define image height //size = 30.0/26.0 * fontSize - double width = 60.0; - double height = 60.0; + double? width = 60.0; + double? height = 60.0; const BoxFit fit = BoxFit.cover; const double num300 = 60.0; const double num400 = 80.0; @@ -49,9 +49,9 @@ class ImageText extends SpecialText { width = num400; const bool knowImageSize = true; if (knowImageSize) { - height = double.tryParse(img.attributes['height']); - width = double.tryParse(img.attributes['width']); - final double n = height / width; + height = double.tryParse(img.attributes['height']!); + width = double.tryParse(img.attributes['width']!); + final double n = height! / width!; if (n >= 4 / 3) { width = num300; height = num400; @@ -69,7 +69,7 @@ class ImageText extends SpecialText { //final double fontSize = 26.0; return ExtendedWidgetSpan( - start: start, + start: start!, actualText: text, child: GestureDetector( onTap: () { diff --git a/example/lib/special_text/my_extended_text_selection_controls.dart b/example/lib/special_text/my_extended_text_selection_controls.dart index daa418b..b0aae79 100644 --- a/example/lib/special_text/my_extended_text_selection_controls.dart +++ b/example/lib/special_text/my_extended_text_selection_controls.dart @@ -1,25 +1,25 @@ import 'dart:math' as math; -import 'package:extended_text_library/extended_text_library.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:url_launcher/url_launcher.dart'; -// Minimal padding from all edges of the selection toolbar to all edges of the -// viewport. +/// +/// create by zmtzawqlp on 2019/8/3 +/// + const double _kHandleSize = 22.0; -const double _kToolbarScreenPadding = 8.0; -const double _kToolbarHeight = 44.0; -// Padding when positioning toolbar below selection. + +// Padding between the toolbar and the anchor. const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0; const double _kToolbarContentDistance = 8.0; -/// -/// create by zmtzawqlp on 2019/8/3 -/// +/// Android Material styled text selection controls. +class MyTextSelectionControls extends TextSelectionControls { + /// Returns the size of the Material handle. + @override + Size getHandleSize(double textLineHeight) => + const Size(_kHandleSize, _kHandleSize); -class MyExtendedMaterialTextSelectionControls - extends ExtendedMaterialTextSelectionControls { - MyExtendedMaterialTextSelectionControls(); + /// Builder for material-style copy/paste text selection toolbar. @override Widget buildToolbar( BuildContext context, @@ -28,72 +28,39 @@ class MyExtendedMaterialTextSelectionControls Offset selectionMidpoint, List endpoints, TextSelectionDelegate delegate, - ClipboardStatusNotifier clipboardStatus, + ClipboardStatusNotifier? clipboardStatus, + Offset? lastSecondaryTapDownPosition, ) { - assert(debugCheckHasMediaQuery(context)); - assert(debugCheckHasMaterialLocalizations(context)); - - // The toolbar should appear below the TextField when there is not enough - // space above the TextField to show it. - final TextSelectionPoint startTextSelectionPoint = endpoints[0]; - final TextSelectionPoint endTextSelectionPoint = - endpoints.length > 1 ? endpoints[1] : endpoints[0]; - const double closedToolbarHeightNeeded = - _kToolbarScreenPadding + _kToolbarHeight + _kToolbarContentDistance; - final double paddingTop = MediaQuery.of(context).padding.top; - final double availableHeight = globalEditableRegion.top + - startTextSelectionPoint.point.dy - - textLineHeight - - paddingTop; - final bool fitsAbove = closedToolbarHeightNeeded <= availableHeight; - final Offset anchor = Offset( - globalEditableRegion.left + selectionMidpoint.dx, - fitsAbove - ? globalEditableRegion.top + - startTextSelectionPoint.point.dy - - textLineHeight - - _kToolbarContentDistance - : globalEditableRegion.top + - endTextSelectionPoint.point.dy + - _kToolbarContentDistanceBelow, - ); - - return Stack( - children: [ - CustomSingleChildLayout( - delegate: ExtendedMaterialTextSelectionToolbarLayout( - anchor, - _kToolbarScreenPadding + paddingTop, - fitsAbove, - ), - child: _TextSelectionToolbar( - handleCut: canCut(delegate) ? () => handleCut(delegate) : null, - handleCopy: canCopy(delegate) - ? () => handleCopy(delegate, clipboardStatus) - : null, - handlePaste: - canPaste(delegate) ? () => handlePaste(delegate) : null, - handleSelectAll: - canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, - handleLike: () { - //mailto:?subject=&body=, e.g. - launch( - 'mailto:zmtzawqlp@live.com?subject=extended_text_share&body=${delegate.textEditingValue.text}'); - delegate.hideToolbar(); - //clear selecction - delegate.textEditingValue = delegate.textEditingValue.copyWith( - selection: TextSelection.collapsed( - offset: delegate.textEditingValue.selection.end)); - }, - ), - ), - ], + return _TextSelectionControlsToolbar( + globalEditableRegion: globalEditableRegion, + textLineHeight: textLineHeight, + selectionMidpoint: selectionMidpoint, + endpoints: endpoints, + delegate: delegate, + clipboardStatus: clipboardStatus, + handleCut: canCut(delegate) ? () => handleCut(delegate, null) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: + canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, + handleLike: () { + launchUrl(Uri.parse( + 'mailto:zmtzawqlp@live.com?subject=extended_text_share&body=${delegate.textEditingValue.text}')); + delegate.hideToolbar(); + delegate.userUpdateTextEditingValue( + delegate.textEditingValue + .copyWith(selection: const TextSelection.collapsed(offset: 0)), + SelectionChangedCause.toolbar, + ); + }, ); } + /// Builder for material-style text selection handles. @override Widget buildHandle( - BuildContext context, TextSelectionHandleType type, double textHeight) { + BuildContext context, TextSelectionHandleType type, double textLineHeight, + [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) { final Widget handle = SizedBox( width: _kHandleSize, height: _kHandleSize, @@ -119,68 +86,198 @@ class MyExtendedMaterialTextSelectionControls case TextSelectionHandleType.collapsed: // points up return handle; } - assert(type != null); - return null; } + + /// Gets anchor for material-style text selection handles. + /// + /// See [TextSelectionControls.getHandleAnchor]. + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, + [double? startGlyphHeight, double? endGlyphHeight]) { + switch (type) { + case TextSelectionHandleType.left: + return const Offset(_kHandleSize, 0); + case TextSelectionHandleType.right: + return Offset.zero; + default: + return const Offset(_kHandleSize / 2, -4); + } + } + + @override + bool canSelectAll(TextSelectionDelegate delegate) { + // Android allows SelectAll when selection is not collapsed, unless + // everything has already been selected. + final TextEditingValue value = delegate.textEditingValue; + return delegate.selectAllEnabled && + value.text.isNotEmpty && + !(value.selection.start == 0 && + value.selection.end == value.text.length); + } +} + +// The label and callback for the available default text selection menu buttons. +class _TextSelectionToolbarItemData { + const _TextSelectionToolbarItemData({ + required this.label, + required this.onPressed, + }); + + final String label; + final VoidCallback? onPressed; } -/// Manages a copy/paste text selection toolbar. -class _TextSelectionToolbar extends StatelessWidget { - const _TextSelectionToolbar({ - Key key, - this.handleCopy, - this.handleSelectAll, - this.handleCut, - this.handlePaste, - this.handleLike, - }) : super(key: key); - - final VoidCallback handleCut; - final VoidCallback handleCopy; - final VoidCallback handlePaste; - final VoidCallback handleSelectAll; - final VoidCallback handleLike; +// The highest level toolbar widget, built directly by buildToolbar. +class _TextSelectionControlsToolbar extends StatefulWidget { + const _TextSelectionControlsToolbar({ + required this.clipboardStatus, + required this.delegate, + required this.endpoints, + required this.globalEditableRegion, + required this.handleCut, + required this.handleCopy, + required this.handlePaste, + required this.handleSelectAll, + required this.selectionMidpoint, + required this.textLineHeight, + required this.handleLike, + }); + + final ClipboardStatusNotifier? clipboardStatus; + final TextSelectionDelegate delegate; + final List endpoints; + final Rect globalEditableRegion; + final VoidCallback? handleCut; + final VoidCallback? handleCopy; + final VoidCallback? handlePaste; + final VoidCallback? handleSelectAll; + final VoidCallback? handleLike; + final Offset selectionMidpoint; + final double textLineHeight; @override - Widget build(BuildContext context) { - final List items = []; - final MaterialLocalizations localizations = - MaterialLocalizations.of(context); + _TextSelectionControlsToolbarState createState() => + _TextSelectionControlsToolbarState(); +} - if (handleCut != null) { - items.add(FlatButton( - child: Text(localizations.cutButtonLabel), onPressed: handleCut)); - } - if (handleCopy != null) { - items.add(FlatButton( - child: Text(localizations.copyButtonLabel), onPressed: handleCopy)); +class _TextSelectionControlsToolbarState + extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin { + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + + @override + void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.clipboardStatus != oldWidget.clipboardStatus) { + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus); } - if (handlePaste != null) { - items.add(FlatButton( - child: Text(localizations.pasteButtonLabel), - onPressed: handlePaste, - )); + } + + @override + void dispose() { + super.dispose(); + widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + } + + @override + Widget build(BuildContext context) { + // If there are no buttons to be shown, don't render anything. + if (widget.handleCut == null && + widget.handleCopy == null && + widget.handlePaste == null && + widget.handleSelectAll == null) { + return const SizedBox.shrink(); } - if (handleSelectAll != null) { - items.add(FlatButton( - child: Text(localizations.selectAllButtonLabel), - onPressed: handleSelectAll)); + // If the paste button is desired, don't render anything until the state of + // the clipboard is known, since it's used to determine if paste is shown. + if (widget.handlePaste != null && + widget.clipboardStatus?.value == ClipboardStatus.unknown) { + return const SizedBox.shrink(); } - if (handleLike != null) { - items.add( - FlatButton(child: const Icon(Icons.favorite), onPressed: handleLike)); - } + // Calculate the positioning of the menu. It is placed above the selection + // if there is enough room, or otherwise below. + final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0]; + final TextSelectionPoint endTextSelectionPoint = + widget.endpoints.length > 1 ? widget.endpoints[1] : widget.endpoints[0]; + final Offset anchorAbove = Offset( + widget.globalEditableRegion.left + widget.selectionMidpoint.dx, + widget.globalEditableRegion.top + + startTextSelectionPoint.point.dy - + widget.textLineHeight - + _kToolbarContentDistance, + ); + final Offset anchorBelow = Offset( + widget.globalEditableRegion.left + widget.selectionMidpoint.dx, + widget.globalEditableRegion.top + + endTextSelectionPoint.point.dy + + _kToolbarContentDistanceBelow, + ); + + // Determine which buttons will appear so that the order and total number is + // known. A button's position in the menu can slightly affect its + // appearance. + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final List<_TextSelectionToolbarItemData> itemDatas = + <_TextSelectionToolbarItemData>[ + if (widget.handleCut != null) + _TextSelectionToolbarItemData( + label: localizations.cutButtonLabel, + onPressed: widget.handleCut!, + ), + if (widget.handleCopy != null) + _TextSelectionToolbarItemData( + label: localizations.copyButtonLabel, + onPressed: widget.handleCopy!, + ), + if (widget.handlePaste != null && + widget.clipboardStatus?.value == ClipboardStatus.pasteable) + _TextSelectionToolbarItemData( + label: localizations.pasteButtonLabel, + onPressed: widget.handlePaste!, + ), + if (widget.handleSelectAll != null) + _TextSelectionToolbarItemData( + label: localizations.selectAllButtonLabel, + onPressed: widget.handleSelectAll!, + ), + _TextSelectionToolbarItemData( + label: 'Like', + onPressed: widget.handleLike, + ), + ]; // If there is no option available, build an empty widget. - if (items.isEmpty) { - return Container(width: 0.0, height: 0.0); + if (itemDatas.isEmpty) { + return const SizedBox(width: 0.0, height: 0.0); } - return Material( - elevation: 1.0, - child: Wrap(children: items), - borderRadius: const BorderRadius.all(Radius.circular(10.0)), + return TextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: itemDatas + .asMap() + .entries + .map((MapEntry entry) { + return TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding( + entry.key, itemDatas.length), + onPressed: entry.value.onPressed, + child: Text(entry.value.label), + ); + }).toList(), ); } } diff --git a/example/lib/special_text/my_special_text_span_builder.dart b/example/lib/special_text/my_special_text_span_builder.dart index 9e96dc4..ae1bbe7 100644 --- a/example/lib/special_text/my_special_text_span_builder.dart +++ b/example/lib/special_text/my_special_text_span_builder.dart @@ -1,6 +1,5 @@ import 'package:example/special_text/image_text.dart'; import 'package:extended_text_library/extended_text_library.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'at_text.dart'; @@ -12,41 +11,34 @@ class MySpecialTextSpanBuilder extends SpecialTextSpanBuilder { /// whether show background for @somebody final bool showAtBackground; - @override - TextSpan build(String data, - {TextStyle textStyle, SpecialTextGestureTapCallback onTap}) { - if (kIsWeb) { - return TextSpan(text: data, style: textStyle); - } - - return super.build(data, textStyle: textStyle, onTap: onTap); - } @override - SpecialText createSpecialText(String flag, - {TextStyle textStyle, SpecialTextGestureTapCallback onTap, int index}) { - if (flag == null || flag == '') { + SpecialText? createSpecialText(String flag, + {TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + int? index}) { + if (flag == '') { return null; } ///index is end index of start flag, so text start index should be index-(flag.length-1) if (isStart(flag, EmojiText.flag)) { - return EmojiText(textStyle, start: index - (EmojiText.flag.length - 1)); + return EmojiText(textStyle, start: index! - (EmojiText.flag.length - 1)); } else if (isStart(flag, ImageText.flag)) { return ImageText(textStyle, - start: index - (ImageText.flag.length - 1), onTap: onTap); + start: index! - (ImageText.flag.length - 1), onTap: onTap); } else if (isStart(flag, AtText.flag)) { return AtText( textStyle, onTap, - start: index - (AtText.flag.length - 1), + start: index! - (AtText.flag.length - 1), showAtBackground: showAtBackground, ); } else if (isStart(flag, EmojiText.flag)) { - return EmojiText(textStyle, start: index - (EmojiText.flag.length - 1)); + return EmojiText(textStyle, start: index! - (EmojiText.flag.length - 1)); } else if (isStart(flag, DollarText.flag)) { return DollarText(textStyle, onTap, - start: index - (DollarText.flag.length - 1)); + start: index! - (DollarText.flag.length - 1)); } return null; } diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..8236f57 --- /dev/null +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig new file mode 100644 index 0000000..2c25018 --- /dev/null +++ b/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -0,0 +1,14 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/Users/zmt/Documents/ohos/flutter/flutter_flutter +FLUTTER_APPLICATION_PATH=/Users/zmt/Documents/ohos/github/extended_text_field/example +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=1.0.0 +FLUTTER_BUILD_NUMBER=1 +FLUTTER_ENGINE=/Users/zmt/Documents/ohos/flutter/engine/src +LOCAL_ENGINE=ohos_debug_unopt_arm64 +ARCHS=arm64 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/example/macos/Flutter/ephemeral/flutter_export_environment.sh b/example/macos/Flutter/ephemeral/flutter_export_environment.sh new file mode 100755 index 0000000..62a343c --- /dev/null +++ b/example/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/Users/zmt/Documents/ohos/flutter/flutter_flutter" +export "FLUTTER_APPLICATION_PATH=/Users/zmt/Documents/ohos/github/extended_text_field/example" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "FLUTTER_ENGINE=/Users/zmt/Documents/ohos/flutter/engine/src" +export "LOCAL_ENGINE=ohos_debug_unopt_arm64" +export "ARCHS=arm64" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/example/macos/Podfile b/example/macos/Podfile new file mode 100644 index 0000000..dade8df --- /dev/null +++ b/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock new file mode 100644 index 0000000..1b2e896 --- /dev/null +++ b/example/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + url_launcher_macos: 45af3d61de06997666568a7149c1be98b41c95d4 + +PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c + +COCOAPODS: 1.10.0 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..cfa0bab --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,632 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 7AF4D8477C1FAF78892E7B47 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F20835456926299CD3CDCE5 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8A07DB08724BE1ACDF26F964 /* 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 = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9F20835456926299CD3CDCE5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7B7FF68CC01EE21C6512FAA /* 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 = ""; }; + FABA45EBEEAD51CFF206571D /* 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 */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7AF4D8477C1FAF78892E7B47 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + E5F3109C001394DBA2121E0A /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9F20835456926299CD3CDCE5 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + E5F3109C001394DBA2121E0A /* Pods */ = { + isa = PBXGroup; + children = ( + FABA45EBEEAD51CFF206571D /* Pods-Runner.debug.xcconfig */, + 8A07DB08724BE1ACDF26F964 /* Pods-Runner.release.xcconfig */, + D7B7FF68CC01EE21C6512FAA /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 3EF585B484D5C35B2252DA86 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + BF4155FDD3B79E6AF9E31812 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 3EF585B484D5C35B2252DA86 /* [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; + }; + BF4155FDD3B79E6AF9E31812 /* [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 */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..fb7259e --- /dev/null +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..d53ef64 --- /dev/null +++ b/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..3c4935a Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..ed4cc16 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..483be61 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bcbf36d Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..9c0a652 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..e71a726 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..8a31fe2 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/example/macos/Runner/Base.lproj/MainMenu.xib b/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..8b42559 --- /dev/null +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved. diff --git a/example/macos/Runner/Configs/Debug.xcconfig b/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Release.xcconfig b/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Warnings.xcconfig b/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..2722837 --- /dev/null +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b9174cc..15af0ce 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,29 +14,33 @@ publish_to: none version: 1.0.0+1 environment: - sdk: ">=2.6.0 <2.12.0" - flutter: ">=1.22.0" + sdk: '>=2.17.0 <3.0.0' + flutter: ">=3.7.0" dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^0.1.2 - extended_text: ^6.0.0-non-null-safety - extended_text_field: - path: ../ + + cupertino_icons: ^1.0.4 + + #extended_text: ^9.0.0 + #extended_text_library: ^9.0.0 + ff_annotation_route_library: ^3.0.0 + extended_text: ^10.0.0 flutter: sdk: flutter - loading_more_list: ^3.1.1 - oktoast: ^2.1.4 - url_launcher: 5.3.0 - + html: ^0.15.0 + loading_more_list: ^4.1.0 + oktoast: ^3.1.5 + url_launcher: ^6.0.17 dev_dependencies: - - ff_annotation_route: ^4.0.2 flutter_test: sdk: flutter +dependency_overrides: + extended_text_field: + path: ../ # For information on the generic Dart part of this file, see the # following page: https://www.dartlang.org/tools/pub/pubspec diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 747db1d..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html index 22fd23d..b6b9dd2 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -1,6 +1,21 @@ + + + @@ -22,12 +37,68 @@ application. For more information, see: https://developers.google.com/web/fundamentals/primers/service-workers --> - diff --git a/example/web/manifest.json b/example/web/manifest.json index 8c01291..096edf8 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -18,6 +18,18 @@ "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt index c7a8c76..744f08a 100644 --- a/example/windows/flutter/CMakeLists.txt +++ b/example/windows/flutter/CMakeLists.txt @@ -91,6 +91,7 @@ add_custom_command( ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" windows-x64 $ + VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 4bfa0f3..4f78848 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -2,8 +2,13 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h index 9846246..dc139d8 100644 --- a/example/windows/flutter/generated_plugin_registrant.h +++ b/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 4d10c25..88b22e5 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST ) set(PLUGIN_BUNDLED_LIBRARIES) @@ -13,3 +17,8 @@ foreach(plugin ${FLUTTER_PLUGIN_LIST}) list(APPEND PLUGIN_BUNDLED_LIBRARIES $) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/lib/extended_text_field.dart b/lib/extended_text_field.dart index 70892b8..c9cbe59 100644 --- a/lib/extended_text_field.dart +++ b/lib/extended_text_field.dart @@ -2,3 +2,5 @@ library extended_text_field; export 'package:extended_text_library/extended_text_library.dart'; export 'src/extended_text_field.dart'; +export 'src/keyboard/binding.dart'; +export 'src/keyboard/focus_node.dart'; diff --git a/lib/src/extended_editable_text.dart b/lib/src/extended_editable_text.dart index 7912eaf..66219f8 100644 --- a/lib/src/extended_editable_text.dart +++ b/lib/src/extended_editable_text.dart @@ -2,24 +2,20 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: unnecessary_null_comparison, always_put_control_body_on_new_line + import 'dart:async'; -import 'dart:math'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:extended_text_field/src/extended_render_editable.dart'; import 'package:extended_text_library/extended_text_library.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart'; -import 'package:flutter/painting.dart'; -import 'package:flutter/rendering.dart'; +import 'package:flutter/rendering.dart' hide VerticalCaretMovementRun; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior; - -/// Signature for the callback that reports when the user changes the selection -/// (including the cursor location). -typedef SelectionChangedCallback = void Function( - TextSelection selection, SelectionChangedCause cause); // The time it takes for the cursor to fade from fully opaque to fully // transparent and vice versa. A full cursor blink, from transparent to opaque @@ -34,6 +30,10 @@ const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150); // is shown in an obscured text field. const int _kObscureShowLatestCharCursorTicks = 3; +// The minimum width of an iPad screen. The smallest iPad is currently the +// iPad Mini 6th Gen according to ios-resolution.com. +const double _kIPadWidth = 1488.0; + /// A basic text input field. /// /// This widget interacts with the [TextInput] service to let the user edit the @@ -112,21 +112,21 @@ class ExtendedEditableText extends StatefulWidget { /// [dragStartBehavior], [toolbarOptions], [rendererIgnoresPointer], and /// [readOnly] arguments must not be null. ExtendedEditableText({ - Key key, + Key? key, this.specialTextSpanBuilder, - @required this.controller, - @required this.focusNode, + required this.controller, + required this.focusNode, this.readOnly = false, this.obscuringCharacter = '•', this.obscureText = false, this.autocorrect = true, - SmartDashesType smartDashesType, - SmartQuotesType smartQuotesType, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, this.enableSuggestions = true, - @required this.style, - StrutStyle strutStyle, - @required this.cursorColor, - @required this.backgroundCursorColor, + required this.style, + StrutStyle? strutStyle, + required this.cursorColor, + required this.backgroundCursorColor, this.textAlign = TextAlign.start, this.textDirection, this.locale, @@ -138,11 +138,11 @@ class ExtendedEditableText extends StatefulWidget { this.textHeightBehavior, this.textWidthBasis = TextWidthBasis.parent, this.autofocus = false, - bool showCursor, + bool? showCursor, this.showSelectionHandles = false, this.selectionColor, this.selectionControls, - TextInputType keyboardType, + TextInputType? keyboardType, this.textInputAction, this.textCapitalization = TextCapitalization.none, this.onChanged, @@ -151,7 +151,7 @@ class ExtendedEditableText extends StatefulWidget { this.onAppPrivateCommand, this.onSelectionChanged, this.onSelectionHandleTapped, - List inputFormatters, + List? inputFormatters, this.mouseCursor, this.rendererIgnoresPointer = false, this.cursorWidth = 2.0, @@ -175,9 +175,14 @@ class ExtendedEditableText extends StatefulWidget { paste: true, selectAll: true, ), - this.autofillHints, + this.autofillHints = const [], + this.autofillClient, this.clipBehavior = Clip.hardEdge, this.restorationId, + this.scrollBehavior, + this.enableIMEPersonalizedLearning = true, + this.showToolbarInWeb = false, + this.scribbleEnabled = true, }) : assert(controller != null), assert(focusNode != null), assert(obscuringCharacter != null && obscuringCharacter.length == 1), @@ -219,6 +224,7 @@ class ExtendedEditableText extends StatefulWidget { assert(dragStartBehavior != null), assert(toolbarOptions != null), assert(clipBehavior != null), + assert(enableIMEPersonalizedLearning != null), _strutStyle = strutStyle, keyboardType = keyboardType ?? _inferKeyboardType( @@ -233,8 +239,10 @@ class ExtendedEditableText extends StatefulWidget { showCursor = showCursor ?? !readOnly, super(key: key); + final bool showToolbarInWeb; + ///build your ccustom text span - final SpecialTextSpanBuilder specialTextSpanBuilder; + final SpecialTextSpanBuilder? specialTextSpanBuilder; /// Controls the text being edited. final TextEditingController controller; @@ -255,14 +263,16 @@ class ExtendedEditableText extends StatefulWidget { /// Whether to hide the text being edited (e.g., for passwords). /// /// When this is set to true, all the characters in the text field are - /// replaced by [obscuringCharacter]. + /// replaced by [obscuringCharacter], and the text in the field cannot be + /// copied with copy or cut. If [readOnly] is also true, then the text cannot + /// be selected. /// /// Defaults to false. Cannot be null. /// {@endtemplate} final bool obscureText; - /// {@macro flutter.dart:ui.textHeightBehavior}, - final TextHeightBehavior textHeightBehavior; + /// {@macro dart.ui.textHeightBehavior} + final TextHeightBehavior? textHeightBehavior; /// {@macro flutter.painting.textPainter.textWidthBasis} final TextWidthBasis textWidthBasis; @@ -291,8 +301,10 @@ class ExtendedEditableText extends StatefulWidget { /// Configuration of toolbar options. /// - /// By default, all options are enabled. If [readOnly] is true, - /// paste and cut will be disabled regardless. + /// By default, all options are enabled. If [readOnly] is true, paste and cut + /// will be disabled regardless. If [obscureText] is true, cut and copy will + /// be disabled regardless. If [readOnly] and [obscureText] are both true, + /// select all will also be disabled. final ToolbarOptions toolbarOptions; /// Whether to show selection handles. @@ -303,7 +315,7 @@ class ExtendedEditableText extends StatefulWidget { /// /// See also: /// - /// * [showCursor], which controls the visibility of the cursor.. + /// * [showCursor], which controls the visibility of the cursor. final bool showSelectionHandles; /// {@template flutter.widgets.editableText.showCursor} @@ -324,13 +336,13 @@ class ExtendedEditableText extends StatefulWidget { /// {@endtemplate} final bool autocorrect; - /// {@macro flutter.services.textInput.smartDashesType} + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} final SmartDashesType smartDashesType; - /// {@macro flutter.services.textInput.smartQuotesType} + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} final SmartQuotesType smartQuotesType; - /// {@macro flutter.services.textInput.enableSuggestions} + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} final bool enableSuggestions; /// The text style to use for the editable text. @@ -361,18 +373,13 @@ class ExtendedEditableText extends StatefulWidget { /// default values, and will instead inherit omitted/null properties from the /// [TextStyle] instead. See [StrutStyle.inheritFromTextStyle]. StrutStyle get strutStyle { - return _strutStyle; - - ///not good for widgetSpan - // if (_strutStyle == null) { - // return style != null - // ? StrutStyle.fromTextStyle(style, forceStrutHeight: true) - // : const StrutStyle(); - // } - // return _strutStyle.inheritFromTextStyle(style); + if (_strutStyle == null) { + return StrutStyle.fromTextStyle(style, forceStrutHeight: true); + } + return _strutStyle!.inheritFromTextStyle(style); } - final StrutStyle _strutStyle; + final StrutStyle? _strutStyle; /// {@template flutter.widgets.editableText.textAlign} /// How the text should be aligned horizontally. @@ -394,18 +401,9 @@ class ExtendedEditableText extends StatefulWidget { /// context, the English phrase will be on the right and the Hebrew phrase on /// its left. /// - /// When LTR text is entered into an RTL field, or RTL text is entered into an - /// LTR field, [LRM](https://en.wikipedia.org/wiki/Left-to-right_mark) or - /// [RLM](https://en.wikipedia.org/wiki/Right-to-left_mark) characters will be - /// inserted alongside whitespace characters, respectively. This is to - /// eliminate ambiguous directionality in whitespace and ensure proper caret - /// placement. These characters will affect the length of the string and may - /// need to be parsed out when doing things like string comparison with other - /// text. - /// /// Defaults to the ambient [Directionality], if any. /// {@endtemplate} - final TextDirection textDirection; + final TextDirection? textDirection; /// {@template flutter.widgets.editableText.textCapitalization} /// Configures how the platform keyboard will select an uppercase or @@ -430,7 +428,7 @@ class ExtendedEditableText extends StatefulWidget { /// is inherited from the enclosing app with `Localizations.localeOf(context)`. /// /// See [RenderEditable.locale] for more information. - final Locale locale; + final Locale? locale; /// {@template flutter.widgets.editableText.textScaleFactor} /// The number of font pixels for each logical pixel. @@ -441,7 +439,7 @@ class ExtendedEditableText extends StatefulWidget { /// Defaults to the [MediaQueryData.textScaleFactor] obtained from the ambient /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. /// {@endtemplate} - final double textScaleFactor; + final double? textScaleFactor; /// The color to use when painting the cursor. /// @@ -458,7 +456,7 @@ class ExtendedEditableText extends StatefulWidget { /// Currently the autocorrection Rect only appears on iOS. /// /// Defaults to null, which disables autocorrection Rect painting. - final Color autocorrectionTextRectColor; + final Color? autocorrectionTextRectColor; /// The color to use when painting the background cursor aligned with the text /// while rendering the floating cursor. @@ -468,23 +466,27 @@ class ExtendedEditableText extends StatefulWidget { final Color backgroundCursorColor; /// {@template flutter.widgets.editableText.maxLines} - /// The maximum number of lines for the text to span, wrapping if necessary. + /// The maximum number of lines to show at one time, wrapping if necessary. + /// + /// This affects the height of the field itself and does not limit the number + /// of lines that can be entered into the field. /// /// If this is 1 (the default), the text will not wrap, but will scroll /// horizontally instead. /// /// If this is null, there is no limit to the number of lines, and the text /// container will start with enough vertical space for one line and - /// automatically grow to accommodate additional lines as they are entered. + /// automatically grow to accommodate additional lines as they are entered, up + /// to the height of its constraints. /// /// If this is not null, the value must be greater than zero, and it will lock /// the input to the given number of lines and take up enough horizontal space /// to accommodate that number of lines. Setting [minLines] as well allows the - /// input to grow between the indicated range. + /// input to grow and shrink between the indicated range. /// /// The full set of behaviors possible with [minLines] and [maxLines] are as - /// follows. These examples apply equally to `TextField`, `TextFormField`, and - /// `EditableText`. + /// follows. These examples apply equally to [TextField], [TextFormField], + /// [CupertinoTextField], and [EditableText]. /// /// Input that occupies a single line and scrolls horizontally as needed. /// ```dart @@ -509,12 +511,21 @@ class ExtendedEditableText extends StatefulWidget { /// ```dart /// TextField(minLines: 2, maxLines: 4) /// ``` + /// + /// See also: + /// + /// * [minLines], which sets the minimum number of lines visible. /// {@endtemplate} - final int maxLines; + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; /// {@template flutter.widgets.editableText.minLines} /// The minimum number of lines to occupy when the content spans fewer lines. /// + /// This affects the height of the field itself and does not limit the number + /// of lines that can be entered into the field. + /// /// If this is null (default), text container starts with enough vertical space /// for one line and grows to accommodate additional lines as they are entered. /// @@ -529,8 +540,8 @@ class ExtendedEditableText extends StatefulWidget { /// starting from [minLines]. /// /// A few examples of behaviors possible with [minLines] and [maxLines] are as follows. - /// These apply equally to `TextField`, `TextFormField`, `CupertinoTextField`, - /// and `EditableText`. + /// These apply equally to [TextField], [TextFormField], [CupertinoTextField], + /// and [EditableText]. /// /// Input that always occupies at least 2 lines and has an infinite max. /// Expands vertically as needed. @@ -545,12 +556,17 @@ class ExtendedEditableText extends StatefulWidget { /// TextField(minLines:2, maxLines: 4) /// ``` /// - /// See the examples in [maxLines] for the complete picture of how [maxLines] - /// and [minLines] interact to produce various behaviors. - /// /// Defaults to null. + /// + /// See also: + /// + /// * [maxLines], which sets the maximum number of lines visible, and has + /// several examples of how minLines and maxLines interact to produce + /// various behaviors. /// {@endtemplate} - final int minLines; + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; /// {@template flutter.widgets.editableText.expands} /// Whether this widget's height will be sized to fill its parent. @@ -590,26 +606,31 @@ class ExtendedEditableText extends StatefulWidget { /// The color to use when painting the selection. /// + /// If this property is null, this widget gets the selection color from the + /// [DefaultSelectionStyle]. + /// /// For [CupertinoTextField]s, the value is set to the ambient /// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the - /// value is set to the ambient [ThemeData.textSelectionColor]. - final Color selectionColor; + /// value is set to the ambient [TextSelectionThemeData.selectionColor]. + final Color? selectionColor; + /// {@template flutter.widgets.editableText.selectionControls} /// Optional delegate for building the text selection handles and toolbar. /// - /// The [ExtendedEditableText] widget used on its own will not trigger the display + /// The [EditableText] widget used on its own will not trigger the display /// of the selection toolbar by itself. The toolbar is shown by calling - /// [ExtendedEditableTextState.showToolbar] in response to an appropriate user event. + /// [EditableTextState.showToolbar] in response to an appropriate user event. /// /// See also: /// - /// * [CupertinoTextField], which wraps an [ExtendedEditableText] and which shows the + /// * [CupertinoTextField], which wraps an [EditableText] and which shows the /// selection toolbar upon user events that are appropriate on the iOS /// platform. - /// * [TextField], a Material Design themed wrapper of [ExtendedEditableText], which + /// * [TextField], a Material Design themed wrapper of [EditableText], which /// shows the selection toolbar upon appropriate user events based on the /// user's platform set in [ThemeData.platform]. - final TextSelectionControls selectionControls; + /// {@endtemplate} + final TextSelectionControls? selectionControls; /// {@template flutter.widgets.editableText.keyboardType} /// The type of keyboard to use for editing the text. @@ -620,7 +641,7 @@ class ExtendedEditableText extends StatefulWidget { final TextInputType keyboardType; /// The type of action button to use with the soft keyboard. - final TextInputAction textInputAction; + final TextInputAction? textInputAction; /// {@template flutter.widgets.editableText.onChanged} /// Called when the user initiates a change to the TextField's @@ -635,69 +656,41 @@ class ExtendedEditableText extends StatefulWidget { /// and selection, one can add a listener to its [controller] with /// [TextEditingController.addListener]. /// - /// {@tool dartpad --template=stateful_widget_material} + /// [onChanged] is called before [onSubmitted] when user indicates completion + /// of editing, such as when pressing the "done" button on the keyboard. That default + /// behavior can be overridden. See [onEditingComplete] for details. /// + /// {@tool dartpad} /// This example shows how onChanged could be used to check the TextField's /// current value each time the user inserts or deletes a character. /// - /// ```dart - /// TextEditingController _controller; - /// - /// void initState() { - /// super.initState(); - /// _controller = TextEditingController(); - /// } - /// - /// void dispose() { - /// _controller.dispose(); - /// super.dispose(); - /// } - /// - /// Widget build(BuildContext context) { - /// return Scaffold( - /// body: Column( - /// mainAxisAlignment: MainAxisAlignment.center, - /// children: [ - /// const Text('What number comes next in the sequence?'), - /// const Text('1, 1, 2, 3, 5, 8...?'), - /// TextField( - /// controller: _controller, - /// onChanged: (String value) async { - /// if (value != '13') { - /// return; - /// } - /// await showDialog( - /// context: context, - /// builder: (BuildContext context) { - /// return AlertDialog( - /// title: const Text('Thats correct!'), - /// content: Text ('13 is the right answer.'), - /// actions: [ - /// TextButton( - /// onPressed: () { Navigator.pop(context); }, - /// child: const Text('OK'), - /// ), - /// ], - /// ); - /// }, - /// ); - /// }, - /// ), - /// ], - /// ), - /// ); - /// } - /// ``` + /// ** See code in examples/api/lib/widgets/editable_text/editable_text.on_changed.0.dart ** /// {@end-tool} /// {@endtemplate} /// + /// ## Handling emojis and other complex characters + /// {@template flutter.widgets.EditableText.onChanged} + /// It's important to always use + /// [characters](https://pub.dev/packages/characters) when dealing with user + /// input text that may contain complex characters. This will ensure that + /// extended grapheme clusters and surrogate pairs are treated as single + /// characters, as they appear to the user. + /// + /// For example, when finding the length of some user input, use + /// `string.characters.length`. Do NOT use `string.length` or even + /// `string.runes.length`. For the complex character "�‍�‍�", this + /// appears to the user as a single character, and `string.characters.length` + /// intuitively returns 1. On the other hand, `string.length` returns 8, and + /// `string.runes.length` returns 5! + /// {@endtemplate} + /// /// See also: /// /// * [inputFormatters], which are called before [onChanged] /// runs and can validate and change ("format") the input value. /// * [onEditingComplete], [onSubmitted], [onSelectionChanged]: /// which are more specialized input change notifications. - final ValueChanged onChanged; + final ValueChanged? onChanged; /// {@template flutter.widgets.editableText.onEditingComplete} /// Called when the user submits editable content (e.g., user presses the "done" @@ -717,89 +710,54 @@ class ExtendedEditableText extends StatefulWidget { /// /// Providing [onEditingComplete] prevents the aforementioned default behavior. /// {@endtemplate} - final VoidCallback onEditingComplete; + final VoidCallback? onEditingComplete; /// {@template flutter.widgets.editableText.onSubmitted} /// Called when the user indicates that they are done editing the text in the /// field. - /// {@endtemplate} /// - /// {@tool dartpad --template=stateful_widget_material} - /// When a non-completion action is pressed, such as "next" or "previous", it - /// is often desirable to move the focus to the next or previous field. To do - /// this, handle it as in this example, by calling [FocusNode.nextFocus] in - /// the `onFieldSubmitted` callback of [TextFormField]. ([TextFormField] wraps - /// [EditableText] internally, and uses the value of `onFieldSubmitted` as its - /// [onSubmitted]). - /// - /// ```dart - /// FocusScopeNode _focusScopeNode = FocusScopeNode(); - /// final _controller1 = TextEditingController(); - /// final _controller2 = TextEditingController(); - /// - /// void dispose() { - /// _focusScopeNode.dispose(); - /// _controller1.dispose(); - /// _controller2.dispose(); - /// super.dispose(); - /// } - /// - /// void _handleSubmitted(String value) { - /// _focusScopeNode.nextFocus(); - /// } - /// - /// Widget build(BuildContext context) { - /// return Scaffold( - /// body: FocusScope( - /// node: _focusScopeNode, - /// child: Column( - /// mainAxisAlignment: MainAxisAlignment.center, - /// children: [ - /// Padding( - /// padding: const EdgeInsets.all(8.0), - /// child: TextFormField( - /// textInputAction: TextInputAction.next, - /// onFieldSubmitted: _handleSubmitted, - /// controller: _controller1, - /// decoration: InputDecoration(border: OutlineInputBorder()), - /// ), - /// ), - /// Padding( - /// padding: const EdgeInsets.all(8.0), - /// child: TextFormField( - /// textInputAction: TextInputAction.next, - /// onFieldSubmitted: _handleSubmitted, - /// controller: _controller2, - /// decoration: InputDecoration(border: OutlineInputBorder()), - /// ), - /// ), - /// ], - /// ), - /// ), - /// ); - /// } - /// ``` - /// {@end-tool} - final ValueChanged onSubmitted; + /// By default, [onSubmitted] is called after [onChanged] when the user + /// has finalized editing; or, if the default behavior has been overridden, + /// after [onEditingComplete]. See [onEditingComplete] for details. + /// {@endtemplate} + final ValueChanged? onSubmitted; /// {@template flutter.widgets.editableText.onAppPrivateCommand} - /// Called when the result of an app private command is received. + /// This is used to receive a private command from the input method. + /// + /// Called when the result of [TextInputClient.performPrivateCommand] is + /// received. + /// + /// This can be used to provide domain-specific features that are only known + /// between certain input methods and their clients. + /// + /// See also: + /// * [performPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputConnection#performPrivateCommand\(java.lang.String,%20android.os.Bundle\)), + /// which is the Android documentation for performPrivateCommand, used to + /// send a command from the input method. + /// * [sendAppPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#sendAppPrivateCommand), + /// which is the Android documentation for sendAppPrivateCommand, used to + /// send a command to the input method. /// {@endtemplate} - final AppPrivateCommandCallback onAppPrivateCommand; + final AppPrivateCommandCallback? onAppPrivateCommand; + /// {@template flutter.widgets.editableText.onSelectionChanged} /// Called when the user changes the selection of text (including the cursor /// location). - final SelectionChangedCallback onSelectionChanged; + /// {@endtemplate} + final SelectionChangedCallback? onSelectionChanged; - /// {@macro flutter.widgets.textSelection.onSelectionHandleTapped} - final VoidCallback onSelectionHandleTapped; + /// {@macro flutter.widgets.TextSelectionOverlay.onSelectionHandleTapped} + final VoidCallback? onSelectionHandleTapped; /// {@template flutter.widgets.editableText.inputFormatters} /// Optional input validation and formatting overrides. /// - /// Formatters are run in the provided order when the text input changes. + /// Formatters are run in the provided order when the text input changes. When + /// this parameter changes, the new formatters will not be applied until the + /// next time the user inserts or deletes text. /// {@endtemplate} - final List inputFormatters; + final List? inputFormatters; /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. @@ -810,7 +768,7 @@ class ExtendedEditableText extends StatefulWidget { /// appearance of the mouse pointer. All other properties related to "cursor" /// stands for the text cursor, which is usually a blinking vertical line at /// the editing position. - final MouseCursor mouseCursor; + final MouseCursor? mouseCursor; /// If true, the [RenderEditable] created by this widget will not handle /// pointer events, see [RenderEditable] and [RenderEditable.ignorePointer]. @@ -821,7 +779,7 @@ class ExtendedEditableText extends StatefulWidget { /// {@template flutter.widgets.editableText.cursorWidth} /// How thick the cursor will be. /// - /// Defaults to 2.0 + /// Defaults to 2.0. /// /// The cursor will draw under the text. The cursor width will extend /// to the right of the boundary between characters for left-to-right text @@ -836,14 +794,14 @@ class ExtendedEditableText extends StatefulWidget { /// /// If this property is null, [RenderEditable.preferredLineHeight] will be used. /// {@endtemplate} - final double cursorHeight; + final double? cursorHeight; /// {@template flutter.widgets.editableText.cursorRadius} /// How rounded the corners of the cursor should be. /// /// By default, the cursor has no radius. /// {@endtemplate} - final Radius cursorRadius; + final Radius? cursorRadius; /// Whether the cursor will animate from fully transparent to fully opaque /// during each cursor blink. @@ -852,10 +810,10 @@ class ExtendedEditableText extends StatefulWidget { /// animate on Android platforms. final bool cursorOpacityAnimates; - ///{@macro flutter.rendering.editable.cursorOffset} - final Offset cursorOffset; + ///{@macro flutter.rendering.RenderEditable.cursorOffset} + final Offset? cursorOffset; - ///{@macro flutter.rendering.editable.paintCursorOnTop} + ///{@macro flutter.rendering.RenderEditable.paintCursorAboveText} final bool paintCursorAboveText; /// Controls how tall the selection highlight boxes are computed to be. @@ -898,6 +856,8 @@ class ExtendedEditableText extends StatefulWidget { /// When this is false, the text selection cannot be adjusted by /// the user, text cannot be copied, and the user cannot paste into /// the text field from the clipboard. + /// + /// Defaults to true. /// {@endtemplate} final bool enableInteractiveSelection; @@ -921,7 +881,7 @@ class ExtendedEditableText extends StatefulWidget { /// /// See [Scrollable.controller]. /// {@endtemplate} - final ScrollController scrollController; + final ScrollController? scrollController; /// {@template flutter.widgets.editableText.scrollPhysics} /// The [ScrollPhysics] to use when vertically scrolling the input. @@ -930,7 +890,20 @@ class ExtendedEditableText extends StatefulWidget { /// /// See [Scrollable.physics]. /// {@endtemplate} - final ScrollPhysics scrollPhysics; + /// + /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the + /// [ScrollPhysics] provided by that behavior will take precedence after + /// [scrollPhysics]. + final ScrollPhysics? scrollPhysics; + + /// {@template flutter.widgets.editableText.scribbleEnabled} + /// Whether iOS 14 Scribble features are enabled for this widget. + /// + /// Only available on iPads. + /// + /// Defaults to true. + /// {@endtemplate} + final bool scribbleEnabled; /// {@template flutter.widgets.editableText.selectionEnabled} /// Same as [enableInteractiveSelection]. @@ -944,16 +917,18 @@ class ExtendedEditableText extends StatefulWidget { /// A list of strings that helps the autofill service identify the type of this /// text input. /// - /// When set to null or empty, the text input will not send any autofill related - /// information to the platform. As a result, it will not participate in - /// autofills triggered by a different [AutofillClient], even if they're in the - /// same [AutofillScope]. Additionally, on Android and web, setting this to null - /// or empty will disable autofill for this text field. + /// When set to null, this text input will not send its autofill information + /// to the platform, preventing it from participating in autofills triggered + /// by a different [AutofillClient], even if they're in the same + /// [AutofillScope]. Additionally, on Android and web, setting this to null + /// will disable autofill for this text field. /// /// The minimum platform SDK version that supports Autofill is API level 26 /// for Android, and iOS 10.0 for iOS. /// - /// ### iOS-specific Concerns: + /// Defaults to an empty list. + /// + /// ### Setting up iOS autofill: /// /// To provide the best user experience and ensure your app fully supports /// password autofill on iOS, follow these steps: @@ -965,11 +940,58 @@ class ExtendedEditableText extends StatefulWidget { /// works only with [TextInputType.emailAddress]. Make sure the input field has a /// compatible [keyboardType]. Empirically, [TextInputType.name] works well /// with many autofill hints that are predefined on iOS. + /// + /// ### Troubleshooting Autofill + /// + /// Autofill service providers rely heavily on [autofillHints]. Make sure the + /// entries in [autofillHints] are supported by the autofill service currently + /// in use (the name of the service can typically be found in your mobile + /// device's system settings). + /// + /// #### Autofill UI refuses to show up when I tap on the text field + /// + /// Check the device's system settings and make sure autofill is turned on, + /// and there are available credentials stored in the autofill service. + /// + /// * iOS password autofill: Go to Settings -> Password, turn on "Autofill + /// Passwords", and add new passwords for testing by pressing the top right + /// "+" button. Use an arbitrary "website" if you don't have associated + /// domains set up for your app. As long as there's at least one password + /// stored, you should be able to see a key-shaped icon in the quick type + /// bar on the software keyboard, when a password related field is focused. + /// + /// * iOS contact information autofill: iOS seems to pull contact info from + /// the Apple ID currently associated with the device. Go to Settings -> + /// Apple ID (usually the first entry, or "Sign in to your iPhone" if you + /// haven't set up one on the device), and fill out the relevant fields. If + /// you wish to test more contact info types, try adding them in Contacts -> + /// My Card. + /// + /// * Android autofill: Go to Settings -> System -> Languages & input -> + /// Autofill service. Enable the autofill service of your choice, and make + /// sure there are available credentials associated with your app. + /// + /// #### I called `TextInput.finishAutofillContext` but the autofill save + /// prompt isn't showing + /// + /// * iOS: iOS may not show a prompt or any other visual indication when it + /// saves user password. Go to Settings -> Password and check if your new + /// password is saved. Neither saving password nor auto-generating strong + /// password works without properly setting up associated domains in your + /// app. To set up associated domains, follow the instructions in + /// . + /// /// {@endtemplate} - /// {@macro flutter.services.autofill.autofillHints} - final Iterable autofillHints; + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; - /// {@macro flutter.widgets.Clip} + /// The [AutofillClient] that controls this input field's autofill behavior. + /// + /// When null, this widget's [EditableTextState] will be used as the + /// [AutofillClient]. This property may override [autofillHints]. + final AutofillClient? autofillClient; + + /// {@macro flutter.material.Material.clipBehavior} /// /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; @@ -991,17 +1013,37 @@ class ExtendedEditableText extends StatefulWidget { /// /// * [RestorationManager], which explains how state restoration works in /// Flutter. - final String restorationId; + final String? restorationId; + + /// {@template flutter.widgets.shadow.scrollBehavior} + /// A [ScrollBehavior] that will be applied to this widget individually. + /// + /// Defaults to null, wherein the inherited [ScrollBehavior] is copied and + /// modified to alter the viewport decoration, like [Scrollbar]s. + /// {@endtemplate} + /// + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [scrollPhysics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + /// + /// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be + /// modified by default to only apply a [Scrollbar] if [maxLines] is greater + /// than 1. + final ScrollBehavior? scrollBehavior; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + // Infer the keyboard type of an `EditableText` if it's not specified. static TextInputType _inferKeyboardType({ - @required Iterable autofillHints, - @required int maxLines, + required Iterable? autofillHints, + required int? maxLines, }) { - if (autofillHints?.isEmpty ?? true) { + if (autofillHints == null || autofillHints.isEmpty) { return maxLines == 1 ? TextInputType.text : TextInputType.multiline; } - TextInputType returnValue; final String effectiveHint = autofillHints.first; // On iOS oftentimes specifying a text content type is not enough to qualify @@ -1056,7 +1098,10 @@ class ExtendedEditableText extends StatefulWidget { AutofillHints.username: TextInputType.text, }; - returnValue = iOSKeyboardType[effectiveHint]; + final TextInputType? keyboardType = iOSKeyboardType[effectiveHint]; + if (keyboardType != null) { + return keyboardType; + } break; case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -1066,8 +1111,9 @@ class ExtendedEditableText extends StatefulWidget { } } - if (returnValue != null || maxLines != 1) - return returnValue ?? TextInputType.multiline; + if (maxLines != 1) { + return TextInputType.multiline; + } const Map inferKeyboardType = { @@ -1167,7 +1213,7 @@ class ExtendedEditableText extends StatefulWidget { properties.add(DiagnosticsProperty( 'enableSuggestions', enableSuggestions, defaultValue: true)); - style?.debugFillProperties(properties); + style.debugFillProperties(properties); properties.add( EnumProperty('textAlign', textAlign, defaultValue: null)); properties.add(EnumProperty('textDirection', textDirection, @@ -1197,6 +1243,9 @@ class ExtendedEditableText extends StatefulWidget { properties.add(DiagnosticsProperty( 'textHeightBehavior', textHeightBehavior, defaultValue: null)); + properties.add(DiagnosticsProperty( + 'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, + defaultValue: true)); } } @@ -1205,30 +1254,40 @@ class ExtendedEditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, - TickerProviderStateMixin - implements TextInputClient, TextSelectionDelegate, AutofillClient { - Timer _cursorTimer; + TickerProviderStateMixin, + // TextEditingActionTarget, + TextSelectionDelegate + implements + TextInputClient, + AutofillClient { + Timer? _cursorTimer; bool _targetCursorVisibility = false; final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); final GlobalKey _editableKey = GlobalKey(); - final ClipboardStatusNotifier _clipboardStatus = - kIsWeb ? null : ClipboardStatusNotifier(); + ClipboardStatusNotifier? _clipboardStatus; + + TextInputConnection? _textInputConnection; + ExtendedTextSelectionOverlay? _selectionOverlay; - TextInputConnection _textInputConnection; - ExtendedTextSelectionOverlay _selectionOverlay; - ScrollController _scrollController; - AnimationController _cursorBlinkOpacityController; + ScrollController? _internalScrollController; + ScrollController get _scrollController => + widget.scrollController ?? + (_internalScrollController ??= ScrollController()); + + AnimationController? _cursorBlinkOpacityController; final LayerLink _toolbarLayerLink = LayerLink(); final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); + bool _didAutoFocus = false; - FocusAttachment _focusAttachment; - AutofillGroupState _currentAutofillScope; + AutofillGroupState? _currentAutofillScope; @override - AutofillScope get currentAutofillScope => _currentAutofillScope; + AutofillScope? get currentAutofillScope => _currentAutofillScope; + + AutofillClient get _effectiveAutofillClient => widget.autofillClient ?? this; ///whether to support build SpecialText bool get supportSpecialText => @@ -1236,9 +1295,6 @@ class ExtendedEditableTextState extends State !widget.obscureText && _textDirection == TextDirection.ltr; - // Is this field in the current autofill context. - bool _isInAutofillContext = false; - /// Whether to create an input connection with the platform for text editing /// or not. /// @@ -1262,13 +1318,13 @@ class ExtendedEditableTextState extends State // cursor position after the user has finished placing it. static const Duration _floatingCursorResetTime = Duration(milliseconds: 125); - AnimationController _floatingCursorResetController; + AnimationController? _floatingCursorResetController; @override bool get wantKeepAlive => widget.focusNode.hasFocus; Color get _cursorColor => - widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); + widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value); @override bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; @@ -1286,45 +1342,164 @@ class ExtendedEditableTextState extends State // Inform the widget that the value of clipboardStatus has changed. }); } + + TextEditingValue get _textEditingValueforTextLayoutMetrics { + final Widget? editableWidget = _editableKey.currentContext?.widget; + if (editableWidget is! _Editable) { + throw StateError('_Editable must be mounted.'); + } + return editableWidget.value; + } + + /// Copy current selection to [Clipboard]. + @override + void copySelection(SelectionChangedCause cause) { + final TextSelection selection = textEditingValue.selection; + final String text = textEditingValue.text; + assert(selection != null); + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(false); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + break; + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Collapse the selection and hide the toolbar and handles. + userUpdateTextEditingValue( + TextEditingValue( + text: textEditingValue.text, + selection: TextSelection.collapsed( + offset: textEditingValue.selection.end), + ), + SelectionChangedCause.toolbar, + ); + break; + } + } + } + + /// Cut current selection to [Clipboard]. + @override + void cutSelection(SelectionChangedCause cause) { + if (widget.readOnly) { + return; + } + final TextSelection selection = textEditingValue.selection; + final String text = textEditingValue.text; + assert(selection != null); + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + _replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause)); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(); + } + } + + /// Paste text from [Clipboard]. + @override + Future pasteText(SelectionChangedCause cause) async { + if (widget.readOnly) { + return; + } + final TextSelection selection = textEditingValue.selection; + assert(selection != null); + if (!selection.isValid) { + return; + } + // Snapshot the input before using `await`. + // See https://github.com/flutter/flutter/issues/11427 + final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); + if (data == null) { + return; + } + + _replaceText( + ReplaceTextIntent(textEditingValue, data.text!, selection, cause)); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(); + } + } + + /// Select the entire text value. + @override + void selectAll(SelectionChangedCause cause) { + userUpdateTextEditingValue( + textEditingValue.copyWith( + selection: TextSelection( + baseOffset: 0, extentOffset: textEditingValue.text.length), + ), + cause, + ); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + } + } + // State lifecycle: @override void initState() { super.initState(); + _cursorBlinkOpacityController = AnimationController( + vsync: this, + duration: _fadeDuration, + )..addListener(_onCursorColorTick); + _clipboardStatus = + kIsWeb && !widget.showToolbarInWeb ? null : ClipboardStatusNotifier(); _clipboardStatus?.addListener(_onChangedClipboardStatus); widget.controller.addListener(_didChangeTextEditingValue); - _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); - _scrollController = widget.scrollController ?? ScrollController(); - _scrollController.addListener(() { - _selectionOverlay?.updateForScroll(); - }); - _cursorBlinkOpacityController = - AnimationController(vsync: this, duration: _fadeDuration); - _cursorBlinkOpacityController.addListener(_onCursorColorTick); - _floatingCursorResetController = AnimationController(vsync: this); - _floatingCursorResetController.addListener(_onFloatingCursorResetTick); + _scrollController.addListener(_updateSelectionOverlayForScroll); _cursorVisibilityNotifier.value = widget.showCursor; } + // Whether `TickerMode.of(context)` is true and animations (like blinking the + // cursor) are supposed to run. + bool _tickersEnabled = true; + @override void didChangeDependencies() { super.didChangeDependencies(); - final AutofillGroupState newAutofillGroup = AutofillGroup.of(context); + final AutofillGroupState? newAutofillGroup = AutofillGroup.maybeOf(context); if (currentAutofillScope != newAutofillGroup) { _currentAutofillScope?.unregister(autofillId); _currentAutofillScope = newAutofillGroup; - newAutofillGroup?.register(this); - _isInAutofillContext = _isInAutofillContext || _shouldBeInAutofillContext; + _currentAutofillScope?.register(_effectiveAutofillClient); } if (!_didAutoFocus && widget.autofocus) { _didAutoFocus = true; SchedulerBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && renderEditable.hasSize) { FocusScope.of(context).autofocus(widget.focusNode); } }); } + + // Restart or stop the blinking cursor when TickerMode changes. + final bool newTickerEnabled = TickerMode.of(context); + if (_tickersEnabled != newTickerEnabled) { + _tickersEnabled = newTickerEnabled; + if (_tickersEnabled && _cursorActive) { + _startCursorTimer(); + } else if (!_tickersEnabled && _cursorTimer != null) { + // Cannot use _stopCursorTimer because it would reset _cursorActive. + _cursorTimer!.cancel(); + _cursorTimer = null; + } + } } @override @@ -1339,27 +1514,43 @@ class ExtendedEditableTextState extends State _selectionOverlay?.update(_value); } _selectionOverlay?.handlesVisible = widget.showSelectionHandles; - _isInAutofillContext = _isInAutofillContext || _shouldBeInAutofillContext; + + if (widget.autofillClient != oldWidget.autofillClient) { + _currentAutofillScope + ?.unregister(oldWidget.autofillClient?.autofillId ?? autofillId); + _currentAutofillScope?.register(_effectiveAutofillClient); + } + if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocusChanged); - _focusAttachment?.detach(); - _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); updateKeepAlive(); } + + if (widget.scrollController != oldWidget.scrollController) { + (oldWidget.scrollController ?? _internalScrollController) + ?.removeListener(_updateSelectionOverlayForScroll); + _scrollController.addListener(_updateSelectionOverlayForScroll); + } + if (!_shouldCreateInputConnection) { _closeInputConnectionIfNeeded(); - } else { - if (oldWidget.readOnly && _hasFocus) { - _openInputConnection(); + } else if (oldWidget.readOnly && _hasFocus) { + _openInputConnection(); + } + + if (kIsWeb && _hasInputConnection) { + if (oldWidget.readOnly != widget.readOnly) { + _textInputConnection! + .updateConfig(_effectiveAutofillClient.textInputConfiguration); } } if (widget.style != oldWidget.style) { final TextStyle style = widget.style; // The _textInputConnection will pick up the new style when it attaches in // _openInputConnection. - if (_textInputConnection != null && _textInputConnection.attached) { - _textInputConnection.setStyle( + if (_hasInputConnection) { + _textInputConnection!.setStyle( fontFamily: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, @@ -1373,52 +1564,58 @@ class ExtendedEditableTextState extends State widget.selectionControls?.canPaste(this) == true) { _clipboardStatus?.update(); } + + if (oldWidget.showToolbarInWeb != widget.showToolbarInWeb) { + _clipboardStatus = + kIsWeb && !widget.showToolbarInWeb ? null : ClipboardStatusNotifier(); + } } @override void dispose() { + _internalScrollController?.dispose(); _currentAutofillScope?.unregister(autofillId); widget.controller.removeListener(_didChangeTextEditingValue); - _cursorBlinkOpacityController.removeListener(_onCursorColorTick); - _floatingCursorResetController.removeListener(_onFloatingCursorResetTick); + _floatingCursorResetController?.dispose(); + _floatingCursorResetController = null; _closeInputConnectionIfNeeded(); assert(!_hasInputConnection); - _stopCursorTimer(); - assert(_cursorTimer == null); + _cursorTimer?.cancel(); + _cursorTimer = null; + _cursorBlinkOpacityController?.dispose(); + _cursorBlinkOpacityController = null; _selectionOverlay?.dispose(); _selectionOverlay = null; - _focusAttachment.detach(); widget.focusNode.removeListener(_handleFocusChanged); WidgetsBinding.instance.removeObserver(this); _clipboardStatus?.removeListener(_onChangedClipboardStatus); _clipboardStatus?.dispose(); super.dispose(); + assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth'); } // TextInputClient implementation: - // _lastFormattedUnmodifiedTextEditingValue tracks the last value - // that the formatter ran on and is used to prevent double-formatting. - TextEditingValue _lastFormattedUnmodifiedTextEditingValue; - // _lastFormattedValue tracks the last post-format value, so that it can be - // reused without rerunning the formatter when the input value is repeated. - TextEditingValue _lastFormattedValue; - // _receivedRemoteTextEditingValue is the direct value last passed in - // updateEditingValue. This value does not get updated with the formatted - // version. - TextEditingValue _receivedRemoteTextEditingValue; + /// The last known [TextEditingValue] of the platform text input plugin. + /// + /// This value is updated when the platform text input plugin sends a new + /// update via [updateEditingValue], or when [EditableText] calls + /// [TextInputConnection.setEditingState] to overwrite the platform text input + /// plugin's [TextEditingValue]. + /// + /// Used in [_updateRemoteEditingValueIfNeeded] to determine whether the + /// remote value is outdated and needs updating. + TextEditingValue? _lastKnownRemoteTextEditingValue; @override TextEditingValue get currentTextEditingValue => _value; - bool _updateEditingValueInProgress = false; @override void updateEditingValue(TextEditingValue value) { - _updateEditingValueInProgress = true; + // This method handles text editing state updates from the platform text // Since we still have to support keyboard select, this is the best place // to disable text updating. if (!_shouldCreateInputConnection) { - _updateEditingValueInProgress = false; return; } if (widget.readOnly) { @@ -1426,69 +1623,64 @@ class ExtendedEditableTextState extends State // everything else. value = _value.copyWith(selection: value.selection); } - _receivedRemoteTextEditingValue = value; + _lastKnownRemoteTextEditingValue = value; value = _handleSpecialTextSpan(value); - if (value.text != _value.text) { - hideToolbar(); - _showCaretOnScreen(); - _currentPromptRectRange = null; - if (widget.obscureText && value.text.length == _value.text.length + 1) { - _obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks; - _obscureLatestCharIndex = _value.selection.baseOffset; - } - } - if (value == _value) { // This is possible, for example, when the numeric keyboard is input, // the engine will notify twice for the same value. // Track at https://github.com/flutter/flutter/issues/65811 - _updateEditingValueInProgress = false; return; } - // else if (value.text == _value.text && - // value.composing == _value.composing && - // value.selection != _value.selection) { - // // `selection` is the only change. - // _handleSelectionChanged(value.selection, SelectionChangedCause.keyboard); - // } - else { - _formatAndSetValue(value); + + if (value.text == _value.text && value.composing == _value.composing) { + // `selection` is the only change. + _handleSelectionChanged(value.selection, SelectionChangedCause.keyboard); + } else { + hideToolbar(); + _currentPromptRectRange = null; + + if (_hasInputConnection) { + if (widget.obscureText && value.text.length == _value.text.length + 1) { + _obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks; + _obscureLatestCharIndex = _value.selection.baseOffset; + } + } + + _formatAndSetValue(value, SelectionChangedCause.keyboard); } + // Wherever the value is changed by the user, schedule a showCaretOnScreen + // to make sure the user can see the changes they just made. Programmatical + // changes to `textEditingValue` do not trigger the behavior even if the + // text field is focused. + _scheduleShowCaretOnScreen(); if (_hasInputConnection) { // To keep the cursor from blinking while typing, we want to restart the // cursor timer every time a new character is typed. _stopCursorTimer(resetCharTicks: false); _startCursorTimer(); } - _updateEditingValueInProgress = false; } ///zmt TextEditingValue _handleSpecialTextSpan(TextEditingValue value) { if (supportSpecialText) { - final bool textChanged = _value?.text != value?.text; - final bool selectionChanged = _value?.selection != value?.selection; + final bool textChanged = _value.text != value.text; + final bool selectionChanged = _value.selection != value.selection; if (textChanged) { - final TextSpan newTextSpan = widget.specialTextSpanBuilder - .build(value?.text, textStyle: widget.style); - if (newTextSpan == null) { - return value; - } + final TextSpan newTextSpan = widget.specialTextSpanBuilder! + .build(value.text, textStyle: widget.style); - final TextSpan oldTextSpan = widget.specialTextSpanBuilder - .build(_value?.text, textStyle: widget.style); + final TextSpan oldTextSpan = widget.specialTextSpanBuilder! + .build(_value.text, textStyle: widget.style); value = handleSpecialTextSpanDelete( - value, _value, oldTextSpan, _textInputConnection); - - if (newTextSpan != null) { - final String text = newTextSpan.toPlainText(); - //correct caret Offset - //make sure caret is not in text when caretIn is false - if (text != value.text || selectionChanged) { - value = - correctCaretOffset(value, newTextSpan, _textInputConnection); - } + value, _value, oldTextSpan, _textInputConnection!); + + final String text = newTextSpan.toPlainText(); + //correct caret Offset + //make sure caret is not in text when caretIn is false + if (text != value.text || selectionChanged) { + value = correctCaretOffset(value, newTextSpan, _textInputConnection!); } } } @@ -1503,9 +1695,7 @@ class ExtendedEditableTextState extends State // If this is a multiline EditableText, do nothing for a "newline" // action; The newline is already inserted. Otherwise, finalize // editing. - if (!_isMultiline) { - _finalizeEditing(action, shouldUnfocus: true); - } + if (!_isMultiline) _finalizeEditing(action, shouldUnfocus: true); break; case TextInputAction.done: case TextInputAction.go: @@ -1530,21 +1720,21 @@ class ExtendedEditableTextState extends State @override void performPrivateCommand(String action, Map data) { - widget.onAppPrivateCommand(action, data); + widget.onAppPrivateCommand!(action, data); } // The original position of the caret on FloatingCursorDragState.start. - Rect _startCaretRect; + Rect? _startCaretRect; // The most recent text position as determined by the location of the floating // cursor. - TextPosition _lastTextPosition; + TextPosition? _lastTextPosition; // The offset of the floating cursor as determined from the start call. - Offset _pointOffsetOrigin; + Offset? _pointOffsetOrigin; // The most recent position of the floating cursor. - Offset _lastBoundedOffset; + Offset? _lastBoundedOffset; // Because the center of the cursor is preferredLineHeight / 2 below the touch // origin, but the touch origin is used to determine which line the cursor is @@ -1554,10 +1744,13 @@ class ExtendedEditableTextState extends State @override void updateFloatingCursor(RawFloatingCursorPoint point) { + _floatingCursorResetController ??= AnimationController( + vsync: this, + )..addListener(_onFloatingCursorResetTick); switch (point.state) { case FloatingCursorDragState.Start: - if (_floatingCursorResetController.isAnimating) { - _floatingCursorResetController.stop(); + if (_floatingCursorResetController!.isAnimating) { + _floatingCursorResetController!.stop(); _onFloatingCursorResetTick(); } @@ -1566,44 +1759,44 @@ class ExtendedEditableTextState extends State _pointOffsetOrigin = point.offset; TextPosition currentTextPosition = - TextPosition(offset: renderEditable.selection.baseOffset); + TextPosition(offset: renderEditable.selection!.baseOffset); //zmt if (supportSpecialText) { currentTextPosition = convertTextInputPostionToTextPainterPostion( - renderEditable.text, renderEditable.selection.base); + renderEditable.text!, renderEditable.selection!.base); } else { currentTextPosition = - TextPosition(offset: renderEditable.selection.baseOffset); + TextPosition(offset: renderEditable.selection!.baseOffset); } _startCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition); - _lastBoundedOffset = _startCaretRect.center - _floatingCursorOffset; + _lastBoundedOffset = _startCaretRect!.center - _floatingCursorOffset; _lastTextPosition = currentTextPosition; renderEditable.setFloatingCursor( - point.state, _lastBoundedOffset, _lastTextPosition); + point.state, _lastBoundedOffset!, _lastTextPosition!); break; case FloatingCursorDragState.Update: - final Offset centeredPoint = point.offset - _pointOffsetOrigin; + final Offset centeredPoint = point.offset! - _pointOffsetOrigin!; final Offset rawCursorOffset = - _startCaretRect.center + centeredPoint - _floatingCursorOffset; + _startCaretRect!.center + centeredPoint - _floatingCursorOffset; _lastBoundedOffset = renderEditable .calculateBoundedFloatingCursorOffset(rawCursorOffset); _lastTextPosition = renderEditable.getPositionForPoint(renderEditable - .localToGlobal(_lastBoundedOffset + _floatingCursorOffset)); - if (renderEditable?.hasSpecialInlineSpanBase ?? false) { + .localToGlobal(_lastBoundedOffset! + _floatingCursorOffset)); + if (renderEditable.hasSpecialInlineSpanBase) { _lastTextPosition = makeSureCaretNotInSpecialText( - renderEditable.text, _lastTextPosition); + renderEditable.text!, _lastTextPosition!); } renderEditable.setFloatingCursor( - point.state, _lastBoundedOffset, _lastTextPosition); + point.state, _lastBoundedOffset!, _lastTextPosition!); break; case FloatingCursorDragState.End: // We skip animation if no update has happened. if (_lastTextPosition != null && _lastBoundedOffset != null) { - _floatingCursorResetController.value = 0.0; - _floatingCursorResetController.animateTo(1.0, + _floatingCursorResetController!.value = 0.0; + _floatingCursorResetController!.animateTo(1.0, duration: _floatingCursorResetTime, curve: Curves.decelerate); } break; @@ -1612,38 +1805,48 @@ class ExtendedEditableTextState extends State void _onFloatingCursorResetTick() { final Offset finalPosition = - renderEditable.getLocalRectForCaret(_lastTextPosition).centerLeft - + renderEditable.getLocalRectForCaret(_lastTextPosition!).centerLeft - _floatingCursorOffset; - if (_floatingCursorResetController.isCompleted) { + if (_floatingCursorResetController!.isCompleted) { renderEditable.setFloatingCursor( - FloatingCursorDragState.End, finalPosition, _lastTextPosition); - if (_lastTextPosition.offset != renderEditable.selection.baseOffset) + FloatingCursorDragState.End, finalPosition, _lastTextPosition!); + if (_lastTextPosition!.offset != renderEditable.selection!.baseOffset) // The cause is technically the force cursor, but the cause is listed as tap as the desired functionality is the same. _handleSelectionChanged( - TextSelection.collapsed(offset: _lastTextPosition.offset), + TextSelection.collapsed(offset: _lastTextPosition!.offset), SelectionChangedCause.forcePress); _startCaretRect = null; _lastTextPosition = null; _pointOffsetOrigin = null; _lastBoundedOffset = null; } else { - final double lerpValue = _floatingCursorResetController.value; + final double lerpValue = _floatingCursorResetController!.value; final double lerpX = - ui.lerpDouble(_lastBoundedOffset.dx, finalPosition.dx, lerpValue); + ui.lerpDouble(_lastBoundedOffset!.dx, finalPosition.dx, lerpValue)!; final double lerpY = - ui.lerpDouble(_lastBoundedOffset.dy, finalPosition.dy, lerpValue); + ui.lerpDouble(_lastBoundedOffset!.dy, finalPosition.dy, lerpValue)!; renderEditable.setFloatingCursor(FloatingCursorDragState.Update, - Offset(lerpX, lerpY), _lastTextPosition, + Offset(lerpX, lerpY), _lastTextPosition!, resetLerpValue: lerpValue); } } - void _finalizeEditing(TextInputAction action, - {@required bool shouldUnfocus}) { + @pragma('vm:notify-debugger-on-exception') + void _finalizeEditing(TextInputAction action, {required bool shouldUnfocus}) { // Take any actions necessary now that the user has completed editing. if (widget.onEditingComplete != null) { - widget.onEditingComplete(); + try { + widget.onEditingComplete!(); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: + ErrorDescription('while calling onEditingComplete for $action'), + )); + } } else { // Default behavior if the developer did not provide an // onEditingComplete callback: Finalize editing and remove focus, or move @@ -1674,28 +1877,70 @@ class ExtendedEditableTextState extends State } } + final ValueChanged? onSubmitted = widget.onSubmitted; + if (onSubmitted == null) { + return; + } + // Invoke optional callback with the user's submitted content. - if (widget.onSubmitted != null) { - widget.onSubmitted(_value.text); + try { + onSubmitted(_value.text); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while calling onSubmitted for $action'), + )); } + + // If `shouldUnfocus` is true, the text field should no longer be focused + // after the microtask queue is drained. But in case the developer cancelled + // the focus change in the `onSubmitted` callback by focusing this input + // field again, reset the soft keyboard. + // See https://github.com/flutter/flutter/issues/84240. + // + // `_restartConnectionIfNeeded` creates a new TextInputConnection to replace + // the current one. This on iOS switches to a new input view and on Android + // restarts the input method, and in both cases the soft keyboard will be + // reset. + if (shouldUnfocus) { + _scheduleRestartConnection(); + } + } + + int _batchEditDepth = 0; + + /// Begins a new batch edit, within which new updates made to the text editing + /// value will not be sent to the platform text input plugin. + /// + /// Batch edits nest. When the outermost batch edit finishes, [endBatchEdit] + /// will attempt to send [currentTextEditingValue] to the text input plugin if + /// it detected a change. + void beginBatchEdit() { + _batchEditDepth += 1; + } + + /// Ends the current batch edit started by the last call to [beginBatchEdit], + /// and send [currentTextEditingValue] to the text input plugin if needed. + /// + /// Throws an error in debug mode if this [EditableText] is not in a batch + /// edit. + void endBatchEdit() { + _batchEditDepth -= 1; + assert( + _batchEditDepth >= 0, + 'Unbalanced call to endBatchEdit: beginBatchEdit must be called first.', + ); + _updateRemoteEditingValueIfNeeded(); } void _updateRemoteEditingValueIfNeeded() { - if (!_hasInputConnection) { - return; - } + if (_batchEditDepth > 0 || !_hasInputConnection) return; final TextEditingValue localValue = _value; - // We should not update back the value notified by the remote(engine) in reverse, this is redundant. - // Unless we modify this value for some reason during processing, such as `TextInputFormatter`. - if (_updateEditingValueInProgress && - localValue == _receivedRemoteTextEditingValue) { - return; - } - // In other cases, as long as the value of the [widget.controller.value] is modified, - // `setEditingState` should be called as we do not want to skip sending real changes - // to the engine. - // Also see https://github.com/flutter/flutter/issues/65059#issuecomment-690254379 - _textInputConnection.setEditingState(localValue); + if (localValue == _lastKnownRemoteTextEditingValue) return; + _textInputConnection!.setEditingState(localValue); + _lastKnownRemoteTextEditingValue = localValue; } TextEditingValue get _value => widget.controller.value; @@ -1720,8 +1965,8 @@ class ExtendedEditableTextState extends State return RevealedOffset(offset: _scrollController.offset, rect: rect); final Size editableSize = renderEditable.size; - double additionalOffset; - Offset unitOffset; + final double additionalOffset; + final Offset unitOffset; if (!_isMultiline) { additionalOffset = rect.width >= editableSize.width @@ -1729,7 +1974,7 @@ class ExtendedEditableTextState extends State ? editableSize.width / 2 - rect.center.dx // Valid additional offsets range from (rect.right - size.width) // to (rect.left). Pick the closest one if out of range. - : 0.0.clamp(rect.right - editableSize.width, rect.left) as double; + : 0.0.clamp(rect.right - editableSize.width, rect.left); unitOffset = const Offset(1, 0); } else { // The caret is vertically centered within the line. Expand the caret's @@ -1738,14 +1983,13 @@ class ExtendedEditableTextState extends State final Rect expandedRect = Rect.fromCenter( center: rect.center, width: rect.width, - height: max(rect.height, renderEditable.preferredLineHeight), + height: math.max(rect.height, renderEditable.preferredLineHeight), ); additionalOffset = expandedRect.height >= editableSize.height ? editableSize.height / 2 - expandedRect.center.dy : 0.0.clamp( - expandedRect.bottom - editableSize.height, expandedRect.top) - as double; + expandedRect.bottom - editableSize.height, expandedRect.top); unitOffset = const Offset(0, 1); } @@ -1755,18 +1999,18 @@ class ExtendedEditableTextState extends State (additionalOffset + _scrollController.offset).clamp( _scrollController.position.minScrollExtent, _scrollController.position.maxScrollExtent, - ) as double; + ); final double offsetDelta = _scrollController.offset - targetOffset; return RevealedOffset( rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset); } - bool get _hasInputConnection => - _textInputConnection != null && _textInputConnection.attached; - bool get _needsAutofill => widget.autofillHints?.isNotEmpty ?? false; - bool get _shouldBeInAutofillContext => - _needsAutofill && currentAutofillScope != null; + bool get _hasInputConnection => _textInputConnection?.attached ?? false; + + /// Whether to send the autofill information to the autofill service. True by + /// default. + bool get _needsAutofill => widget.autofillHints?.isNotEmpty ?? true; void _openInputConnection() { if (!_shouldCreateInputConnection) { @@ -1774,7 +2018,6 @@ class ExtendedEditableTextState extends State } if (!_hasInputConnection) { final TextEditingValue localValue = _value; - _lastFormattedUnmodifiedTextEditingValue = localValue; // When _needsAutofill == true && currentAutofillScope == null, autofill // is allowed but saving the user input from the text field is @@ -1785,20 +2028,15 @@ class ExtendedEditableTextState extends State // notified to exclude this field from the autofill context. So we need to // provide the autofillId. _textInputConnection = _needsAutofill && currentAutofillScope != null - ? currentAutofillScope.attach(this, textInputConfiguration) + ? currentAutofillScope! + .attach(this, _effectiveAutofillClient.textInputConfiguration) : TextInput.attach( - this, - _createTextInputConfiguration( - _isInAutofillContext || _needsAutofill)); - _textInputConnection.show(); + this, _effectiveAutofillClient.textInputConfiguration); _updateSizeAndTransform(); - if (_needsAutofill) { - // Request autofill AFTER the size and the transform have been sent to - // the platform text input plugin. - _textInputConnection.requestAutofill(); - } + _updateComposingRectIfNeeded(); + _updateCaretRectIfNeeded(); final TextStyle style = widget.style; - _textInputConnection + _textInputConnection! ..setStyle( fontFamily: style.fontFamily, fontSize: style.fontSize, @@ -1806,18 +2044,24 @@ class ExtendedEditableTextState extends State textDirection: _textDirection, textAlign: widget.textAlign, ) - ..setEditingState(localValue); + ..setEditingState(localValue) + ..show(); + if (_needsAutofill) { + // Request autofill AFTER the size and the transform have been sent to + // the platform text input plugin. + _textInputConnection!.requestAutofill(); + } + _lastKnownRemoteTextEditingValue = localValue; } else { - _textInputConnection.show(); + _textInputConnection!.show(); } } void _closeInputConnectionIfNeeded() { if (_hasInputConnection) { - _textInputConnection.close(); + _textInputConnection!.close(); _textInputConnection = null; - _lastFormattedUnmodifiedTextEditingValue = null; - _receivedRemoteTextEditingValue = null; + _lastKnownRemoteTextEditingValue = null; } } @@ -1830,13 +2074,55 @@ class ExtendedEditableTextState extends State } } + bool _restartConnectionScheduled = false; + void _scheduleRestartConnection() { + if (_restartConnectionScheduled) { + return; + } + _restartConnectionScheduled = true; + scheduleMicrotask(_restartConnectionIfNeeded); + } + + // Discards the current [TextInputConnection] and establishes a new one. + // + // This method is rarely needed. This is currently used to reset the input + // type when the "submit" text input action is triggered and the developer + // puts the focus back to this input field.. + void _restartConnectionIfNeeded() { + _restartConnectionScheduled = false; + if (!_hasInputConnection || !_shouldCreateInputConnection) { + return; + } + _textInputConnection!.close(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + + final AutofillScope? currentAutofillScope = + _needsAutofill ? this.currentAutofillScope : null; + final TextInputConnection newConnection = currentAutofillScope?.attach( + this, textInputConfiguration) ?? + TextInput.attach(this, _effectiveAutofillClient.textInputConfiguration); + _textInputConnection = newConnection; + + final TextStyle style = widget.style; + newConnection + ..setStyle( + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + textDirection: _textDirection, + textAlign: widget.textAlign, + ) + ..setEditingState(_value); + _lastKnownRemoteTextEditingValue = _value; + } + @override void connectionClosed() { if (_hasInputConnection) { - _textInputConnection.connectionClosedReceived(); + _textInputConnection!.connectionClosedReceived(); _textInputConnection = null; - _lastFormattedUnmodifiedTextEditingValue = null; - _receivedRemoteTextEditingValue = null; + _lastKnownRemoteTextEditingValue = null; _finalizeEditing(TextInputAction.done, shouldUnfocus: true); } } @@ -1852,23 +2138,29 @@ class ExtendedEditableTextState extends State if (_hasFocus) { _openInputConnection(); } else { - widget.focusNode.requestFocus(); + widget.focusNode + .requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged. } } void _updateOrDisposeSelectionOverlayIfNeeded() { if (_selectionOverlay != null) { if (_hasFocus) { - _selectionOverlay.update(_value); + _selectionOverlay!.update(_value); } else { - _selectionOverlay.dispose(); + _selectionOverlay!.dispose(); _selectionOverlay = null; } } } + void _updateSelectionOverlayForScroll() { + _selectionOverlay?.updateForScroll(); + } + + @pragma('vm:notify-debugger-on-exception') void _handleSelectionChanged( - TextSelection selection, SelectionChangedCause cause) { + TextSelection selection, SelectionChangedCause? cause) { // We return early if the selection is not valid. This can happen when the // text of [EditableText] is updated at the same time as the selection is // changed by a gesture event. @@ -1876,17 +2168,19 @@ class ExtendedEditableTextState extends State return; } - if (renderEditable?.hasSpecialInlineSpanBase ?? false) { - final TextEditingValue value = correctCaretOffset( - _value, renderEditable?.text, _textInputConnection, - newSelection: selection); - - ///change - if (value != _value) { - selection = value.selection; - _value = value; - } - } + // #172 remove this code + // renderEditable.text is not the same as build from _value + // if (renderEditable.hasSpecialInlineSpanBase) { + // final TextEditingValue value = correctCaretOffset( + // _value, renderEditable.text!, _textInputConnection, + // newSelection: selection); + + // ///change + // if (value != _value) { + // selection = value.selection; + // _value = value; + // } + // } final bool textChanged = widget.controller.text != renderEditable.plainText; // zmt @@ -1900,58 +2194,57 @@ class ExtendedEditableTextState extends State // This will show the keyboard for all selection changes on the // EditableWidget, not just changes triggered by user gestures. requestKeyboard(); - - _hideSelectionOverlayIfNeeded(); - - if (widget.selectionControls != null) { - createSelectionOverlay(renderObject: renderEditable); - -// final bool longPress = cause == SelectionChangedCause.longPress; -// if (cause != SelectionChangedCause.keyboard && -// (_value.text.isNotEmpty || longPress)) -// _selectionOverlay.showHandles(); - + if (widget.selectionControls == null) { + _selectionOverlay?.dispose(); + _selectionOverlay = null; + } else { + if (_selectionOverlay == null) { + _selectionOverlay = ExtendedTextSelectionOverlay( + clipboardStatus: _clipboardStatus, + context: context, + value: _value, + debugRequiredFor: widget, + toolbarLayerLink: _toolbarLayerLink, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: renderEditable, + selectionControls: widget.selectionControls, + selectionDelegate: this, + dragStartBehavior: widget.dragStartBehavior, + onSelectionHandleTapped: widget.onSelectionHandleTapped, + ); + } else { + _selectionOverlay!.update(_value); + } + _selectionOverlay!.handlesVisible = widget.showSelectionHandles; + _selectionOverlay!.showHandles(); + } + // TODO(chunhtai): we should make sure selection actually changed before + // we call the onSelectionChanged. + // https://github.com/flutter/flutter/issues/76349. + try { + widget.onSelectionChanged?.call(selection, cause); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: + ErrorDescription('while calling onSelectionChanged for $cause'), + )); } - if (!textChanged && widget.onSelectionChanged != null) - widget.onSelectionChanged(selection, cause); - } - - void createSelectionOverlay({ - ExtendedRenderEditable renderObject, - bool showHandles = true, - }) { - _selectionOverlay = ExtendedTextSelectionOverlay( - clipboardStatus: _clipboardStatus, - context: context, - value: _value, - debugRequiredFor: widget, - toolbarLayerLink: _toolbarLayerLink, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - renderObject: renderObject ?? renderEditable, - selectionControls: widget.selectionControls, - selectionDelegate: this, - dragStartBehavior: widget.dragStartBehavior, - onSelectionHandleTapped: widget.onSelectionHandleTapped, - ); - _selectionOverlay.handlesVisible = widget.showSelectionHandles; - if (showHandles) { - _selectionOverlay.showHandles(); + // To keep the cursor from blinking while it moves, restart the timer here. + if (_cursorTimer != null) { + _stopCursorTimer(resetCharTicks: false); + _startCursorTimer(); } } - bool _textChangedSinceLastCaretUpdate = false; - Rect _currentCaretRect; - + Rect? _currentCaretRect; + // ignore: use_setters_to_change_properties, (this is used as a callback, can't be a setter) void _handleCaretChanged(Rect caretRect) { _currentCaretRect = caretRect; - // If the caret location has changed due to an update to the text or - // selection, then scroll the caret into view. - if (_textChangedSinceLastCaretUpdate) { - _textChangedSinceLastCaretUpdate = false; - _showCaretOnScreen(); - } } // Animation configuration for scrolling the caret back on screen. @@ -1960,7 +2253,7 @@ class ExtendedEditableTextState extends State bool _showCaretOnScreenScheduled = false; - void _showCaretOnScreen() { + void _scheduleShowCaretOnScreen() { if (_showCaretOnScreenScheduled) { return; } @@ -1977,20 +2270,20 @@ class ExtendedEditableTextState extends State // positioned directly at the edge after scrolling. double bottomSpacing = widget.scrollPadding.bottom; if (_selectionOverlay?.selectionControls != null) { - final double handleHeight = _selectionOverlay.selectionControls + final double handleHeight = _selectionOverlay!.selectionControls! .getHandleSize(lineHeight) .height; - final double interactiveHandleHeight = max( + final double interactiveHandleHeight = math.max( handleHeight, kMinInteractiveDimension, ); final Offset anchor = - _selectionOverlay.selectionControls.getHandleAnchor( + _selectionOverlay!.selectionControls!.getHandleAnchor( TextSelectionHandleType.collapsed, lineHeight, ); final double handleCenter = handleHeight / 2 - anchor.dy; - bottomSpacing = max( + bottomSpacing = math.max( handleCenter + interactiveHandleHeight / 2, bottomSpacing, ); @@ -2000,7 +2293,7 @@ class ExtendedEditableTextState extends State widget.scrollPadding.copyWith(bottom: bottomSpacing); final RevealedOffset targetOffset = - _getOffsetToRevealCaret(_currentCaretRect); + _getOffsetToRevealCaret(_currentCaretRect!); _scrollController.animateTo( targetOffset.offset, @@ -2016,80 +2309,97 @@ class ExtendedEditableTextState extends State }); } - double _lastBottomViewInset; + late double _lastBottomViewInset; @override void didChangeMetrics() { - if (_lastBottomViewInset < + if (_lastBottomViewInset != WidgetsBinding.instance.window.viewInsets.bottom) { - _showCaretOnScreen(); + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _selectionOverlay?.updateForScroll(); + }); + if (_lastBottomViewInset < + WidgetsBinding.instance.window.viewInsets.bottom) { + _scheduleShowCaretOnScreen(); + } } _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom; } - _WhitespaceDirectionalityFormatter _whitespaceFormatter; - void _formatAndSetValue(TextEditingValue value) { - _whitespaceFormatter ??= - _WhitespaceDirectionalityFormatter(textDirection: _textDirection); - - // Check if the new value is the same as the current local value, or is the same - // as the pre-formatting value of the previous pass (repeat call). - final bool textChanged = _value?.text != value?.text; - final bool isRepeat = value == _lastFormattedUnmodifiedTextEditingValue; - - // There's no need to format when starting to compose or when continuing - // an existing composition. - final bool isComposing = value?.composing?.isValid ?? false; - final bool isPreviouslyComposing = - _lastFormattedUnmodifiedTextEditingValue?.composing?.isValid ?? false; - - if ((textChanged || (!isComposing && isPreviouslyComposing)) && - widget.inputFormatters != null && - widget.inputFormatters.isNotEmpty) { - // Only format when the text has changed and there are available formatters. - // Pass through the formatter regardless of repeat status if the input value is - // different than the stored value. - for (final TextInputFormatter formatter in widget.inputFormatters) { - value = formatter.formatEditUpdate(_value, value); - } - // Always pass the text through the whitespace directionality formatter to - // maintain expected behavior with carets on trailing whitespace. - value = _whitespaceFormatter.formatEditUpdate(_value, value); - _lastFormattedValue = value; - } + @pragma('vm:notify-debugger-on-exception') + void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, + {bool userInteraction = false}) { + // Only apply input formatters if the text has changed (including uncommitted + // text in the composing region), or when the user committed the composing + // text. + // Gboard is very persistent in restoring the composing region. Applying + // input formatters on composing-region-only changes (except clearing the + // current composing region) is very infinite-loop-prone: the formatters + // will keep trying to modify the composing region while Gboard will keep + // trying to restore the original composing region. + final bool textChanged = _value.text != value.text || + (!_value.composing.isCollapsed && value.composing.isCollapsed); + final bool selectionChanged = _value.selection != value.selection; - //https://github.com/flutter/flutter/issues/36048 if (textChanged) { - _hideSelectionOverlayIfNeeded(); + try { + value = widget.inputFormatters?.fold( + value, + (TextEditingValue newValue, TextInputFormatter formatter) => + formatter.formatEditUpdate(_value, newValue), + ) ?? + value; + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while applying input formatters'), + )); + } } - // Setting _value here ensures the selection and composing region info is passed. + // Put all optional user callback invocations in a batch edit to prevent + // sending multiple `TextInput.updateEditingValue` messages. + beginBatchEdit(); _value = value; - // Use the last formatted value when an identical repeat pass is detected. - if (isRepeat && textChanged && _lastFormattedValue != null) { - _value = _lastFormattedValue; + // Changes made by the keyboard can sometimes be "out of band" for listening + // components, so always send those events, even if we didn't think it + // changed. Also, the user long pressing should always send a selection change + // as well. + if (selectionChanged || + (userInteraction && + (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.keyboard))) { + _handleSelectionChanged(_value.selection, cause); } - - // Always attempt to send the value. If the value has changed, then it will send, - // otherwise, it will short-circuit. - _updateRemoteEditingValueIfNeeded(); - if (textChanged && widget.onChanged != null) { - widget.onChanged(value.text); + if (textChanged) { + try { + widget.onChanged?.call(_value.text); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while calling onChanged'), + )); + } } - _lastFormattedUnmodifiedTextEditingValue = _receivedRemoteTextEditingValue; + + endBatchEdit(); } void _onCursorColorTick() { renderEditable.cursorColor = - widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); + widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value); _cursorVisibilityNotifier.value = - widget.showCursor && _cursorBlinkOpacityController.value > 0; + widget.showCursor && _cursorBlinkOpacityController!.value > 0; } /// Whether the blinking cursor is actually visible at this precise moment /// (it's hidden half the time, since it blinks). @visibleForTesting - bool get cursorCurrentlyVisible => _cursorBlinkOpacityController.value > 0; + bool get cursorCurrentlyVisible => _cursorBlinkOpacityController!.value > 0; /// The cursor blink interval (the amount of time the cursor is in the "on" /// state or the "off" state). A complete cursor blink period is twice this @@ -2099,10 +2409,10 @@ class ExtendedEditableTextState extends State /// The current status of the text selection handles. //@visibleForTesting - ExtendedTextSelectionOverlay get selectionOverlay => _selectionOverlay; + ExtendedTextSelectionOverlay? get selectionOverlay => _selectionOverlay; int _obscureShowCharTicksPending = 0; - int _obscureLatestCharIndex; + int? _obscureLatestCharIndex; void _cursorTick(Timer timer) { _targetCursorVisibility = !_targetCursorVisibility; @@ -2115,10 +2425,10 @@ class ExtendedEditableTextState extends State // // These values and curves have been obtained through eyeballing, so are // likely not exactly the same as the values for native iOS. - _cursorBlinkOpacityController.animateTo(targetOpacity, - curve: Curves.easeOut); + _cursorBlinkOpacityController! + .animateTo(targetOpacity, curve: Curves.easeOut); } else { - _cursorBlinkOpacityController.value = targetOpacity; + _cursorBlinkOpacityController!.value = targetOpacity; } if (_obscureShowCharTicksPending > 0) { @@ -2130,16 +2440,24 @@ class ExtendedEditableTextState extends State void _cursorWaitForStart(Timer timer) { assert(_kCursorBlinkHalfPeriod > _fadeDuration); + assert(!EditableText.debugDeterministicCursor); _cursorTimer?.cancel(); _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); } + // Indicates whether the cursor should be blinking right now (but it may + // actually not blink because it's disabled via TickerMode.of(context)). + bool _cursorActive = false; + void _startCursorTimer() { - _targetCursorVisibility = true; - _cursorBlinkOpacityController.value = 1.0; - if (ExtendedEditableText.debugDeterministicCursor) { + assert(_cursorTimer == null); + _cursorActive = true; + if (!_tickersEnabled) { return; } + _targetCursorVisibility = true; + _cursorBlinkOpacityController!.value = 1.0; + if (EditableText.debugDeterministicCursor) return; if (widget.cursorOpacityAnimates) { _cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart); @@ -2149,39 +2467,34 @@ class ExtendedEditableTextState extends State } void _stopCursorTimer({bool resetCharTicks = true}) { + _cursorActive = false; _cursorTimer?.cancel(); _cursorTimer = null; _targetCursorVisibility = false; - _cursorBlinkOpacityController.value = 0.0; - if (ExtendedEditableText.debugDeterministicCursor) { - return; - } - if (resetCharTicks) { - _obscureShowCharTicksPending = 0; - } + _cursorBlinkOpacityController!.value = 0.0; + if (EditableText.debugDeterministicCursor) return; + if (resetCharTicks) _obscureShowCharTicksPending = 0; if (widget.cursorOpacityAnimates) { - _cursorBlinkOpacityController.stop(); - _cursorBlinkOpacityController.value = 0.0; + _cursorBlinkOpacityController!.stop(); + _cursorBlinkOpacityController!.value = 0.0; } } void _startOrStopCursorTimerIfNeeded() { if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) _startCursorTimer(); - else if (_cursorTimer != null && - (!_hasFocus || !_value.selection.isCollapsed)) { + else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) _stopCursorTimer(); - } } void _didChangeTextEditingValue() { _updateRemoteEditingValueIfNeeded(); _startOrStopCursorTimerIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded(); - _textChangedSinceLastCaretUpdate = true; // TODO(abarth): Teach RenderEditable about ValueNotifier // to avoid this setState(). setState(() {/* We use widget.controller.value in build(). */}); + _adjacentLineAction.stopCurrentVerticalRunIfSelectionChanges(); } void _handleFocusChanged() { @@ -2192,18 +2505,20 @@ class ExtendedEditableTextState extends State // Listen for changing viewInsets, which indicates keyboard showing up. WidgetsBinding.instance.addObserver(this); _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom; - _showCaretOnScreen(); + if (!widget.readOnly) { + _scheduleShowCaretOnScreen(); + } if (!_value.selection.isValid) { // Place cursor at the end if the selection is invalid when we receive focus. - widget.controller.selection = - TextSelection.collapsed(offset: _value.text.length); + _handleSelectionChanged( + TextSelection.collapsed(offset: _value.text.length), null); } } else { WidgetsBinding.instance.removeObserver(this); - // Clear the selection and composition state if this widget lost focus. - _value = TextEditingValue(text: _value.text); - _currentPromptRectRange = null; - } + setState(() { + _currentPromptRectRange = null; + }); + } updateKeepAlive(); } @@ -2211,9 +2526,53 @@ class ExtendedEditableTextState extends State if (_hasInputConnection) { final Size size = renderEditable.size; final Matrix4 transform = renderEditable.getTransformTo(null); - _textInputConnection.setEditableSizeAndTransform(size, transform); + _textInputConnection!.setEditableSizeAndTransform(size, transform); + _updateSelectionRects(); SchedulerBinding.instance .addPostFrameCallback((Duration _) => _updateSizeAndTransform()); + } else if (_placeholderLocation != -1) { + removeTextPlaceholder(); + } + } + + // Sends the current composing rect to the iOS text input plugin via the text + // input channel. We need to keep sending the information even if no text is + // currently marked, as the information usually lags behind. The text input + // plugin needs to estimate the composing rect based on the latest caret rect, + // when the composing rect info didn't arrive in time. + void _updateComposingRectIfNeeded() { + final TextRange composingRange = _value.composing; + if (_hasInputConnection) { + assert(mounted); + Rect? composingRect = + renderEditable.getRectForComposingRange(composingRange); + // Send the caret location instead if there's no marked text yet. + if (composingRect == null) { + assert(!composingRange.isValid || composingRange.isCollapsed); + final int offset = composingRange.isValid ? composingRange.start : 0; + composingRect = + renderEditable.getLocalRectForCaret(TextPosition(offset: offset)); + } + assert(composingRect != null); + _textInputConnection!.setComposingRect(composingRect); + SchedulerBinding.instance + .addPostFrameCallback((Duration _) => _updateComposingRectIfNeeded()); + } + } + + void _updateCaretRectIfNeeded() { + if (_hasInputConnection) { + if (renderEditable.selection != null && + renderEditable.selection!.isValid && + renderEditable.selection!.isCollapsed) { + final TextPosition currentTextPosition = + TextPosition(offset: renderEditable.selection!.baseOffset); + final Rect caretRect = + renderEditable.getLocalRectForCaret(currentTextPosition); + _textInputConnection!.setCaretRect(caretRect); + } + SchedulerBinding.instance + .addPostFrameCallback((Duration _) => _updateCaretRectIfNeeded()); } } @@ -2230,27 +2589,38 @@ class ExtendedEditableTextState extends State /// This property is typically used to notify the renderer of input gestures /// when [ignorePointer] is true. See [RenderEditable.ignorePointer]. ExtendedRenderEditable get renderEditable => - _editableKey.currentContext.findRenderObject() as ExtendedRenderEditable; + _editableKey.currentContext!.findRenderObject() as ExtendedRenderEditable; @override TextEditingValue get textEditingValue => _value; - double get _devicePixelRatio => - MediaQuery.of(context).devicePixelRatio ?? 1.0; + double get _devicePixelRatio => MediaQuery.of(context).devicePixelRatio; @override - set textEditingValue(TextEditingValue value) { + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause? cause) { value = _handleSpecialTextSpan(value); - _selectionOverlay?.update(value); - _formatAndSetValue(value); + // Compare the current TextEditingValue with the pre-format new + // TextEditingValue value, in case the formatter would reject the change. + final bool shouldShowCaret = + widget.readOnly ? _value.selection != value.selection : _value != value; + if (shouldShowCaret) { + _scheduleShowCaretOnScreen(); + } + _formatAndSetValue(value, cause, userInteraction: true); } @override - void bringIntoView(TextPosition position) { + void bringIntoView(TextPosition position, {double offset = 0}) { + if (supportSpecialText) { + position = convertTextInputPostionToTextPainterPostion( + renderEditable.text!, position); + } + final Rect localRect = renderEditable.getLocalRectForCaret(position); - final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect); - _scrollController.jumpTo(targetOffset.offset); + final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect); + _scrollController.jumpTo(targetOffset.offset + offset); renderEditable.showOnScreen(rect: targetOffset.rect); } @@ -2258,67 +2628,95 @@ class ExtendedEditableTextState extends State /// /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar /// is already shown, or when no text selection currently exists. - bool showToolbar() { + @override + bool showToolbar({bool showToolbarInWeb = false}) { // Web is using native dom elements to enable clipboard functionality of the // toolbar: copy, paste, select, cut. It might also provide additional // functionality depending on the browser (such as translate). Due to this // we should not show a Flutter toolbar for the editable text elements. - if (kIsWeb) { + if (kIsWeb && !showToolbarInWeb) { return false; } - if (_selectionOverlay == null && - FocusScope.of(context).focusedChild == widget.focusNode) { - createSelectionOverlay(); - } - - if (_selectionOverlay == null || _selectionOverlay.toolbarIsVisible) { + if (_selectionOverlay == null || _selectionOverlay!.toolbarIsVisible) { return false; } - _selectionOverlay.showToolbar(); + _selectionOverlay!.showToolbar(); return true; } - void _hideSelectionOverlayIfNeeded() { - _selectionOverlay?.hide(); - _selectionOverlay = null; - } - @override - void hideToolbar() { - _selectionOverlay?.hide(); + void hideToolbar([bool hideHandles = true]) { + if (hideHandles) { + // Hide the handles and the toolbar. + _selectionOverlay?.hide(); + } else if (_selectionOverlay?.toolbarIsVisible ?? false) { + // Hide only the toolbar but not the handles. + _selectionOverlay?.hideToolbar(); + } } /// Toggles the visibility of the toolbar. void toggleToolbar() { assert(_selectionOverlay != null); - if (_selectionOverlay.toolbarIsVisible) { + if (_selectionOverlay!.toolbarIsVisible) { hideToolbar(); } else { showToolbar(); } } + // Tracks the location a [_ScribblePlaceholder] should be rendered in the + // text. + // + // A value of -1 indicates there should be no placeholder, otherwise the + // value should be between 0 and the length of the text, inclusive. + int _placeholderLocation = -1; + + @override + void insertTextPlaceholder(Size size) { + if (!widget.scribbleEnabled) return; + + if (!widget.controller.selection.isValid) return; + + setState(() { + _placeholderLocation = + _value.text.length - widget.controller.selection.end; + }); + } + + @override + void removeTextPlaceholder() { + if (!widget.scribbleEnabled) return; + + setState(() { + _placeholderLocation = -1; + }); + } + @override String get autofillId => 'EditableText-$hashCode'; - TextInputConfiguration _createTextInputConfiguration( - bool needsAutofillConfiguration) { - assert(needsAutofillConfiguration != null); + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = + widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: currentTextEditingValue, + ) + : AutofillConfiguration.disabled; + return TextInputConfiguration( inputType: widget.keyboardType, readOnly: widget.readOnly, obscureText: widget.obscureText, autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType ?? - (widget.obscureText - ? SmartDashesType.disabled - : SmartDashesType.enabled), - smartQuotesType: widget.smartQuotesType ?? - (widget.obscureText - ? SmartQuotesType.disabled - : SmartQuotesType.enabled), + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, enableSuggestions: widget.enableSuggestions, inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline @@ -2326,24 +2724,16 @@ class ExtendedEditableTextState extends State : TextInputAction.done), textCapitalization: widget.textCapitalization, keyboardAppearance: widget.keyboardAppearance, - autofillConfiguration: !needsAutofillConfiguration - ? null - : AutofillConfiguration( - uniqueIdentifier: autofillId, - autofillHints: - widget.autofillHints?.toList(growable: false) ?? [], - currentEditingValue: currentTextEditingValue, - ), + autofillConfiguration: autofillConfiguration, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, ); } @override - TextInputConfiguration get textInputConfiguration { - return _createTextInputConfiguration(_needsAutofill); - } + void autofill(TextEditingValue value) => updateEditingValue(value); // null if no promptRect should be shown. - TextRange _currentPromptRectRange; + TextRange? _currentPromptRectRange; @override void showAutocorrectionPromptRect(int start, int end) { @@ -2352,123 +2742,274 @@ class ExtendedEditableTextState extends State }); } - VoidCallback _semanticsOnCopy(TextSelectionControls controls) { + VoidCallback? _semanticsOnCopy(TextSelectionControls? controls) { return widget.selectionEnabled && copyEnabled && _hasFocus && controls?.canCopy(this) == true - ? () => controls.handleCopy(this, _clipboardStatus) + ? () => controls!.handleCopy(this, _clipboardStatus) : null; } - VoidCallback _semanticsOnCut(TextSelectionControls controls) { + VoidCallback? _semanticsOnCut(TextSelectionControls? controls) { return widget.selectionEnabled && cutEnabled && _hasFocus && controls?.canCut(this) == true - ? () => controls.handleCut(this) + ? () => controls!.handleCut(this, _clipboardStatus) : null; } - VoidCallback _semanticsOnPaste(TextSelectionControls controls) { + VoidCallback? _semanticsOnPaste(TextSelectionControls? controls) { return widget.selectionEnabled && pasteEnabled && _hasFocus && controls?.canPaste(this) == true && (_clipboardStatus == null || - _clipboardStatus.value == ClipboardStatus.pasteable) - ? () => controls.handlePaste(this) + _clipboardStatus!.value == ClipboardStatus.pasteable) + ? () => controls!.handlePaste(this) : null; } + // --------------------------- Text Editing Actions --------------------------- + + _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary = widget.obscureText + ? _CodeUnitBoundary(_value) + : _CharacterBoundary(_value); + return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward); + } + + _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + if (widget.obscureText) { + atomicTextBoundary = _CodeUnitBoundary(_value); + boundary = _DocumentBoundary(_value); + } else { + final TextEditingValue textEditingValue = + _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + // This isn't enough. Newline characters. + boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), + _WordBoundary(renderEditable, textEditingValue)); + } + + final _MixedBoundary mixedBoundary = intent.forward + ? _MixedBoundary(atomicTextBoundary, boundary) + : _MixedBoundary(boundary, atomicTextBoundary); + // Use a _MixedBoundary to make sure we don't leave invalid codepoints in + // the field after deletion. + return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); + } + + _TextBoundary _linebreak(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + if (widget.obscureText) { + atomicTextBoundary = _CodeUnitBoundary(_value); + boundary = _DocumentBoundary(_value); + } else { + final TextEditingValue textEditingValue = + _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + boundary = _LineBreak(renderEditable, textEditingValue); + } + + // The _MixedBoundary is to make sure we don't leave invalid code units in + // the field after deletion. + // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, + // since the document boundary is unique and the linebreak boundary is + // already caret-location based. + return intent.forward + ? _MixedBoundary( + _CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) + : _MixedBoundary( + boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false)); + } + + _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => + _DocumentBoundary(_value); + + Action _makeOverridable(Action defaultAction) { + return Action.overridable( + context: context, defaultAction: defaultAction); + } + + void _replaceText(ReplaceTextIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue + .replaced(intent.replacementRange, intent.replacementText), + intent.cause, + ); + } + + late final Action _replaceTextAction = + CallbackAction(onInvoke: _replaceText); + + void _updateSelection(UpdateSelectionIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.cause, + ); + } + + late final Action _updateSelectionAction = + CallbackAction(onInvoke: _updateSelection); + + late final _UpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent> _adjacentLineAction = + _UpdateTextSelectionToAdjacentLineAction< + ExtendSelectionVerticallyToAdjacentLineIntent>(this); + + late final Map> _actions = >{ + DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), + ReplaceTextIntent: _replaceTextAction, + UpdateSelectionIntent: _updateSelectionAction, + DirectionalFocusIntent: DirectionalFocusAction.forTextField(), + + // Delete + DeleteCharacterIntent: _makeOverridable( + _DeleteTextAction(this, _characterBoundary)), + DeleteToNextWordBoundaryIntent: _makeOverridable( + _DeleteTextAction( + this, _nextWordBoundary)), + DeleteToLineBreakIntent: _makeOverridable( + _DeleteTextAction(this, _linebreak)), + + // Extend/Move Selection + ExtendSelectionByCharacterIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + false, + _characterBoundary, + )), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _nextWordBoundary)), + ExtendSelectionToLineBreakIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _linebreak)), + ExtendSelectionVerticallyToAdjacentLineIntent: + _makeOverridable(_adjacentLineAction), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, true, _documentBoundary)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + _ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), + + // Copy Paste + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + PasteTextIntent: _makeOverridable(CallbackAction( + onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))), + }; + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); - _focusAttachment.reparent(); super.build(context); // See AutomaticKeepAliveClientMixin. - final TextSelectionControls controls = widget.selectionControls; + final TextSelectionControls? controls = widget.selectionControls; return MouseRegion( cursor: widget.mouseCursor ?? SystemMouseCursors.text, - child: Scrollable( - excludeFromSemantics: true, - axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, - controller: _scrollController, - physics: widget.scrollPhysics, - dragStartBehavior: widget.dragStartBehavior, - restorationId: widget.restorationId, - viewportBuilder: (BuildContext context, ViewportOffset offset) { - if (offset != null && offset is ScrollPosition) { - if (offset.minScrollExtent != null && - offset.maxScrollExtent != null) { - // pixels should >= minScrollExtent - // pixels should <= maxScrollExtent - offset.correctPixels(offset.pixels.clamp( - offset.minScrollExtent, offset.maxScrollExtent) as double); - } - } - - return CompositedTransformTarget( - link: _toolbarLayerLink, - child: Semantics( - onCopy: _semanticsOnCopy(controls), - onCut: _semanticsOnCut(controls), - onPaste: _semanticsOnPaste(controls), - child: _Editable( - key: _editableKey, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - textSpan: _buildTextSpan(), - value: _value, - cursorColor: _cursorColor, - backgroundCursorColor: widget.backgroundCursorColor, - showCursor: ExtendedEditableText.debugDeterministicCursor - ? ValueNotifier(widget.showCursor) - : _cursorVisibilityNotifier, - forceLine: widget.forceLine, - readOnly: widget.readOnly, - hasFocus: _hasFocus, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - strutStyle: widget.strutStyle, - selectionColor: widget.selectionColor, - textScaleFactor: widget.textScaleFactor ?? - MediaQuery.textScaleFactorOf(context), - textAlign: widget.textAlign, - textDirection: _textDirection, - locale: widget.locale, - textHeightBehavior: widget.textHeightBehavior ?? - DefaultTextHeightBehavior.of(context), - textWidthBasis: widget.textWidthBasis, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType, - smartQuotesType: widget.smartQuotesType, - enableSuggestions: widget.enableSuggestions, - offset: offset, - onSelectionChanged: _handleSelectionChanged, - onCaretChanged: _handleCaretChanged, - rendererIgnoresPointer: widget.rendererIgnoresPointer, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: widget.cursorRadius, - cursorOffset: widget.cursorOffset, - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - paintCursorAboveText: widget.paintCursorAboveText, - enableInteractiveSelection: widget.enableInteractiveSelection, - textSelectionDelegate: this, - devicePixelRatio: _devicePixelRatio, - supportSpecialText: supportSpecialText, - promptRectRange: _currentPromptRectRange, - promptRectColor: widget.autocorrectionTextRectColor, - clipBehavior: widget.clipBehavior, - ), - ), - ); - }, + child: Actions( + actions: _actions, + child: Focus( + focusNode: widget.focusNode, + includeSemantics: false, + debugLabel: 'EditableText', + child: Scrollable( + excludeFromSemantics: true, + axisDirection: + _isMultiline ? AxisDirection.down : AxisDirection.right, + controller: _scrollController, + physics: widget.scrollPhysics, + dragStartBehavior: widget.dragStartBehavior, + restorationId: widget.restorationId, + // If a ScrollBehavior is not provided, only apply scrollbars when + // multiline. The overscroll indicator should not be applied in + // either case, glowing or stretching. + scrollBehavior: widget.scrollBehavior ?? + ScrollConfiguration.of(context).copyWith( + scrollbars: _isMultiline, + overscroll: false, + ), + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return CompositedTransformTarget( + link: _toolbarLayerLink, + child: Semantics( + onCopy: _semanticsOnCopy(controls), + onCut: _semanticsOnCut(controls), + onPaste: _semanticsOnPaste(controls), + child: ScribbleFocusable( + focusNode: widget.focusNode, + editableKey: _editableKey, + enabled: widget.scribbleEnabled, + updateSelectionRects: () { + _openInputConnection(); + _updateSelectionRects(force: true); + }, + child: _Editable( + key: _editableKey, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + inlineSpan: _buildTextSpan(), + value: _value, + cursorColor: _cursorColor, + backgroundCursorColor: widget.backgroundCursorColor, + showCursor: EditableText.debugDeterministicCursor + ? ValueNotifier(widget.showCursor) + : _cursorVisibilityNotifier, + forceLine: widget.forceLine, + readOnly: widget.readOnly, + hasFocus: _hasFocus, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + strutStyle: widget.strutStyle, + selectionColor: widget.selectionColor, + textScaleFactor: widget.textScaleFactor ?? + MediaQuery.textScaleFactorOf(context), + textAlign: widget.textAlign, + textDirection: _textDirection, + locale: widget.locale, + textHeightBehavior: widget.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + textWidthBasis: widget.textWidthBasis, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + offset: offset, + onCaretChanged: _handleCaretChanged, + rendererIgnoresPointer: widget.rendererIgnoresPointer, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorOffset: widget.cursorOffset ?? Offset.zero, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + paintCursorAboveText: widget.paintCursorAboveText, + enableInteractiveSelection: + widget.enableInteractiveSelection, + textSelectionDelegate: this, + devicePixelRatio: _devicePixelRatio, + promptRectRange: _currentPromptRectRange, + promptRectColor: widget.autocorrectionTextRectColor, + clipBehavior: widget.clipBehavior, + supportSpecialText: supportSpecialText, + ), + ), + ), + ); + }, + ), + ), ), ); } @@ -2486,7 +3027,7 @@ class ExtendedEditableTextState extends State defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.fuchsia) && !kIsWeb) { - final int o = + final int? o = _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null; if (o != null && o >= 0 && o < text.length) text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1)); @@ -2503,25 +3044,21 @@ class ExtendedEditableTextState extends State final String afterText = _value.composing.textAfter(_value.text); if (supportSpecialText) { - final TextSpan before = widget.specialTextSpanBuilder + final TextSpan before = widget.specialTextSpanBuilder! .build(beforeText, textStyle: widget.style); - final TextSpan after = widget.specialTextSpanBuilder + final TextSpan after = widget.specialTextSpanBuilder! .build(afterText, textStyle: widget.style); final List children = []; - if (before != null) { - children.add(before); - } + children.add(before); children.add(TextSpan( style: composingStyle, text: insideText, )); - if (after != null) { - children.add(after); - } + children.add(after); return TextSpan(style: widget.style, children: children); } @@ -2539,77 +3076,201 @@ class ExtendedEditableTextState extends State //final String text = _value.text; if (supportSpecialText) { - final TextSpan specialTextSpan = widget.specialTextSpanBuilder + final TextSpan? specialTextSpan = widget.specialTextSpanBuilder ?.build(_value.text, textStyle: widget.style); if (specialTextSpan != null) { return specialTextSpan; } } + if (_placeholderLocation >= 0 && + _placeholderLocation <= _value.text.length) { + final List placeholders = []; + final int placeholderLocation = _value.text.length - _placeholderLocation; + if (_isMultiline) { + // The zero size placeholder here allows the line to break and keep the caret on the first line. + placeholders + .add(const ScribblePlaceholder(child: SizedBox(), size: Size.zero)); + placeholders.add(ScribblePlaceholder( + child: const SizedBox(), + size: Size(renderEditable.size.width, 0.0))); + } else { + placeholders.add(const ScribblePlaceholder( + child: SizedBox(), size: Size(100.0, 0.0))); + } + return TextSpan( + style: widget.style, + children: [ + TextSpan(text: _value.text.substring(0, placeholderLocation)), + ...placeholders, + TextSpan(text: _value.text.substring(placeholderLocation)), + ], + ); + } // Read only mode should not paint text composing. return widget.controller.buildTextSpan( + context: context, style: widget.style, - withComposing: !widget.readOnly, + withComposing: !widget.readOnly && _hasFocus, ); //return TextSpan(style: widget.style, text: text); } + + String _cachedText = ''; + Rect? _cachedFirstRect; + Size _cachedSize = Size.zero; + int _cachedPlaceholder = -1; + TextStyle? _cachedTextStyle; + + void _updateSelectionRects({bool force = false}) { + if (!widget.scribbleEnabled) return; + if (defaultTargetPlatform != TargetPlatform.iOS) return; + // This is to avoid sending selection rects on non-iPad devices. + if (WidgetsBinding.instance.window.physicalSize.shortestSide < _kIPadWidth) + return; + + final String text = + renderEditable.text?.toPlainText(includeSemanticsLabels: false) ?? ''; + final List firstSelectionBoxes = + renderEditable.getBoxesForSelectionRects( + const TextSelection(baseOffset: 0, extentOffset: 1)); + final Rect? firstRect = + firstSelectionBoxes.isNotEmpty ? firstSelectionBoxes.first : null; + final ScrollDirection scrollDirection = + _scrollController.position.userScrollDirection; + final Size size = renderEditable.size; + final bool textChanged = text != _cachedText; + final bool textStyleChanged = _cachedTextStyle != widget.style; + final bool firstRectChanged = _cachedFirstRect != firstRect; + final bool sizeChanged = _cachedSize != size; + final bool placeholderChanged = _cachedPlaceholder != _placeholderLocation; + if (scrollDirection == ScrollDirection.idle && + (force || + textChanged || + textStyleChanged || + firstRectChanged || + sizeChanged || + placeholderChanged)) { + _cachedText = text; + _cachedFirstRect = firstRect; + _cachedTextStyle = widget.style; + _cachedSize = size; + _cachedPlaceholder = _placeholderLocation; + bool belowRenderEditableBottom = false; + final List rects = List.generate( + _cachedText.characters.length, + (int i) { + if (belowRenderEditableBottom) return null; + + final int offset = + _cachedText.characters.getRange(0, i).string.length; + final List boxes = renderEditable.getBoxesForSelectionRects( + TextSelection( + baseOffset: offset, + extentOffset: offset + + _cachedText.characters.characterAt(i).string.length)); + if (boxes.isEmpty) return null; + + final SelectionRect selectionRect = SelectionRect( + bounds: boxes.first, + position: offset, + ); + if (renderEditable.paintBounds.bottom < selectionRect.bounds.top) { + belowRenderEditableBottom = true; + return null; + } + return selectionRect; + }, + ) + .where((SelectionRect? selectionRect) { + if (selectionRect == null) return false; + if (renderEditable.paintBounds.right < selectionRect.bounds.left || + selectionRect.bounds.right < renderEditable.paintBounds.left) + return false; + if (renderEditable.paintBounds.bottom < selectionRect.bounds.top || + selectionRect.bounds.bottom < renderEditable.paintBounds.top) + return false; + return true; + }) + .map((SelectionRect? selectionRect) => selectionRect!) + .toList(); + _textInputConnection!.setSelectionRects(rects); + } + } + + @override + void didChangeInputControl( + TextInputControl? oldControl, TextInputControl? newControl) { + if (_hasFocus && _hasInputConnection) { + oldControl?.hide(); + newControl?.show(); + } + } + + @override + void performSelector(String selectorName) { + final Intent? intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final BuildContext? primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } + } } class _Editable extends MultiChildRenderObjectWidget { _Editable({ - Key key, - this.textSpan, - this.value, - this.startHandleLayerLink, - this.endHandleLayerLink, + Key? key, + required this.inlineSpan, + required this.value, + required this.startHandleLayerLink, + required this.endHandleLayerLink, this.cursorColor, this.backgroundCursorColor, - this.showCursor, - this.forceLine, - this.readOnly, + required this.showCursor, + required this.forceLine, + required this.readOnly, this.textHeightBehavior, - this.textWidthBasis, - this.hasFocus, - this.maxLines, + required this.textWidthBasis, + required this.hasFocus, + required this.maxLines, this.minLines, - this.expands, + required this.expands, this.strutStyle, this.selectionColor, - this.textScaleFactor, - this.textAlign, - @required this.textDirection, + required this.textScaleFactor, + required this.textAlign, + required this.textDirection, this.locale, - this.obscuringCharacter, - this.obscureText, - this.autocorrect, - this.smartDashesType, - this.smartQuotesType, - this.enableSuggestions, - this.offset, - this.onSelectionChanged, + required this.obscuringCharacter, + required this.obscureText, + required this.autocorrect, + required this.smartDashesType, + required this.smartQuotesType, + required this.enableSuggestions, + required this.offset, this.onCaretChanged, this.rendererIgnoresPointer = false, - this.cursorWidth, + required this.cursorWidth, this.cursorHeight, this.cursorRadius, - this.cursorOffset, - this.paintCursorAboveText, + required this.cursorOffset, + required this.paintCursorAboveText, this.selectionHeightStyle = ui.BoxHeightStyle.tight, this.selectionWidthStyle = ui.BoxWidthStyle.tight, this.enableInteractiveSelection = true, - this.textSelectionDelegate, - this.devicePixelRatio, - this.supportSpecialText, + required this.textSelectionDelegate, + required this.devicePixelRatio, + this.supportSpecialText = false, this.promptRectRange, this.promptRectColor, - this.clipBehavior, + required this.clipBehavior, }) : assert(textDirection != null), assert(rendererIgnoresPointer != null), - super( - key: key, - children: _extractChildren(textSpan), - ); + super(key: key, children: _extractChildren(inlineSpan)); // Traverses the InlineSpan tree and depth-first collects the list of // child widgets that are created in WidgetSpans. @@ -2625,40 +3286,39 @@ class _Editable extends MultiChildRenderObjectWidget { } final bool supportSpecialText; - final InlineSpan textSpan; + final InlineSpan inlineSpan; final TextEditingValue value; - final Color cursorColor; + final Color? cursorColor; final LayerLink startHandleLayerLink; final LayerLink endHandleLayerLink; - final Color backgroundCursorColor; + final Color? backgroundCursorColor; final ValueNotifier showCursor; final bool forceLine; final bool readOnly; final bool hasFocus; - final int maxLines; - final int minLines; + final int? maxLines; + final int? minLines; final bool expands; - final StrutStyle strutStyle; - final Color selectionColor; + final StrutStyle? strutStyle; + final Color? selectionColor; final double textScaleFactor; final TextAlign textAlign; final TextDirection textDirection; - final Locale locale; + final Locale? locale; final String obscuringCharacter; final bool obscureText; - final TextHeightBehavior textHeightBehavior; + final TextHeightBehavior? textHeightBehavior; final TextWidthBasis textWidthBasis; final bool autocorrect; final SmartDashesType smartDashesType; final SmartQuotesType smartQuotesType; final bool enableSuggestions; final ViewportOffset offset; - final TextSelectionChangedHandler onSelectionChanged; - final CaretChangedHandler onCaretChanged; + final CaretChangedHandler? onCaretChanged; final bool rendererIgnoresPointer; final double cursorWidth; - final double cursorHeight; - final Radius cursorRadius; + final double? cursorHeight; + final Radius? cursorRadius; final Offset cursorOffset; final bool paintCursorAboveText; final ui.BoxHeightStyle selectionHeightStyle; @@ -2666,15 +3326,15 @@ class _Editable extends MultiChildRenderObjectWidget { final bool enableInteractiveSelection; final TextSelectionDelegate textSelectionDelegate; final double devicePixelRatio; - final TextRange promptRectRange; - final Color promptRectColor; + final TextRange? promptRectRange; + final Color? promptRectColor; final Clip clipBehavior; @override ExtendedRenderEditable createRenderObject(BuildContext context) { return ExtendedRenderEditable( supportSpecialText: supportSpecialText, - text: textSpan, + text: inlineSpan, cursorColor: cursorColor, startHandleLayerLink: startHandleLayerLink, endHandleLayerLink: endHandleLayerLink, @@ -2691,10 +3351,9 @@ class _Editable extends MultiChildRenderObjectWidget { textScaleFactor: textScaleFactor, textAlign: textAlign, textDirection: textDirection, - locale: locale ?? Localizations.localeOf(context, nullOk: true), + locale: locale ?? Localizations.maybeLocaleOf(context), selection: value.selection, offset: offset, - onSelectionChanged: onSelectionChanged, onCaretChanged: onCaretChanged, ignorePointer: rendererIgnoresPointer, obscuringCharacter: obscuringCharacter, @@ -2722,7 +3381,7 @@ class _Editable extends MultiChildRenderObjectWidget { BuildContext context, ExtendedRenderEditable renderObject) { renderObject ..supportSpecialText = supportSpecialText - ..text = textSpan + ..text = inlineSpan ..cursorColor = cursorColor ..startHandleLayerLink = startHandleLayerLink ..endHandleLayerLink = endHandleLayerLink @@ -2738,10 +3397,9 @@ class _Editable extends MultiChildRenderObjectWidget { ..textScaleFactor = textScaleFactor ..textAlign = textAlign ..textDirection = textDirection - ..locale = locale ?? Localizations.localeOf(context, nullOk: true) + ..locale = locale ?? Localizations.maybeLocaleOf(context) ..selection = value.selection ..offset = offset - ..onSelectionChanged = onSelectionChanged ..onCaretChanged = onCaretChanged ..ignorePointer = rendererIgnoresPointer ..textHeightBehavior = textHeightBehavior @@ -2763,195 +3421,629 @@ class _Editable extends MultiChildRenderObjectWidget { } } -// This formatter inserts [Unicode.RLM] and [Unicode.LRM] into the -// string in order to preserve expected caret behavior when trailing -// whitespace is inserted. -// -// When typing in a direction that opposes the base direction -// of the paragraph, un-enclosed whitespace gets the directionality -// of the paragraph. This is often at odds with what is immediately -// being typed causing the caret to jump to the wrong side of the text. -// This formatter makes use of the RLM and LRM to cause the text -// shaper to inherently treat the whitespace as being surrounded -// by the directionality of the previous non-whitespace codepoint. -class _WhitespaceDirectionalityFormatter extends TextInputFormatter { - // The [textDirection] should be the base directionality of the - // paragraph/editable. - _WhitespaceDirectionalityFormatter({TextDirection textDirection}) - : _baseDirection = textDirection, - _previousNonWhitespaceDirection = textDirection; - - // Using regex here instead of ICU is suboptimal, but is enough - // to produce the correct results for any reasonable input where this - // is even relevant. Using full ICU would be a much heavier change, - // requiring exposure of the C++ ICU API. - // - // LTR covers most scripts and symbols, including but not limited to Latin, - // ideographic scripts (Chinese, Japanese, etc), Cyrilic, Indic, and - // SE Asian scripts. - final RegExp _ltrRegExp = RegExp( - r'[A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF]'); - // RTL covers Arabic, Hebrew, and other RTL languages such as Urdu, - // Aramic, Farsi, Dhivehi. - final RegExp _rtlRegExp = - RegExp(r'[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]'); - // Although whitespaces are not the only codepoints that have weak directionality, - // these are the primary cause of the caret being misplaced. - final RegExp _whitespaceRegExp = RegExp(r'\s'); - - final TextDirection _baseDirection; - // Tracks the directionality of the most recently encountered - // codepoint that was not whitespace. This becomes the direction of - // marker inserted to fully surround ambiguous whitespace. - TextDirection _previousNonWhitespaceDirection; - - // Prevents the formatter from attempting more expensive formatting - // operations mixed directionality is found. - bool _hasOpposingDirection = false; - - // See [Unicode.RLM] and [Unicode.LRM]. - // - // We do not directly use the [Unicode] constants since they are strings. - static const int _rlm = 0x200F; - static const int _lrm = 0x200E; +/// An interface for retriving the logical text boundary (left-closed-right-open) +/// at a given location in a document. +/// +/// Depending on the implementation of the [_TextBoundary], the input +/// [TextPosition] can either point to a code unit, or a position between 2 code +/// units (which can be visually represented by the caret if the selection were +/// to collapse to that position). +/// +/// For example, [_LineBreak] interprets the input [TextPosition] as a caret +/// location, since in Flutter the caret is generally painted between the +/// character the [TextPosition] points to and its previous character, and +/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most +/// other text boundaries however, interpret the input [TextPosition] as the +/// location of a code unit in the document, since it's easier to reason about +/// the text boundary given a code unit in the text. +/// +/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based", +/// use the [_CollapsedSelectionBoundary] combinator. +abstract class _TextBoundary { + const _TextBoundary(); + + TextEditingValue get textEditingValue; + + /// Returns the leading text boundary at the given location, inclusive. + TextPosition getLeadingTextBoundaryAt(TextPosition position); + + /// Returns the trailing text boundary at the given location, exclusive. + TextPosition getTrailingTextBoundaryAt(TextPosition position); + + TextRange getTextBoundaryAt(TextPosition position) { + return TextRange( + start: getLeadingTextBoundaryAt(position).offset, + end: getTrailingTextBoundaryAt(position).offset, + ); + } +} + +// ----------------------------- Text Boundaries ----------------------------- + +class _CodeUnitBoundary extends _TextBoundary { + const _CodeUnitBoundary(this.textEditingValue); @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - // Skip formatting (which can be more expensive) if there are no cases of - // mixing directionality. Once a case of mixed directionality is found, - // always perform the formatting. - if (!_hasOpposingDirection) { - _hasOpposingDirection = _baseDirection == TextDirection.ltr - ? _rtlRegExp.hasMatch(newValue.text) - : _ltrRegExp.hasMatch(newValue.text); - } + final TextEditingValue textEditingValue; - if (_hasOpposingDirection) { - _previousNonWhitespaceDirection = _baseDirection; + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + TextPosition(offset: position.offset); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => TextPosition( + offset: math.min(position.offset + 1, textEditingValue.text.length)); +} - final List outputCodepoints = []; +// The word modifier generally removes the word boundaries around white spaces +// (and newlines), IOW white spaces and some other punctuations are considered +// a part of the next word in the search direction. +class _WhitespaceBoundary extends _TextBoundary { + const _WhitespaceBoundary(this.textEditingValue); - // We add/subtract from these as we insert/remove markers. - int selectionBase = newValue.selection.baseOffset; - int selectionExtent = newValue.selection.extentOffset; - int composingStart = newValue.composing.start; - int composingEnd = newValue.composing.end; + @override + final TextEditingValue textEditingValue; - void addToLength() { - selectionBase += outputCodepoints.length <= selectionBase ? 1 : 0; - selectionExtent += outputCodepoints.length <= selectionExtent ? 1 : 0; + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + for (int index = position.offset; index >= 0; index -= 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index); + } + } + return const TextPosition(offset: 0); + } - composingStart += outputCodepoints.length <= composingStart ? 1 : 0; - composingEnd += outputCodepoints.length <= composingEnd ? 1 : 0; + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + for (int index = position.offset; + index < textEditingValue.text.length; + index += 1) { + if (!TextLayoutMetrics.isWhitespace( + textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index + 1); } + } + return TextPosition(offset: textEditingValue.text.length); + } +} - void subtractFromLength() { - selectionBase -= outputCodepoints.length < selectionBase ? 1 : 0; - selectionExtent -= outputCodepoints.length < selectionExtent ? 1 : 0; +// Most apps delete the entire grapheme when the backspace key is pressed. +// Also always put the new caret location to character boundaries to avoid +// sending malformed UTF-16 code units to the paragraph builder. +class _CharacterBoundary extends _TextBoundary { + const _CharacterBoundary(this.textEditingValue); - composingStart -= outputCodepoints.length < composingStart ? 1 : 0; - composingEnd -= outputCodepoints.length < composingEnd ? 1 : 0; - } + @override + final TextEditingValue textEditingValue; - final bool isBackspace = - oldValue.text.runes.length - newValue.text.runes.length == 1 && - isDirectionalityMarker(oldValue.text.runes.last) && - oldValue.text.substring(0, oldValue.text.length - 1) == - newValue.text; - - bool previousWasWhitespace = false; - bool previousWasDirectionalityMarker = false; - int previousNonWhitespaceCodepoint; - int index = 0; - for (final int codepoint in newValue.text.runes) { - if (isWhitespace(codepoint)) { - // Only compute the directionality of the non-whitespace - // when the value is needed. - if (!previousWasWhitespace && - previousNonWhitespaceCodepoint != null) { - _previousNonWhitespaceDirection = - getDirection(previousNonWhitespaceCodepoint); - } - // If we already added directionality for this run of whitespace, - // "shift" the marker added to the end of the whitespace run. - if (previousWasWhitespace) { - subtractFromLength(); - outputCodepoints.removeLast(); - } - // Handle trailing whitespace deleting the directionality char instead of the whitespace. - if (isBackspace && index == newValue.text.runes.length - 1) { - // Do not append the whitespace to the outputCodepoints. - subtractFromLength(); - } else { - outputCodepoints.add(codepoint); - addToLength(); - outputCodepoints.add( - _previousNonWhitespaceDirection == TextDirection.rtl - ? _rlm - : _lrm); - } + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + return TextPosition( + offset: + CharacterRange.at(textEditingValue.text, position.offset, endOffset) + .stringBeforeLength, + ); + } - previousWasWhitespace = true; - previousWasDirectionalityMarker = false; - } else if (isDirectionalityMarker(codepoint)) { - // Handle pre-existing directionality markers. Use pre-existing marker - // instead of the one we add. - if (previousWasWhitespace) { - subtractFromLength(); - outputCodepoints.removeLast(); - } - outputCodepoints.add(codepoint); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final CharacterRange range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextPosition( + offset: textEditingValue.text.length - range.stringAfterLength, + ); + } - previousWasWhitespace = false; - previousWasDirectionalityMarker = true; - } else { - // If the whitespace was already enclosed by the same directionality, - // we can remove the artificially added marker. - if (!previousWasDirectionalityMarker && - previousWasWhitespace && - getDirection(codepoint) == _previousNonWhitespaceDirection) { - subtractFromLength(); - outputCodepoints.removeLast(); - } - // Normal character, track its codepoint add it to the string. - previousNonWhitespaceCodepoint = codepoint; - outputCodepoints.add(codepoint); + @override + TextRange getTextBoundaryAt(TextPosition position) { + final int endOffset = + math.min(position.offset + 1, textEditingValue.text.length); + final CharacterRange range = + CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextRange( + start: range.stringBeforeLength, + end: textEditingValue.text.length - range.stringAfterLength, + ); + } +} - previousWasWhitespace = false; - previousWasDirectionalityMarker = false; - } - index++; - } - final String formatted = String.fromCharCodes(outputCodepoints); - return TextEditingValue( - text: formatted, - selection: TextSelection( - baseOffset: selectionBase, - extentOffset: selectionExtent, - affinity: newValue.selection.affinity, - isDirectional: newValue.selection.isDirectional), - composing: TextRange(start: composingStart, end: composingEnd), +// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. +class _WordBoundary extends _TextBoundary { + const _WordBoundary(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).start, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).end, + // Word boundary seems to always report downstream on many platforms. + affinity: + TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } +} + +// The linebreaks of the current text layout. The input [TextPosition]s are +// interpreted as caret locations because [TextPainter.getLineAtOffset] is +// text-affinity-aware. +class _LineBreak extends _TextBoundary { + const _LineBreak(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).start, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + } +} + +// The document boundary is unique and is a constant function of the input +// position. +class _DocumentBoundary extends _TextBoundary { + const _DocumentBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + const TextPosition(offset: 0); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textEditingValue.text.length, + affinity: TextAffinity.upstream, + ); + } +} + +// ------------------------ Text Boundary Combinators ------------------------ + +// Expands the innerTextBoundary with outerTextBoundary. +class _ExpandedTextBoundary extends _TextBoundary { + _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary); + + final _TextBoundary innerTextBoundary; + final _TextBoundary outerTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(innerTextBoundary.textEditingValue == + outerTextBoundary.textEditingValue); + return innerTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getLeadingTextBoundaryAt( + innerTextBoundary.getLeadingTextBoundaryAt(position), + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getTrailingTextBoundaryAt( + innerTextBoundary.getTrailingTextBoundaryAt(position), + ); + } +} + +// Force the innerTextBoundary to interpret the input [TextPosition]s as caret +// locations instead of code unit positions. +// +// The innerTextBoundary must be a [_TextBoundary] that interprets the input +// [TextPosition]s as code unit positions. +class _CollapsedSelectionBoundary extends _TextBoundary { + _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); + + final _TextBoundary innerTextBoundary; + final bool isForward; + + @override + TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getLeadingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getLeadingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getTrailingTextBoundaryAt(position) + : position.offset <= 0 + ? const TextPosition(offset: 0) + : innerTextBoundary.getTrailingTextBoundaryAt( + TextPosition(offset: position.offset - 1)); + } +} + +// A _TextBoundary that creates a [TextRange] where its start is from the +// specified leading text boundary and its end is from the specified trailing +// text boundary. +class _MixedBoundary extends _TextBoundary { + _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); + + final _TextBoundary leadingTextBoundary; + final _TextBoundary trailingTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(leadingTextBoundary.textEditingValue == + trailingTextBoundary.textEditingValue); + return leadingTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => + leadingTextBoundary.getLeadingTextBoundaryAt(position); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => + trailingTextBoundary.getTrailingTextBoundaryAt(position); +} + +// ------------------------------- Text Actions ------------------------------- +class _DeleteTextAction + extends ContextAction { + _DeleteTextAction(this.state, this.getTextBoundariesForIntent); + + final ExtendedEditableTextState state; + final _TextBoundary Function(T intent) getTextBoundariesForIntent; + + TextRange _expandNonCollapsedRange(TextEditingValue value) { + final TextRange selection = value.selection; + assert(selection.isValid); + assert(!selection.isCollapsed); + final _TextBoundary atomicBoundary = state.widget.obscureText + ? _CodeUnitBoundary(value) + : _CharacterBoundary(value); + + return TextRange( + start: atomicBoundary + .getLeadingTextBoundaryAt(TextPosition(offset: selection.start)) + .offset, + end: atomicBoundary + .getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)) + .offset, + ); + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + if (!selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent( + state._value, + '', + _expandNonCollapsedRange(state._value), + SelectionChangedCause.keyboard), ); } - return newValue; + + final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); + if (!textBoundary.textEditingValue.selection.isValid) { + return null; + } + if (!textBoundary.textEditingValue.selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent( + state._value, + '', + _expandNonCollapsedRange(textBoundary.textEditingValue), + SelectionChangedCause.keyboard), + ); + } + + return Actions.invoke( + context!, + ReplaceTextIntent( + textBoundary.textEditingValue, + '', + textBoundary + .getTextBoundaryAt(textBoundary.textEditingValue.selection.base), + SelectionChangedCause.keyboard, + ), + ); } - bool isWhitespace(int value) { - return _whitespaceRegExp.hasMatch(String.fromCharCode(value)); + @override + bool get isActionEnabled => + !state.widget.readOnly && state._value.selection.isValid; +} + +class _UpdateTextSelectionAction + extends ContextAction { + _UpdateTextSelectionAction(this.state, this.ignoreNonCollapsedSelection, + this.getTextBoundariesForIntent); + + final ExtendedEditableTextState state; + final bool ignoreNonCollapsedSelection; + final _TextBoundary Function(T intent) getTextBoundariesForIntent; + + @override + Object? invoke(T intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + final bool collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + // Collapse to the logical start/end. + TextSelection _collapse(TextSelection selection) { + assert(selection.isValid); + assert(!selection.isCollapsed); + return selection.copyWith( + baseOffset: intent.forward ? selection.end : selection.start, + extentOffset: intent.forward ? selection.end : selection.start, + ); + } + + if (!selection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, _collapse(selection), SelectionChangedCause.keyboard), + ); + } + + final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); + final TextSelection textBoundarySelection = + textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + if (!textBoundarySelection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent(state._value, _collapse(textBoundarySelection), + SelectionChangedCause.keyboard), + ); + } + + final TextPosition extent = textBoundarySelection.extent; + final TextPosition newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final TextSelection newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : textBoundarySelection.extendTo(newExtent); + + // If collapseAtReversal is true and would have an effect, collapse it. + if (!selection.isCollapsed && + intent.collapseAtReversal && + (selection.baseOffset < selection.extentOffset != + newSelection.baseOffset < newSelection.extentOffset)) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, + TextSelection.fromPosition(selection.base), + SelectionChangedCause.keyboard, + ), + ); + } + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, + SelectionChangedCause.keyboard), + ); } - bool isDirectionalityMarker(int value) { - return value == _rlm || value == _lrm; + @override + bool get isActionEnabled => state._value.selection.isValid; +} + +class _ExtendSelectionOrCaretPositionAction extends ContextAction< + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent> { + _ExtendSelectionOrCaretPositionAction( + this.state, this.getTextBoundariesForIntent); + + final ExtendedEditableTextState state; + final _TextBoundary Function( + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) + getTextBoundariesForIntent; + + @override + Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, + [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); + final TextSelection textBoundarySelection = + textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + + final TextPosition extent = textBoundarySelection.extent; + final TextPosition newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final TextSelection newSelection = + (newExtent.offset - textBoundarySelection.baseOffset) * + (textBoundarySelection.extentOffset - + textBoundarySelection.baseOffset) < + 0 + ? textBoundarySelection.copyWith( + extentOffset: textBoundarySelection.baseOffset, + affinity: textBoundarySelection.extentOffset > + textBoundarySelection.baseOffset + ? TextAffinity.downstream + : TextAffinity.upstream, + ) + : textBoundarySelection.extendTo(newExtent); + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, + SelectionChangedCause.keyboard), + ); } - TextDirection getDirection(int value) { - // Use the LTR version as short-circuiting will be more efficient since - // there are more LTR codepoints. - return _ltrRegExp.hasMatch(String.fromCharCode(value)) - ? TextDirection.ltr - : TextDirection.rtl; + @override + bool get isActionEnabled => + state.widget.selectionEnabled && state._value.selection.isValid; +} + +class _UpdateTextSelectionToAdjacentLineAction< + T extends DirectionalCaretMovementIntent> extends ContextAction { + _UpdateTextSelectionToAdjacentLineAction(this.state); + + final ExtendedEditableTextState state; + + VerticalCaretMovementRun? _verticalMovementRun; + TextSelection? _runSelection; + + void stopCurrentVerticalRunIfSelectionChanges() { + final TextSelection? runSelection = _runSelection; + if (runSelection == null) { + assert(_verticalMovementRun == null); + return; + } + _runSelection = state._value.selection; + final TextSelection currentSelection = state.widget.controller.selection; + final bool continueCurrentRun = currentSelection.isValid && + currentSelection.isCollapsed && + currentSelection.baseOffset == runSelection.baseOffset && + currentSelection.extentOffset == runSelection.extentOffset; + if (!continueCurrentRun) { + _verticalMovementRun = null; + _runSelection = null; + } + } + + @override + void invoke(T intent, [BuildContext? context]) { + assert(state._value.selection.isValid); + + final bool collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + final TextEditingValue value = state._textEditingValueforTextLayoutMetrics; + if (!value.selection.isValid) { + return; + } + + if (_verticalMovementRun?.isValid == false) { + _verticalMovementRun = null; + _runSelection = null; + } + + final VerticalCaretMovementRun currentRun = _verticalMovementRun ?? + state.renderEditable + .startVerticalCaretMovement(state.renderEditable.selection!.extent); + + final bool shouldMove = + intent.forward ? currentRun.moveNext() : currentRun.movePrevious(); + final TextPosition newExtent = shouldMove + ? currentRun.current + : (intent.forward + ? TextPosition(offset: state._value.text.length) + : const TextPosition(offset: 0)); + final TextSelection newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : value.selection.extendTo(newExtent); + + Actions.invoke( + context!, + UpdateSelectionIntent( + value, newSelection, SelectionChangedCause.keyboard), + ); + if (state._value.selection == newSelection) { + _verticalMovementRun = currentRun; + _runSelection = newSelection; + } } + + @override + bool get isActionEnabled => state._value.selection.isValid; +} + +class _SelectAllAction extends ContextAction { + _SelectAllAction(this.state); + + final ExtendedEditableTextState state; + + @override + Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, + TextSelection(baseOffset: 0, extentOffset: state._value.text.length), + intent.cause, + ), + ); + } + + @override + bool get isActionEnabled => state.widget.selectionEnabled; +} + +class _CopySelectionAction extends ContextAction { + _CopySelectionAction(this.state); + + final ExtendedEditableTextState state; + + @override + void invoke(CopySelectionTextIntent intent, [BuildContext? context]) { + if (intent.collapseSelection) { + state.cutSelection(intent.cause); + } else { + state.copySelection(intent.cause); + } + } + + @override + bool get isActionEnabled => + state._value.selection.isValid && !state._value.selection.isCollapsed; } diff --git a/lib/src/extended_render_editable.dart b/lib/src/extended_render_editable.dart index 90db1da..9092f78 100644 --- a/lib/src/extended_render_editable.dart +++ b/lib/src/extended_render_editable.dart @@ -2,32 +2,179 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: unnecessary_null_comparison, always_put_control_body_on_new_line + +import 'dart:collection'; import 'dart:math' as math; -import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle; +import 'dart:ui' as ui + show + TextBox, + BoxHeightStyle, + BoxWidthStyle, + LineMetrics, + PlaceholderAlignment; import 'package:extended_text_library/extended_text_library.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; const double _kCaretGap = 1.0; // pixels -/// -///Make Ios/Android caret the same height -///https://github.com/fluttercandies/extended_text_field/issues/14 -///https://github.com/fluttercandies/extended_text_field/issues/19 -///https://github.com/fluttercandies/extended_text_field/issues/10 -//const double _kCaretHeightOffset = 2.0; // pixels -const double _kCaretHeightOffset = 0.0; // pixels +const double _kCaretHeightOffset = 2.0; // pixels // The additional size on the x and y axis with which to expand the prototype // cursor to render the floating cursor in pixels. -const Offset _kFloatingCaretSizeIncrease = Offset(0.5, 1.0); +const EdgeInsets _kFloatingCaretSizeIncrease = + EdgeInsets.symmetric(horizontal: 0.5, vertical: 1.0); // The corner radius of the floating cursor in pixels. -const double _kFloatingCaretRadius = 1.0; +const Radius _kFloatingCaretRadius = Radius.circular(1.0); + +/// The consecutive sequence of [TextPosition]s that the caret should move to +/// when the user navigates the paragraph using the upward arrow key or the +/// downward arrow key. +/// +/// {@template flutter.rendering.RenderEditable.verticalArrowKeyMovement} +/// When the user presses the upward arrow key or the downward arrow key, on +/// many platforms (macOS for instance), the caret will move to the previous +/// line or the next line, while maintaining its original horizontal location. +/// When it encounters a shorter line, the caret moves to the closest horizontal +/// location within that line, and restores the original horizontal location +/// when a long enough line is encountered. +/// +/// Additionally, the caret will move to the beginning of the document if the +/// upward arrow key is pressed and the caret is already on the first line. If +/// the downward arrow key is pressed next, the caret will restore its original +/// horizontal location and move to the second line. Similarly the caret moves +/// to the end of the document if the downward arrow key is pressed when it's +/// already on the last line. +/// +/// Consider a left-aligned paragraph: +/// aa| +/// a +/// aaa +/// where the caret was initially placed at the end of the first line. Pressing +/// the downward arrow key once will move the caret to the end of the second +/// line, and twice the arrow key moves to the third line after the second "a" +/// on that line. Pressing the downward arrow key again, the caret will move to +/// the end of the third line (the end of the document). Pressing the upward +/// arrow key in this state will result in the caret moving to the end of the +/// second line. +/// +/// Vertical caret runs are typically interrupted when the layout of the text +/// changes (including when the text itself changes), or when the selection is +/// changed by other input events or programmatically (for example, when the +/// user pressed the left arrow key). +/// {@endtemplate} +/// +/// The [movePrevious] method moves the caret location (which is +/// [VerticalCaretMovementRun.current]) to the previous line, and in case +/// the caret is already on the first line, the method does nothing and returns +/// false. Similarly the [moveNext] method moves the caret to the next line, and +/// returns false if the caret is already on the last line. +/// +/// If the underlying paragraph's layout changes, [isValid] becomes false and +/// the [VerticalCaretMovementRun] must not be used. The [isValid] property must +/// be checked before calling [movePrevious] and [moveNext], or accessing +/// [current]. +class VerticalCaretMovementRun extends BidirectionalIterator { + VerticalCaretMovementRun._( + this._editable, + this._lineMetrics, + this._currentTextPosition, + this._currentLine, + this._currentOffset, + ); + + Offset _currentOffset; + int _currentLine; + TextPosition _currentTextPosition; + + final List _lineMetrics; + final ExtendedRenderEditable _editable; + + bool _isValid = true; + + /// Whether this [VerticalCaretMovementRun] can still continue. + /// + /// A [VerticalCaretMovementRun] run is valid if the underlying text layout + /// hasn't changed. + /// + /// The [current] value and the [movePrevious] and [moveNext] methods must not + /// be accessed when [isValid] is false. + bool get isValid { + if (!_isValid) { + return false; + } + final List newLineMetrics = + _editable._textPainter.computeLineMetrics(); + // Use the implementation detail of the computeLineMetrics method to figure + // out if the current text layout has been invalidated. + if (!identical(newLineMetrics, _lineMetrics)) { + _isValid = false; + } + return _isValid; + } + + final Map> _positionCache = + >{}; + + MapEntry _getTextPositionForLine(int lineNumber) { + assert(isValid); + assert(lineNumber >= 0); + final MapEntry? cachedPosition = + _positionCache[lineNumber]; + if (cachedPosition != null) { + return cachedPosition; + } + assert(lineNumber != _currentLine); + + final Offset newOffset = + Offset(_currentOffset.dx, _lineMetrics[lineNumber].baseline); + final TextPosition closestPosition = + _editable._textPainter.getPositionForOffset(newOffset); + final MapEntry position = + MapEntry(newOffset, closestPosition); + _positionCache[lineNumber] = position; + return position; + } + + @override + TextPosition get current { + assert(isValid); + return _currentTextPosition; + } + + @override + bool moveNext() { + assert(isValid); + if (_currentLine + 1 >= _lineMetrics.length) { + return false; + } + final MapEntry position = + _getTextPositionForLine(_currentLine + 1); + _currentLine += 1; + _currentOffset = position.key; + _currentTextPosition = position.value; + return true; + } + + @override + bool movePrevious() { + assert(isValid); + if (_currentLine <= 0) { + return false; + } + final MapEntry position = + _getTextPositionForLine(_currentLine - 1); + _currentLine -= 1; + _currentOffset = position.key; + _currentTextPosition = position.value; + return true; + } +} /// Displays some text in a scrollable container with a potentially blinking /// cursor and with gesture recognizers. @@ -46,10 +193,6 @@ const double _kFloatingCaretRadius = 1.0; /// If, when the render object paints, the caret is found to have changed /// location, [onCaretChanged] is called. /// -/// The user may interact with the render object by tapping or long-pressing. -/// When the user does so, the selection is updated, and [onSelectionChanged] is -/// called. -/// /// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value /// to actually blink the cursor, and other features not mentioned above are the /// responsibility of higher layers and not handled by this object. @@ -66,53 +209,53 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// the number of lines. By default, it is 1, meaning this is a single-line /// text field. If it is not null, it must be greater than zero. /// - /// The [offset] is required and must not be null. You can use [new + /// The [offset] is required and must not be null. You can use [ /// ViewportOffset.zero] if you have no need for scrolling. ExtendedRenderEditable({ - InlineSpan text, - @required TextDirection textDirection, + required InlineSpan text, + required TextDirection textDirection, TextAlign textAlign = TextAlign.start, - Color cursorColor, - Color backgroundCursorColor, - ValueNotifier showCursor, - bool hasFocus, - @required LayerLink startHandleLayerLink, - @required LayerLink endHandleLayerLink, - int maxLines = 1, - int minLines, + Color? cursorColor, + Color? backgroundCursorColor, + ValueNotifier? showCursor, + bool? hasFocus, + required LayerLink startHandleLayerLink, + required LayerLink endHandleLayerLink, + int? maxLines = 1, + int? minLines, bool expands = false, - StrutStyle strutStyle, - Color selectionColor, + StrutStyle? strutStyle, + Color? selectionColor, double textScaleFactor = 1.0, - TextSelection selection, - @required ViewportOffset offset, - this.onSelectionChanged, + TextSelection? selection, + required ViewportOffset offset, this.onCaretChanged, this.ignorePointer = false, bool readOnly = false, bool forceLine = true, - TextHeightBehavior textHeightBehavior, + TextHeightBehavior? textHeightBehavior, TextWidthBasis textWidthBasis = TextWidthBasis.parent, String obscuringCharacter = '•', bool obscureText = false, - Locale locale, + Locale? locale, double cursorWidth = 1.0, - double cursorHeight, - Radius cursorRadius, + double? cursorHeight, + Radius? cursorRadius, bool paintCursorAboveText = false, - Offset cursorOffset, + Offset cursorOffset = Offset.zero, double devicePixelRatio = 1.0, ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight, ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight, - bool enableInteractiveSelection, - EdgeInsets floatingCursorAddedMargin = - const EdgeInsets.fromLTRB(4, 4, 4, 5), - TextRange promptRectRange, - Color promptRectColor, + bool? enableInteractiveSelection, + this.floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), + TextRange? promptRectRange, + Color? promptRectColor, Clip clipBehavior = Clip.hardEdge, - @required this.textSelectionDelegate, - this.supportSpecialText, - List children, + required this.textSelectionDelegate, + ExtendedRenderEditablePainter? painter, + ExtendedRenderEditablePainter? foregroundPainter, + this.supportSpecialText = false, + List? children, }) : assert(textAlign != null), assert(textDirection != null, 'RenderEditable created without a textDirection.'), @@ -155,43 +298,45 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { strutStyle: strutStyle, textHeightBehavior: textHeightBehavior, textWidthBasis: textWidthBasis, -// supportSpecialText && hasSpecialText(text) ? null : strutStyle, ), - _cursorColor = cursorColor, - _backgroundCursorColor = backgroundCursorColor, _showCursor = showCursor ?? ValueNotifier(false), _maxLines = maxLines, _minLines = minLines, _expands = expands, - _selectionColor = selectionColor, _selection = selection, _offset = offset, _cursorWidth = cursorWidth, _cursorHeight = cursorHeight, - _cursorRadius = cursorRadius, _paintCursorOnTop = paintCursorAboveText, - _cursorOffset = cursorOffset, - _floatingCursorAddedMargin = floatingCursorAddedMargin, _enableInteractiveSelection = enableInteractiveSelection, _devicePixelRatio = devicePixelRatio, - _selectionHeightStyle = selectionHeightStyle, - _selectionWidthStyle = selectionWidthStyle, _startHandleLayerLink = startHandleLayerLink, _endHandleLayerLink = endHandleLayerLink, _obscuringCharacter = obscuringCharacter, _obscureText = obscureText, _readOnly = readOnly, _forceLine = forceLine, - _promptRectRange = promptRectRange, _clipBehavior = clipBehavior { assert(_showCursor != null); assert(!_showCursor.value || cursorColor != null); this.hasFocus = hasFocus ?? false; + _selectionPainter.highlightColor = selectionColor; + _selectionPainter.highlightedRange = selection; + _selectionPainter.selectionHeightStyle = selectionHeightStyle; + _selectionPainter.selectionWidthStyle = selectionWidthStyle; + + _autocorrectHighlightPainter.highlightColor = promptRectColor; + _autocorrectHighlightPainter.highlightedRange = promptRectRange; + + _caretPainter.caretColor = cursorColor; + _caretPainter.cursorRadius = cursorRadius; + _caretPainter.cursorOffset = cursorOffset; + _caretPainter.backgroundCursorColor = backgroundCursorColor; + + _updateForegroundPainter(foregroundPainter); + _updatePainter(painter); addAll(children); extractPlaceholderSpans(text); - if (promptRectColor != null) { - _promptRectPaint.color = promptRectColor; - } } ///whether to support build SpecialText @@ -200,26 +345,156 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override bool get hasSpecialInlineSpanBase => supportSpecialText && super.hasSpecialInlineSpanBase; + @override + void setupParentData(RenderBox child) { + if (child.parentData is! TextParentData) + child.parentData = TextParentData(); + } + + /// Child render objects + _RenderEditableCustomPaint? _foregroundRenderObject; + _RenderEditableCustomPaint? _backgroundRenderObject; - /// Called when the selection changes. - /// - /// If this is null, then selection changes will be ignored. @override - TextSelectionChangedHandler onSelectionChanged; + void dispose() { + _foregroundRenderObject?.dispose(); + _foregroundRenderObject = null; + _backgroundRenderObject?.dispose(); + _backgroundRenderObject = null; + _clipRectLayer.layer = null; + _cachedBuiltInForegroundPainters?.dispose(); + _cachedBuiltInPainters?.dispose(); + super.dispose(); + } + + void _updateForegroundPainter(ExtendedRenderEditablePainter? newPainter) { + final _CompositeRenderEditablePainter effectivePainter = newPainter == null + ? _builtInForegroundPainters + : _CompositeRenderEditablePainter( + painters: [ + _builtInForegroundPainters, + newPainter, + ]); + + if (_foregroundRenderObject == null) { + final _RenderEditableCustomPaint foregroundRenderObject = + _RenderEditableCustomPaint(painter: effectivePainter); + adoptChild(foregroundRenderObject); + _foregroundRenderObject = foregroundRenderObject; + } else { + _foregroundRenderObject?.painter = effectivePainter; + } + _foregroundPainter = newPainter; + } + /// The [ExtendedRenderEditablePainter] to use for painting above this + /// [RenderEditable]'s text content. + /// + /// The new [ExtendedRenderEditablePainter] will replace the previously specified + /// foreground painter, and schedule a repaint if the new painter's + /// `shouldRepaint` method returns true. + ExtendedRenderEditablePainter? get foregroundPainter => _foregroundPainter; + ExtendedRenderEditablePainter? _foregroundPainter; + set foregroundPainter(ExtendedRenderEditablePainter? newPainter) { + if (newPainter == _foregroundPainter) return; + _updateForegroundPainter(newPainter); + } + + void _updatePainter(ExtendedRenderEditablePainter? newPainter) { + final _CompositeRenderEditablePainter effectivePainter = newPainter == null + ? _builtInPainters + : _CompositeRenderEditablePainter( + painters: [ + _builtInPainters, + newPainter + ]); + + if (_backgroundRenderObject == null) { + final _RenderEditableCustomPaint backgroundRenderObject = + _RenderEditableCustomPaint(painter: effectivePainter); + adoptChild(backgroundRenderObject); + _backgroundRenderObject = backgroundRenderObject; + } else { + _backgroundRenderObject?.painter = effectivePainter; + } + _painter = newPainter; + } + + /// Sets the [ExtendedRenderEditablePainter] to use for painting beneath this + /// [RenderEditable]'s text content. + /// + /// The new [ExtendedRenderEditablePainter] will replace the previously specified + /// painter, and schedule a repaint if the new painter's `shouldRepaint` + /// method returns true. + ExtendedRenderEditablePainter? get painter => _painter; + ExtendedRenderEditablePainter? _painter; + set painter(ExtendedRenderEditablePainter? newPainter) { + if (newPainter == _painter) return; + _updatePainter(newPainter); + } + + // Caret Painters: + // The floating painter. This painter paints the regular caret as well. + late final _FloatingCursorPainter _caretPainter = + _FloatingCursorPainter(_onCaretChanged); + + // Text Highlight painters: + final TextHighlightPainter _selectionPainter = TextHighlightPainter(); + final TextHighlightPainter _autocorrectHighlightPainter = + TextHighlightPainter(); + + _CompositeRenderEditablePainter get _builtInForegroundPainters => + _cachedBuiltInForegroundPainters ??= _createBuiltInForegroundPainters(); + _CompositeRenderEditablePainter? _cachedBuiltInForegroundPainters; + _CompositeRenderEditablePainter _createBuiltInForegroundPainters() { + return _CompositeRenderEditablePainter( + painters: [ + if (paintCursorAboveText) _caretPainter, + ], + ); + } + + _CompositeRenderEditablePainter get _builtInPainters => + _cachedBuiltInPainters ??= _createBuiltInPainters(); + _CompositeRenderEditablePainter? _cachedBuiltInPainters; + _CompositeRenderEditablePainter _createBuiltInPainters() { + return _CompositeRenderEditablePainter( + painters: [ + _autocorrectHighlightPainter, + _selectionPainter, + if (!paintCursorAboveText) _caretPainter, + ], + ); + } + + Rect? _lastCaretRect; + // TODO(LongCatIsLooong): currently EditableText uses this callback to keep + // the text field visible. But we don't always paint the caret, for example + // when the selection is not collapsed. /// Called during the paint phase when the caret location changes. - CaretChangedHandler onCaretChanged; + CaretChangedHandler? onCaretChanged; + void _onCaretChanged(Rect caretRect) { + if (_lastCaretRect != caretRect) onCaretChanged?.call(caretRect); + _lastCaretRect = onCaretChanged == null ? null : caretRect; + } - /// If true [handleEvent] does nothing and it's assumed that this - /// renderer will be notified of input gestures via [handleTapDown], - /// [handleTap], [handleDoubleTap], and [handleLongPress]. + /// Whether the [handleEvent] will propagate pointer events to selection + /// handlers. + /// + /// If this property is true, the [handleEvent] assumes that this renderer + /// will be notified of input gestures via [handleTapDown], [handleTap], + /// [handleDoubleTap], and [handleLongPress]. + /// + /// If there are any gesture recognizers in the text span, the [handleEvent] + /// will still propagate pointer events to those recognizers. /// /// The default value of this property is false. + @override bool ignorePointer; /// {@macro flutter.dart:ui.textHeightBehavior} - TextHeightBehavior get textHeightBehavior => _textPainter.textHeightBehavior; - set textHeightBehavior(TextHeightBehavior value) { + TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior; + set textHeightBehavior(TextHeightBehavior? value) { if (_textPainter.textHeightBehavior == value) { return; } @@ -230,7 +505,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// {@macro flutter.widgets.text.DefaultTextStyle.textWidthBasis} TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; set textWidthBasis(TextWidthBasis value) { - assert(value != null); if (_textPainter.textWidthBasis == value) { return; } @@ -244,9 +518,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { double get devicePixelRatio => _devicePixelRatio; double _devicePixelRatio; set devicePixelRatio(double value) { - if (devicePixelRatio == value) { - return; - } + if (devicePixelRatio == value) return; _devicePixelRatio = value; markNeedsTextLayout(); } @@ -270,13 +542,31 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { bool get obscureText => _obscureText; bool _obscureText; set obscureText(bool value) { - if (_obscureText == value) { - return; - } + if (_obscureText == value) return; _obscureText = value; markNeedsSemanticsUpdate(); } + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + @override + ui.BoxHeightStyle get selectionHeightStyle => + _selectionPainter.selectionHeightStyle; + set selectionHeightStyle(ui.BoxHeightStyle value) { + _selectionPainter.selectionHeightStyle = value; + } + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + @override + ui.BoxWidthStyle get selectionWidthStyle => + _selectionPainter.selectionWidthStyle; + set selectionWidthStyle(ui.BoxWidthStyle value) { + _selectionPainter.selectionWidthStyle = value; + } + /// The object that controls the text selection, used by this render object /// for implementing cut, copy, and paste keyboard shortcuts. /// @@ -285,8 +575,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override TextSelectionDelegate textSelectionDelegate; - Rect _lastCaretRect; - /// Track whether position of the start of the selected text is within the viewport. /// /// For example, if the text contains "Hello World", and the user selects @@ -335,8 +623,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { effectiveOffset: effectiveOffset, caretPrototype: _caretPrototype, ); - - // (justinmc): https://github.com/flutter/flutter/issues/31495 // Check if the selection is visible with an approximation because a // difference between rounded and unrounded values causes the caret to be // reported as having a slightly (< 0.5) negative y offset. This rounding @@ -358,66 +644,36 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { visibleRegion.inflate(visibleRegionSlop).contains(endOffset); } - ///some times _visibleRegionMinY will lower than 0.0; - ///that the _selectionStartInViewport and _selectionEndInViewport will not right. - /// - //final double _visibleRegionMinY = -_kCaretHeightOffset; - -// ///zmt -// void _updateVisibleRegionMinY() { -// // if (textSelectionDelegate.textEditingValue == null || -// // textSelectionDelegate.textEditingValue.text == null || -// // textSelectionDelegate.textEditingValue.selection == null || -// // _textPainter.text == null) return; -// // List boxs = _textPainter.getBoxesForSelection( -// // textSelectionDelegate.textEditingValue.selection.copyWith( -// // baseOffset: 0, -// // extentOffset: _textPainter.text.toPlainText().length)); -// // boxs.forEach((f) { -// // _visibleRegionMinY = math.min(f.top, _visibleRegionMinY); -// // }); -// } - - // Call through to onSelectionChanged. - void _handleSelectionChange( - TextSelection nextSelection, - SelectionChangedCause cause, - ) { - // Changes made by the keyboard can sometimes be "out of band" for listening - // components, so always send those events, even if we didn't think it - // changed. Also, focusing an empty field is sent as a selection change even - // if the selection offset didn't change. - final bool focusingEmpty = nextSelection.baseOffset == 0 && - nextSelection.extentOffset == 0 && - !hasFocus; - if (nextSelection == selection && - cause != SelectionChangedCause.keyboard && - !focusingEmpty) { - return; - } - if (onSelectionChanged != null) { - onSelectionChanged(nextSelection, cause); - } + @override + void markNeedsPaint() { + super.markNeedsPaint(); + // Tell the painers to repaint since text layout may have changed. + _foregroundRenderObject?.markNeedsPaint(); + _backgroundRenderObject?.markNeedsPaint(); } // Retuns a cached plain text version of the text in the painter. - String _cachedPlainText; + String? _cachedPlainText; @override String get plainText { - _cachedPlainText ??= textSpanToActualText(_textPainter.text); - return _cachedPlainText; + _cachedPlainText ??= textSpanToActualText(_textPainter.text!); + return _cachedPlainText!; } /// The text to display. @override - InlineSpan get text => _textPainter.text; + InlineSpan? get text => _textPainter.text; final TextPainter _textPainter; - set text(InlineSpan value) { + AttributedString? _cachedAttributedValue; + List? _cachedCombinedSemanticsInfos; + set text(InlineSpan? value) { if (_textPainter.text == value) { return; } _textPainter.text = value; _cachedPlainText = null; + _cachedAttributedValue = null; + _cachedCombinedSemanticsInfos = null; extractPlaceholderSpans(value); markNeedsTextLayout(); markNeedsSemanticsUpdate(); @@ -429,9 +685,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { TextAlign get textAlign => _textPainter.textAlign; set textAlign(TextAlign value) { assert(value != null); - if (_textPainter.textAlign == value) { - return; - } + if (_textPainter.textAlign == value) return; _textPainter.textAlign = value; markNeedsTextLayout(); } @@ -449,13 +703,14 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// its left. /// /// This must not be null. + // TextPainter.textDirection is nullable, but it is set to a + // non-null value in the RenderEditable constructor and we refuse to + // set it to null here, so _textPainter.textDirection cannot be null. @override - TextDirection get textDirection => _textPainter.textDirection; + TextDirection get textDirection => _textPainter.textDirection!; set textDirection(TextDirection value) { assert(value != null); - if (_textPainter.textDirection == value) { - return; - } + if (_textPainter.textDirection == value) return; _textPainter.textDirection = value; markNeedsTextLayout(); markNeedsSemanticsUpdate(); @@ -471,49 +726,35 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// /// If this value is null, a system-dependent algorithm is used to select /// the font. - Locale get locale => _textPainter.locale; - set locale(Locale value) { - if (_textPainter.locale == value) { - return; - } + Locale? get locale => _textPainter.locale; + set locale(Locale? value) { + if (_textPainter.locale == value) return; _textPainter.locale = value; markNeedsTextLayout(); } /// The [StrutStyle] used by the renderer's internal [TextPainter] to /// determine the strut to use. - StrutStyle get strutStyle => _textPainter.strutStyle; - set strutStyle(StrutStyle value) { - if (_textPainter.strutStyle == value) { - return; - } + StrutStyle? get strutStyle => _textPainter.strutStyle; + set strutStyle(StrutStyle? value) { + if (_textPainter.strutStyle == value) return; _textPainter.strutStyle = value; markNeedsTextLayout(); } /// The color to use when painting the cursor. - Color get cursorColor => _cursorColor; - Color _cursorColor; - set cursorColor(Color value) { - if (_cursorColor == value) { - return; - } - _cursorColor = value; - markNeedsPaint(); + Color? get cursorColor => _caretPainter.caretColor; + set cursorColor(Color? value) { + _caretPainter.caretColor = value; } /// The color to use when painting the cursor aligned to the text while /// rendering the floating cursor. /// /// The default is light grey. - Color get backgroundCursorColor => _backgroundCursorColor; - Color _backgroundCursorColor; - set backgroundCursorColor(Color value) { - if (backgroundCursorColor == value) { - return; - } - _backgroundCursorColor = value; - markNeedsPaint(); + Color? get backgroundCursorColor => _caretPainter.backgroundCursorColor; + set backgroundCursorColor(Color? value) { + _caretPainter.backgroundCursorColor = value; } /// Whether to paint the cursor. @@ -521,17 +762,17 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { ValueNotifier _showCursor; set showCursor(ValueNotifier value) { assert(value != null); - if (_showCursor == value) { - return; - } - if (attached) { - _showCursor.removeListener(markNeedsPaint); - } + if (_showCursor == value) return; + if (attached) _showCursor.removeListener(_showHideCursor); _showCursor = value; if (attached) { - _showCursor.addListener(markNeedsPaint); + _showHideCursor(); + _showCursor.addListener(_showHideCursor); } - markNeedsPaint(); + } + + void _showHideCursor() { + _caretPainter.shouldPaint = showCursor.value; } /// Whether this rendering object will take a full line regardless the text width. @@ -540,9 +781,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { bool _forceLine = false; set forceLine(bool value) { assert(value != null); - if (_forceLine == value) { - return; - } + if (_forceLine == value) return; _forceLine = value; markNeedsLayout(); } @@ -553,9 +792,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { bool _readOnly = false; set readOnly(bool value) { assert(value != null); - if (_readOnly == value) { - return; - } + if (_readOnly == value) return; _readOnly = value; markNeedsSemanticsUpdate(); } @@ -570,29 +807,25 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// When this is not null, the intrinsic height of the render object is the /// height of one line of text multiplied by this value. In other words, this /// also controls the height of the actual editing widget. - int get maxLines => _maxLines; - int _maxLines; + int? get maxLines => _maxLines; + int? _maxLines; /// The value may be null. If it is not null, then it must be greater than zero. - set maxLines(int value) { + set maxLines(int? value) { assert(value == null || value > 0); - if (maxLines == value) { - return; - } + if (maxLines == value) return; _maxLines = value; markNeedsTextLayout(); } /// {@macro flutter.widgets.editableText.minLines} - int get minLines => _minLines; - int _minLines; + int? get minLines => _minLines; + int? _minLines; /// The value may be null. If it is not null, then it must be greater than zero. - set minLines(int value) { + set minLines(int? value) { assert(value == null || value > 0); - if (minLines == value) { - return; - } + if (minLines == value) return; _minLines = value; markNeedsTextLayout(); } @@ -602,51 +835,33 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { bool _expands; set expands(bool value) { assert(value != null); - if (expands == value) { - return; - } + if (expands == value) return; _expands = value; markNeedsTextLayout(); } /// The color to use when painting the selection. @override - Color get selectionColor => _selectionColor; - Color _selectionColor; - set selectionColor(Color value) { - if (_selectionColor == value) { - return; - } - _selectionColor = value; - markNeedsPaint(); - } - - /// The number of font pixels for each logical pixel. - /// - /// For example, if the text scale factor is 1.5, text will be 50% larger than - /// the specified font size. - double get textScaleFactor => _textPainter.textScaleFactor; - set textScaleFactor(double value) { - assert(value != null); - if (_textPainter.textScaleFactor == value) { - return; - } - _textPainter.textScaleFactor = value; - markNeedsTextLayout(); + Color? get selectionColor => _selectionPainter.highlightColor; + @override + set selectionColor(Color? value) { + _selectionPainter.highlightColor = value; } - List _selectionRects; - /// The region of text that is selected, if any. + /// + /// The caret position is represented by a collapsed selection. + /// + /// If [selection] is null, there is no selection and attempts to + /// manipulate the selection will throw. @override - TextSelection get selection => _selection; - TextSelection _selection; - set selection(TextSelection value) { - if (_selection == value) { - return; - } + TextSelection? get selection => _selection; + TextSelection? _selection; + @override + set selection(TextSelection? value) { + if (_selection == value) return; _selection = value; - _selectionRects = null; + _selectionPainter.highlightedRange = getActualSelection(); markNeedsPaint(); markNeedsSemanticsUpdate(); } @@ -660,16 +875,10 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { ViewportOffset _offset; set offset(ViewportOffset value) { assert(value != null); - if (_offset == value) { - return; - } - if (attached) { - _offset.removeListener(markNeedsPaint); - } + if (_offset == value) return; + if (attached) _offset.removeListener(markNeedsPaint); _offset = value; - if (attached) { - _offset.addListener(markNeedsPaint); - } + if (attached) _offset.addListener(markNeedsPaint); markNeedsLayout(); } @@ -677,9 +886,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { double get cursorWidth => _cursorWidth; double _cursorWidth = 1.0; set cursorWidth(double value) { - if (_cursorWidth == value) { - return; - } + if (_cursorWidth == value) return; _cursorWidth = value; markNeedsLayout(); } @@ -689,19 +896,17 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// This can be null, in which case the getter will actually return [preferredLineHeight]. /// /// Setting this to itself fixes the value to the current [preferredLineHeight]. Setting - /// this to null returns the behaviour of deferring to [preferredLineHeight]. + /// this to null returns the behavior of deferring to [preferredLineHeight]. // TODO(ianh): This is a confusing API. We should have a separate getter for the effective cursor height. double get cursorHeight => _cursorHeight ?? preferredLineHeight; - double _cursorHeight; - set cursorHeight(double value) { - if (_cursorHeight == value) { - return; - } + double? _cursorHeight; + set cursorHeight(double? value) { + if (_cursorHeight == value) return; _cursorHeight = value; markNeedsLayout(); } - /// {@template flutter.rendering.editable.paintCursorOnTop} + /// {@template flutter.rendering.RenderEditable.paintCursorAboveText} /// If the cursor should be painted on top of the text or underneath it. /// /// By default, the cursor should be painted on top for iOS platforms and @@ -710,14 +915,17 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { bool get paintCursorAboveText => _paintCursorOnTop; bool _paintCursorOnTop; set paintCursorAboveText(bool value) { - if (_paintCursorOnTop == value) { - return; - } + if (_paintCursorOnTop == value) return; _paintCursorOnTop = value; - markNeedsLayout(); + // Clear cached built-in painters and reconfigure painters. + _cachedBuiltInForegroundPainters = null; + _cachedBuiltInPainters = null; + // Call update methods to rebuild and set the effective painters. + _updateForegroundPainter(_foregroundPainter); + _updatePainter(_painter); } - /// {@template flutter.rendering.editable.cursorOffset} + /// {@template flutter.rendering.RenderEditable.cursorOffset} /// The offset that is used, in pixels, when painting the cursor on screen. /// /// By default, the cursor position should be set to an offset of @@ -725,25 +933,17 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// platforms. The origin from where the offset is applied to is the arbitrary /// location where the cursor ends up being rendered from by default. /// {@endtemplate} - Offset get cursorOffset => _cursorOffset; - Offset _cursorOffset; + Offset get cursorOffset => _caretPainter.cursorOffset; set cursorOffset(Offset value) { - if (_cursorOffset == value) { - return; - } - _cursorOffset = value; - markNeedsLayout(); + _caretPainter.cursorOffset = value; } /// How rounded the corners of the cursor should be. - Radius get cursorRadius => _cursorRadius; - Radius _cursorRadius; - set cursorRadius(Radius value) { - if (_cursorRadius == value) { - return; - } - _cursorRadius = value; - markNeedsPaint(); + /// + /// A null value is the same as [Radius.zero]. + Radius? get cursorRadius => _caretPainter.cursorRadius; + set cursorRadius(Radius? value) { + _caretPainter.cursorRadius = value; } /// The [LayerLink] of start selection handle. @@ -753,11 +953,10 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override LayerLink get startHandleLayerLink => _startHandleLayerLink; LayerLink _startHandleLayerLink; - set startHandleLayerLink(LayerLink value) { - if (_startHandleLayerLink == value) { - return; - } - _startHandleLayerLink = value; + @override + set startHandleLayerLink(LayerLink? value) { + if (_startHandleLayerLink == value) return; + _startHandleLayerLink = value!; markNeedsPaint(); } @@ -768,11 +967,10 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override LayerLink get endHandleLayerLink => _endHandleLayerLink; LayerLink _endHandleLayerLink; - set endHandleLayerLink(LayerLink value) { - if (_endHandleLayerLink == value) { - return; - } - _endHandleLayerLink = value; + @override + set endHandleLayerLink(LayerLink? value) { + if (_endHandleLayerLink == value) return; + _endHandleLayerLink = value!; markNeedsPaint(); } @@ -780,75 +978,53 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// moving the floating cursor. /// /// Defaults to a padding with left, top and right set to 4, bottom to 5. - EdgeInsets get floatingCursorAddedMargin => _floatingCursorAddedMargin; - EdgeInsets _floatingCursorAddedMargin; - set floatingCursorAddedMargin(EdgeInsets value) { - if (_floatingCursorAddedMargin == value) { - return; - } - _floatingCursorAddedMargin = value; - markNeedsPaint(); - } + EdgeInsets floatingCursorAddedMargin; bool _floatingCursorOn = false; - Offset _floatingCursorOffset; - TextPosition _floatingCursorTextPosition; - - /// Controls how tall the selection highlight boxes are computed to be. - /// - /// See [ui.BoxHeightStyle] for details on available styles. - ui.BoxHeightStyle get selectionHeightStyle => _selectionHeightStyle; - ui.BoxHeightStyle _selectionHeightStyle; - set selectionHeightStyle(ui.BoxHeightStyle value) { - assert(value != null); - if (_selectionHeightStyle == value) { - return; - } - _selectionHeightStyle = value; - markNeedsPaint(); - } + late TextPosition _floatingCursorTextPosition; - /// Controls how wide the selection highlight boxes are computed to be. + /// Whether to allow the user to change the selection. /// - /// See [ui.BoxWidthStyle] for details on available styles. - ui.BoxWidthStyle get selectionWidthStyle => _selectionWidthStyle; - ui.BoxWidthStyle _selectionWidthStyle; - set selectionWidthStyle(ui.BoxWidthStyle value) { - assert(value != null); - if (_selectionWidthStyle == value) { - return; - } - _selectionWidthStyle = value; - markNeedsPaint(); - } - - /// If false, [describeSemanticsConfiguration] will not set the - /// configuration's cursor motion or set selection callbacks. + /// Since [RenderEditable] does not handle selection manipulation + /// itself, this actually only affects whether the accessibility + /// hints provided to the system (via + /// [describeSemanticsConfiguration]) will enable selection + /// manipulation. It's the responsibility of this object's owner + /// to provide selection manipulation affordances. /// - /// True by default. - bool get enableInteractiveSelection => _enableInteractiveSelection; - bool _enableInteractiveSelection; - set enableInteractiveSelection(bool value) { - if (_enableInteractiveSelection == value) { - return; - } + /// This field is used by [selectionEnabled] (which then controls + /// the accessibility hints mentioned above). When null, + /// [obscureText] is used to determine the value of + /// [selectionEnabled] instead. + bool? get enableInteractiveSelection => _enableInteractiveSelection; + bool? _enableInteractiveSelection; + set enableInteractiveSelection(bool? value) { + if (_enableInteractiveSelection == value) return; _enableInteractiveSelection = value; markNeedsTextLayout(); markNeedsSemanticsUpdate(); } - /// {@template flutter.rendering.editable.selectionEnabled} - /// True if interactive selection is enabled based on the values of + /// Whether interactive selection are enabled based on the values of /// [enableInteractiveSelection] and [obscureText]. /// - /// By default [enableInteractiveSelection] is null, obscureText is false, - /// and this method returns true. - /// If [enableInteractiveSelection] is null and obscureText is true, then this - /// method returns false. This is the common case for password fields. - /// If [enableInteractiveSelection] is non-null then its value is returned. An - /// app might set it to true to enable interactive selection for a password - /// field, or to false to unconditionally disable interactive selection. - /// {@endtemplate} + /// Since [RenderEditable] does not handle selection manipulation + /// itself, this actually only affects whether the accessibility + /// hints provided to the system (via + /// [describeSemanticsConfiguration]) will enable selection + /// manipulation. It's the responsibility of this object's owner + /// to provide selection manipulation affordances. + /// + /// By default, [enableInteractiveSelection] is null, [obscureText] is false, + /// and this getter returns true. + /// + /// If [enableInteractiveSelection] is null and [obscureText] is true, then this + /// getter returns false. This is the common case for password fields. + /// + /// If [enableInteractiveSelection] is non-null then its value is + /// returned. An application might [enableInteractiveSelection] to + /// true to enable interactive selection for a password field, or to + /// false to unconditionally disable interactive selection. bool get selectionEnabled { return enableInteractiveSelection ?? !obscureText; } @@ -856,38 +1032,24 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// The color used to paint the prompt rectangle. /// /// The prompt rectangle will only be requested on non-web iOS applications. - Color get promptRectColor => _promptRectPaint.color; - set promptRectColor(Color newValue) { - // Painter.color can not be null. - if (newValue == null) { - setPromptRectRange(null); - return; - } - - if (promptRectColor == newValue) { - return; - } - - _promptRectPaint.color = newValue; - if (_promptRectRange != null) { - markNeedsPaint(); - } + // TODO(ianh): We should change the getter to return null when _promptRectRange is null + // (otherwise, if you set it to null and then get it, you get back non-null). + // Alternatively, we could stop supporting setting this to null. + Color? get promptRectColor => _autocorrectHighlightPainter.highlightColor; + set promptRectColor(Color? newValue) { + _autocorrectHighlightPainter.highlightColor = newValue; } - TextRange _promptRectRange; - /// Dismisses the currently displayed prompt rectangle and displays a new prompt rectangle /// over [newRange] in the given color [promptRectColor]. /// /// The prompt rectangle will only be requested on non-web iOS applications. /// /// When set to null, the currently displayed prompt rectangle (if any) will be dismissed. - void setPromptRectRange(TextRange newRange) { - // ignore: always_put_control_body_on_new_line - if (_promptRectRange == newRange) return; - - _promptRectRange = newRange; - markNeedsPaint(); + // ignore: use_setters_to_change_properties, (API predates enforcing the lint) + void setPromptRectRange(TextRange? newRange) { + _autocorrectHighlightPainter.highlightedRange = + getActualSelection(newRange: newRange); } /// The maximum amount the text is allowed to scroll. @@ -900,7 +1062,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { double get _caretMargin => _kCaretGap + cursorWidth; - /// {@macro flutter.widgets.Clip} + /// {@macro flutter.material.Material.clipBehavior} /// /// Defaults to [Clip.hardEdge], and must not be null. Clip get clipBehavior => _clipBehavior; @@ -914,12 +1076,65 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { } } + /// Collected during [describeSemanticsConfiguration], used by + /// [assembleSemanticsNode] and [_combineSemanticsInfo]. + List? _semanticsInfo; + + // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they + // can be re-used when [assembleSemanticsNode] is called again. This ensures + // stable ids for the [SemanticsNode]s of [TextSpan]s across + // [assembleSemanticsNode] invocations. + Queue? _cachedChildNodes; + @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); - + _semanticsInfo = _textPainter.text!.getSemanticsInformation(); + // TODO(chunhtai): the macOS does not provide a public API to support text + // selections across multiple semantics nodes. Remove this platform check + // once we can support it. + // https://github.com/flutter/flutter/issues/77957 + if (_semanticsInfo!.any( + (InlineSpanSemanticsInformation info) => info.recognizer != null) && + defaultTargetPlatform != TargetPlatform.macOS) { + // TODO(zmtzawqlp): error on ios simulator + //assert(readOnly && !obscureText); + // For Selectable rich text with recognizer, we need to create a semantics + // node for each text fragment. + config + ..isSemanticBoundary = true + ..explicitChildNodes = true; + return; + } + if (_cachedAttributedValue == null) { + if (obscureText) { + _cachedAttributedValue = + AttributedString(obscuringCharacter * plainText.length); + } else { + final StringBuffer buffer = StringBuffer(); + int offset = 0; + final List attributes = []; + for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { + final String label = info.semanticsLabel ?? info.text; + for (final StringAttribute infoAttribute in info.stringAttributes) { + final TextRange originalRange = infoAttribute.range; + attributes.add( + infoAttribute.copy( + range: TextRange( + start: offset + originalRange.start, + end: offset + originalRange.end), + ), + ); + } + buffer.write(label); + offset += label.length; + } + _cachedAttributedValue = + AttributedString(buffer.toString(), attributes: attributes); + } + } config - ..value = obscureText ? obscuringCharacter * plainText.length : plainText + ..attributedValue = _cachedAttributedValue! ..isObscured = obscureText ..isMultiline = _isMultiline ..textDirection = textDirection @@ -928,17 +1143,19 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { ..isReadOnly = readOnly; if (hasFocus && selectionEnabled) - config.onSetSelection = _handleSetSelection; + config.onSetSelection = handleSetSelection; - if (selectionEnabled && _selection?.isValid == true) { - config.textSelection = _selection; - if (_textPainter.getOffsetBefore(_selection.extentOffset) != null) { + if (hasFocus && !readOnly) config.onSetText = _handleSetText; + + if (selectionEnabled && selection?.isValid == true) { + config.textSelection = selection; + if (_textPainter.getOffsetBefore(selection!.extentOffset) != null) { config ..onMoveCursorBackwardByWord = _handleMoveCursorBackwardByWord ..onMoveCursorBackwardByCharacter = _handleMoveCursorBackwardByCharacter; } - if (_textPainter.getOffsetAfter(_selection.extentOffset) != null) { + if (_textPainter.getOffsetAfter(selection!.extentOffset) != null) { config ..onMoveCursorForwardByWord = _handleMoveCursorForwardByWord ..onMoveCursorForwardByCharacter = @@ -947,51 +1164,163 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { } } - void _handleSetSelection(TextSelection selection) { - _handleSelectionChange(selection, SelectionChangedCause.keyboard); + void _handleSetText(String text) { + textSelectionDelegate.userUpdateTextEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + SelectionChangedCause.keyboard, + ); } - void _handleMoveCursorForwardByCharacter(bool extentSelection) { - final int extentOffset = - _textPainter.getOffsetAfter(_selection.extentOffset); - if (extentOffset == null) { - return; + @override + void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, + Iterable children) { + assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); + final List newChildren = []; + TextDirection currentDirection = textDirection; + Rect currentRect; + double ordinal = 0.0; + int start = 0; + int placeholderIndex = 0; + int childIndex = 0; + RenderBox? child = firstChild; + final Queue newChildCache = Queue(); + _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); + for (final InlineSpanSemanticsInformation info + in _cachedCombinedSemanticsInfos!) { + final TextSelection selection = TextSelection( + baseOffset: start, + extentOffset: start + info.text.length, + ); + start += info.text.length; + + if (info.isPlaceholder) { + // A placeholder span may have 0 to multiple semantics nodes, we need + // to annotate all of the semantics nodes belong to this span. + while (children.length > childIndex && + children + .elementAt(childIndex) + .isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { + final SemanticsNode childNode = children.elementAt(childIndex); + final TextParentData parentData = + child!.parentData! as TextParentData; + assert(parentData.scale != null); + childNode.rect = Rect.fromLTWH( + childNode.rect.left, + childNode.rect.top, + childNode.rect.width * parentData.scale!, + childNode.rect.height * parentData.scale!, + ); + newChildren.add(childNode); + childIndex += 1; + } + child = childAfter(child!); + placeholderIndex += 1; + } else { + final TextDirection initialDirection = currentDirection; + final List rects = + _textPainter.getBoxesForSelection(selection); + if (rects.isEmpty) { + continue; + } + Rect rect = rects.first.toRect(); + currentDirection = rects.first.direction; + for (final ui.TextBox textBox in rects.skip(1)) { + rect = rect.expandToInclude(textBox.toRect()); + currentDirection = textBox.direction; + } + // Any of the text boxes may have had infinite dimensions. + // We shouldn't pass infinite dimensions up to the bridges. + rect = Rect.fromLTWH( + math.max(0.0, rect.left), + math.max(0.0, rect.top), + math.min(rect.width, constraints.maxWidth), + math.min(rect.height, constraints.maxHeight), + ); + // Round the current rectangle to make this API testable and add some + // padding so that the accessibility rects do not overlap with the text. + currentRect = Rect.fromLTRB( + rect.left.floorToDouble() - 4.0, + rect.top.floorToDouble() - 4.0, + rect.right.ceilToDouble() + 4.0, + rect.bottom.ceilToDouble() + 4.0, + ); + final SemanticsConfiguration configuration = SemanticsConfiguration() + ..sortKey = OrdinalSortKey(ordinal++) + ..textDirection = initialDirection + ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, + attributes: info.stringAttributes); + final GestureRecognizer? recognizer = info.recognizer; + if (recognizer != null) { + if (recognizer is TapGestureRecognizer) { + if (recognizer.onTap != null) { + configuration.onTap = recognizer.onTap; + configuration.isLink = true; + } + } else if (recognizer is DoubleTapGestureRecognizer) { + if (recognizer.onDoubleTap != null) { + configuration.onTap = recognizer.onDoubleTap; + configuration.isLink = true; + } + } else if (recognizer is LongPressGestureRecognizer) { + if (recognizer.onLongPress != null) { + configuration.onLongPress = recognizer.onLongPress; + } + } else { + assert(false, '${recognizer.runtimeType} is not supported.'); + } + } + final SemanticsNode newChild = (_cachedChildNodes?.isNotEmpty == true) + ? _cachedChildNodes!.removeFirst() + : SemanticsNode(); + newChild + ..updateWith(config: configuration) + ..rect = currentRect; + newChildCache.addLast(newChild); + newChildren.add(newChild); + } } + _cachedChildNodes = newChildCache; + node.updateWith(config: config, childrenInInversePaintOrder: newChildren); + } + + void _handleMoveCursorForwardByCharacter(bool extentSelection) { + assert(selection != null); + final int? extentOffset = + _textPainter.getOffsetAfter(selection!.extentOffset); + if (extentOffset == null) return; final int baseOffset = - !extentSelection ? extentOffset : _selection.baseOffset; - _handleSelectionChange( + !extentSelection ? extentOffset : selection!.baseOffset; + setSelection( TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard, ); } void _handleMoveCursorBackwardByCharacter(bool extentSelection) { - final int extentOffset = - _textPainter.getOffsetBefore(_selection.extentOffset); - if (extentOffset == null) { - return; - } + assert(selection != null); + final int? extentOffset = + _textPainter.getOffsetBefore(selection!.extentOffset); + if (extentOffset == null) return; final int baseOffset = - !extentSelection ? extentOffset : _selection.baseOffset; - _handleSelectionChange( + !extentSelection ? extentOffset : selection!.baseOffset; + setSelection( TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard, ); } void _handleMoveCursorForwardByWord(bool extentSelection) { + assert(selection != null); final TextRange currentWord = - _textPainter.getWordBoundary(_selection.extent); - if (currentWord == null) { - return; - } - final TextRange nextWord = _getNextWord(currentWord.end); - if (nextWord == null) { - return; - } + _textPainter.getWordBoundary(selection!.extent); + final TextRange? nextWord = _getNextWord(currentWord.end); + if (nextWord == null) return; final int baseOffset = - extentSelection ? _selection.baseOffset : nextWord.start; - _handleSelectionChange( + extentSelection ? selection!.baseOffset : nextWord.start; + setSelection( TextSelection( baseOffset: baseOffset, extentOffset: nextWord.start, @@ -1001,18 +1330,14 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { } void _handleMoveCursorBackwardByWord(bool extentSelection) { + assert(selection != null); final TextRange currentWord = - _textPainter.getWordBoundary(_selection.extent); - if (currentWord == null) { - return; - } - final TextRange previousWord = _getPreviousWord(currentWord.start - 1); - if (previousWord == null) { - return; - } + _textPainter.getWordBoundary(selection!.extent); + final TextRange? previousWord = _getPreviousWord(currentWord.start - 1); + if (previousWord == null) return; final int baseOffset = - extentSelection ? _selection.baseOffset : previousWord.start; - _handleSelectionChange( + extentSelection ? selection!.baseOffset : previousWord.start; + setSelection( TextSelection( baseOffset: baseOffset, extentOffset: previousWord.start, @@ -1021,30 +1346,22 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { ); } - TextRange _getNextWord(int offset) { + TextRange? _getNextWord(int offset) { while (true) { final TextRange range = _textPainter.getWordBoundary(TextPosition(offset: offset)); - if (range == null || !range.isValid || range.isCollapsed) { - return null; - } - if (!_onlyWhitespace(range)) { - return range; - } + if (range == null || !range.isValid || range.isCollapsed) return null; + if (!_onlyWhitespace(range)) return range; offset = range.end; } } - TextRange _getPreviousWord(int offset) { + TextRange? _getPreviousWord(int offset) { while (offset >= 0) { final TextRange range = _textPainter.getWordBoundary(TextPosition(offset: offset)); - if (range == null || !range.isValid || range.isCollapsed) { - return null; - } - if (!_onlyWhitespace(range)) { - return range; - } + if (range == null || !range.isValid || range.isCollapsed) return null; + if (!_onlyWhitespace(range)) return range; offset = range.start - 1; } return null; @@ -1055,11 +1372,11 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { // // Includes newline characters from ASCII and separators from the // [unicode separator category](https://www.compart.com/en/unicode/category/Zs) - // TODO(jonahwilliams): replace when we expose this ICU information. + // TODO(zanderso): replace when we expose this ICU information. bool _onlyWhitespace(TextRange range) { for (int i = range.start; i < range.end; i++) { - final int codeUnit = text.codeUnitAt(i); - if (!isWhitespace(codeUnit)) { + final int codeUnit = text!.codeUnitAt(i)!; + if (!TextLayoutMetrics.isWhitespace(codeUnit)) { return false; } } @@ -1069,13 +1386,16 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override void attach(PipelineOwner owner) { super.attach(owner); - // _tap = TapGestureRecognizer(debugOwner: this) - // ..onTapDown = _handleTapDown - // ..onTap = _handleTap; - // _longPress = LongPressGestureRecognizer(debugOwner: this) - // ..onLongPress = _handleLongPress; + _foregroundRenderObject?.attach(owner); + _backgroundRenderObject?.attach(owner); + + //_tap = TapGestureRecognizer(debugOwner: this) + // ..onTapDown = _handleTapDown + // ..onTap = _handleTap; + //_longPress = LongPressGestureRecognizer(debugOwner: this)..onLongPress = _handleLongPress; _offset.addListener(markNeedsPaint); - _showCursor.addListener(markNeedsPaint); + _showHideCursor(); + _showCursor.addListener(_showHideCursor); } @override @@ -1083,8 +1403,28 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { // _tap.dispose(); // _longPress.dispose(); _offset.removeListener(markNeedsPaint); - _showCursor.removeListener(markNeedsPaint); + _showCursor.removeListener(_showHideCursor); super.detach(); + _foregroundRenderObject?.detach(); + _backgroundRenderObject?.detach(); + } + + @override + void redepthChildren() { + final RenderObject? foregroundChild = _foregroundRenderObject; + final RenderObject? backgroundChild = _backgroundRenderObject; + if (foregroundChild != null) redepthChild(foregroundChild); + if (backgroundChild != null) redepthChild(backgroundChild); + super.redepthChildren(); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + final RenderObject? foregroundChild = _foregroundRenderObject; + final RenderObject? backgroundChild = _backgroundRenderObject; + if (foregroundChild != null) visitor(foregroundChild); + if (backgroundChild != null) visitor(backgroundChild); + super.visitChildren(visitor); } bool get _isMultiline => maxLines != 1; @@ -1099,7 +1439,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { case Axis.vertical: return Offset(0.0, -offset.pixels); } - return null; } double get _viewportExtent { @@ -1110,7 +1449,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { case Axis.vertical: return size.height; } - return null; } double _getMaxScrollExtent(Size contentSize) { @@ -1121,7 +1459,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { case Axis.vertical: return math.max(0.0, contentSize.height - size.height); } - return null; } // We need to check the paint offset here because during animation, the start of @@ -1143,9 +1480,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// a [TextPosition] rather than a [TextSelection]. @override List getEndpointsForSelection(TextSelection selection) { - assert(constraints != null); - layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); - + computeTextMetricsIfNeeded(); //final Offset paintOffset = _paintOffset; ///zmt final Offset effectiveOffset = _effectiveOffset; @@ -1153,12 +1488,12 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { TextSelection textPainterSelection = selection; if (hasSpecialInlineSpanBase) { textPainterSelection = - convertTextInputSelectionToTextPainterSelection(text, selection); + convertTextInputSelectionToTextPainterSelection(text!, selection); } if (selection.isCollapsed) { // todo(mpcomplete): This doesn't work well at an RTL/LTR boundary. - double caretHeight; + double? caretHeight; final ValueChanged caretHeightCallBack = (double value) { caretHeight = value; }; @@ -1177,8 +1512,11 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { return [TextSelectionPoint(start, null)]; } else { - final List boxes = - _textPainter.getBoxesForSelection(textPainterSelection); + final List boxes = _textPainter.getBoxesForSelection( + textPainterSelection, + boxWidthStyle: selectionWidthStyle, + boxHeightStyle: selectionHeightStyle, + ); final Offset start = Offset(boxes.first.start, boxes.first.bottom) + effectiveOffset; final Offset end = @@ -1200,7 +1538,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// for a [TextPainter] object. @override TextPosition getPositionForPoint(Offset globalPosition) { - layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + computeTextMetricsIfNeeded(); globalPosition += -paintOffset; return _textPainter.getPositionForOffset(globalToLocal(globalPosition)); } @@ -1217,15 +1555,22 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { /// * [TextPainter.getOffsetForCaret], the equivalent method for a /// [TextPainter] object. Rect getLocalRectForCaret(TextPosition caretPosition) { - layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); - final Offset caretOffset = - _textPainter.getOffsetForCaret(caretPosition, _caretPrototype); + computeTextMetricsIfNeeded(); + // final Offset caretOffset = + // _textPainter.getOffsetForCaret(caretPosition, _caretPrototype); + + final Offset caretOffset = getCaretOffset( + caretPosition, + caretPrototype: _caretPrototype, + // effectiveOffset: effectiveOffset, + ); + // This rect is the same as _caretPrototype but without the vertical padding. Rect rect = Rect.fromLTWH(0.0, 0.0, cursorWidth, preferredLineHeight) .shift(caretOffset + paintOffset); // Add additional cursor offset (generally only if on iOS). - if (_cursorOffset != null) { - rect = rect.shift(_cursorOffset); + if (cursorOffset != null) { + rect = rect.shift(cursorOffset); } return rect.shift(_getPixelPerfectCursorOffset(rect)); @@ -1237,35 +1582,28 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { double get preferredLineHeight => _textPainter.preferredLineHeight; double _preferredHeight(double width) { - final bool singleLine = maxLines == 1; - - // issue: #67,#76 - if (singleLine) { - //preferredLineHeight is not right for WidgetSpan. - return _textPainter.size.height; - } - // Lock height to maxLines if needed + // Lock height to maxLines if needed. final bool lockedMax = maxLines != null && minLines == null; final bool lockedBoth = minLines != null && minLines == maxLines; - - if (lockedMax || lockedBoth) { - return preferredLineHeight * maxLines; + final bool singleLine = maxLines == 1; + if (singleLine || lockedMax || lockedBoth) { + return preferredLineHeight * maxLines!; } - // Clamp height to minLines or maxLines if needed - final bool minLimited = minLines != null && minLines > 1; + // Clamp height to minLines or maxLines if needed. + final bool minLimited = minLines != null && minLines! > 1; final bool maxLimited = maxLines != null; if (minLimited || maxLimited) { layoutText(maxWidth: width); - if (minLimited && _textPainter.height < preferredLineHeight * minLines) { - return preferredLineHeight * minLines; + if (minLimited && _textPainter.height < preferredLineHeight * minLines!) { + return preferredLineHeight * minLines!; } - if (maxLimited && _textPainter.height > preferredLineHeight * maxLines) { - return preferredLineHeight * maxLines; + if (maxLimited && _textPainter.height > preferredLineHeight * maxLines!) { + return preferredLineHeight * maxLines!; } } - // Set the height based on the content + // Set the height based on the content. if (width == double.infinity) { final String text = plainText; int lines = 1; @@ -1281,31 +1619,23 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override double computeDistanceToActualBaseline(TextBaseline baseline) { - layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + computeTextMetricsIfNeeded(); return _textPainter.computeDistanceToActualBaseline(baseline); } @override bool hitTestSelf(Offset position) => true; - TapGestureRecognizer _tap; - LongPressGestureRecognizer _longPress; + late TapGestureRecognizer _tap; + late LongPressGestureRecognizer _longPress; @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); if (event is PointerDownEvent) { assert(!debugNeedsLayout); - // Checks if there is any gesture recognizer in the text span. - final Offset offset = entry.localPosition; - final TextPosition position = _textPainter.getPositionForOffset(offset); - final InlineSpan span = _textPainter.text.getSpanForPosition(position); - if (span != null && span is TextSpan) { - final TextSpan textSpan = span; - textSpan.recognizer?.addPointer(event); - } - if (!ignorePointer && onSelectionChanged != null) { + if (!ignorePointer) { // Propagates the pointer event to selection handlers. _tap.addPointer(event); _longPress.addPointer(event); @@ -1313,26 +1643,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { } } - // void _handleTapDown(TapDownDetails details) { - // assert(!ignorePointer); - // handleTapDown(details); - // } - - /// If [ignorePointer] is false (the default) then this method is called by - /// the internal gesture recognizer's [TapGestureRecognizer.onTap] - /// callback. - /// - /// When [ignorePointer] is true, an ancestor widget must respond to tap - /// events by calling this method. - void handleTap() { - selectPosition(cause: SelectionChangedCause.tap); - } - - // void _handleTap() { - // assert(!ignorePointer); - // handleTap(); - // } - /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap] /// callback. @@ -1343,24 +1653,9 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { selectWord(cause: SelectionChangedCause.doubleTap); } - /// If [ignorePointer] is false (the default) then this method is called by - /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress] - /// callback. - /// - /// When [ignorePointer] is true, an ancestor widget must respond to long - /// press events by calling this method. - void handleLongPress() { - selectWord(cause: SelectionChangedCause.longPress); - } - - // void _handleLongPress() { - // assert(!ignorePointer); - // handleLongPress(); - // } + late Rect _caretPrototype; - Rect _caretPrototype; - - // todo(garyq): This is no longer producing the highest-fidelity caret + // TODO(garyq): This is no longer producing the highest-fidelity caret // heights for Android, especially when non-alphabetic languages // are involved. The current implementation overrides the height set // here with the full measured height of the text on Android which looks @@ -1389,6 +1684,60 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { } } + // Computes the offset to apply to the given [sourceOffset] so it perfectly + // snaps to physical pixels. + Offset _snapToPhysicalPixel(Offset sourceOffset) { + final Offset globalOffset = localToGlobal(sourceOffset); + final double pixelMultiple = 1.0 / _devicePixelRatio; + return Offset( + globalOffset.dx.isFinite + ? (globalOffset.dx / pixelMultiple).round() * pixelMultiple - + globalOffset.dx + : 0, + globalOffset.dy.isFinite + ? (globalOffset.dy / pixelMultiple).round() * pixelMultiple - + globalOffset.dy + : 0, + ); + } + + bool _canComputeDryLayout() { + // Dry layout cannot be calculated without a full layout for + // alignments that require the baseline (baseline, aboveBaseline, + // belowBaseline). + for (final PlaceholderSpan span in placeholderSpans) { + switch (span.alignment) { + case ui.PlaceholderAlignment.baseline: + case ui.PlaceholderAlignment.aboveBaseline: + case ui.PlaceholderAlignment.belowBaseline: + return false; + case ui.PlaceholderAlignment.top: + case ui.PlaceholderAlignment.middle: + case ui.PlaceholderAlignment.bottom: + continue; + } + } + return true; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (!_canComputeDryLayout()) { + assert(debugCannotComputeDryLayout( + reason: + 'Dry layout not available for alignments that require baseline.', + )); + return Size.zero; + } + layoutChildren(constraints, dry: true); + computeTextMetricsIfNeeded(); + final double width = forceLine + ? constraints.maxWidth + : constraints.constrainWidth(_textPainter.size.width + _caretMargin); + return Size(width, + constraints.constrainHeight(_preferredHeight(constraints.maxWidth))); + } + @override void performLayout() { layoutChildren(constraints); @@ -1398,7 +1747,6 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { forceLayout: true); setParentData(); _computeCaretPrototype(); - _selectionRects = null; // We grab _textPainter.size here because assigning to `size` on the next // line will trigger us to validate our intrinsic sizes, which will change // _textPainter's layout because the intrinsic size calculations are @@ -1415,6 +1763,12 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { constraints.constrainHeight(_preferredHeight(constraints.maxWidth))); final Size contentSize = Size(textPainterSize.width + _caretMargin, textPainterSize.height); + + final BoxConstraints painterConstraints = BoxConstraints.tight(contentSize); + + _foregroundRenderObject?.layout(painterConstraints); + _backgroundRenderObject?.layout(painterConstraints); + _maxScrollExtent = _getMaxScrollExtent(contentSize); offset.applyViewportDimension(_viewportExtent); offset.applyContentDimensions(0.0, _maxScrollExtent); @@ -1432,101 +1786,16 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY); } - void _paintCaret(Canvas canvas, Offset effectiveOffset, - TextPosition textPosition, TextPosition textInputPosition) { - assert( - textLayoutLastMaxWidth == constraints.maxWidth && - textLayoutLastMinWidth == constraints.minWidth, - 'Last width ($textLayoutLastMinWidth, $textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); - - // If the floating cursor is enabled, the text cursor's color is [backgroundCursorColor] while - // the floating cursor's color is _cursorColor; - final Paint paint = Paint() - ..color = _floatingCursorOn ? backgroundCursorColor : _cursorColor; - - double caretHeight; - final ValueChanged caretHeightCallBack = (double value) { - caretHeight = value; - }; - final Offset caretOffset = getCaretOffset( - textPosition, - caretHeightCallBack: caretHeightCallBack, - effectiveOffset: effectiveOffset, - caretPrototype: _caretPrototype, - ); - - Rect caretRect = _caretPrototype.shift(caretOffset); - if (_cursorOffset != null) { - caretRect = caretRect.shift(_cursorOffset); - } - - final double fullHeight = - _textPainter.getFullHeightForCaret(textPosition, _caretPrototype) ?? - caretHeight; - if (fullHeight != null) { - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - { -// final double heightDiff = fullHeight - caretRect.height; -// // Center the caret vertically along the text. -// caretRect = Rect.fromLTWH( -// caretRect.left, -// caretRect.top + heightDiff / 2, -// caretRect.width, -// caretRect.height, -// ); - caretRect = Rect.fromLTWH( - caretRect.left, - caretRect.top, - caretRect.width, - fullHeight, - ); - break; - } - default: - { - // Override the height to take the full height of the glyph at the TextPosition - // when not on iOS. iOS has special handling that creates a taller caret. - // todo(garyq): See the todo for _getCaretPrototype. - caretRect = Rect.fromLTWH( - caretRect.left, - caretRect.top - _kCaretHeightOffset, - caretRect.width, - fullHeight, - ); - break; - } - } - } - - caretRect = caretRect.shift(_getPixelPerfectCursorOffset(caretRect)); - - if (cursorRadius == null) { - canvas.drawRect(caretRect, paint); - } else { - final RRect caretRRect = RRect.fromRectAndRadius(caretRect, cursorRadius); - canvas.drawRRect(caretRRect, paint); - } - - if (caretRect != _lastCaretRect) { - _lastCaretRect = caretRect; - if (onCaretChanged != null) { - onCaretChanged(caretRect); - } - } - } - /// Sets the screen position of the floating cursor and the text position /// closest to the cursor. void setFloatingCursor(FloatingCursorDragState state, Offset boundedOffset, TextPosition lastTextPosition, - {double resetLerpValue}) { + {double? resetLerpValue}) { assert(state != null); assert(boundedOffset != null); assert(lastTextPosition != null); - if (state == FloatingCursorDragState.Start) { - _relativeOrigin = const Offset(0, 0); + _relativeOrigin = Offset.zero; _previousOffset = null; _resetOriginOnBottom = false; _resetOriginOnTop = false; @@ -1536,59 +1805,34 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { _floatingCursorOn = state != FloatingCursorDragState.End; _resetFloatingCursorAnimationValue = resetLerpValue; if (_floatingCursorOn) { - _floatingCursorOffset = boundedOffset; _floatingCursorTextPosition = lastTextPosition; + final double? animationValue = _resetFloatingCursorAnimationValue; + final EdgeInsets sizeAdjustment = animationValue != null + ? EdgeInsets.lerp( + _kFloatingCaretSizeIncrease, EdgeInsets.zero, animationValue)! + : _kFloatingCaretSizeIncrease; + _caretPainter.floatingCursorRect = + sizeAdjustment.inflateRect(_caretPrototype).shift(boundedOffset); + } else { + _caretPainter.floatingCursorRect = null; } - markNeedsPaint(); - } - - void _paintFloatingCaret(Canvas canvas, Offset effectiveOffset) { - assert( - textLayoutLastMaxWidth == constraints.maxWidth && - textLayoutLastMinWidth == constraints.minWidth, - 'Last width ($textLayoutLastMinWidth, $textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); - assert(_floatingCursorOn); - - // We always want the floating cursor to render at full opacity. - final Paint paint = Paint()..color = _cursorColor.withOpacity(0.75); - double sizeAdjustmentX = _kFloatingCaretSizeIncrease.dx; - double sizeAdjustmentY = _kFloatingCaretSizeIncrease.dy; - - if (_resetFloatingCursorAnimationValue != null) { - sizeAdjustmentX = - ui.lerpDouble(sizeAdjustmentX, 0, _resetFloatingCursorAnimationValue); - sizeAdjustmentY = - ui.lerpDouble(sizeAdjustmentY, 0, _resetFloatingCursorAnimationValue); - } - - final Rect floatingCaretPrototype = Rect.fromLTRB( - _caretPrototype.left - sizeAdjustmentX, - _caretPrototype.top - sizeAdjustmentY, - _caretPrototype.right + sizeAdjustmentX, - _caretPrototype.bottom + sizeAdjustmentY, - ); - - final Rect caretRect = floatingCaretPrototype.shift(effectiveOffset); - const Radius floatingCursorRadius = Radius.circular(_kFloatingCaretRadius); - final RRect caretRRect = - RRect.fromRectAndRadius(caretRect, floatingCursorRadius); - canvas.drawRRect(caretRRect, paint); + _caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null; } // The relative origin in relation to the distance the user has theoretically // dragged the floating cursor offscreen. This value is used to account for the // difference in the rendering position and the raw offset value. - Offset _relativeOrigin = const Offset(0, 0); - Offset _previousOffset; + Offset _relativeOrigin = Offset.zero; + Offset? _previousOffset; bool _resetOriginOnLeft = false; bool _resetOriginOnRight = false; bool _resetOriginOnTop = false; bool _resetOriginOnBottom = false; - double _resetFloatingCursorAnimationValue; + double? _resetFloatingCursorAnimationValue; /// Returns the position within the text field closest to the raw cursor offset. Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset) { - Offset deltaPosition = const Offset(0, 0); + Offset deltaPosition = Offset.zero; final double topBound = -floatingCursorAddedMargin.top; final double bottomBound = _textPainter.height - preferredLineHeight + @@ -1598,7 +1842,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { _textPainter.width + floatingCursorAddedMargin.right; if (_previousOffset != null) - deltaPosition = rawCursorOffset - _previousOffset; + deltaPosition = rawCursorOffset - _previousOffset!; // If the raw cursor offset has gone off an edge, we want to reset the relative // origin of the dragging when the user drags back into the field. @@ -1643,22 +1887,56 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { return adjustedOffset; } - final Paint _promptRectPaint = Paint(); - void _paintPromptRectIfNeeded(Canvas canvas, Offset effectiveOffset) { - if (_promptRectRange == null || promptRectColor == null) { - return; + MapEntry _lineNumberFor( + TextPosition startPosition, List metrics) { + // TODO(LongCatIsLooong): include line boundaries information in + // ui.LineMetrics, then we can get rid of this. + final Offset offset = + _textPainter.getOffsetForCaret(startPosition, Rect.zero); + for (final ui.LineMetrics lineMetrics in metrics) { + if (lineMetrics.baseline + lineMetrics.descent > offset.dy) { + return MapEntry( + lineMetrics.lineNumber, Offset(offset.dx, lineMetrics.baseline)); + } } - - final List boxes = _textPainter.getBoxesForSelection( - TextSelection( - baseOffset: _promptRectRange.start, - extentOffset: _promptRectRange.end, - ), + assert(startPosition.offset == 0, + 'unable to find the line for $startPosition'); + return MapEntry( + math.max(0, metrics.length - 1), + Offset( + offset.dx, + metrics.isNotEmpty + ? metrics.last.baseline + metrics.last.descent + : 0.0), ); + } - for (final TextBox box in boxes) { - canvas.drawRect(box.toRect().shift(effectiveOffset), _promptRectPaint); - } + /// Starts a [VerticalCaretMovementRun] at the given location in the text, for + /// handling consecutive vertical caret movements. + /// + /// This can be used to handle consecutive upward/downward arrow key movements + /// in an input field. + /// + /// {@macro flutter.rendering.RenderEditable.verticalArrowKeyMovement} + /// + /// The [VerticalCaretMovementRun.isValid] property indicates whether the text + /// layout has changed and the vertical caret run is invalidated. + /// + /// The caller should typically discard a [VerticalCaretMovementRun] when + /// its [VerticalCaretMovementRun.isValid] becomes false, or on other + /// occasions where the vertical caret run should be interrupted. + VerticalCaretMovementRun startVerticalCaretMovement( + TextPosition startPosition) { + final List metrics = _textPainter.computeLineMetrics(); + final MapEntry currentLine = + _lineNumberFor(startPosition, metrics); + return VerticalCaretMovementRun._( + this, + metrics, + startPosition, + currentLine.key, + currentLine.value, + ); } void _paintContents(PaintingContext context, Offset offset) { @@ -1668,62 +1946,49 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { 'Last width ($textLayoutLastMinWidth, $textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); final Offset effectiveOffset = offset + paintOffset; - bool showSelection = false; - bool showCaret = false; + // bool showSelection = false; + // bool showCaret = false; ///zmt - final TextSelection actualSelection = hasSpecialInlineSpanBase - ? convertTextInputSelectionToTextPainterSelection(text, _selection) - : _selection; - - if (actualSelection != null && !_floatingCursorOn) { - if (actualSelection.isCollapsed && - _showCursor.value && - cursorColor != null) - showCaret = true; - else if (!actualSelection.isCollapsed && _selectionColor != null) - showSelection = true; - _updateSelectionExtentsVisibility(effectiveOffset, actualSelection); - } - if (showSelection) { - _selectionRects ??= _textPainter.getBoxesForSelection(actualSelection, - boxHeightStyle: _selectionHeightStyle, - boxWidthStyle: _selectionWidthStyle); - paintSelection(context.canvas, effectiveOffset); - } - + //final TextSelection? actualSelection = getActualSelection(); + + if (_autocorrectHighlightPainter.highlightedRange != null && + !_floatingCursorOn) { + _updateSelectionExtentsVisibility(effectiveOffset, + _autocorrectHighlightPainter.highlightedRange as TextSelection); + // if (actualSelection.isCollapsed && + // _showCursor.value && + // cursorColor != null) + // showCaret = true; + // else if (!actualSelection.isCollapsed && _selectionColor != null) + // showSelection = true; + } + // if (showSelection) { + // _selectionRects ??= _textPainter.getBoxesForSelection( + // actualSelection!, + // boxWidthStyle: selectionWidthStyle, + // boxHeightStyle: selectionHeightStyle, + // ); + // paintSelection(context.canvas, effectiveOffset); + // } + + final RenderBox? foregroundChild = _foregroundRenderObject; + final RenderBox? backgroundChild = _backgroundRenderObject; + + // The painters paint in the viewport's coordinate space, since the + // textPainter's coordinate space is not known to high level widgets. + if (backgroundChild != null) context.paintChild(backgroundChild, offset); + + _textPainter.paint(context.canvas, effectiveOffset); paintWidgets(context, effectiveOffset); ///zmt _paintSpecialText(context, effectiveOffset); - _paintPromptRectIfNeeded(context.canvas, effectiveOffset); - // On iOS, the cursor is painted over the text, on Android, it's painted - // under it. - if (paintCursorAboveText) - _textPainter.paint(context.canvas, effectiveOffset); - - if (showCaret) - _paintCaret(context.canvas, effectiveOffset, actualSelection.extent, - _selection.extent); - - if (!paintCursorAboveText) - _textPainter.paint(context.canvas, effectiveOffset); - - if (_floatingCursorOn) { - if (_resetFloatingCursorAnimationValue == null) { - _paintCaret( - context.canvas, - effectiveOffset, - convertTextInputPostionToTextPainterPostion( - text, _floatingCursorTextPosition), - _floatingCursorTextPosition); - } - _paintFloatingCaret(context.canvas, _floatingCursorOffset); - } + if (foregroundChild != null) context.paintChild(foregroundChild, offset); } - Offset _initialOffset; + Offset? _initialOffset; Offset get _effectiveOffset => (_initialOffset ?? Offset.zero) + paintOffset; @override @@ -1731,12 +1996,20 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { ///zmt _initialOffset = offset; - layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + computeTextMetricsIfNeeded(); if (_hasVisualOverflow) context.pushClipRect( - needsCompositing, offset, Offset.zero & size, _paintContents); - else + needsCompositing, + offset, + Offset.zero & size, + _paintContents, + clipBehavior: clipBehavior, + oldLayer: _clipRectLayer.layer, + ); + else { + _clipRectLayer.layer = null; _paintContents(context, offset); + } paintHandleLayers(context, super.paint); } @@ -1755,32 +2028,31 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { ///we have move the canvas, so rect top left should be (0,0) final Rect rect = const Offset(0.0, 0.0) & size; - _paintSpecialTextChildren([text], canvas, rect); + _paintSpecialTextChildren([text], canvas, rect); canvas.restore(); } void _paintSpecialTextChildren( - List textSpans, Canvas canvas, Rect rect, + List? textSpans, Canvas canvas, Rect rect, {int textOffset = 0}) { if (textSpans == null) { return; } - for (final InlineSpan ts in textSpans) { + for (final InlineSpan? ts in textSpans) { final Offset topLeftOffset = getOffsetForCaret( TextPosition(offset: textOffset), rect, ); //skip invalid or overflow - if (topLeftOffset == null || - (textOffset != 0 && topLeftOffset == Offset.zero)) { + if (textOffset != 0 && topLeftOffset == Offset.zero) { return; } if (ts is BackgroundTextSpan) { - final TextPainter painter = ts.layout(_textPainter); + final TextPainter painter = ts.layout(_textPainter)!; final Rect textRect = topLeftOffset & painter.size; - Offset endOffset; + Offset? endOffset; if (textRect.right > rect.right) { final int endTextOffset = textOffset + ts.toPlainText().length; endOffset = _findEndOffset(rect, endTextOffset); @@ -1792,7 +2064,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { _paintSpecialTextChildren(ts.children, canvas, rect, textOffset: textOffset); } - textOffset += ts.toPlainText().length; + textOffset += ts!.toPlainText().length; } } @@ -1802,7 +2074,7 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { rect, ); //overflow - if (endOffset == null || (endTextOffset != 0 && endOffset == Offset.zero)) { + if (endTextOffset != 0 && endOffset == Offset.zero) { return _findEndOffset(rect, endTextOffset - 1); } return endOffset; @@ -1810,11 +2082,15 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { assert(!debugNeedsLayout); - return _textPainter.getOffsetForCaret(position, caretPrototype); + return getCaretOffset(position, caretPrototype: caretPrototype); + //return _textPainter.getOffsetForCaret(position, caretPrototype); } + final LayerHandle _clipRectLayer = + LayerHandle(); + @override - Rect describeApproximatePaintClip(RenderObject child) => + Rect? describeApproximatePaintClip(RenderObject child) => _hasVisualOverflow ? Offset.zero & size : null; @override @@ -1838,49 +2114,313 @@ class ExtendedRenderEditable extends ExtendedTextSelectionRenderObject { @override List debugDescribeChildren() { return [ - text.toDiagnosticsNode( - name: 'text', - style: DiagnosticsTreeStyle.transition, - ), + if (text != null) + text!.toDiagnosticsNode( + name: 'text', + style: DiagnosticsTreeStyle.transition, + ), ]; } -// double _computeIntrinsicHeight(double width) { -// if (!_canComputeIntrinsics()) { -// return 0.0; -// } -// _computeChildrenHeightWithMinIntrinsics(width); -// _layoutText(width); -// return _textPainter.height; -// } + @override + double get caretMargin => _caretMargin; + + @override + Rect get caretPrototype => _caretPrototype; + + @override + Offset get effectiveOffset => _effectiveOffset; @override bool get isAttached => attached; + @override + bool get isMultiline => maxLines != 1; + @override TextOverflow get overflow => TextOverflow.visible; + @override + Widget? get overflowWidget => null; + + @override + List? get selectionRects => null; + @override bool get softWrap => false; @override TextPainter get textPainter => _textPainter; +} + +class _RenderEditableCustomPaint extends RenderBox { + _RenderEditableCustomPaint({ + ExtendedRenderEditablePainter? painter, + }) : _painter = painter, + super(); @override - double get caretMargin => _caretMargin; + ExtendedRenderEditable? get parent => super.parent as ExtendedRenderEditable?; @override - bool get isMultiline => _isMultiline; + bool get isRepaintBoundary => true; @override - List get selectionRects => _selectionRects; + bool get sizedByParent => true; + + ExtendedRenderEditablePainter? get painter => _painter; + ExtendedRenderEditablePainter? _painter; + set painter(ExtendedRenderEditablePainter? newValue) { + if (newValue == painter) return; + + final ExtendedRenderEditablePainter? oldPainter = painter; + _painter = newValue; + + if (newValue?.shouldRepaint(oldPainter) ?? true) markNeedsPaint(); + + if (attached) { + oldPainter?.removeListener(markNeedsPaint); + newValue?.addListener(markNeedsPaint); + } + } @override - Offset get effectiveOffset => _effectiveOffset; + void paint(PaintingContext context, Offset offset) { + final ExtendedRenderEditable? parent = this.parent; + assert(parent != null); + final ExtendedRenderEditablePainter? painter = this.painter; + if (painter != null && parent != null) { + parent.computeTextMetricsIfNeeded(); + painter.paint(context.canvas, size, parent); + } + } @override - Widget get overflowWidget => null; + void attach(PipelineOwner owner) { + super.attach(owner); + _painter?.addListener(markNeedsPaint); + } @override - Rect get caretPrototype => _caretPrototype; + void detach() { + _painter?.removeListener(markNeedsPaint); + super.detach(); + } + + @override + Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; +} + +class _FloatingCursorPainter extends ExtendedRenderEditablePainter { + _FloatingCursorPainter(this.caretPaintCallback); + + bool get shouldPaint => _shouldPaint; + bool _shouldPaint = true; + set shouldPaint(bool value) { + if (shouldPaint == value) return; + _shouldPaint = value; + notifyListeners(); + } + + CaretChangedHandler caretPaintCallback; + + bool showRegularCaret = false; + + final Paint caretPaint = Paint(); + late final Paint floatingCursorPaint = Paint(); + + Color? get caretColor => _caretColor; + Color? _caretColor; + set caretColor(Color? value) { + if (caretColor?.value == value?.value) return; + + _caretColor = value; + notifyListeners(); + } + + Radius? get cursorRadius => _cursorRadius; + Radius? _cursorRadius; + set cursorRadius(Radius? value) { + if (_cursorRadius == value) return; + _cursorRadius = value; + notifyListeners(); + } + + Offset get cursorOffset => _cursorOffset; + Offset _cursorOffset = Offset.zero; + set cursorOffset(Offset value) { + if (_cursorOffset == value) return; + _cursorOffset = value; + notifyListeners(); + } + + Color? get backgroundCursorColor => _backgroundCursorColor; + Color? _backgroundCursorColor; + set backgroundCursorColor(Color? value) { + if (backgroundCursorColor?.value == value?.value) return; + + _backgroundCursorColor = value; + if (showRegularCaret) notifyListeners(); + } + + Rect? get floatingCursorRect => _floatingCursorRect; + Rect? _floatingCursorRect; + set floatingCursorRect(Rect? value) { + if (_floatingCursorRect == value) return; + _floatingCursorRect = value; + notifyListeners(); + } + + void paintRegularCursor(Canvas canvas, ExtendedRenderEditable renderEditable, + Color caretColor, TextPosition textPosition) { + final Rect caretPrototype = renderEditable._caretPrototype; + final Offset caretOffset = + renderEditable.getOffsetForCaret(textPosition, caretPrototype); + Rect caretRect = caretPrototype.shift(caretOffset + cursorOffset); + + final double? caretHeight = renderEditable._textPainter + .getFullHeightForCaret(textPosition, caretPrototype); + if (caretHeight != null) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final double heightDiff = caretHeight - caretRect.height; + // Center the caret vertically along the text. + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top + heightDiff / 2, + caretRect.width, + caretRect.height, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Override the height to take the full height of the glyph at the TextPosition + // when not on iOS. iOS has special handling that creates a taller caret. + // TODO(garyq): See the TODO for _computeCaretPrototype(). + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top - _kCaretHeightOffset, + caretRect.width, + caretHeight, + ); + break; + } + } + + caretRect = caretRect.shift(renderEditable.paintOffset); + final Rect integralRect = + caretRect.shift(renderEditable._snapToPhysicalPixel(caretRect.topLeft)); + + if (shouldPaint) { + final Radius? radius = cursorRadius; + caretPaint.color = caretColor; + if (radius == null) { + canvas.drawRect(integralRect, caretPaint); + } else { + final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius); + canvas.drawRRect(caretRRect, caretPaint); + } + } + caretPaintCallback(integralRect); + } + + @override + void paint(Canvas canvas, Size size, + ExtendedTextSelectionRenderObject renderEditable) { + // Compute the caret location even when `shouldPaint` is false. + renderEditable = renderEditable as ExtendedRenderEditable; + assert(renderEditable != null); + final TextSelection? selection = renderEditable.getActualSelection(); + + // TODO(LongCatIsLooong): skip painting the caret when the selection is + // (-1, -1). + if (selection == null || !selection.isCollapsed) return; + + final Rect? floatingCursorRect = this.floatingCursorRect; + + final Color? caretColor = floatingCursorRect == null + ? this.caretColor + : showRegularCaret + ? backgroundCursorColor + : null; + final TextPosition caretTextPosition = floatingCursorRect == null + ? selection.extent + : renderEditable._floatingCursorTextPosition; + + if (caretColor != null) { + paintRegularCursor(canvas, renderEditable, caretColor, caretTextPosition); + } + + final Color? floatingCursorColor = this.caretColor?.withOpacity(0.75); + // Floating Cursor. + if (floatingCursorRect == null || + floatingCursorColor == null || + !shouldPaint) return; + + canvas.drawRRect( + RRect.fromRectAndRadius( + floatingCursorRect.shift(renderEditable.paintOffset), + _kFloatingCaretRadius), + floatingCursorPaint..color = floatingCursorColor, + ); + } + + @override + bool shouldRepaint(ExtendedRenderEditablePainter? oldDelegate) { + if (identical(this, oldDelegate)) return false; + + if (oldDelegate == null) return shouldPaint; + return oldDelegate is! _FloatingCursorPainter || + oldDelegate.shouldPaint != shouldPaint || + oldDelegate.showRegularCaret != showRegularCaret || + oldDelegate.caretColor != caretColor || + oldDelegate.cursorRadius != cursorRadius || + oldDelegate.cursorOffset != cursorOffset || + oldDelegate.backgroundCursorColor != backgroundCursorColor || + oldDelegate.floatingCursorRect != floatingCursorRect; + } +} + +class _CompositeRenderEditablePainter extends ExtendedRenderEditablePainter { + _CompositeRenderEditablePainter({required this.painters}); + + final List painters; + + @override + void addListener(VoidCallback listener) { + for (final ExtendedRenderEditablePainter painter in painters) + painter.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + for (final ExtendedRenderEditablePainter painter in painters) + painter.removeListener(listener); + } + + @override + void paint(Canvas canvas, Size size, + ExtendedTextSelectionRenderObject renderEditable) { + for (final ExtendedRenderEditablePainter painter in painters) + painter.paint(canvas, size, renderEditable); + } + + @override + bool shouldRepaint(ExtendedRenderEditablePainter? oldDelegate) { + if (identical(oldDelegate, this)) return false; + if (oldDelegate is! _CompositeRenderEditablePainter || + oldDelegate.painters.length != painters.length) return true; + + final Iterator oldPainters = + oldDelegate.painters.iterator; + final Iterator newPainters = + painters.iterator; + while (oldPainters.moveNext() && newPainters.moveNext()) + if (newPainters.current.shouldRepaint(oldPainters.current)) return true; + + return false; + } } diff --git a/lib/src/extended_text_field.dart b/lib/src/extended_text_field.dart index d166a0b..387f100 100644 --- a/lib/src/extended_text_field.dart +++ b/lib/src/extended_text_field.dart @@ -1,12 +1,14 @@ +// ignore_for_file: unnecessary_null_comparison, always_put_control_body_on_new_line + import 'dart:ui' as ui; import 'package:extended_text_field/src/extended_editable_text.dart'; import 'package:extended_text_library/extended_text_library.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; /// /// create by zmtzawqlp on 2019/4/22 @@ -16,16 +18,15 @@ import 'package:flutter/widgets.dart'; typedef InputCounterWidgetBuilder = Widget Function( /// The build context for the TextField BuildContext context, { - /// The length of the string currently in the input. - @required int currentLength, + required int currentLength, /// The maximum string length that can be entered into the TextField. - @required int maxLength, + required int? maxLength, /// Whether or not the TextField is currently focused. Mainly provided for /// the [liveRegion] parameter in the [Semantics] widget for accessibility. - @required bool isFocused, + required bool isFocused, }); /// A material design text field. @@ -53,19 +54,29 @@ typedef InputCounterWidgetBuilder = Widget Function( /// If [decoration] is non-null (which is the default), the text field requires /// one of its ancestors to be a [Material] widget. /// -/// To integrate the [ExtendedTextField] into a [Form] with other [FormField] widgets, +/// To integrate the [TextField] into a [Form] with other [FormField] widgets, /// consider using [TextFormField]. /// -/// Remember to [dispose] of the [TextEditingController] when it is no longer needed. -/// This will ensure we discard any resources used by the object. +/// {@template flutter.material.textfield.wantKeepAlive} +/// When the widget has focus, it will prevent itself from disposing via its +/// underlying [EditableText]'s [AutomaticKeepAliveClientMixin.wantKeepAlive] in +/// order to avoid losing the selection. Removing the focus will allow it to be +/// disposed. +/// {@endtemplate} /// -/// {@tool sample} -/// This example shows how to create a [ExtendedTextField] that will obscure input. The +/// Remember to call [TextEditingController.dispose] of the [TextEditingController] +/// when it is no longer needed. This will ensure we discard any resources used +/// by the object. +/// +/// {@tool snippet} +/// This example shows how to create a [TextField] that will obscure input. The /// [InputDecoration] surrounds the field in a border using [OutlineInputBorder] /// and adds a label. /// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/text_field.png) +/// /// ```dart -/// TextField( +/// const TextField( /// obscureText: true, /// decoration: InputDecoration( /// border: OutlineInputBorder(), @@ -75,9 +86,45 @@ typedef InputCounterWidgetBuilder = Widget Function( /// ``` /// {@end-tool} /// +/// ## Reading values +/// +/// A common way to read a value from a TextField is to use the [onSubmitted] +/// callback. This callback is applied to the text field's current value when +/// the user finishes editing. +/// +/// {@tool dartpad} +/// This sample shows how to get a value from a TextField via the [onSubmitted] +/// callback. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.1.dart ** +/// {@end-tool} +/// +/// {@macro flutter.widgets.EditableText.lifeCycle} +/// +/// For most applications the [onSubmitted] callback will be sufficient for +/// reacting to user input. +/// +/// The [onEditingComplete] callback also runs when the user finishes editing. +/// It's different from [onSubmitted] because it has a default value which +/// updates the text controller and yields the keyboard focus. Applications that +/// require different behavior can override the default [onEditingComplete] +/// callback. +/// +/// Keep in mind you can also always read the current string from a TextField's +/// [TextEditingController] using [TextEditingController.text]. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// In the live Dartpad example above, try typing the emoji �‍�‍� +/// into the field and submitting. Because the example code measures the length +/// with `value.characters.length`, the emoji is correctly counted as a single +/// character. +/// +/// {@macro flutter.widgets.editableText.showCaretOnScreen} +/// /// See also: /// -/// * /// * [TextFormField], which integrates with the [Form] widget. /// * [InputDecorator], which shows the labels and other visual elements that /// surround the actual text editing widget. @@ -85,8 +132,11 @@ typedef InputCounterWidgetBuilder = Widget Function( /// [TextField]. The [EditableText] widget is rarely used directly unless /// you are implementing an entirely different design language, such as /// Cupertino. -/// * Learn how to use a [TextEditingController] in one of our -/// [cookbook recipe](https://flutter.dev/docs/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller)s. +/// * +/// * Cookbook: [Create and style a text field](https://flutter.dev/docs/cookbook/forms/text-input) +/// * Cookbook: [Handle changes to a text field](https://flutter.dev/docs/cookbook/forms/text-field-changes) +/// * Cookbook: [Retrieve the value of a text field](https://flutter.dev/docs/cookbook/forms/retrieve-input) +/// * Cookbook: [Focus and text fields](https://flutter.dev/docs/cookbook/forms/focus) class ExtendedTextField extends StatefulWidget { /// Creates a Material Design text field. /// @@ -107,17 +157,19 @@ class ExtendedTextField extends StatefulWidget { /// field showing how many characters have been entered. If the value is /// set to a positive integer it will also display the maximum allowed /// number of characters to be entered. If the value is set to - /// [ExtendedTextField.noMaxLength] then only the current length is displayed. + /// [TextField.noMaxLength] then only the current length is displayed. /// /// After [maxLength] characters have been input, additional input - /// is ignored, unless [maxLengthEnforced] is set to false. The text field - /// enforces the length with a [LengthLimitingTextInputFormatter], which is - /// evaluated after the supplied [inputFormatters], if any. The [maxLength] - /// value must be either null or greater than zero. + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. + /// The [maxLength] value must be either null or greater than zero. /// - /// If [maxLengthEnforced] is set to false, then more than [maxLength] - /// characters may be entered, and the error counter and divider will - /// switch to the [decoration.errorStyle] when the limit is exceeded. + /// If [maxLengthEnforcement] is set to [MaxLengthEnforcement.none], then more + /// than [maxLength] characters may be entered, and the error counter and + /// divider will switch to the [decoration].errorStyle when the limit is + /// exceeded. /// /// The text cursor is not shown if [showCursor] is false or if [showCursor] /// is null (the default) and [readOnly] is true. @@ -128,20 +180,21 @@ class ExtendedTextField extends StatefulWidget { /// must not be null. /// /// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect], - /// [maxLengthEnforced], [scrollPadding], [maxLines], [maxLength], - /// [selectionHeightStyle], [selectionWidthStyle], and [enableSuggestions] - /// [enableSuggestions] arguments must not be null. + /// [scrollPadding], [maxLines], [maxLength], [selectionHeightStyle], + /// [selectionWidthStyle], [enableSuggestions], and + /// [enableIMEPersonalizedLearning] arguments must not be null. /// /// See also: /// /// * [maxLength], which discusses the precise meaning of "number of /// characters" and how it may differ from the intuitive meaning. const ExtendedTextField({ - Key key, + Key? key, + this.specialTextSpanBuilder, this.controller, this.focusNode, this.decoration = const InputDecoration(), - TextInputType keyboardType, + TextInputType? keyboardType, this.textInputAction, this.textCapitalization = TextCapitalization.none, this.style, @@ -150,20 +203,20 @@ class ExtendedTextField extends StatefulWidget { this.textAlignVertical, this.textDirection, this.readOnly = false, - ToolbarOptions toolbarOptions, + ToolbarOptions? toolbarOptions, this.showCursor, this.autofocus = false, this.obscuringCharacter = '•', this.obscureText = false, this.autocorrect = true, - SmartDashesType smartDashesType, - SmartQuotesType smartQuotesType, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, this.enableSuggestions = true, this.maxLines = 1, this.minLines, this.expands = false, this.maxLength, - this.maxLengthEnforced = true, + this.maxLengthEnforcement, this.onChanged, this.onEditingComplete, this.onSubmitted, @@ -179,16 +232,20 @@ class ExtendedTextField extends StatefulWidget { this.keyboardAppearance, this.scrollPadding = const EdgeInsets.all(20.0), this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, + bool? enableInteractiveSelection, + this.selectionControls, this.onTap, this.mouseCursor, this.buildCounter, this.scrollController, this.scrollPhysics, - this.autofillHints, - this.specialTextSpanBuilder, - this.textSelectionControls, + this.autofillHints = const [], + this.clipBehavior = Clip.hardEdge, this.restorationId, + this.scribbleEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.shouldShowSelectionHandles, + this.textSelectionGestureDetectorBuilder, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -200,8 +257,6 @@ class ExtendedTextField extends StatefulWidget { smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), assert(enableSuggestions != null), - assert(enableInteractiveSelection != null), - assert(maxLengthEnforced != null), assert(scrollPadding != null), assert(dragStartBehavior != null), assert(selectionHeightStyle != null), @@ -224,37 +279,59 @@ class ExtendedTextField extends StatefulWidget { maxLength > 0), // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. assert( - !identical(textInputAction, TextInputAction.newline) || - maxLines == 1 || - !identical(keyboardType, TextInputType.text), - 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.'), + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + assert(clipBehavior != null), + assert(enableIMEPersonalizedLearning != null), keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = + enableInteractiveSelection ?? (!readOnly || !obscureText), toolbarOptions = toolbarOptions ?? (obscureText - ? const ToolbarOptions( - selectAll: true, - paste: true, - ) - : const ToolbarOptions( - copy: true, - cut: true, - selectAll: true, - paste: true, - )), + ? (readOnly + // No point in even offering "Select All" in a read-only obscured + // field. + ? const ToolbarOptions() + // Writable, but obscured. + : const ToolbarOptions( + selectAll: true, + paste: true, + )) + : (readOnly + // Read-only, not obscured. + ? const ToolbarOptions( + selectAll: true, + copy: true, + ) + // Writable, not obscured. + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ))), super(key: key); - /// An interface for building the selection UI, to be provided by the - /// implementor of the toolbar widget or handle widget - final TextSelectionControls textSelectionControls; + /// create custom TextSelectionGestureDetectorBuilder + final TextSelectionGestureDetectorBuilderCallback? + textSelectionGestureDetectorBuilder; - ///build your ccustom text span - final SpecialTextSpanBuilder specialTextSpanBuilder; + /// Whether should show selection handles + /// handles are not shown in desktop or web as default + /// you can define your behavior + final ShouldShowSelectionHandlesCallback? shouldShowSelectionHandles; + + /// build your ccustom text span + final SpecialTextSpanBuilder? specialTextSpanBuilder; /// Controls the text being edited. /// /// If null, this widget will create its own [TextEditingController]. - final TextEditingController controller; + final TextEditingController? controller; /// Defines the keyboard focus for this widget. /// @@ -294,7 +371,7 @@ class ExtendedTextField extends StatefulWidget { /// /// This widget builds an [EditableText] and will ensure that the keyboard is /// showing when it is tapped by calling [EditableTextState.requestKeyboard()]. - final FocusNode focusNode; + final FocusNode? focusNode; /// The decoration to show around the text field. /// @@ -303,7 +380,7 @@ class ExtendedTextField extends StatefulWidget { /// /// Specify null to remove the decoration entirely (including the /// extra padding introduced by the decoration to save space for the labels). - final InputDecoration decoration; + final InputDecoration? decoration; /// {@macro flutter.widgets.editableText.keyboardType} final TextInputType keyboardType; @@ -312,7 +389,7 @@ class ExtendedTextField extends StatefulWidget { /// /// Defaults to [TextInputAction.newline] if [keyboardType] is /// [TextInputType.multiline] and [TextInputAction.done] otherwise. - final TextInputAction textInputAction; + final TextInputAction? textInputAction; /// {@macro flutter.widgets.editableText.textCapitalization} final TextCapitalization textCapitalization; @@ -322,19 +399,19 @@ class ExtendedTextField extends StatefulWidget { /// This text style is also used as the base style for the [decoration]. /// /// If null, defaults to the `subtitle1` text style from the current [Theme]. - final TextStyle style; + final TextStyle? style; /// {@macro flutter.widgets.editableText.strutStyle} - final StrutStyle strutStyle; + final StrutStyle? strutStyle; /// {@macro flutter.widgets.editableText.textAlign} final TextAlign textAlign; - /// {@macro flutter.widgets.inputDecorator.textAlignVertical} - final TextAlignVertical textAlignVertical; + /// {@macro flutter.material.InputDecorator.textAlignVertical} + final TextAlignVertical? textAlignVertical; /// {@macro flutter.widgets.editableText.textDirection} - final TextDirection textDirection; + final TextDirection? textDirection; /// {@macro flutter.widgets.editableText.autofocus} final bool autofocus; @@ -348,20 +425,24 @@ class ExtendedTextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.autocorrect} final bool autocorrect; - /// {@macro flutter.services.textInput.smartDashesType} + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} final SmartDashesType smartDashesType; - /// {@macro flutter.services.textInput.smartQuotesType} + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} final SmartQuotesType smartQuotesType; - /// {@macro flutter.services.textInput.enableSuggestions} + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} final bool enableSuggestions; /// {@macro flutter.widgets.editableText.maxLines} - final int maxLines; + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; /// {@macro flutter.widgets.editableText.minLines} - final int minLines; + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; /// {@macro flutter.widgets.editableText.expands} final bool expands; @@ -377,7 +458,7 @@ class ExtendedTextField extends StatefulWidget { final ToolbarOptions toolbarOptions; /// {@macro flutter.widgets.editableText.showCursor} - final bool showCursor; + final bool? showCursor; /// If [maxLength] is set to this value, only the "current input length" /// part of the character counter is shown. @@ -389,60 +470,37 @@ class ExtendedTextField extends StatefulWidget { /// If set, a character counter will be displayed below the /// field showing how many characters have been entered. If set to a number /// greater than 0, it will also display the maximum number allowed. If set - /// to [ExtendedTextField.noMaxLength] then only the current character count is displayed. + /// to [TextField.noMaxLength] then only the current character count is displayed. /// /// After [maxLength] characters have been input, additional input - /// is ignored, unless [maxLengthEnforced] is set to false. The text field - /// enforces the length with a [LengthLimitingTextInputFormatter], which is - /// evaluated after the supplied [inputFormatters], if any. + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. /// - /// This value must be either null, [ExtendedTextField.noMaxLength], or greater than 0. + /// This value must be either null, [TextField.noMaxLength], or greater than 0. /// If null (the default) then there is no limit to the number of characters - /// that can be entered. If set to [ExtendedTextField.noMaxLength], then no limit will + /// that can be entered. If set to [TextField.noMaxLength], then no limit will /// be enforced, but the number of characters entered will still be displayed. /// /// Whitespace characters (e.g. newline, space, tab) are included in the /// character count. /// - /// If [maxLengthEnforced] is set to false, then more than [maxLength] - /// characters may be entered, but the error counter and divider will - /// switch to the [decoration.errorStyle] when the limit is exceeded. - /// - /// ## Limitations - /// - /// The text field does not currently count Unicode grapheme clusters (i.e. - /// characters visible to the user), it counts Unicode scalar values, which - /// leaves out a number of useful possible characters (like many emoji and - /// composed characters), so this will be inaccurate in the presence of those - /// characters. If you expect to encounter these kinds of characters, be - /// generous in the maxLength used. + /// If [maxLengthEnforcement] is [MaxLengthEnforcement.none], then more than + /// [maxLength] characters may be entered, but the error counter and divider + /// will switch to the [decoration]'s [InputDecoration.errorStyle] when the + /// limit is exceeded. /// - /// For instance, the character "ö" can be represented as '\u{006F}\u{0308}', - /// which is the letter "o" followed by a composed diaeresis "¨", or it can - /// be represented as '\u{00F6}', which is the Unicode scalar value "LATIN - /// SMALL LETTER O WITH DIAERESIS". In the first case, the text field will - /// count two characters, and the second case will be counted as one - /// character, even though the user can see no difference in the input. - /// - /// Similarly, some emoji are represented by multiple scalar values. The - /// Unicode "THUMBS UP SIGN + MEDIUM SKIN TONE MODIFIER", "������������", should be - /// counted as a single character, but because it is a combination of two - /// Unicode scalar values, '\u{1F44D}\u{1F3FD}', it is counted as two - /// characters. - /// - /// See also: - /// - /// * [LengthLimitingTextInputFormatter] for more information on how it - /// counts characters, and how it may differ from the intuitive meaning. - final int maxLength; + /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} + final int? maxLength; - /// If true, prevents the field from allowing more than [maxLength] - /// characters. + /// Determines how the [maxLength] limit should be enforced. + /// + /// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement} /// - /// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to - /// enforce the limit, or merely provide a character counter and warning when - /// [maxLength] is exceeded. - final bool maxLengthEnforced; + /// {@macro flutter.services.textFormatter.maxLengthEnforcement} + final MaxLengthEnforcement? maxLengthEnforcement; /// {@macro flutter.widgets.editableText.onChanged} /// @@ -450,61 +508,55 @@ class ExtendedTextField extends StatefulWidget { /// /// * [inputFormatters], which are called before [onChanged] /// runs and can validate and change ("format") the input value. - /// * [onEditingComplete], [onSubmitted], [onSelectionChanged]: + /// * [onEditingComplete], [onSubmitted]: /// which are more specialized input change notifications. - final ValueChanged onChanged; + final ValueChanged? onChanged; /// {@macro flutter.widgets.editableText.onEditingComplete} - final VoidCallback onEditingComplete; + final VoidCallback? onEditingComplete; /// {@macro flutter.widgets.editableText.onSubmitted} /// /// See also: /// - /// * [EditableText.onSubmitted] for an example of how to handle moving to - /// the next/previous field when using [TextInputAction.next] and - /// [TextInputAction.previous] for [textInputAction]. - final ValueChanged onSubmitted; + /// * [TextInputAction.next] and [TextInputAction.previous], which + /// automatically shift the focus to the next/previous focusable item when + /// the user is done editing. + final ValueChanged? onSubmitted; /// {@macro flutter.widgets.editableText.onAppPrivateCommand} - final AppPrivateCommandCallback onAppPrivateCommand; + final AppPrivateCommandCallback? onAppPrivateCommand; /// {@macro flutter.widgets.editableText.inputFormatters} - final List inputFormatters; + final List? inputFormatters; /// If false the text field is "disabled": it ignores taps and its /// [decoration] is rendered in grey. /// /// If non-null this property overrides the [decoration]'s /// [InputDecoration.enabled] property. - final bool enabled; + final bool? enabled; /// {@macro flutter.widgets.editableText.cursorWidth} final double cursorWidth; /// {@macro flutter.widgets.editableText.cursorHeight} - final double cursorHeight; + final double? cursorHeight; /// {@macro flutter.widgets.editableText.cursorRadius} - final Radius cursorRadius; + final Radius? cursorRadius; /// The color of the cursor. /// /// The cursor indicates the current location of text insertion point in /// the field. /// - /// If this is null it will default to a value based on the following: - /// - /// * If the ambient [ThemeData.useTextSelectionTheme] is true then it - /// will use the value of the ambient [TextSelectionThemeData.cursorColor]. - /// If that is null then if the [ThemeData.platform] is [TargetPlatform.iOS] - /// or [TargetPlatform.macOS] then it will use [CupertinoThemeData.primaryColor]. - /// Otherwise it will use the value of [ColorScheme.primary] of [ThemeData.colorScheme]. - /// - /// * If the ambient [ThemeData.useTextSelectionTheme] is false then it - /// will use either [ThemeData.cursorColor] or [CupertinoThemeData.primaryColor] - /// depending on [ThemeData.platform]. - final Color cursorColor; + /// If this is null it will default to the ambient + /// [DefaultSelectionStyle.cursorColor]. If that is null, and the + /// [ThemeData.platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS] + /// it will use [CupertinoThemeData.primaryColor]. Otherwise it will use + /// the value of [ColorScheme.primary] of [ThemeData.colorScheme]. + final Color? cursorColor; /// Controls how tall the selection highlight boxes are computed to be. /// @@ -520,8 +572,8 @@ class ExtendedTextField extends StatefulWidget { /// /// This setting is only honored on iOS devices. /// - /// If unset, defaults to the brightness of [ThemeData.primaryColorBrightness]. - final Brightness keyboardAppearance; + /// If unset, defaults to [ThemeData.brightness]. + final Brightness? keyboardAppearance; /// {@macro flutter.widgets.editableText.scrollPadding} final EdgeInsets scrollPadding; @@ -529,6 +581,9 @@ class ExtendedTextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.enableInteractiveSelection} final bool enableInteractiveSelection; + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + /// {@macro flutter.widgets.scrollable.dragStartBehavior} final DragStartBehavior dragStartBehavior; @@ -555,7 +610,7 @@ class ExtendedTextField extends StatefulWidget { /// To listen to arbitrary pointer events without competing with the /// text field's internal gesture detector, use a [Listener]. /// {@endtemplate} - final GestureTapCallback onTap; + final GestureTapCallback? onTap; /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. @@ -574,7 +629,7 @@ class ExtendedTextField extends StatefulWidget { /// appearance of the mouse pointer. All other properties related to "cursor" /// stand for the text cursor, which is usually a blinking vertical line at /// the editing position. - final MouseCursor mouseCursor; + final MouseCursor? mouseCursor; /// Callback that generates a custom [InputDecoration.counter] widget. /// @@ -591,9 +646,9 @@ class ExtendedTextField extends StatefulWidget { /// Widget counter( /// BuildContext context, /// { - /// int currentLength, - /// int maxLength, - /// bool isFocused, + /// required int currentLength, + /// required int? maxLength, + /// required bool isFocused, /// } /// ) { /// return Text( @@ -606,17 +661,22 @@ class ExtendedTextField extends StatefulWidget { /// /// If buildCounter returns null, then no counter and no Semantics widget will /// be created at all. - final InputCounterWidgetBuilder buildCounter; + final InputCounterWidgetBuilder? buildCounter; /// {@macro flutter.widgets.editableText.scrollPhysics} - final ScrollPhysics scrollPhysics; + final ScrollPhysics? scrollPhysics; /// {@macro flutter.widgets.editableText.scrollController} - final ScrollController scrollController; + final ScrollController? scrollController; /// {@macro flutter.widgets.editableText.autofillHints} - /// {@macro flutter.services.autofill.autofillHints} - final Iterable autofillHints; + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; /// {@template flutter.material.textfield.restorationId} /// Restoration ID to save and restore the state of the text field. @@ -635,9 +695,16 @@ class ExtendedTextField extends StatefulWidget { /// * [RestorationManager], which explains how state restoration works in /// Flutter. /// {@endtemplate} - final String restorationId; + final String? restorationId; + + /// {@macro flutter.widgets.editableText.scribbleEnabled} + final bool scribbleEnabled; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + @override - _ExtendedTextFieldState createState() => _ExtendedTextFieldState(); + ExtendedTextFieldState createState() => ExtendedTextFieldState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -682,10 +749,9 @@ class ExtendedTextField extends StatefulWidget { properties.add( DiagnosticsProperty('expands', expands, defaultValue: false)); properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); - properties.add(FlagProperty('maxLengthEnforced', - value: maxLengthEnforced, - defaultValue: true, - ifFalse: 'maxLength not enforced')); + properties.add(EnumProperty( + 'maxLengthEnforcement', maxLengthEnforcement, + defaultValue: null)); properties.add(EnumProperty( 'textInputAction', textInputAction, defaultValue: null)); @@ -717,46 +783,61 @@ class ExtendedTextField extends StatefulWidget { value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled')); + properties.add(DiagnosticsProperty( + 'selectionControls', selectionControls, + defaultValue: null)); properties.add(DiagnosticsProperty( 'scrollController', scrollController, defaultValue: null)); properties.add(DiagnosticsProperty( 'scrollPhysics', scrollPhysics, defaultValue: null)); + properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, + defaultValue: Clip.hardEdge)); + properties.add(DiagnosticsProperty( + 'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, + defaultValue: true)); } } -class _ExtendedTextFieldState extends State +class ExtendedTextFieldState extends State with RestorationMixin - implements ExtendedTextSelectionGestureDetectorBuilderDelegate { - RestorableTextEditingController _controller; + implements + ExtendedTextSelectionGestureDetectorBuilderDelegate, + AutofillClient { + RestorableTextEditingController? _controller; TextEditingController get _effectiveController => - widget.controller ?? _controller.value; + widget.controller ?? _controller!.value; - FocusNode _focusNode; + FocusNode? _focusNode; FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); + MaxLengthEnforcement get _effectiveMaxLengthEnforcement => + widget.maxLengthEnforcement ?? + LengthLimitingTextInputFormatter.getDefaultMaxLengthEnforcement( + Theme.of(context).platform); bool _isHovering = false; bool get needsCounter => widget.maxLength != null && widget.decoration != null && - widget.decoration.counterText == null; + widget.decoration!.counterText == null; bool _showSelectionHandles = false; - CommonTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; + late CommonTextSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; // API for TextSelectionGestureDetectorBuilderDelegate. @override - bool forcePressEnabled; + late bool forcePressEnabled; final GlobalKey editableTextKey = GlobalKey(); @override ExtendedTextSelectionRenderObject get renderEditable => - editableTextKey.currentState.renderEditable; + editableTextKey.currentState!.renderEditable; @override bool get selectionEnabled => widget.selectionEnabled; @@ -767,8 +848,8 @@ class _ExtendedTextFieldState extends State int get _currentLength => _effectiveController.value.text.characters.length; bool get _hasIntrinsicError => widget.maxLength != null && - widget.maxLength > 0 && - _effectiveController.value.text.characters.length > widget.maxLength; + widget.maxLength! > 0 && + _effectiveController.value.text.characters.length > widget.maxLength!; bool get _hasError => widget.decoration?.errorText != null || _hasIntrinsicError; InputDecoration _getEffectiveDecoration() { @@ -790,13 +871,13 @@ class _ExtendedTextFieldState extends State } // If buildCounter was provided, use it to generate a counter widget. - Widget counter; + Widget? counter; final int currentLength = _currentLength; if (effectiveDecoration.counter == null && effectiveDecoration.counterText == null && widget.buildCounter != null) { final bool isFocused = _effectiveFocusNode.hasFocus; - final Widget builtCounter = widget.buildCounter( + final Widget? builtCounter = widget.buildCounter!( context, currentLength: currentLength, maxLength: widget.maxLength, @@ -820,11 +901,11 @@ class _ExtendedTextFieldState extends State String semanticCounterText = ''; // Handle a real maxLength (positive number) - if (widget.maxLength > 0) { + if (widget.maxLength! > 0) { // Show the maxLength in the counter counterText += '/${widget.maxLength}'; final int remaining = - (widget.maxLength - currentLength).clamp(0, widget.maxLength) as int; + (widget.maxLength! - currentLength).clamp(0, widget.maxLength!); semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining); } @@ -833,7 +914,7 @@ class _ExtendedTextFieldState extends State return effectiveDecoration.copyWith( errorText: effectiveDecoration.errorText ?? '', counterStyle: effectiveDecoration.errorStyle ?? - themeData.textTheme.caption.copyWith(color: themeData.errorColor), + themeData.textTheme.caption!.copyWith(color: themeData.errorColor), counterText: counterText, semanticCounterText: semanticCounterText, ); @@ -848,37 +929,60 @@ class _ExtendedTextFieldState extends State @override void initState() { super.initState(); - _selectionGestureDetectorBuilder = - CommonTextSelectionGestureDetectorBuilder( - delegate: this, - hideToolbar: () { - _editableText.hideToolbar(); - }, - showToolbar: () { - _editableText.showToolbar(); - }, - onTap: widget.onTap, - context: context, - requestKeyboard: _requestKeyboard, - ); + _initGestureDetectorBuilder(); + if (widget.controller == null) { _createLocalController(); } _effectiveFocusNode.canRequestFocus = _isEnabled; + _effectiveFocusNode.addListener(_handleFocusChanged); + } + + void _initGestureDetectorBuilder() { + if (widget.textSelectionGestureDetectorBuilder != null) { + _selectionGestureDetectorBuilder = + widget.textSelectionGestureDetectorBuilder!( + delegate: this, + hideToolbar: () { + _editableText!.hideToolbar(); + }, + showToolbar: () { + _editableText!.showToolbar( + showToolbarInWeb: _selectionGestureDetectorBuilder.showToolbarInWeb, + ); + }, + onTap: widget.onTap, + context: context, + requestKeyboard: _requestKeyboard, + ); + } else { + _selectionGestureDetectorBuilder = + CommonTextSelectionGestureDetectorBuilder( + delegate: this, + hideToolbar: () { + _editableText!.hideToolbar(); + }, + showToolbar: () { + _editableText!.showToolbar( + showToolbarInWeb: _selectionGestureDetectorBuilder.showToolbarInWeb, + ); + }, + onTap: widget.onTap, + context: context, + requestKeyboard: _requestKeyboard, + ); + } } bool get _canRequestFocus { - final NavigationMode mode = - MediaQuery.of(context, nullOk: true)?.navigationMode ?? - NavigationMode.traditional; + final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? + NavigationMode.traditional; switch (mode) { case NavigationMode.traditional: return _isEnabled; case NavigationMode.directional: return true; } - assert(false, 'Navigation mode $mode not handled'); - return null; } @override @@ -891,22 +995,34 @@ class _ExtendedTextFieldState extends State void didUpdateWidget(ExtendedTextField oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller == null && oldWidget.controller != null) { - _createLocalController(oldWidget.controller.value); + _createLocalController(oldWidget.controller!.value); } else if (widget.controller != null && oldWidget.controller == null) { - unregisterFromRestoration(_controller); - _controller.dispose(); + unregisterFromRestoration(_controller!); + _controller!.dispose(); _controller = null; } + + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + _effectiveFocusNode.canRequestFocus = _canRequestFocus; - if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly) { + + if (_effectiveFocusNode.hasFocus && + widget.readOnly != oldWidget.readOnly && + _isEnabled) { if (_effectiveController.selection.isCollapsed) { _showSelectionHandles = !widget.readOnly; } } + if (widget.textSelectionGestureDetectorBuilder != + oldWidget.textSelectionGestureDetectorBuilder) + _initGestureDetectorBuilder(); } @override - void restoreState(RestorationBucket oldBucket, bool initialRestore) { + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { if (_controller != null) { _registerController(); } @@ -914,10 +1030,10 @@ class _ExtendedTextFieldState extends State void _registerController() { assert(_controller != null); - registerForRestoration(_controller, 'controller'); + registerForRestoration(_controller!, 'controller'); } - void _createLocalController([TextEditingValue value]) { + void _createLocalController([TextEditingValue? value]) { assert(_controller == null); _controller = value == null ? RestorableTextEditingController() @@ -928,75 +1044,88 @@ class _ExtendedTextFieldState extends State } @override - String get restorationId => widget.restorationId; + String? get restorationId => widget.restorationId; + @override void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); _focusNode?.dispose(); _controller?.dispose(); super.dispose(); } - ExtendedEditableTextState get _editableText => editableTextKey.currentState; + ExtendedEditableTextState? get _editableText => editableTextKey.currentState; void _requestKeyboard() { _editableText?.requestKeyboard(); } - bool _shouldShowSelectionHandles(SelectionChangedCause cause) { + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + if (widget.shouldShowSelectionHandles != null) { + return widget.shouldShowSelectionHandles!( + cause, + _selectionGestureDetectorBuilder, + _editableText!.textEditingValue, + ); + } // When the text field is activated by something that doesn't trigger the // selection overlay, we shouldn't show the handles either. if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) return false; - if (cause == SelectionChangedCause.keyboard) { - return false; - } + if (cause == SelectionChangedCause.keyboard) return false; if (widget.readOnly && _effectiveController.selection.isCollapsed) return false; - if (!_isEnabled) { - return false; - } - if (cause == SelectionChangedCause.longPress) { - return true; - } + if (!_isEnabled) return false; - if (_effectiveController.text.isNotEmpty) { - return true; - } + if (cause == SelectionChangedCause.longPress) return true; + + if (_effectiveController.text.isNotEmpty) return true; return false; } + void _handleFocusChanged() { + setState(() { + // Rebuild the widget on focus change to show/hide the text selection + // highlight. + }); + } + void _handleSelectionChanged( - TextSelection selection, SelectionChangedCause cause) { + TextSelection selection, SelectionChangedCause? cause) { final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); - if (willShowSelectionHandles != _showSelectionHandles) { setState(() { _showSelectionHandles = willShowSelectionHandles; }); } + switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - if (cause == SelectionChangedCause.longPress) { - _editableText?.bringIntoView(selection.base); + if (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.drag) { + _editableText?.bringIntoView(selection.extent); } return; - case TargetPlatform.android: - case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - // Do nothing. + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.drag) { + _editableText?.bringIntoView(selection.extent); + } + return; } } /// Toggle the toolbar when a selection handle is tapped. void _handleSelectionHandleTapped() { if (_effectiveController.selection.isCollapsed) { - _editableText.toggleToolbar(); + _editableText!.toggleToolbar(); } } @@ -1008,10 +1137,31 @@ class _ExtendedTextFieldState extends State } } - Color _defaultSelectionColor(BuildContext context, Color primary) { - final bool isDark = Theme.of(context).brightness == Brightness.dark; - return primary.withOpacity(isDark ? 0.40 : 0.12); + // AutofillClient implementation start. + @override + String get autofillId => _editableText!.autofillId; + + @override + void autofill(TextEditingValue newEditingValue) => + _editableText!.autofill(newEditingValue); + + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = + widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: _effectiveController.value, + hintText: (widget.decoration ?? const InputDecoration()).hintText, + ) + : AutofillConfiguration.disabled; + + return _editableText!.textInputConfiguration + .copyWith(autofillConfiguration: autofillConfiguration); } + // AutofillClient implementation end. @override Widget build(BuildContext context) { @@ -1020,73 +1170,124 @@ class _ExtendedTextFieldState extends State assert(debugCheckHasDirectionality(context)); assert( !(widget.style != null && - widget.style.inherit == false && - (widget.style.fontSize == null || widget.style.textBaseline == null)), + widget.style!.inherit == false && + (widget.style!.fontSize == null || + widget.style!.textBaseline == null)), 'inherit false style must supply fontSize and textBaseline', ); final ThemeData theme = Theme.of(context); + // final DefaultSelectionStyle selectionStyle = + // DefaultSelectionStyle.of(context); final TextSelectionThemeData selectionTheme = TextSelectionTheme.of(context); - final TextStyle style = theme.textTheme.subtitle1.merge(widget.style); + final TextStyle style = theme.textTheme.subtitle1!.merge(widget.style); final Brightness keyboardAppearance = - widget.keyboardAppearance ?? theme.primaryColorBrightness; + widget.keyboardAppearance ?? theme.brightness; final TextEditingController controller = _effectiveController; final FocusNode focusNode = _effectiveFocusNode; - final List formatters = - widget.inputFormatters ?? []; - if (widget.maxLength != null && widget.maxLengthEnforced) - formatters.add(LengthLimitingTextInputFormatter(widget.maxLength)); - - TextSelectionControls textSelectionControls = widget.textSelectionControls; - bool paintCursorAboveText; - bool cursorOpacityAnimates; - Offset cursorOffset; - Color cursorColor = widget.cursorColor; - Color selectionColor; - Color autocorrectionTextRectColor; - Radius cursorRadius = widget.cursorRadius; + final List formatters = [ + ...?widget.inputFormatters, + if (widget.maxLength != null) + LengthLimitingTextInputFormatter( + widget.maxLength, + maxLengthEnforcement: _effectiveMaxLengthEnforcement, + ), + ]; + + TextSelectionControls? textSelectionControls = widget.selectionControls; + final bool paintCursorAboveText; + final bool cursorOpacityAnimates; + Offset? cursorOffset; + final Color cursorColor; + final Color selectionColor; + Color? autocorrectionTextRectColor; + Radius? cursorRadius = widget.cursorRadius; + VoidCallback? handleDidGainAccessibilityFocus; switch (theme.platform) { case TargetPlatform.iOS: - case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); forcePressEnabled = true; - textSelectionControls ??= extendedCupertinoTextSelectionControls; + textSelectionControls ??= cupertinoTextSelectionControls; paintCursorAboveText = true; cursorOpacityAnimates = true; - if (theme.useTextSelectionTheme) { - cursorColor ??= selectionTheme.cursorColor ?? - CupertinoTheme.of(context).primaryColor; - selectionColor = selectionTheme.selectionColor ?? - _defaultSelectionColor( - context, CupertinoTheme.of(context).primaryColor); - } else { - cursorColor ??= CupertinoTheme.of(context).primaryColor; - selectionColor = theme.textSelectionColor; - } + cursorColor = widget.cursorColor ?? + selectionTheme.cursorColor ?? + cupertinoTheme.primaryColor; + selectionColor = selectionTheme.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); cursorRadius ??= const Radius.circular(2.0); cursorOffset = Offset( iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); autocorrectionTextRectColor = selectionColor; break; + case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor = widget.cursorColor ?? + selectionTheme.cursorColor ?? + cupertinoTheme.primaryColor; + selectionColor = selectionTheme.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset( + iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && + _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + break; + case TargetPlatform.android: case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor = widget.cursorColor ?? + selectionTheme.cursorColor ?? + theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + break; + case TargetPlatform.linux: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor = widget.cursorColor ?? + selectionTheme.cursorColor ?? + theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + break; + case TargetPlatform.windows: forcePressEnabled = false; - textSelectionControls ??= extendedMaterialTextSelectionControls; + textSelectionControls ??= desktopTextSelectionControls; paintCursorAboveText = false; cursorOpacityAnimates = false; - if (theme.useTextSelectionTheme) { - cursorColor ??= - selectionTheme.cursorColor ?? theme.colorScheme.primary; - selectionColor = selectionTheme.selectionColor ?? - _defaultSelectionColor(context, theme.colorScheme.primary); - } else { - cursorColor ??= theme.cursorColor; - selectionColor = theme.textSelectionColor; - } + cursorColor = widget.cursorColor ?? + selectionTheme.cursorColor ?? + theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && + _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; break; } @@ -1119,8 +1320,8 @@ class _ExtendedTextFieldState extends State maxLines: widget.maxLines, minLines: widget.minLines, expands: widget.expands, - selectionColor: selectionColor, - + // Only show the selection highlight when the text field is focused. + selectionColor: focusNode.hasFocus ? selectionColor : null, selectionControls: widget.selectionEnabled ? textSelectionControls : null, onChanged: widget.onChanged, @@ -1148,9 +1349,13 @@ class _ExtendedTextFieldState extends State dragStartBehavior: widget.dragStartBehavior, scrollController: widget.scrollController, scrollPhysics: widget.scrollPhysics, - autofillHints: widget.autofillHints, + autofillClient: this, autocorrectionTextRectColor: autocorrectionTextRectColor, - restorationId: 'editable', + clipBehavior: widget.clipBehavior, + restorationId: 'ExtendedEditableText', + scribbleEnabled: widget.scribbleEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + showToolbarInWeb: _selectionGestureDetectorBuilder.showToolbarInWeb, ), ), ); @@ -1158,7 +1363,7 @@ class _ExtendedTextFieldState extends State if (widget.decoration != null) { child = AnimatedBuilder( animation: Listenable.merge([focusNode, controller]), - builder: (BuildContext context, Widget child) { + builder: (BuildContext context, Widget? child) { return InputDecorator( decoration: _getEffectiveDecoration(), baseStyle: widget.style, @@ -1185,28 +1390,41 @@ class _ExtendedTextFieldState extends State }, ); + final int? semanticsMaxValueLength; + if (_effectiveMaxLengthEnforcement != MaxLengthEnforcement.none && + widget.maxLength != null && + widget.maxLength! > 0) { + semanticsMaxValueLength = widget.maxLength; + } else { + semanticsMaxValueLength = null; + } + return MouseRegion( cursor: effectiveMouseCursor, onEnter: (PointerEnterEvent event) => _handleHover(true), onExit: (PointerExitEvent event) => _handleHover(false), - child: IgnorePointer( + child: + // we didn't join the tap region + // TextFieldTapRegion( + // child: + IgnorePointer( ignoring: !_isEnabled, child: AnimatedBuilder( animation: controller, // changes the _currentLength - builder: (BuildContext context, Widget child) { + builder: (BuildContext context, Widget? child) { return Semantics( - maxValueLength: widget.maxLengthEnforced && - widget.maxLength != null && - widget.maxLength > 0 - ? widget.maxLength - : null, + maxValueLength: semanticsMaxValueLength, currentValueLength: _currentLength, - onTap: () { - if (!_effectiveController.selection.isValid) - _effectiveController.selection = TextSelection.collapsed( - offset: _effectiveController.text.length); - _requestKeyboard(); - }, + onTap: widget.readOnly + ? null + : () { + if (!_effectiveController.selection.isValid) + _effectiveController.selection = + TextSelection.collapsed( + offset: _effectiveController.text.length); + _requestKeyboard(); + }, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, child: child, ); }, @@ -1216,6 +1434,11 @@ class _ExtendedTextFieldState extends State ), ), ), + // ), ); } + + void bringIntoView(TextPosition position, {double offset = 0}) { + _editableText?.bringIntoView(position, offset: offset); + } } diff --git a/lib/src/keyboard/binding.dart b/lib/src/keyboard/binding.dart new file mode 100644 index 0000000..162cbd5 --- /dev/null +++ b/lib/src/keyboard/binding.dart @@ -0,0 +1,80 @@ +import 'package:extended_text_field/src/keyboard/focus_node.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// void main() { +/// TextInputBinding(); +/// runApp(const MyApp()); +/// } +class TextInputBinding extends WidgetsFlutterBinding + with TextInputBindingMixin {} + +/// class YourBinding extends WidgetsFlutterBinding with TextInputBindingMixin,YourBindingMixin { +/// @override +/// // ignore: unnecessary_overrides +/// bool ignoreTextInputShow() { +/// // you can override it base on your case +/// // if NoKeyboardFocusNode is not enough +/// return super.ignoreTextInputShow(); +/// } +/// } +/// +/// void main() { +/// YourBinding(); +/// runApp(const MyApp()); +/// } +mixin TextInputBindingMixin on WidgetsFlutterBinding { + @override + BinaryMessenger createBinaryMessenger() { + return TextInputBinaryMessenger(super.createBinaryMessenger(), this); + } + + bool ignoreSendMessage(MethodCall methodCall) => false; + + bool ignoreTextInputShow() { + final FocusNode? focus = FocusManager.instance.primaryFocus; + if (focus != null && + focus is TextInputFocusNode && + focus.ignoreSystemKeyboardShow) { + return true; + } + return false; + } +} + +class TextInputBinaryMessenger extends BinaryMessenger { + TextInputBinaryMessenger(this.origin, this.textInputBindingMixin); + final BinaryMessenger origin; + final TextInputBindingMixin textInputBindingMixin; + @override + Future handlePlatformMessage(String channel, ByteData? data, + PlatformMessageResponseCallback? callback) { + return origin.handlePlatformMessage(channel, data, callback); + } + + @override + Future? send(String channel, ByteData? message) async { + if (channel == SystemChannels.textInput.name) { + final MethodCall methodCall = + SystemChannels.textInput.codec.decodeMethodCall(message); + bool ignore = false; + switch (methodCall.method) { + case 'TextInput.show': + ignore = textInputBindingMixin.ignoreTextInputShow(); + break; + default: + ignore = textInputBindingMixin.ignoreSendMessage(methodCall); + } + + if (ignore) { + return null; + } + } + return origin.send(channel, message); + } + + @override + void setMessageHandler(String channel, MessageHandler? handler) { + origin.setMessageHandler(channel, handler); + } +} diff --git a/lib/src/keyboard/focus_node.dart b/lib/src/keyboard/focus_node.dart new file mode 100644 index 0000000..d4ec7da --- /dev/null +++ b/lib/src/keyboard/focus_node.dart @@ -0,0 +1,9 @@ +import 'package:flutter/widgets.dart'; + +/// The FocusNode to be used in [TextInputBindingMixin] +/// +class TextInputFocusNode extends FocusNode { + /// no system keyboard show + /// if it's true, it stop Flutter Framework send `TextInput.show` message to Flutter Engine + bool ignoreSystemKeyboardShow = true; +} diff --git a/pubspec.yaml b/pubspec.yaml index 561ae9b..2678baf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,15 @@ name: extended_text_field description: Extended official text field to build special text like inline image, @somebody, custom background etc quickly.It also support to build custom seleciton toolbar and handles. -version: 7.0.0-non-null-safety +version: 11.0.1 homepage: https://github.com/fluttercandies/extended_text_field environment: - sdk: ">=2.6.0 <2.12.0" - flutter: ">=1.22.0" - + sdk: '>=2.17.0 <3.0.0' + flutter: ">=3.7.0" dependencies: - extended_text_library: ^6.0.0-non-null-safety - flutter: + extended_text_library: ^10.0.0 + flutter: sdk: flutter dev_dependencies: flutter_test: