Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 8828a63

Browse files
[iOSTextInputPlugin] bypass UIKit floating cursor coordinates clamping (#26486)
1 parent d7e28a9 commit 8828a63

File tree

2 files changed

+123
-9
lines changed

2 files changed

+123
-9
lines changed

shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
// returns kInvalidFirstRect, iOS will not show the IME candidates view.
2323
const CGRect kInvalidFirstRect = {{-1, -1}, {9999, 9999}};
2424

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+
2537
#pragma mark - TextInputConfiguration Field Names
2638
static NSString* const kSecureTextEntry = @"obscureText";
2739
static NSString* const kKeyboardType = @"inputType";
@@ -505,6 +517,7 @@ @implementation FlutterTextInputView {
505517
const char* _selectionAffinity;
506518
FlutterTextRange* _selectedTextRange;
507519
CGRect _cachedFirstRect;
520+
bool _isFloatingCursorActive;
508521
// The view has reached end of life, and is no longer
509522
// allowed to access its textInputDelegate.
510523
BOOL _decommissioned;
@@ -527,6 +540,7 @@ - (instancetype)init {
527540
// Initialize with the zero matrix which is not
528541
// an affine transform.
529542
_editableTransform = CATransform3D();
543+
_isFloatingCursorActive = false;
530544

531545
// UITextInputTraits
532546
_autocapitalizationType = UITextAutocapitalizationTypeSentences;
@@ -543,6 +557,16 @@ - (instancetype)init {
543557
_smartQuotesType = UITextSmartQuotesTypeYes;
544558
_smartDashesType = UITextSmartDashesTypeYes;
545559
}
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+
}
546570
}
547571

548572
return self;
@@ -612,9 +636,9 @@ - (UITextContentType)textContentType {
612636
// from the view hierarchy) so that it may outlive the plugin/engine,
613637
// in which case _textInputDelegate will become a dangling pointer.
614638

615-
// The text input plugin needs to call decommision when it should
639+
// The text input plugin needs to call decommission when it should
616640
// not have access to its FlutterTextInputDelegate any more.
617-
- (void)decommision {
641+
- (void)decommission {
618642
_decommissioned = YES;
619643
}
620644

@@ -1094,9 +1118,23 @@ - (CGRect)firstRectForRange:(UITextRange*)range {
10941118

10951119
- (CGRect)caretRectForPosition:(UITextPosition*)position {
10961120
// 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.
10971131
return CGRectZero;
10981132
}
10991133

1134+
- (CGRect)bounds {
1135+
return _isFloatingCursorActive ? kSpacePanBounds : super.bounds;
1136+
}
1137+
11001138
- (UITextPosition*)closestPositionToPoint:(CGPoint)point {
11011139
// TODO(cbracken) Implement.
11021140
NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index;
@@ -1120,18 +1158,47 @@ - (UITextRange*)characterRangeAtPoint:(CGPoint)point {
11201158
}
11211159

11221160
- (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;
11231185
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateStart
11241186
withClient:_textInputClient
11251187
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
11261188
}
11271189

11281190
- (void)updateFloatingCursorAtPoint:(CGPoint)point {
1191+
NSAssert(_isFloatingCursorActive,
1192+
@"updateFloatingCursorAtPoint is called without an active floating cursor.");
11291193
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
11301194
withClient:_textInputClient
11311195
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
11321196
}
11331197

11341198
- (void)endFloatingCursor {
1199+
NSAssert(_isFloatingCursorActive,
1200+
@"endFloatingCursor is called without an active floating cursor.");
1201+
_isFloatingCursorActive = false;
11351202
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateEnd
11361203
withClient:_textInputClient
11371204
withPosition:@{@"X" : @(0), @"Y" : @(0)}];
@@ -1410,6 +1477,7 @@ - (void)hideTextInput {
14101477
[self removeEnableFlutterTextInputViewAccessibilityTimer];
14111478
_activeView.accessibilityEnabled = NO;
14121479
[_activeView resignFirstResponder];
1480+
[_activeView decommission];
14131481
[_activeView removeFromSuperview];
14141482
[_inputHider removeFromSuperview];
14151483
}
@@ -1572,10 +1640,10 @@ - (UIView*)keyWindow {
15721640
return _inputHider.subviews;
15731641
}
15741642

1575-
// Decommisions (See the "decommision" method on FlutterTextInputView) and removes
1643+
// Decommissions (See the "decommission" method on FlutterTextInputView) and removes
15761644
// every installed input field, unless it's in the current autofill context.
15771645
//
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
15791647
// includeActiveView is YES.
15801648
// When clearText is YES, the text on the input fields will be set to empty before
15811649
// they are removed from the view hierarchy, to avoid triggering autofill save.
@@ -1595,7 +1663,7 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView
15951663
if (clearText) {
15961664
[inputView replaceRangeLocal:NSMakeRange(0, inputView.text.length) withText:@""];
15971665
}
1598-
[inputView decommision];
1666+
[inputView decommission];
15991667
if (delayRemoval) {
16001668
[inputView performSelector:@selector(removeFromSuperview) withObject:nil afterDelay:0.1];
16011669
} else {

shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ - (void)setEditableTransform:(NSArray*)matrix;
1919
- (void)setTextInputState:(NSDictionary*)state;
2020
- (void)setMarkedRect:(CGRect)markedRect;
2121
- (void)updateEditingState;
22-
- (void)decommisson;
2322
- (BOOL)isVisibleToAutofill;
2423

2524
@end
@@ -52,7 +51,6 @@ @interface FlutterSecureTextInputView : FlutterTextInputView
5251
@end
5352

5453
@interface FlutterTextInputPlugin ()
55-
@property(nonatomic, strong) FlutterTextInputView* reusableInputView;
5654
@property(nonatomic, assign) FlutterTextInputView* activeView;
5755
@property(nonatomic, readonly)
5856
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
@@ -82,10 +80,11 @@ - (void)setUp {
8280
}
8381

8482
- (void)tearDown {
85-
[engine stopMocking];
83+
[textInputPlugin.autofillContext removeAllObjects];
84+
[textInputPlugin cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
8685
[[[[textInputPlugin textInputView] superview] subviews]
8786
makeObjectsPerformSelector:@selector(removeFromSuperview)];
88-
87+
[engine stopMocking];
8988
[super tearDown];
9089
}
9190

@@ -529,6 +528,53 @@ - (void)testUpdateFirstRectForRange {
529528
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
530529
}
531530

531+
#pragma mark - Floating Cursor - Tests
532+
533+
- (void)testInputViewsHaveUIInteractions {
534+
if (@available(iOS 13.0, *)) {
535+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
536+
XCTAssertGreaterThan(inputView.interactions.count, 0);
537+
}
538+
}
539+
540+
- (void)testBoundsForFloatingCursor {
541+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
542+
543+
CGRect initialBounds = inputView.bounds;
544+
// Make sure the initial bounds.size is not as large.
545+
XCTAssertLessThan(inputView.bounds.size.width, 100);
546+
XCTAssertLessThan(inputView.bounds.size.height, 100);
547+
548+
[inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
549+
CGRect bounds = inputView.bounds;
550+
XCTAssertGreaterThan(bounds.size.width, 1000);
551+
XCTAssertGreaterThan(bounds.size.height, 1000);
552+
553+
// Verify the caret is centered.
554+
XCTAssertEqual(
555+
CGRectGetMidX(bounds),
556+
CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:1235]]));
557+
XCTAssertEqual(
558+
CGRectGetMidY(bounds),
559+
CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:4567]]));
560+
561+
[inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)];
562+
bounds = inputView.bounds;
563+
XCTAssertGreaterThan(bounds.size.width, 1000);
564+
XCTAssertGreaterThan(bounds.size.height, 1000);
565+
566+
// Verify the caret is centered.
567+
XCTAssertEqual(
568+
CGRectGetMidX(bounds),
569+
CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:21]]));
570+
XCTAssertEqual(
571+
CGRectGetMidY(bounds),
572+
CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:42]]));
573+
574+
[inputView endFloatingCursor];
575+
XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
576+
}
577+
532578
#pragma mark - Autofill - Utilities
533579

534580
- (NSMutableDictionary*)mutablePasswordTemplateCopy {

0 commit comments

Comments
 (0)