From fdcb82b13b662cd6225d1ba957d942191a0305d2 Mon Sep 17 00:00:00 2001 From: zmtzawqlp Date: Sun, 18 Feb 2024 22:20:11 +0800 Subject: [PATCH] Migrate to Flutter 3.19.0 --- CHANGELOG.md | 5 + analysis_options.yaml | 2 +- example/analysis_options.yaml | 2 +- example/lib/pages/simple/no_keyboard.dart | 5 +- example/pubspec.yaml | 6 +- .../spell_check_suggestions_toolbar.dart | 2 +- .../extended/material/selectable_text.dart | 2 +- .../spell_check_suggestions_toolbar.dart | 2 +- lib/src/extended/widgets/editable_text.dart | 60 ++- lib/src/extended/widgets/text_field.dart | 6 +- .../official/material/selectable_text.dart | 2 +- lib/src/official/rendering/editable.dart | 142 ++++-- lib/src/official/widgets/editable_text.dart | 212 +++++++-- lib/src/official/widgets/text_field.dart | 128 +++++- lib/src/official/widgets/text_selection.dart | 426 +++++------------- pubspec.yaml | 9 +- 16 files changed, 563 insertions(+), 448 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 779a812..32a469b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 14.0.0 + +* Migrate to Flutter 3.19.0 +* Fix wrong postion of Magnifier + ## 13.0.1 * Update readme about HarmonyOS diff --git a/analysis_options.yaml b/analysis_options.yaml index 351331b..47879b9 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -45,7 +45,7 @@ linter: - 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_require_non_null_named_parameters - always_specify_types - annotate_overrides # - avoid_annotating_with_dynamic # conflicts with always_specify_types diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 502ef6c..04e68ba 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -45,7 +45,7 @@ linter: - 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_require_non_null_named_parameters - always_specify_types - annotate_overrides # - avoid_annotating_with_dynamic # conflicts with always_specify_types diff --git a/example/lib/pages/simple/no_keyboard.dart b/example/lib/pages/simple/no_keyboard.dart index 02308a2..825adf3 100644 --- a/example/lib/pages/simple/no_keyboard.dart +++ b/example/lib/pages/simple/no_keyboard.dart @@ -101,7 +101,7 @@ class TextFieldCaseState extends State mixin CustomKeyboardShowStateMixin on State { final TextInputFocusNode _focusNode = TextInputFocusNode(); final TextEditingController _controller = TextEditingController(); - PersistentBottomSheetController? _bottomSheetController; + PersistentBottomSheetController? _bottomSheetController; final List _inputFormatters = [ // digit or decimal @@ -129,12 +129,13 @@ mixin CustomKeyboardShowStateMixin on State { void _handleFocusChanged() { if (_focusNode.hasFocus) { // just demo, you can define your custom keyboard as you want - _bottomSheetController = showBottomSheet( + _bottomSheetController = showBottomSheet( context: FocusManager.instance.primaryFocus!.context!, // set false, if don't want to drag to close custom keyboard enableDrag: true, builder: (BuildContext b) { final MediaQueryData mediaQueryData = MediaQuery.of(b); + return Material( //shadowColor: Colors.grey, color: Colors.grey.withOpacity(0.3), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a16b2c9..dbfd888 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,8 +14,8 @@ publish_to: none version: 1.0.0+1 environment: - sdk: '>=3.0.0 <4.0.0' - flutter: ">=3.16.0" + sdk: '>=3.3.0 <4.0.0' + flutter: ">=3.19.0" dependencies: # The following adds the Cupertino Icons font to your application. @@ -25,7 +25,7 @@ dependencies: cupertino_icons: ^1.0.4 ff_annotation_route_library: ^3.0.0 - extended_text: ^12.0.0 + extended_text: ^13.0.0 # extended_text: # version: ^11.0.0-dev.1 # hosted: "https://pub.dev" diff --git a/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart b/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart index 951b26c..49c585a 100644 --- a/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart +++ b/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart @@ -136,7 +136,7 @@ class ExtendedCupertinoSpellCheckSuggestionsToolbar extends StatelessWidget { editableTextState .bringIntoView(editableTextState.textEditingValue.selection.extent); } - }); + }, debugLabel: 'SpellCheckSuggestions.bringIntoView'); editableTextState.hideToolbar(); } diff --git a/lib/src/extended/material/selectable_text.dart b/lib/src/extended/material/selectable_text.dart index 6cf6bfd..3b847b9 100644 --- a/lib/src/extended/material/selectable_text.dart +++ b/lib/src/extended/material/selectable_text.dart @@ -140,7 +140,7 @@ class _ExtendedSelectableTextState extends _SelectableTextState { assert(debugCheckHasDirectionality(context)); assert( !(widget.style != null && - widget.style!.inherit == false && + !widget.style!.inherit && (widget.style!.fontSize == null || widget.style!.textBaseline == null)), 'inherit false style must supply fontSize and textBaseline', diff --git a/lib/src/extended/material/spell_check_suggestions_toolbar.dart b/lib/src/extended/material/spell_check_suggestions_toolbar.dart index faadc5b..0b7b0b3 100644 --- a/lib/src/extended/material/spell_check_suggestions_toolbar.dart +++ b/lib/src/extended/material/spell_check_suggestions_toolbar.dart @@ -148,7 +148,7 @@ class ExtendedSpellCheckSuggestionsToolbar extends StatelessWidget { editableTextState .bringIntoView(editableTextState.textEditingValue.selection.extent); } - }); + }, debugLabel: 'SpellCheckerSuggestionsToolbar.bringIntoView'); editableTextState.hideToolbar(); } diff --git a/lib/src/extended/widgets/editable_text.dart b/lib/src/extended/widgets/editable_text.dart index 513b3b8..99a8177 100644 --- a/lib/src/extended/widgets/editable_text.dart +++ b/lib/src/extended/widgets/editable_text.dart @@ -229,7 +229,8 @@ class ExtendedEditableTextState extends _EditableTextState { compositeCallback: _compositeCallback, enabled: _hasInputConnection, child: TextFieldTapRegion( - onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside, + onTapOutside: + _hasFocus ? widget.onTapOutside ?? _defaultOnTapOutside : null, debugLabel: kReleaseMode ? null : 'EditableText', child: MouseRegion( cursor: widget.mouseCursor ?? SystemMouseCursors.text, @@ -271,6 +272,15 @@ class ExtendedEditableTextState extends _EditableTextState { return oldValue.text != newValue.text || oldValue.composing != newValue.composing; }, + undoStackModifier: (TextEditingValue value) { + // On Android we should discard the composing region when pushing + // a new entry to the undo stack. This prevents the TextInputPlugin + // from restarting the input on every undo/redo when the composing + // region is changed by the framework. + return defaultTargetPlatform == TargetPlatform.android + ? value.copyWith(composing: TextRange.empty) + : value; + }, focusNode: widget.focusNode, controller: widget.undoController, child: Focus( @@ -764,29 +774,41 @@ class ExtendedEditableTextState extends _EditableTextState { // we cache the position. _pointOffsetOrigin = point.offset; - // zmtzawqlp - final TextPosition currentTextPosition = supportSpecialText - ? ExtendedTextLibraryUtils - .convertTextInputPostionToTextPainterPostion( - renderEditable.text!, - renderEditable.selection!.base, - ) - : TextPosition( - offset: renderEditable.selection!.baseOffset, - affinity: renderEditable.selection!.affinity); - - _startCaretRect = - renderEditable.getLocalRectForCaret(currentTextPosition); - - _lastBoundedOffset = _startCaretRect!.center - _floatingCursorOffset; + final Offset startCaretCenter; + final TextPosition currentTextPosition; + final bool shouldResetOrigin; + // Only non-null when starting a floating cursor via long press. + if (point.startLocation != null) { + shouldResetOrigin = false; + (startCaretCenter, currentTextPosition) = point.startLocation!; + } else { + shouldResetOrigin = true; + // zmtzawqlp + currentTextPosition = supportSpecialText + ? ExtendedTextLibraryUtils + .convertTextInputPostionToTextPainterPostion( + renderEditable.text!, + renderEditable.selection!.base, + ) + : TextPosition( + offset: renderEditable.selection!.baseOffset, + affinity: renderEditable.selection!.affinity); + startCaretCenter = + renderEditable.getLocalRectForCaret(currentTextPosition).center; + } + + _startCaretCenter = startCaretCenter; + _lastBoundedOffset = + renderEditable.calculateBoundedFloatingCursorOffset( + _startCaretCenter! - _floatingCursorOffset, + shouldResetOrigin: shouldResetOrigin); _lastTextPosition = currentTextPosition; renderEditable.setFloatingCursor( point.state, _lastBoundedOffset!, _lastTextPosition!); - break; case FloatingCursorDragState.Update: final Offset centeredPoint = point.offset! - _pointOffsetOrigin!; final Offset rawCursorOffset = - _startCaretRect!.center + centeredPoint - _floatingCursorOffset; + _startCaretCenter! + centeredPoint - _floatingCursorOffset; _lastBoundedOffset = renderEditable .calculateBoundedFloatingCursorOffset(rawCursorOffset); @@ -801,7 +823,6 @@ class ExtendedEditableTextState extends _EditableTextState { renderEditable.setFloatingCursor( point.state, _lastBoundedOffset!, _lastTextPosition!); - break; case FloatingCursorDragState.End: // Resume cursor blinking. _startCursorBlink(); @@ -813,7 +834,6 @@ class ExtendedEditableTextState extends _EditableTextState { duration: _EditableTextState._floatingCursorResetTime, curve: Curves.decelerate); } - break; } } diff --git a/lib/src/extended/widgets/text_field.dart b/lib/src/extended/widgets/text_field.dart index 5b72b46..64711e3 100644 --- a/lib/src/extended/widgets/text_field.dart +++ b/lib/src/extended/widgets/text_field.dart @@ -49,6 +49,7 @@ class ExtendedTextField extends _TextField { super.toolbarOptions, super.showCursor, super.autofocus = false, + super.statesController, super.obscuringCharacter = '•', super.obscureText = false, super.autocorrect = true, @@ -71,6 +72,7 @@ class ExtendedTextField extends _TextField { super.cursorRadius, super.cursorOpacityAnimates, super.cursorColor, + super.cursorErrorColor, super.selectionHeightStyle = ui.BoxHeightStyle.tight, super.selectionWidthStyle = ui.BoxWidthStyle.tight, super.keyboardAppearance, @@ -79,6 +81,7 @@ class ExtendedTextField extends _TextField { super.enableInteractiveSelection, super.selectionControls, super.onTap, + super.onTapAlwaysCalled = false, super.onTapOutside, super.mouseCursor, super.buildCounter, @@ -98,6 +101,7 @@ class ExtendedTextField extends _TextField { // super.spellCheckConfiguration, this.extendedSpellCheckConfiguration, this.specialTextSpanBuilder, + super.magnifierConfiguration, }); /// build your ccustom text span @@ -554,7 +558,7 @@ class ExtendedTextFieldState extends _TextFieldState { final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs( widget.mouseCursor ?? MaterialStateMouseCursor.textable, - _materialState, + _statesController.value, ); final int? semanticsMaxValueLength; diff --git a/lib/src/official/material/selectable_text.dart b/lib/src/official/material/selectable_text.dart index 557f9b2..61443f1 100644 --- a/lib/src/official/material/selectable_text.dart +++ b/lib/src/official/material/selectable_text.dart @@ -632,7 +632,7 @@ class _SelectableTextState extends State<_SelectableText> assert(debugCheckHasDirectionality(context)); assert( !(widget.style != null && - widget.style!.inherit == false && + !widget.style!.inherit && (widget.style!.fontSize == null || widget.style!.textBaseline == null)), 'inherit false style must supply fontSize and textBaseline', diff --git a/lib/src/official/rendering/editable.dart b/lib/src/official/rendering/editable.dart index 7b2146f..56e1861 100644 --- a/lib/src/official/rendering/editable.dart +++ b/lib/src/official/rendering/editable.dart @@ -10,6 +10,13 @@ const EdgeInsets _kFloatingCursorSizeIncrease = // The corner radius of the floating cursor in pixels. const Radius _kFloatingCursorRadius = Radius.circular(1.0); +// This constant represents the shortest squared distance required between the floating cursor +// and the regular cursor when both are present in the text field. +// If the squared distance between the two cursors is less than this value, +// it's not necessary to display both cursors at the same time. +// This behavior is consistent with the one observed in iOS UITextField. +const double _kShortestDistanceSquaredWithFloatingAndRegularCursors = + 15.0 * 15.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 @@ -306,7 +313,8 @@ class _RenderEditable extends RenderBox _readOnly = readOnly, _forceLine = forceLine, _clipBehavior = clipBehavior, - _hasFocus = hasFocus ?? false { + _hasFocus = hasFocus ?? false, + _disposeShowCursor = showCursor == null { assert(!_showCursor.value || cursorColor != null); _selectionPainter.highlightColor = selectionColor; @@ -333,6 +341,7 @@ class _RenderEditable extends RenderBox @override void dispose() { + _leaderLayerHandler.layer = null; _foregroundRenderObject?.dispose(); _foregroundRenderObject = null; _backgroundRenderObject?.dispose(); @@ -346,6 +355,10 @@ class _RenderEditable extends RenderBox _selectionPainter.dispose(); _caretPainter.dispose(); _textPainter.dispose(); + if (_disposeShowCursor) { + _showCursor.dispose(); + _disposeShowCursor = false; + } super.dispose(); } @@ -834,6 +847,8 @@ class _RenderEditable extends RenderBox _caretPainter.backgroundCursorColor = value; } + bool _disposeShowCursor; + /// Whether to paint the cursor. ValueNotifier get showCursor => _showCursor; ValueNotifier _showCursor; @@ -844,6 +859,10 @@ class _RenderEditable extends RenderBox if (attached) { _showCursor.removeListener(_showHideCursor); } + if (_disposeShowCursor) { + _showCursor.dispose(); + _disposeShowCursor = false; + } _showCursor = value; if (attached) { _showHideCursor(); @@ -1806,12 +1825,30 @@ class _RenderEditable extends RenderBox @override double computeMinIntrinsicWidth(double height) { + if (!_canComputeIntrinsics) { + return 0.0; + } + _textPainter.setPlaceholderDimensions(layoutInlineChildren( + double.infinity, + (RenderBox child, BoxConstraints constraints) => + Size(child.getMinIntrinsicWidth(double.infinity), 0.0), + )); _layoutText(); return _textPainter.minIntrinsicWidth; } @override double computeMaxIntrinsicWidth(double height) { + if (!_canComputeIntrinsics) { + return 0.0; + } + _textPainter.setPlaceholderDimensions(layoutInlineChildren( + double.infinity, + // Height and baseline is irrelevant as all text will be laid + // out in a single line. Therefore, using 0.0 as a dummy for the height. + (RenderBox child, BoxConstraints constraints) => + Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), + )); _layoutText(); return _textPainter.maxIntrinsicWidth + _caretMargin; } @@ -1880,12 +1917,16 @@ class _RenderEditable extends RenderBox } @override - double computeMinIntrinsicHeight(double width) { - return _preferredHeight(width); - } + double computeMinIntrinsicHeight(double width) => + computeMaxIntrinsicHeight(width); @override double computeMaxIntrinsicHeight(double width) { + if (!_canComputeIntrinsics) { + return 0.0; + } + _textPainter.setPlaceholderDimensions( + layoutInlineChildren(width, ChildLayoutHelper.dryLayoutChild)); return _preferredHeight(width); } @@ -1902,9 +1943,19 @@ class _RenderEditable extends RenderBox @protected bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { final Offset effectivePosition = position - _paintOffset; - final InlineSpan? textSpan = _textPainter.text; - switch (textSpan?.getSpanForPosition( - _textPainter.getPositionForOffset(effectivePosition))) { + final GlyphInfo? glyph = + _textPainter.getClosestGlyphForOffset(effectivePosition); + // The hit-test can't fall through the horizontal gaps between visually + // adjacent characters on the same line, even with a large letter-spacing or + // text justification, as graphemeClusterLayoutBounds.width is the advance + // width to the next character, so there's no gap between their + // graphemeClusterLayoutBounds rects. + final InlineSpan? spanHit = glyph != null && + glyph.graphemeClusterLayoutBounds.contains(effectivePosition) + ? _textPainter.text!.getSpanForPosition( + TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start)) + : null; + switch (spanHit) { case final HitTestTarget span: result.add(HitTestEntry(span)); return true; @@ -2290,7 +2341,8 @@ class _RenderEditable extends RenderBox _canComputeIntrinsicsCached ??= _canComputeDryLayoutForInlineWidgets(); @override - Size computeDryLayout(BoxConstraints constraints) { + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { if (!_canComputeIntrinsics) { assert(debugCannotComputeDryLayout( reason: @@ -2349,22 +2401,43 @@ class _RenderEditable extends RenderBox // difference in the rendering position and the raw offset value. Offset _relativeOrigin = Offset.zero; Offset? _previousOffset; + bool _shouldResetOrigin = true; bool _resetOriginOnLeft = false; bool _resetOriginOnRight = false; bool _resetOriginOnTop = false; bool _resetOriginOnBottom = false; double? _resetFloatingCursorAnimationValue; + static Offset _calculateAdjustedCursorOffset( + Offset offset, Rect boundingRects) { + final double adjustedX = + clampDouble(offset.dx, boundingRects.left, boundingRects.right); + final double adjustedY = + clampDouble(offset.dy, boundingRects.top, boundingRects.bottom); + return Offset(adjustedX, adjustedY); + } + /// Returns the position within the text field closest to the raw cursor offset. - Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset) { + Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset, + {bool? shouldResetOrigin}) { Offset deltaPosition = Offset.zero; final double topBound = -floatingCursorAddedMargin.top; - final double bottomBound = _textPainter.height - + final double bottomBound = math.min(size.height, _textPainter.height) - preferredLineHeight + floatingCursorAddedMargin.bottom; final double leftBound = -floatingCursorAddedMargin.left; - final double rightBound = - _textPainter.width + floatingCursorAddedMargin.right; + final double rightBound = math.min(size.width, _textPainter.width) + + floatingCursorAddedMargin.right; + final Rect boundingRects = + Rect.fromLTRB(leftBound, topBound, rightBound, bottomBound); + + if (shouldResetOrigin != null) { + _shouldResetOrigin = shouldResetOrigin; + } + + if (!_shouldResetOrigin) { + return _calculateAdjustedCursorOffset(rawCursorOffset, boundingRects); + } if (_previousOffset != null) { deltaPosition = rawCursorOffset - _previousOffset!; @@ -2374,39 +2447,36 @@ class _RenderEditable extends RenderBox // origin of the dragging when the user drags back into the field. if (_resetOriginOnLeft && deltaPosition.dx > 0) { _relativeOrigin = - Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); + Offset(rawCursorOffset.dx - boundingRects.left, _relativeOrigin.dy); _resetOriginOnLeft = false; } else if (_resetOriginOnRight && deltaPosition.dx < 0) { _relativeOrigin = - Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); + Offset(rawCursorOffset.dx - boundingRects.right, _relativeOrigin.dy); _resetOriginOnRight = false; } if (_resetOriginOnTop && deltaPosition.dy > 0) { _relativeOrigin = - Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); + Offset(_relativeOrigin.dx, rawCursorOffset.dy - boundingRects.top); _resetOriginOnTop = false; } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { _relativeOrigin = - Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); + Offset(_relativeOrigin.dx, rawCursorOffset.dy - boundingRects.bottom); _resetOriginOnBottom = false; } final double currentX = rawCursorOffset.dx - _relativeOrigin.dx; final double currentY = rawCursorOffset.dy - _relativeOrigin.dy; - final double adjustedX = - math.min(math.max(currentX, leftBound), rightBound); - final double adjustedY = - math.min(math.max(currentY, topBound), bottomBound); - final Offset adjustedOffset = Offset(adjustedX, adjustedY); + final Offset adjustedOffset = _calculateAdjustedCursorOffset( + Offset(currentX, currentY), boundingRects); - if (currentX < leftBound && deltaPosition.dx < 0) { + if (currentX < boundingRects.left && deltaPosition.dx < 0) { _resetOriginOnLeft = true; - } else if (currentX > rightBound && deltaPosition.dx > 0) { + } else if (currentX > boundingRects.right && deltaPosition.dx > 0) { _resetOriginOnRight = true; } - if (currentY < topBound && deltaPosition.dy < 0) { + if (currentY < boundingRects.top && deltaPosition.dy < 0) { _resetOriginOnTop = true; - } else if (currentY > bottomBound && deltaPosition.dy > 0) { + } else if (currentY > boundingRects.bottom && deltaPosition.dy > 0) { _resetOriginOnBottom = true; } @@ -2420,9 +2490,10 @@ class _RenderEditable extends RenderBox void setFloatingCursor(FloatingCursorDragState state, Offset boundedOffset, TextPosition lastTextPosition, {double? resetLerpValue}) { - if (state == FloatingCursorDragState.Start) { + if (state == FloatingCursorDragState.End) { _relativeOrigin = Offset.zero; _previousOffset = null; + _shouldResetOrigin = true; _resetOriginOnBottom = false; _resetOriginOnTop = false; _resetOriginOnRight = false; @@ -2522,6 +2593,9 @@ class _RenderEditable extends RenderBox } } + final LayerHandle _leaderLayerHandler = + LayerHandle(); + void _paintHandleLayers(PaintingContext context, List endpoints, Offset offset) { Offset startPoint = endpoints[0].point; @@ -2529,8 +2603,10 @@ class _RenderEditable extends RenderBox clampDouble(startPoint.dx, 0.0, size.width), clampDouble(startPoint.dy, 0.0, size.height), ); + _leaderLayerHandler.layer = + LeaderLayer(link: startHandleLayerLink, offset: startPoint + offset); context.pushLayer( - LeaderLayer(link: startHandleLayerLink, offset: startPoint + offset), + _leaderLayerHandler.layer!, super.paint, Offset.zero, ); @@ -2685,7 +2761,9 @@ class _RenderEditableCustomPaint extends RenderBox { } @override - Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; + @protected + Size computeDryLayout(covariant BoxConstraints constraints) => + constraints.biggest; } /// An interface that paints within a [RenderEditable]'s bounds, above or @@ -2919,6 +2997,14 @@ class _CaretPainter extends RenderEditablePainter { Color caretColor, TextPosition textPosition) { final Rect integralRect = renderEditable.getLocalRectForCaret(textPosition); if (shouldPaint) { + if (floatingCursorRect != null) { + final double distanceSquared = + (floatingCursorRect!.center - integralRect.center).distanceSquared; + if (distanceSquared < + _kShortestDistanceSquaredWithFloatingAndRegularCursors) { + return; + } + } final Radius? radius = cursorRadius; caretPaint.color = caretColor; if (radius == null) { diff --git a/lib/src/official/widgets/editable_text.dart b/lib/src/official/widgets/editable_text.dart index 101c7f3..a6e526d 100644 --- a/lib/src/official/widgets/editable_text.dart +++ b/lib/src/official/widgets/editable_text.dart @@ -474,7 +474,7 @@ class _EditableText extends StatefulWidget { assert( spellCheckConfiguration == null || spellCheckConfiguration == - const SpellCheckConfiguration.disabled() || + const _SpellCheckConfiguration.disabled() || spellCheckConfiguration.misspelledTextStyle != null, 'spellCheckConfiguration must specify a misspelledTextStyle if spell check behavior is desired', ), @@ -628,7 +628,7 @@ class _EditableText extends StatefulWidget { if (_strutStyle == null) { return StrutStyle.fromTextStyle(style, forceStrutHeight: true); } - return _strutStyle!.inheritFromTextStyle(style); + return _strutStyle.inheritFromTextStyle(style); } final StrutStyle? _strutStyle; @@ -1503,6 +1503,11 @@ class _EditableText extends StatefulWidget { // If the paste button is enabled, don't render anything until the state // of the clipboard is known, since it's used to determine if paste is // shown. + + // On Android, the share button is before the select all button. + final bool showShareBeforeSelectAll = + defaultTargetPlatform == TargetPlatform.android; + resultButtonItem.addAll([ if (onCut != null) ContextMenuButtonItem( @@ -1519,6 +1524,11 @@ class _EditableText extends StatefulWidget { onPressed: onPaste, type: ContextMenuButtonType.paste, ), + if (onShare != null && showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, + type: ContextMenuButtonType.share, + ), if (onSelectAll != null) ContextMenuButtonItem( onPressed: onSelectAll, @@ -1534,7 +1544,7 @@ class _EditableText extends StatefulWidget { onPressed: onSearchWeb, type: ContextMenuButtonType.searchWeb, ), - if (onShare != null) + if (onShare != null && !showShareBeforeSelectAll) ContextMenuButtonItem( onPressed: onShare, type: ContextMenuButtonType.share, @@ -1869,7 +1879,7 @@ class _EditableTextState extends State<_EditableText> /// Whether or not spell check is enabled. /// - /// Spell check is enabled when a [_SpellCheckConfiguration] has been specified + /// Spell check is enabled when a [SpellCheckConfiguration] has been specified /// for the widget. bool get spellCheckEnabled => _spellCheckConfiguration.spellCheckEnabled; @@ -1885,6 +1895,12 @@ class _EditableTextState extends State<_EditableText> spellCheckResults != null && spellCheckResults!.suggestionSpans.isNotEmpty; + /// The text processing service used to retrieve the native text processing actions. + final ProcessTextService _processTextService = DefaultProcessTextService(); + + /// The list of native text processing actions provided by the engine. + final List _processTextActions = []; + /// Whether to create an input connection with the platform for text editing /// or not. /// @@ -2000,14 +2016,21 @@ class _EditableTextState extends State<_EditableText> @override bool get shareEnabled { - if (defaultTargetPlatform != TargetPlatform.iOS) { - return false; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return !widget.obscureText && + !textEditingValue.selection.isCollapsed && + textEditingValue.selection + .textInside(textEditingValue.text) + .trim() != + ''; + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; } - - return !widget.obscureText && - !textEditingValue.selection.isCollapsed && - textEditingValue.selection.textInside(textEditingValue.text).trim() != - ''; } @override @@ -2092,20 +2115,20 @@ class _EditableTextState extends State<_EditableText> if (mounted) { bringIntoView(textEditingValue.selection.extent); } - }); + }, debugLabel: 'EditableText.bringSelectionIntoView'); hideToolbar(); } clipboardStatus.update(); } + bool get _allowPaste { + return !widget.readOnly && textEditingValue.selection.isValid; + } + /// Paste text from [Clipboard]. @override Future pasteText(SelectionChangedCause cause) async { - if (widget.readOnly) { - return; - } - final TextSelection selection = textEditingValue.selection; - if (!selection.isValid) { + if (!_allowPaste) { return; } // Snapshot the input before using `await`. @@ -2114,9 +2137,17 @@ class _EditableTextState extends State<_EditableText> if (data == null) { return; } + _pasteText(cause, data.text!); + } + + void _pasteText(SelectionChangedCause cause, String text) { + if (!_allowPaste) { + return; + } // After the paste, the cursor should be collapsed and located after the // pasted content. + final TextSelection selection = textEditingValue.selection; final int lastSelectionIndex = math.max(selection.baseOffset, selection.extentOffset); final TextEditingValue collapsedTextEditingValue = @@ -2125,7 +2156,7 @@ class _EditableTextState extends State<_EditableText> ); userUpdateTextEditingValue( - collapsedTextEditingValue.replaced(selection, data.text!), + collapsedTextEditingValue.replaced(selection, text), cause, ); if (cause == SelectionChangedCause.toolbar) { @@ -2134,7 +2165,7 @@ class _EditableTextState extends State<_EditableText> if (mounted) { bringIntoView(textEditingValue.selection.extent); } - }); + }, debugLabel: 'EditableText.bringSelectionIntoView'); hideToolbar(); } } @@ -2223,9 +2254,9 @@ class _EditableTextState extends State<_EditableText> } /// Launch the share interface for the current selection, - /// as in the "Share" edit menu button on iOS. + /// as in the "Share..." edit menu button on iOS. /// - /// Currently this is only implemented for iOS. + /// Currently this is only implemented for iOS and Android. /// /// When 'obscureText' is true or the selection is empty, /// this function will not do anything @@ -2497,7 +2528,38 @@ class _EditableTextState extends State<_EditableText> onLiveTextInput: liveTextInputEnabled ? () => _startLiveTextInput(SelectionChangedCause.toolbar) : null, - ); + ) + ..addAll(_textProcessingActionButtonItems); + } + + List get _textProcessingActionButtonItems { + final List buttonItems = []; + final TextSelection selection = textEditingValue.selection; + if (widget.obscureText || !selection.isValid || selection.isCollapsed) { + return buttonItems; + } + + for (final ProcessTextAction action in _processTextActions) { + buttonItems.add(ContextMenuButtonItem( + label: action.label, + onPressed: () async { + final String selectedText = + selection.textInside(textEditingValue.text); + if (selectedText.isNotEmpty) { + final String? processedText = await _processTextService + .processTextAction(action.id, selectedText, widget.readOnly); + // If an activity does not return a modified version, just hide the toolbar. + // Otherwise use the result to replace the selected text. + if (processedText != null && _allowPaste) { + _pasteText(SelectionChangedCause.toolbar, processedText); + } else { + hideToolbar(); + } + } + }, + )); + } + return buttonItems; } // State lifecycle: @@ -2514,6 +2576,14 @@ class _EditableTextState extends State<_EditableText> // zmtzawqlp // _spellCheckConfiguration = // _inferSpellCheckConfiguration(widget.spellCheckConfiguration); + _initProcessTextActions(); + } + + /// Query the engine to initialize the list of text processing actions to show + /// in the text selection toolbar. + Future _initProcessTextActions() async { + _processTextActions.clear(); + _processTextActions.addAll(await _processTextService.queryTextActions()); } // Whether `TickerMode.of(context)` is true and animations (like blinking the @@ -2542,7 +2612,7 @@ class _EditableTextState extends State<_EditableText> _flagInternalFocus(); FocusScope.of(context).autofocus(widget.focusNode); } - }); + }, debugLabel: 'EditableText.autofocus'); } // Restart or stop the blinking cursor when TickerMode changes. @@ -2618,7 +2688,7 @@ class _EditableTextState extends State<_EditableText> // See https://github.com/flutter/flutter/issues/126312 SchedulerBinding.instance.addPostFrameCallback((Duration _) { _openInputConnection(); - }); + }, debugLabel: 'EditableText.openInputConnection'); } if (kIsWeb && _hasInputConnection) { @@ -2836,7 +2906,7 @@ class _EditableTextState extends State<_EditableText> } // The original position of the caret on FloatingCursorDragState.start. - Rect? _startCaretRect; + Offset? _startCaretCenter; // The most recent text position as determined by the location of the floating // cursor. @@ -2872,20 +2942,34 @@ class _EditableTextState extends State<_EditableText> // we cache the position. _pointOffsetOrigin = point.offset; - final TextPosition currentTextPosition = TextPosition( - offset: renderEditable.selection!.baseOffset, - affinity: renderEditable.selection!.affinity); - _startCaretRect = - renderEditable.getLocalRectForCaret(currentTextPosition); + final Offset startCaretCenter; + final TextPosition currentTextPosition; + final bool shouldResetOrigin; + // Only non-null when starting a floating cursor via long press. + if (point.startLocation != null) { + shouldResetOrigin = false; + (startCaretCenter, currentTextPosition) = point.startLocation!; + } else { + shouldResetOrigin = true; + currentTextPosition = TextPosition( + offset: renderEditable.selection!.baseOffset, + affinity: renderEditable.selection!.affinity); + startCaretCenter = + renderEditable.getLocalRectForCaret(currentTextPosition).center; + } - _lastBoundedOffset = _startCaretRect!.center - _floatingCursorOffset; + _startCaretCenter = startCaretCenter; + _lastBoundedOffset = + renderEditable.calculateBoundedFloatingCursorOffset( + _startCaretCenter! - _floatingCursorOffset, + shouldResetOrigin: shouldResetOrigin); _lastTextPosition = currentTextPosition; renderEditable.setFloatingCursor( point.state, _lastBoundedOffset!, _lastTextPosition!); case FloatingCursorDragState.Update: final Offset centeredPoint = point.offset! - _pointOffsetOrigin!; final Offset rawCursorOffset = - _startCaretRect!.center + centeredPoint - _floatingCursorOffset; + _startCaretCenter! + centeredPoint - _floatingCursorOffset; _lastBoundedOffset = renderEditable .calculateBoundedFloatingCursorOffset(rawCursorOffset); @@ -2933,7 +3017,7 @@ class _EditableTextState extends State<_EditableText> _handleSelectionChanged(TextSelection.fromPosition(_lastTextPosition!), SelectionChangedCause.forcePress); } - _startCaretRect = null; + _startCaretCenter = null; _lastTextPosition = null; _pointOffsetOrigin = null; _lastBoundedOffset = null; @@ -3496,7 +3580,7 @@ class _EditableTextState extends State<_EditableText> rect: caretPadding.inflateRect(rectToReveal), ); } - }); + }, debugLabel: 'EditableText.showCaret'); } late double _lastBottomViewInset; @@ -3510,7 +3594,7 @@ class _EditableTextState extends State<_EditableText> if (_lastBottomViewInset != view.viewInsets.bottom) { SchedulerBinding.instance.addPostFrameCallback((Duration _) { _selectionOverlay?.updateForScroll(); - }); + }, debugLabel: 'EditableText.updateForScroll'); if (_lastBottomViewInset < view.viewInsets.bottom) { // Because the metrics change signal from engine will come here every frame // (on both iOS and Android). So we don't need to show caret with animation. @@ -3649,10 +3733,13 @@ class _EditableTextState extends State<_EditableText> } void _onCursorColorTick() { + final double effectiveOpacity = math.min( + widget.cursorColor.alpha / 255.0, _cursorBlinkOpacityController.value); renderEditable.cursorColor = - widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value); - _cursorVisibilityNotifier.value = - widget.showCursor && _cursorBlinkOpacityController.value > 0; + widget.cursorColor.withOpacity(effectiveOpacity); + _cursorVisibilityNotifier.value = widget.showCursor && + (EditableText.debugDeterministicCursor || + _cursorBlinkOpacityController.value > 0); } bool get _showBlinkingCursor => @@ -3844,8 +3931,9 @@ class _EditableTextState extends State<_EditableText> _updateSelectionRects(); _updateComposingRectIfNeeded(); _updateCaretRectIfNeeded(); - SchedulerBinding.instance - .addPostFrameCallback(_schedulePeriodicPostFrameCallbacks); + SchedulerBinding.instance.addPostFrameCallback( + _schedulePeriodicPostFrameCallbacks, + debugLabel: 'EditableText.postFrameCallbacks'); } _ScribbleCacheKey? _scribbleCacheKey; @@ -3921,11 +4009,13 @@ class _EditableTextState extends State<_EditableText> _textInputConnection!.setSelectionRects(rects); } - // 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. + // Sends the current composing rect to the embedder's text input plugin. + // + // In cases where the composing rect hasn't been updated in the embedder due + // to the lag of asynchronous messages over the channel, the position of the + // current caret rect is used instead. + // + // See: [_updateCaretRectIfNeeded] void _updateComposingRectIfNeeded() { final TextRange composingRange = _value.composing; assert(mounted); @@ -3941,9 +4031,21 @@ class _EditableTextState extends State<_EditableText> _textInputConnection!.setComposingRect(composingRect); } + // Sends the current caret rect to the embedder's text input plugin. + // + // The position of the caret rect is updated periodically such that if the + // user initiates composing input, the current cursor rect can be used for + // the first character until the composing rect can be sent. + // + // On selection changes, the start of the selection is used. This ensures + // that regardless of the direction the selection was created, the cursor is + // set to the position where next text input occurs. This position is used to + // position the IME's candidate selection menu. + // + // See: [_updateComposingRectIfNeeded] void _updateCaretRectIfNeeded() { final TextSelection? selection = renderEditable.selection; - if (selection == null || !selection.isValid || !selection.isCollapsed) { + if (selection == null || !selection.isValid) { return; } final TextPosition currentTextPosition = @@ -4510,6 +4612,15 @@ class _EditableTextState extends State<_EditableText> } void _updateSelection(UpdateSelectionIntent intent) { + assert( + intent.newSelection.start <= intent.currentTextEditingValue.text.length, + 'invalid selection: ${intent.newSelection}: it must not exceed the current text length ${intent.currentTextEditingValue.text.length}', + ); + assert( + intent.newSelection.end <= intent.currentTextEditingValue.text.length, + 'invalid selection: ${intent.newSelection}: it must not exceed the current text length ${intent.currentTextEditingValue.text.length}', + ); + bringIntoView(intent.newSelection.extent); userUpdateTextEditingValue( intent.currentTextEditingValue.copyWith(selection: intent.newSelection), @@ -4661,7 +4772,7 @@ class _EditableTextState extends State<_EditableText> // compositeCallback: _compositeCallback, // enabled: _hasInputConnection, // child: TextFieldTapRegion( - // onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside, + // onTapOutside: _hasFocus ? widget.onTapOutside ?? _defaultOnTapOutside : null, // debugLabel: kReleaseMode ? null : 'EditableText', // child: MouseRegion( // cursor: widget.mouseCursor ?? SystemMouseCursors.text, @@ -4700,6 +4811,13 @@ class _EditableTextState extends State<_EditableText> // return oldValue.text != newValue.text || oldValue.composing != newValue.composing; // }, + // undoStackModifier: (TextEditingValue value) { + // // On Android we should discard the composing region when pushing + // // a new entry to the undo stack. This prevents the TextInputPlugin + // // from restarting the input on every undo/redo when the composing + // // region is changed by the framework. + // return defaultTargetPlatform == TargetPlatform.android ? value.copyWith(composing: TextRange.empty) : value; + // }, // focusNode: widget.focusNode, // controller: widget.undoController, // child: Focus( diff --git a/lib/src/official/widgets/text_field.dart b/lib/src/official/widgets/text_field.dart index 45d487a..44a98f0 100644 --- a/lib/src/official/widgets/text_field.dart +++ b/lib/src/official/widgets/text_field.dart @@ -27,6 +27,13 @@ class _TextFieldSelectionGestureDetectorBuilder void onSingleTapUp(TapDragUpDetails details) { super.onSingleTapUp(details); _state._requestKeyboard(); + } + + @override + bool get onUserTapAlwaysCalled => _state.widget.onTapAlwaysCalled; + + @override + void onUserTap() { _state.widget.onTap?.call(); } @@ -224,6 +231,7 @@ class _TextField extends StatefulWidget { this.toolbarOptions, this.showCursor, this.autofocus = false, + this.statesController, this.obscuringCharacter = '•', this.obscureText = false, this.autocorrect = true, @@ -246,6 +254,7 @@ class _TextField extends StatefulWidget { this.cursorRadius, this.cursorOpacityAnimates, this.cursorColor, + this.cursorErrorColor, this.selectionHeightStyle = ui.BoxHeightStyle.tight, this.selectionWidthStyle = ui.BoxWidthStyle.tight, this.keyboardAppearance, @@ -254,6 +263,7 @@ class _TextField extends StatefulWidget { bool? enableInteractiveSelection, this.selectionControls, this.onTap, + this.onTapAlwaysCalled = false, this.onTapOutside, this.mouseCursor, this.buildCounter, @@ -375,10 +385,12 @@ class _TextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.keyboardType} final TextInputType keyboardType; + /// {@template flutter.widgets.TextField.textInputAction} /// The type of action button to use for the keyboard. /// /// Defaults to [TextInputAction.newline] if [keyboardType] is /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + /// {@endtemplate} final TextInputAction? textInputAction; /// {@macro flutter.widgets.editableText.textCapitalization} @@ -411,6 +423,27 @@ class _TextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.autofocus} final bool autofocus; + /// Represents the interactive "state" of this widget in terms of a set of + /// [MaterialState]s, including [MaterialState.disabled], [MaterialState.hovered], + /// [MaterialState.error], and [MaterialState.focused]. + /// + /// Classes based on this one can provide their own + /// [MaterialStatesController] to which they've added listeners. + /// They can also update the controller's [MaterialStatesController.value] + /// however, this may only be done when it's safe to call + /// [State.setState], like in an event handler. + /// + /// The controller's [MaterialStatesController.value] represents the set of + /// states that a widget's visual properties, typically [MaterialStateProperty] + /// values, are resolved against. It is _not_ the intrinsic state of the widget. + /// The widget is responsible for ensuring that the controller's + /// [MaterialStatesController.value] tracks its intrinsic state. For example + /// one cannot request the keyboard focus for a widget by adding [MaterialState.focused] + /// to its controller. When the widget gains the or loses the focus it will + /// [MaterialStatesController.update] its controller's [MaterialStatesController.value] + /// and notify listeners of the change. + final MaterialStatesController? statesController; + /// {@macro flutter.widgets.editableText.obscuringCharacter} final String obscuringCharacter; @@ -560,6 +593,13 @@ class _TextField extends StatefulWidget { /// the value of [ColorScheme.primary] of [ThemeData.colorScheme]. final Color? cursorColor; + /// The color of the cursor when the [InputDecorator] is showing an error. + /// + /// If this is null it will default to [TextStyle.color] of + /// [InputDecoration.errorStyle]. If that is null, it will use + /// [ColorScheme.error] of [ThemeData.colorScheme]. + final Color? cursorErrorColor; + /// Controls how tall the selection highlight boxes are computed to be. /// /// See [ui.BoxHeightStyle] for details on available styles. @@ -593,7 +633,7 @@ class _TextField extends StatefulWidget { bool get selectionEnabled => enableInteractiveSelection; /// {@template flutter.material.textfield.onTap} - /// Called for each distinct tap except for every second tap of a double tap. + /// Called for the first tap in a series of taps. /// /// The text field builds a [GestureDetector] to handle input events like tap, /// to trigger focus requests, to move the caret, adjust the selection, etc. @@ -612,8 +652,17 @@ class _TextField extends StatefulWidget { /// To listen to arbitrary pointer events without competing with the /// text field's internal gesture detector, use a [Listener]. /// {@endtemplate} + /// + /// If [onTapAlwaysCalled] is enabled, this will also be called for consecutive + /// taps. final GestureTapCallback? onTap; + /// Whether [onTap] should be called for every tap. + /// + /// Defaults to false, so [onTap] is only called for each distinct tap. When + /// enabled, [onTap] is called for every tap including consecutive taps. + final bool onTapAlwaysCalled; + /// {@macro flutter.widgets.editableText.onTapOutside} /// /// {@tool dartpad} @@ -900,6 +949,8 @@ class _TextField extends StatefulWidget { defaultValue: null)); properties .add(ColorProperty('cursorColor', cursorColor, defaultValue: null)); + properties.add(ColorProperty('cursorErrorColor', cursorErrorColor, + defaultValue: null)); properties.add(DiagnosticsProperty( 'keyboardAppearance', keyboardAppearance, defaultValue: null)); @@ -985,12 +1036,20 @@ class _TextFieldState extends State<_TextField> bool get _hasIntrinsicError => widget.maxLength != null && widget.maxLength! > 0 && - _effectiveController.value.text.characters.length > widget.maxLength!; + (widget.controller == null + ? !restorePending && + _effectiveController.value.text.characters.length > + widget.maxLength! + : _effectiveController.value.text.characters.length > + widget.maxLength!); bool get _hasError => - widget.decoration?.errorText != null || _hasIntrinsicError; + widget.decoration?.errorText != null || + widget.decoration?.error != null || + _hasIntrinsicError; Color get _errorColor => + widget.cursorErrorColor ?? widget.decoration?.errorStyle?.color ?? Theme.of(context).colorScheme.error; @@ -1047,8 +1106,8 @@ class _TextFieldState extends State<_TextField> if (widget.maxLength! > 0) { // Show the maxLength in the counter counterText += '/${widget.maxLength}'; - final int remaining = (widget.maxLength! - currentLength) - .clamp(0, widget.maxLength!); // ignore_clamp_double_lint + final int remaining = + (widget.maxLength! - currentLength).clamp(0, widget.maxLength!); semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining); } @@ -1081,6 +1140,7 @@ class _TextFieldState extends State<_TextField> } _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled; _effectiveFocusNode.addListener(_handleFocusChanged); + _initStatesController(); } bool get _canRequestFocus { @@ -1125,6 +1185,21 @@ class _TextFieldState extends State<_TextField> _showSelectionHandles = !widget.readOnly; } } + + if (widget.statesController == oldWidget.statesController) { + _statesController.update(MaterialState.disabled, !_isEnabled); + _statesController.update(MaterialState.hovered, _isHovering); + _statesController.update( + MaterialState.focused, _effectiveFocusNode.hasFocus); + _statesController.update(MaterialState.error, _hasError); + } else { + oldWidget.statesController?.removeListener(_handleStatesControllerChange); + if (widget.statesController != null) { + _internalStatesController?.dispose(); + _internalStatesController = null; + } + _initStatesController(); + } } @override @@ -1157,6 +1232,8 @@ class _TextFieldState extends State<_TextField> _effectiveFocusNode.removeListener(_handleFocusChanged); _focusNode?.dispose(); _controller?.dispose(); + _statesController.removeListener(_handleStatesControllerChange); + _internalStatesController?.dispose(); super.dispose(); } @@ -1203,6 +1280,8 @@ class _TextFieldState extends State<_TextField> // Rebuild the widget on focus change to show/hide the text selection // highlight. }); + _statesController.update( + MaterialState.focused, _effectiveFocusNode.hasFocus); } void _handleSelectionChanged( @@ -1252,9 +1331,33 @@ class _TextFieldState extends State<_TextField> setState(() { _isHovering = hovering; }); + _statesController.update(MaterialState.hovered, _isHovering); } } + // Material states controller. + MaterialStatesController? _internalStatesController; + + void _handleStatesControllerChange() { + // Force a rebuild to resolve MaterialStateProperty properties. + setState(() {}); + } + + MaterialStatesController get _statesController => + widget.statesController ?? _internalStatesController!; + + void _initStatesController() { + if (widget.statesController == null) { + _internalStatesController = MaterialStatesController(); + } + _statesController.update(MaterialState.disabled, !_isEnabled); + _statesController.update(MaterialState.hovered, _isHovering); + _statesController.update( + MaterialState.focused, _effectiveFocusNode.hasFocus); + _statesController.update(MaterialState.error, _hasError); + _statesController.addListener(_handleStatesControllerChange); + } + // AutofillClient implementation start. @override String get autofillId => _editableText!.autofillId; @@ -1281,24 +1384,15 @@ class _TextFieldState extends State<_TextField> } // AutofillClient implementation end. - Set get _materialState { - return { - if (!_isEnabled) MaterialState.disabled, - if (_isHovering) MaterialState.hovered, - if (_effectiveFocusNode.hasFocus) MaterialState.focused, - if (_hasError) MaterialState.error, - }; - } - TextStyle _getInputStyleForState(TextStyle style) { final ThemeData theme = Theme.of(context); final TextStyle stateStyle = MaterialStateProperty.resolveAs( theme.useMaterial3 ? _m3StateInputStyle(context)! : _m2StateInputStyle(context)!, - _materialState); + _statesController.value); final TextStyle providedStyle = - MaterialStateProperty.resolveAs(style, _materialState); + MaterialStateProperty.resolveAs(style, _statesController.value); return providedStyle.merge(stateStyle); } @@ -1569,7 +1663,7 @@ class _TextFieldState extends State<_TextField> final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs( widget.mouseCursor ?? MaterialStateMouseCursor.textable, - _materialState, + _statesController.value, ); final int? semanticsMaxValueLength; diff --git a/lib/src/official/widgets/text_selection.dart b/lib/src/official/widgets/text_selection.dart index 7b30871..f8809f5 100644 --- a/lib/src/official/widgets/text_selection.dart +++ b/lib/src/official/widgets/text_selection.dart @@ -29,6 +29,15 @@ class _TextSelectionOverlay { required TextMagnifierConfiguration magnifierConfiguration, }) : _handlesVisible = handlesVisible, _value = value { + // TODO(polina-c): stop duplicating code across disposables + // https://github.com/flutter/flutter/issues/137435 + if (kFlutterMemoryAllocationsEnabled) { + FlutterMemoryAllocations.instance.dispatchObjectCreated( + library: 'package:flutter/widgets.dart', + className: '$TextSelectionOverlay', + object: this, + ); + } renderObject.selectionStartInViewport .addListener(_updateTextSelectionOverlayVisibilities); renderObject.selectionEndInViewport @@ -295,6 +304,11 @@ class _TextSelectionOverlay { /// {@macro flutter.widgets.SelectionOverlay.dispose} void dispose() { + // TODO(polina-c): stop duplicating code across disposables + // https://github.com/flutter/flutter/issues/137435 + if (kFlutterMemoryAllocationsEnabled) { + FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this); + } _selectionOverlay.dispose(); renderObject.selectionStartInViewport .removeListener(_updateTextSelectionOverlayVisibilities); @@ -359,6 +373,7 @@ class _TextSelectionOverlay { final TextSelection lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition); + final TextPosition positionAtEndOfLine = TextPosition( offset: lineAtOffset.extentOffset, affinity: TextAffinity.upstream, @@ -692,7 +707,17 @@ class _SelectionOverlay { _lineHeightAtEnd = lineHeightAtEnd, _selectionEndpoints = selectionEndpoints, _toolbarLocation = toolbarLocation, - assert(debugCheckHasOverlay(context)); + assert(debugCheckHasOverlay(context)) { + // TODO(polina-c): stop duplicating code across disposables + // https://github.com/flutter/flutter/issues/137435 + if (kFlutterMemoryAllocationsEnabled) { + FlutterMemoryAllocations.instance.dispatchObjectCreated( + library: 'package:flutter/widgets.dart', + className: '$SelectionOverlay', + object: this, + ); + } + } /// {@macro flutter.widgets.SelectionOverlay.context} final BuildContext context; @@ -761,7 +786,7 @@ class _SelectionOverlay { context: context, below: magnifierConfiguration.shouldDisplayHandlesInMagnifier ? null - : _handles?.first, + : _handles?.start, builder: (_) => builtMagnifier); } @@ -1065,11 +1090,11 @@ class _SelectionOverlay { } /// Controls the fade-in and fade-out animations for the toolbar and handles. - static const Duration fadeDuration = Duration(milliseconds: 150); + // static const Duration fadeDuration = Duration(milliseconds: 150); /// A pair of handles. If this is non-null, there are always 2, though the /// second is hidden when the selection is collapsed. - List? _handles; + ({OverlayEntry start, OverlayEntry end})? _handles; /// A copy/paste toolbar. OverlayEntry? _toolbar; @@ -1088,12 +1113,12 @@ class _SelectionOverlay { return; } - _handles = [ - OverlayEntry(builder: _buildStartHandle), - OverlayEntry(builder: _buildEndHandle), - ]; + _handles = ( + start: OverlayEntry(builder: _buildStartHandle), + end: OverlayEntry(builder: _buildEndHandle), + ); Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) - .insertAll(_handles!); + .insertAll([_handles!.start, _handles!.end]); } /// {@template flutter.widgets.SelectionOverlay.hideHandles} @@ -1101,10 +1126,10 @@ class _SelectionOverlay { /// {@endtemplate} void hideHandles() { if (_handles != null) { - _handles![0].remove(); - _handles![0].dispose(); - _handles![1].remove(); - _handles![1].dispose(); + _handles!.start.remove(); + _handles!.start.dispose(); + _handles!.end.remove(); + _handles!.end.dispose(); _handles = null; } } @@ -1185,8 +1210,8 @@ class _SelectionOverlay { SchedulerBinding.instance.addPostFrameCallback((Duration duration) { _buildScheduled = false; if (_handles != null) { - _handles![0].markNeedsBuild(); - _handles![1].markNeedsBuild(); + _handles!.start.markNeedsBuild(); + _handles!.end.markNeedsBuild(); } _toolbar?.markNeedsBuild(); if (_contextMenuController.isShown) { @@ -1194,11 +1219,11 @@ class _SelectionOverlay { } else if (_spellCheckToolbarController.isShown) { _spellCheckToolbarController.markNeedsBuild(); } - }); + }, debugLabel: 'SelectionOverlay.markNeedsBuild'); } else { if (_handles != null) { - _handles![0].markNeedsBuild(); - _handles![1].markNeedsBuild(); + _handles!.start.markNeedsBuild(); + _handles!.end.markNeedsBuild(); } _toolbar?.markNeedsBuild(); if (_contextMenuController.isShown) { @@ -1215,10 +1240,10 @@ class _SelectionOverlay { void hide() { _magnifierController.hide(); if (_handles != null) { - _handles![0].remove(); - _handles![0].dispose(); - _handles![1].remove(); - _handles![1].dispose(); + _handles!.start.remove(); + _handles!.start.dispose(); + _handles!.end.remove(); + _handles!.end.dispose(); _handles = null; } if (_toolbar != null || @@ -1248,6 +1273,11 @@ class _SelectionOverlay { /// Disposes this object and release resources. /// {@endtemplate} void dispose() { + // TODO(polina-c): stop duplicating code across disposables + // https://github.com/flutter/flutter/issues/137435 + if (kFlutterMemoryAllocationsEnabled) { + FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this); + } hide(); _magnifierInfo.dispose(); } @@ -2032,6 +2062,27 @@ class _TextSelectionGestureDetectorBuilder { } } + /// Whether the provided [onUserTap] callback should be dispatched on every + /// tap or only non-consecutive taps. + /// + /// Defaults to false. + @protected + bool get onUserTapAlwaysCalled => false; + + /// Handler for [TextSelectionGestureDetector.onUserTap]. + /// + /// By default, it serves as placeholder to enable subclass override. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onUserTap], which triggers this + /// callback. + /// * [TextSelectionGestureDetector.onUserTapAlwaysCalled], which controls + /// whether this callback is called only on the first tap in a series + /// of taps. + @protected + void onUserTap() {/* Subclass should override this method if needed. */} + /// Handler for [TextSelectionGestureDetector.onSingleTapUp]. /// /// By default, it selects word edge if selection is enabled. @@ -2150,7 +2201,7 @@ class _TextSelectionGestureDetectorBuilder { /// Handler for [TextSelectionGestureDetector.onSingleTapCancel]. /// - /// By default, it services as place holder to enable subclass override. + /// By default, it serves as placeholder to enable subclass override. /// /// See also: /// @@ -2184,6 +2235,19 @@ class _TextSelectionGestureDetectorBuilder { from: details.globalPosition, cause: SelectionChangedCause.longPress, ); + // Show the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.Start, + startLocation: ( + renderEditable.globalToLocal(details.globalPosition), + TextPosition( + offset: editableText.textEditingValue.selection.baseOffset, + affinity: editableText.textEditingValue.selection.affinity, + ), + ), + offset: Offset.zero, + ); + editableText.updateFloatingCursor(cursorPoint); } case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -2238,6 +2302,12 @@ class _TextSelectionGestureDetectorBuilder { from: details.globalPosition, cause: SelectionChangedCause.longPress, ); + // Update the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.Update, + offset: details.offsetFromOrigin, + ); + editableText.updateFloatingCursor(cursorPoint); } case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -2274,6 +2344,14 @@ class _TextSelectionGestureDetectorBuilder { _longPressStartedWithoutFocus = false; _dragStartViewportOffset = 0.0; _dragStartScrollOffset = 0.0; + if (defaultTargetPlatform == TargetPlatform.iOS && + delegate.selectionEnabled && + editableText.textEditingValue.selection.isCollapsed) { + // Update the floating cursor. + final RawFloatingCursorPoint cursorPoint = + RawFloatingCursorPoint(state: FloatingCursorDragState.End); + editableText.updateFloatingCursor(cursorPoint); + } } /// Handler for [TextSelectionGestureDetector.onSecondaryTap]. @@ -2540,11 +2618,8 @@ class _TextSelectionGestureDetectorBuilder { ); _showMagnifierIfSupportedByPlatform(details.globalPosition); } - break; case null: - break; } - break; case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: @@ -2821,6 +2896,7 @@ class _TextSelectionGestureDetectorBuilder { onSecondaryTapDown: onSecondaryTapDown, onSingleTapUp: onSingleTapUp, onSingleTapCancel: onSingleTapCancel, + onUserTap: onUserTap, onSingleLongTapStart: onSingleLongTapStart, onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, onSingleLongTapEnd: onSingleLongTapEnd, @@ -2829,131 +2905,15 @@ class _TextSelectionGestureDetectorBuilder { onDragSelectionStart: onDragSelectionStart, onDragSelectionUpdate: onDragSelectionUpdate, onDragSelectionEnd: onDragSelectionEnd, + onUserTapAlwaysCalled: onUserTapAlwaysCalled, behavior: behavior, child: child, ); } } -/// A gesture detector to respond to non-exclusive event chains for a text field. -/// -/// An ordinary [GestureDetector] configured to handle events like tap and -/// double tap will only recognize one or the other. This widget detects both: -/// the first tap and then any subsequent taps that occurs within a time limit -/// after the first. -/// -/// See also: -/// -/// * [TextField], a Material text field which uses this gesture detector. -/// * [CupertinoTextField], a Cupertino text field which uses this gesture -/// detector. -class TextSelectionGestureDetector extends StatefulWidget { - /// Create a [TextSelectionGestureDetector]. - /// - /// Multiple callbacks can be called for one sequence of input gesture. - const TextSelectionGestureDetector({ - super.key, - this.onTapTrackStart, - this.onTapTrackReset, - this.onTapDown, - this.onForcePressStart, - this.onForcePressEnd, - this.onSecondaryTap, - this.onSecondaryTapDown, - this.onSingleTapUp, - this.onSingleTapCancel, - this.onSingleLongTapStart, - this.onSingleLongTapMoveUpdate, - this.onSingleLongTapEnd, - this.onDoubleTapDown, - this.onTripleTapDown, - this.onDragSelectionStart, - this.onDragSelectionUpdate, - this.onDragSelectionEnd, - this.behavior, - required this.child, - }); - - /// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.onTapTrackStart} - final VoidCallback? onTapTrackStart; - - /// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.onTapTrackReset} - final VoidCallback? onTapTrackReset; - - /// Called for every tap down including every tap down that's part of a - /// double click or a long press, except touches that include enough movement - /// to not qualify as taps (e.g. pans and flings). - final GestureTapDragDownCallback? onTapDown; - - /// Called when a pointer has tapped down and the force of the pointer has - /// just become greater than [ForcePressGestureRecognizer.startPressure]. - final GestureForcePressStartCallback? onForcePressStart; - - /// Called when a pointer that had previously triggered [onForcePressStart] is - /// lifted off the screen. - final GestureForcePressEndCallback? onForcePressEnd; - - /// Called for a tap event with the secondary mouse button. - final GestureTapCallback? onSecondaryTap; - - /// Called for a tap down event with the secondary mouse button. - final GestureTapDownCallback? onSecondaryTapDown; - - /// Called for the first tap in a series of taps, consecutive taps do not call - /// this method. - /// - /// For example, if the detector was configured with [onTapDown] and - /// [onDoubleTapDown], three quick taps would be recognized as a single tap - /// down, followed by a tap up, then a double tap down, followed by a single tap down. - final GestureTapDragUpCallback? onSingleTapUp; - - /// Called for each touch that becomes recognized as a gesture that is not a - /// short tap, such as a long tap or drag. It is called at the moment when - /// another gesture from the touch is recognized. - final GestureCancelCallback? onSingleTapCancel; - - /// Called for a single long tap that's sustained for longer than - /// [kLongPressTimeout] but not necessarily lifted. Not called for a - /// double-tap-hold, which calls [onDoubleTapDown] instead. - final GestureLongPressStartCallback? onSingleLongTapStart; - - /// Called after [onSingleLongTapStart] when the pointer is dragged. - final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; - - /// Called after [onSingleLongTapStart] when the pointer is lifted. - final GestureLongPressEndCallback? onSingleLongTapEnd; - - /// Called after a momentary hold or a short tap that is close in space and - /// time (within [kDoubleTapTimeout]) to a previous short tap. - final GestureTapDragDownCallback? onDoubleTapDown; - - /// Called after a momentary hold or a short tap that is close in space and - /// time (within [kDoubleTapTimeout]) to a previous double-tap. - final GestureTapDragDownCallback? onTripleTapDown; - - /// Called when a mouse starts dragging to select text. - final GestureTapDragStartCallback? onDragSelectionStart; - - /// Called repeatedly as a mouse moves while dragging. - final GestureTapDragUpdateCallback? onDragSelectionUpdate; - - /// Called when a mouse that was previously dragging is released. - final GestureTapDragEndCallback? onDragSelectionEnd; - - /// How this gesture detector should behave during hit testing. - /// - /// This defaults to [HitTestBehavior.deferToChild]. - final HitTestBehavior? behavior; - - /// Child below this widget. - final Widget child; - - @override - State createState() => _TextSelectionGestureDetectorState(); -} - -class _TextSelectionGestureDetectorState - extends State { +class _TextSelectionGestureDetectorState { + _TextSelectionGestureDetectorState._(); // Converts the details.consecutiveTapCount from a TapAndDrag*Details object, // which can grow to be infinitely large, to a value between 1 and 3. The value // that the raw count is converted to is based on the default observed behavior @@ -2991,180 +2951,4 @@ class _TextSelectionGestureDetectorState return rawCount < 2 ? rawCount : 2 + rawCount % 2; } } - - void _handleTapTrackStart() { - widget.onTapTrackStart?.call(); - } - - void _handleTapTrackReset() { - widget.onTapTrackReset?.call(); - } - - // The down handler is force-run on success of a single tap and optimistically - // run before a long press success. - void _handleTapDown(TapDragDownDetails details) { - widget.onTapDown?.call(details); - // This isn't detected as a double tap gesture in the gesture recognizer - // because it's 2 single taps, each of which may do different things depending - // on whether it's a single tap, the first tap of a double tap, the second - // tap held down, a clean double tap etc. - if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) { - return widget.onDoubleTapDown?.call(details); - } - - if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 3) { - return widget.onTripleTapDown?.call(details); - } - } - - void _handleTapUp(TapDragUpDetails details) { - if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) { - widget.onSingleTapUp?.call(details); - } - } - - void _handleTapCancel() { - widget.onSingleTapCancel?.call(); - } - - void _handleDragStart(TapDragStartDetails details) { - widget.onDragSelectionStart?.call(details); - } - - void _handleDragUpdate(TapDragUpdateDetails details) { - widget.onDragSelectionUpdate?.call(details); - } - - void _handleDragEnd(TapDragEndDetails details) { - widget.onDragSelectionEnd?.call(details); - } - - void _forcePressStarted(ForcePressDetails details) { - widget.onForcePressStart?.call(details); - } - - void _forcePressEnded(ForcePressDetails details) { - widget.onForcePressEnd?.call(details); - } - - void _handleLongPressStart(LongPressStartDetails details) { - if (widget.onSingleLongTapStart != null) { - widget.onSingleLongTapStart!(details); - } - } - - void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - if (widget.onSingleLongTapMoveUpdate != null) { - widget.onSingleLongTapMoveUpdate!(details); - } - } - - void _handleLongPressEnd(LongPressEndDetails details) { - if (widget.onSingleLongTapEnd != null) { - widget.onSingleLongTapEnd!(details); - } - } - - @override - Widget build(BuildContext context) { - final Map gestures = - {}; - - gestures[TapGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (TapGestureRecognizer instance) { - instance - ..onSecondaryTap = widget.onSecondaryTap - ..onSecondaryTapDown = widget.onSecondaryTapDown; - }, - ); - - if (widget.onSingleLongTapStart != null || - widget.onSingleLongTapMoveUpdate != null || - widget.onSingleLongTapEnd != null) { - gestures[LongPressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer( - debugOwner: this, - supportedDevices: {PointerDeviceKind.touch}), - (LongPressGestureRecognizer instance) { - instance - ..onLongPressStart = _handleLongPressStart - ..onLongPressMoveUpdate = _handleLongPressMoveUpdate - ..onLongPressEnd = _handleLongPressEnd; - }, - ); - } - - if (widget.onDragSelectionStart != null || - widget.onDragSelectionUpdate != null || - widget.onDragSelectionEnd != null) { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.iOS: - gestures[TapAndHorizontalDragGestureRecognizer] = - GestureRecognizerFactoryWithHandlers< - TapAndHorizontalDragGestureRecognizer>( - () => TapAndHorizontalDragGestureRecognizer(debugOwner: this), - (TapAndHorizontalDragGestureRecognizer instance) { - instance - // Text selection should start from the position of the first pointer - // down event. - ..dragStartBehavior = DragStartBehavior.down - ..onTapTrackStart = _handleTapTrackStart - ..onTapTrackReset = _handleTapTrackReset - ..onTapDown = _handleTapDown - ..onDragStart = _handleDragStart - ..onDragUpdate = _handleDragUpdate - ..onDragEnd = _handleDragEnd - ..onTapUp = _handleTapUp - ..onCancel = _handleTapCancel; - }, - ); - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - gestures[TapAndPanGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => TapAndPanGestureRecognizer(debugOwner: this), - (TapAndPanGestureRecognizer instance) { - instance - // Text selection should start from the position of the first pointer - // down event. - ..dragStartBehavior = DragStartBehavior.down - ..onTapTrackStart = _handleTapTrackStart - ..onTapTrackReset = _handleTapTrackReset - ..onTapDown = _handleTapDown - ..onDragStart = _handleDragStart - ..onDragUpdate = _handleDragUpdate - ..onDragEnd = _handleDragEnd - ..onTapUp = _handleTapUp - ..onCancel = _handleTapCancel; - }, - ); - } - } - - if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { - gestures[ForcePressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => ForcePressGestureRecognizer(debugOwner: this), - (ForcePressGestureRecognizer instance) { - instance - ..onStart = - widget.onForcePressStart != null ? _forcePressStarted : null - ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; - }, - ); - } - - return RawGestureDetector( - gestures: gestures, - excludeFromSemantics: true, - behavior: widget.behavior, - child: widget.child, - ); - } } diff --git a/pubspec.yaml b/pubspec.yaml index 2787961..3fd269b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +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: 13.0.1 +version: 14.0.0 repository: https://github.com/fluttercandies/extended_text_field issue_tracker: https://github.com/fluttercandies/extended_text_field/issues +topics: + - extended-text-field + - rich-text environment: - sdk: '>=3.0.0 <4.0.0' - flutter: ">=3.16.0" + sdk: '>=3.3.0 <4.0.0' + flutter: ">=3.19.0" dependencies: extended_text_library: ^12.0.0