[iOSTextInputPlugin] bypass UIKit floating cursor coordinates clamping (flutter/engine#26486)

This commit is contained in:
LongCatIsLooong 2021-06-04 12:19:01 -07:00 committed by GitHub
parent a7db818527
commit 949b10ee70
2 changed files with 123 additions and 9 deletions

View File

@ -22,6 +22,18 @@ static constexpr double kUITextInputAccessibilityEnablingDelaySeconds = 0.5;
// returns kInvalidFirstRect, iOS will not show the IME candidates view.
const CGRect kInvalidFirstRect = {{-1, -1}, {9999, 9999}};
// The `bounds` value a FlutterTextInputView returns when the floating cursor
// is activated in that view.
//
// DO NOT use extremely large values (such as CGFloat_MAX) in this rect, for that
// will significantly reduce the precision of the floating cursor's coordinates.
//
// It is recommended for this CGRect to be roughly centered at caretRectForPosition
// (which currently always return CGRectZero), so the initial floating cursor will
// be placed at (0, 0).
// See the comments in beginFloatingCursorAtPoint and caretRectForPosition.
const CGRect kSpacePanBounds = {{-2500, -2500}, {5000, 5000}};
#pragma mark - TextInputConfiguration Field Names
static NSString* const kSecureTextEntry = @"obscureText";
static NSString* const kKeyboardType = @"inputType";
@ -505,6 +517,7 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
const char* _selectionAffinity;
FlutterTextRange* _selectedTextRange;
CGRect _cachedFirstRect;
bool _isFloatingCursorActive;
// The view has reached end of life, and is no longer
// allowed to access its textInputDelegate.
BOOL _decommissioned;
@ -527,6 +540,7 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
// Initialize with the zero matrix which is not
// an affine transform.
_editableTransform = CATransform3D();
_isFloatingCursorActive = false;
// UITextInputTraits
_autocapitalizationType = UITextAutocapitalizationTypeSentences;
@ -543,6 +557,16 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
_smartQuotesType = UITextSmartQuotesTypeYes;
_smartDashesType = UITextSmartDashesTypeYes;
}
// This makes sure UITextSelectionView.interactionAssistant is not nil so
// UITextSelectionView has access to this view (and its bounds). Otherwise
// floating cursor breaks: https://github.com/flutter/flutter/issues/70267.
if (@available(iOS 13.0, *)) {
UITextInteraction* interaction =
[UITextInteraction textInteractionForMode:UITextInteractionModeEditable];
interaction.textInput = self;
[self addInteraction:interaction];
}
}
return self;
@ -612,9 +636,9 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
// from the view hierarchy) so that it may outlive the plugin/engine,
// in which case _textInputDelegate will become a dangling pointer.
// The text input plugin needs to call decommision when it should
// The text input plugin needs to call decommission when it should
// not have access to its FlutterTextInputDelegate any more.
- (void)decommision {
- (void)decommission {
_decommissioned = YES;
}
@ -1094,9 +1118,23 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
- (CGRect)caretRectForPosition:(UITextPosition*)position {
// TODO(cbracken) Implement.
// As of iOS 14.4, this call is used by iOS's
// _UIKeyboardTextSelectionController to determine the position
// of the floating cursor when the user force touches the space
// bar to initiate floating cursor.
//
// It is recommended to return a value that's roughly the
// center of kSpacePanBounds to make sure the floating cursor
// has ample space in all directions and does not hit kSpacePanBounds.
// See the comments in beginFloatingCursorAtPoint.
return CGRectZero;
}
- (CGRect)bounds {
return _isFloatingCursorActive ? kSpacePanBounds : super.bounds;
}
- (UITextPosition*)closestPositionToPoint:(CGPoint)point {
// TODO(cbracken) Implement.
NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index;
@ -1120,18 +1158,47 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
}
- (void)beginFloatingCursorAtPoint:(CGPoint)point {
// For "beginFloatingCursorAtPoint" and "updateFloatingCursorAtPoint", "point" is roughly:
//
// CGPoint(
// width >= 0 ? point.x.clamp(boundingBox.left, boundingBox.right) : point.x,
// height >= 0 ? point.y.clamp(boundingBox.top, boundingBox.bottom) : point.y,
// )
// where
// point = keyboardPanGestureRecognizer.translationInView(textInputView) +
// caretRectForPosition boundingBox = self.convertRect(bounds, fromView:textInputView) bounds
// = self._selectionClipRect ?? self.bounds
//
// It's tricky to provide accurate "bounds" and "caretRectForPosition" so it's preferred to bypass
// the clamping and implement the same clamping logic in the framework where we have easy access
// to the bounding box of the input field and the caret location.
//
// The current implementation returns kSpacePanBounds for "bounds" when "_isFloatingCursorActive"
// is true. kSpacePanBounds centers "caretRectForPosition" so the floating cursor has enough
// clearance in all directions to move around.
//
// It seems impossible to use a negative "width" or "height", as the "convertRect"
// call always turns a CGRect's negative dimensions into non-negative values, e.g.,
// (1, 2, -3, -4) would become (-2, -2, 3, 4).
NSAssert(!_isFloatingCursorActive, @"Another floating cursor is currently active.");
_isFloatingCursorActive = true;
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateStart
withClient:_textInputClient
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
}
- (void)updateFloatingCursorAtPoint:(CGPoint)point {
NSAssert(_isFloatingCursorActive,
@"updateFloatingCursorAtPoint is called without an active floating cursor.");
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
withClient:_textInputClient
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
}
- (void)endFloatingCursor {
NSAssert(_isFloatingCursorActive,
@"endFloatingCursor is called without an active floating cursor.");
_isFloatingCursorActive = false;
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateEnd
withClient:_textInputClient
withPosition:@{@"X" : @(0), @"Y" : @(0)}];
@ -1410,6 +1477,7 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
[self removeEnableFlutterTextInputViewAccessibilityTimer];
_activeView.accessibilityEnabled = NO;
[_activeView resignFirstResponder];
[_activeView decommission];
[_activeView removeFromSuperview];
[_inputHider removeFromSuperview];
}
@ -1572,10 +1640,10 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
return _inputHider.subviews;
}
// Decommisions (See the "decommision" method on FlutterTextInputView) and removes
// Decommissions (See the "decommission" method on FlutterTextInputView) and removes
// every installed input field, unless it's in the current autofill context.
//
// The active view will be decommisioned and removed from its superview too, if
// The active view will be decommissioned and removed from its superview too, if
// includeActiveView is YES.
// When clearText is YES, the text on the input fields will be set to empty before
// they are removed from the view hierarchy, to avoid triggering autofill save.
@ -1595,7 +1663,7 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) {
if (clearText) {
[inputView replaceRangeLocal:NSMakeRange(0, inputView.text.length) withText:@""];
}
[inputView decommision];
[inputView decommission];
if (delayRemoval) {
[inputView performSelector:@selector(removeFromSuperview) withObject:nil afterDelay:0.1];
} else {

View File

@ -19,7 +19,6 @@ FLUTTER_ASSERT_ARC
- (void)setTextInputState:(NSDictionary*)state;
- (void)setMarkedRect:(CGRect)markedRect;
- (void)updateEditingState;
- (void)decommisson;
- (BOOL)isVisibleToAutofill;
@end
@ -52,7 +51,6 @@ FLUTTER_ASSERT_ARC
@end
@interface FlutterTextInputPlugin ()
@property(nonatomic, strong) FlutterTextInputView* reusableInputView;
@property(nonatomic, assign) FlutterTextInputView* activeView;
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
@ -82,10 +80,11 @@ FLUTTER_ASSERT_ARC
}
- (void)tearDown {
[engine stopMocking];
[textInputPlugin.autofillContext removeAllObjects];
[textInputPlugin cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
[[[[textInputPlugin textInputView] superview] subviews]
makeObjectsPerformSelector:@selector(removeFromSuperview)];
[engine stopMocking];
[super tearDown];
}
@ -529,6 +528,53 @@ FLUTTER_ASSERT_ARC
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
}
#pragma mark - Floating Cursor - Tests
- (void)testInputViewsHaveUIInteractions {
if (@available(iOS 13.0, *)) {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
XCTAssertGreaterThan(inputView.interactions.count, 0);
}
}
- (void)testBoundsForFloatingCursor {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
CGRect initialBounds = inputView.bounds;
// Make sure the initial bounds.size is not as large.
XCTAssertLessThan(inputView.bounds.size.width, 100);
XCTAssertLessThan(inputView.bounds.size.height, 100);
[inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
CGRect bounds = inputView.bounds;
XCTAssertGreaterThan(bounds.size.width, 1000);
XCTAssertGreaterThan(bounds.size.height, 1000);
// Verify the caret is centered.
XCTAssertEqual(
CGRectGetMidX(bounds),
CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:1235]]));
XCTAssertEqual(
CGRectGetMidY(bounds),
CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:4567]]));
[inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)];
bounds = inputView.bounds;
XCTAssertGreaterThan(bounds.size.width, 1000);
XCTAssertGreaterThan(bounds.size.height, 1000);
// Verify the caret is centered.
XCTAssertEqual(
CGRectGetMidX(bounds),
CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:21]]));
XCTAssertEqual(
CGRectGetMidY(bounds),
CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:42]]));
[inputView endFloatingCursor];
XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
}
#pragma mark - Autofill - Utilities
- (NSMutableDictionary*)mutablePasswordTemplateCopy {