22
22
// returns kInvalidFirstRect, iOS will not show the IME candidates view.
23
23
const CGRect kInvalidFirstRect = {{-1 , -1 }, {9999 , 9999 }};
24
24
25
+ // The `bounds` value a FlutterTextInputView returns when the floating cursor
26
+ // is activated in that view.
27
+ //
28
+ // DO NOT use extremely large values (such as CGFloat_MAX) in this rect, for that
29
+ // will significantly reduce the precision of the floating cursor's coordinates.
30
+ //
31
+ // It is recommended for this CGRect to be roughly centered at caretRectForPosition
32
+ // (which currently always return CGRectZero), so the initial floating cursor will
33
+ // be placed at (0, 0).
34
+ // See the comments in beginFloatingCursorAtPoint and caretRectForPosition.
35
+ const CGRect kSpacePanBounds = {{-2500 , -2500 }, {5000 , 5000 }};
36
+
25
37
#pragma mark - TextInputConfiguration Field Names
26
38
static NSString * const kSecureTextEntry = @" obscureText" ;
27
39
static NSString * const kKeyboardType = @" inputType" ;
@@ -505,6 +517,7 @@ @implementation FlutterTextInputView {
505
517
const char * _selectionAffinity;
506
518
FlutterTextRange* _selectedTextRange;
507
519
CGRect _cachedFirstRect;
520
+ bool _isFloatingCursorActive;
508
521
// The view has reached end of life, and is no longer
509
522
// allowed to access its textInputDelegate.
510
523
BOOL _decommissioned;
@@ -527,6 +540,7 @@ - (instancetype)init {
527
540
// Initialize with the zero matrix which is not
528
541
// an affine transform.
529
542
_editableTransform = CATransform3D ();
543
+ _isFloatingCursorActive = false ;
530
544
531
545
// UITextInputTraits
532
546
_autocapitalizationType = UITextAutocapitalizationTypeSentences;
@@ -543,6 +557,16 @@ - (instancetype)init {
543
557
_smartQuotesType = UITextSmartQuotesTypeYes;
544
558
_smartDashesType = UITextSmartDashesTypeYes;
545
559
}
560
+
561
+ // This makes sure UITextSelectionView.interactionAssistant is not nil so
562
+ // UITextSelectionView has access to this view (and its bounds). Otherwise
563
+ // floating cursor breaks: https://github.com/flutter/flutter/issues/70267.
564
+ if (@available (iOS 13.0 , *)) {
565
+ UITextInteraction* interaction =
566
+ [UITextInteraction textInteractionForMode: UITextInteractionModeEditable];
567
+ interaction.textInput = self;
568
+ [self addInteraction: interaction];
569
+ }
546
570
}
547
571
548
572
return self;
@@ -612,9 +636,9 @@ - (UITextContentType)textContentType {
612
636
// from the view hierarchy) so that it may outlive the plugin/engine,
613
637
// in which case _textInputDelegate will become a dangling pointer.
614
638
615
- // The text input plugin needs to call decommision when it should
639
+ // The text input plugin needs to call decommission when it should
616
640
// not have access to its FlutterTextInputDelegate any more.
617
- - (void )decommision {
641
+ - (void )decommission {
618
642
_decommissioned = YES ;
619
643
}
620
644
@@ -1094,9 +1118,23 @@ - (CGRect)firstRectForRange:(UITextRange*)range {
1094
1118
1095
1119
- (CGRect )caretRectForPosition : (UITextPosition*)position {
1096
1120
// TODO(cbracken) Implement.
1121
+
1122
+ // As of iOS 14.4, this call is used by iOS's
1123
+ // _UIKeyboardTextSelectionController to determine the position
1124
+ // of the floating cursor when the user force touches the space
1125
+ // bar to initiate floating cursor.
1126
+ //
1127
+ // It is recommended to return a value that's roughly the
1128
+ // center of kSpacePanBounds to make sure the floating cursor
1129
+ // has ample space in all directions and does not hit kSpacePanBounds.
1130
+ // See the comments in beginFloatingCursorAtPoint.
1097
1131
return CGRectZero ;
1098
1132
}
1099
1133
1134
+ - (CGRect )bounds {
1135
+ return _isFloatingCursorActive ? kSpacePanBounds : super.bounds ;
1136
+ }
1137
+
1100
1138
- (UITextPosition*)closestPositionToPoint : (CGPoint )point {
1101
1139
// TODO(cbracken) Implement.
1102
1140
NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start ).index ;
@@ -1120,18 +1158,47 @@ - (UITextRange*)characterRangeAtPoint:(CGPoint)point {
1120
1158
}
1121
1159
1122
1160
- (void )beginFloatingCursorAtPoint : (CGPoint )point {
1161
+ // For "beginFloatingCursorAtPoint" and "updateFloatingCursorAtPoint", "point" is roughly:
1162
+ //
1163
+ // CGPoint(
1164
+ // width >= 0 ? point.x.clamp(boundingBox.left, boundingBox.right) : point.x,
1165
+ // height >= 0 ? point.y.clamp(boundingBox.top, boundingBox.bottom) : point.y,
1166
+ // )
1167
+ // where
1168
+ // point = keyboardPanGestureRecognizer.translationInView(textInputView) +
1169
+ // caretRectForPosition boundingBox = self.convertRect(bounds, fromView:textInputView) bounds
1170
+ // = self._selectionClipRect ?? self.bounds
1171
+ //
1172
+ // It's tricky to provide accurate "bounds" and "caretRectForPosition" so it's preferred to bypass
1173
+ // the clamping and implement the same clamping logic in the framework where we have easy access
1174
+ // to the bounding box of the input field and the caret location.
1175
+ //
1176
+ // The current implementation returns kSpacePanBounds for "bounds" when "_isFloatingCursorActive"
1177
+ // is true. kSpacePanBounds centers "caretRectForPosition" so the floating cursor has enough
1178
+ // clearance in all directions to move around.
1179
+ //
1180
+ // It seems impossible to use a negative "width" or "height", as the "convertRect"
1181
+ // call always turns a CGRect's negative dimensions into non-negative values, e.g.,
1182
+ // (1, 2, -3, -4) would become (-2, -2, 3, 4).
1183
+ NSAssert (!_isFloatingCursorActive, @" Another floating cursor is currently active." );
1184
+ _isFloatingCursorActive = true ;
1123
1185
[self .textInputDelegate updateFloatingCursor: FlutterFloatingCursorDragStateStart
1124
1186
withClient: _textInputClient
1125
1187
withPosition: @{@" X" : @(point.x ), @" Y" : @(point.y )}];
1126
1188
}
1127
1189
1128
1190
- (void )updateFloatingCursorAtPoint : (CGPoint )point {
1191
+ NSAssert (_isFloatingCursorActive,
1192
+ @" updateFloatingCursorAtPoint is called without an active floating cursor." );
1129
1193
[self .textInputDelegate updateFloatingCursor: FlutterFloatingCursorDragStateUpdate
1130
1194
withClient: _textInputClient
1131
1195
withPosition: @{@" X" : @(point.x ), @" Y" : @(point.y )}];
1132
1196
}
1133
1197
1134
1198
- (void )endFloatingCursor {
1199
+ NSAssert (_isFloatingCursorActive,
1200
+ @" endFloatingCursor is called without an active floating cursor." );
1201
+ _isFloatingCursorActive = false ;
1135
1202
[self .textInputDelegate updateFloatingCursor: FlutterFloatingCursorDragStateEnd
1136
1203
withClient: _textInputClient
1137
1204
withPosition: @{@" X" : @(0 ), @" Y" : @(0 )}];
@@ -1410,6 +1477,7 @@ - (void)hideTextInput {
1410
1477
[self removeEnableFlutterTextInputViewAccessibilityTimer ];
1411
1478
_activeView.accessibilityEnabled = NO ;
1412
1479
[_activeView resignFirstResponder ];
1480
+ [_activeView decommission ];
1413
1481
[_activeView removeFromSuperview ];
1414
1482
[_inputHider removeFromSuperview ];
1415
1483
}
@@ -1572,10 +1640,10 @@ - (UIView*)keyWindow {
1572
1640
return _inputHider.subviews ;
1573
1641
}
1574
1642
1575
- // Decommisions (See the "decommision " method on FlutterTextInputView) and removes
1643
+ // Decommissions (See the "decommission " method on FlutterTextInputView) and removes
1576
1644
// every installed input field, unless it's in the current autofill context.
1577
1645
//
1578
- // The active view will be decommisioned and removed from its superview too, if
1646
+ // The active view will be decommissioned and removed from its superview too, if
1579
1647
// includeActiveView is YES.
1580
1648
// When clearText is YES, the text on the input fields will be set to empty before
1581
1649
// they are removed from the view hierarchy, to avoid triggering autofill save.
@@ -1595,7 +1663,7 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView
1595
1663
if (clearText) {
1596
1664
[inputView replaceRangeLocal: NSMakeRange (0 , inputView.text.length) withText: @" " ];
1597
1665
}
1598
- [inputView decommision ];
1666
+ [inputView decommission ];
1599
1667
if (delayRemoval) {
1600
1668
[inputView performSelector: @selector (removeFromSuperview ) withObject: nil afterDelay: 0.1 ];
1601
1669
} else {
0 commit comments