mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[iOSTextInputPlugin] bypass UIKit floating cursor coordinates clamping (flutter/engine#26486)
This commit is contained in:
parent
a7db818527
commit
949b10ee70
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user