diff --git a/AUTHORS b/AUTHORS index 455f2c6699c8f..0cab6e7e7b937 100644 --- a/AUTHORS +++ b/AUTHORS @@ -20,4 +20,5 @@ Hidenori Matsubayashi Sarbagya Dhaubanjar Callum Moffat Koutaro Mori -TheOneWithTheBraid \ No newline at end of file +TheOneWithTheBraid +Twin Sun, LLC diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 1870ef8896eba..8fcd54fe69f2d 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -1094,6 +1094,7 @@ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterFakeKeyEvents.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterFakeKeyEvents.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterHeadlessDartRunner.mm +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyPrimaryResponder.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterKeySecondaryResponder.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardManager.h @@ -1133,6 +1134,7 @@ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewTest.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/IOKit.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/KeyCodeMap.mm diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 9d942a27ee12f..46a6ea6f077b8 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -20,6 +20,7 @@ #import "flutter/shell/platform/darwin/common/command_line.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterBinaryMessengerRelay.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject_Internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterObservatoryPublisher.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" @@ -42,7 +43,9 @@ @interface FlutterEngineRegistrar : NSObject - (instancetype)initWithPlugin:(NSString*)pluginKey flutterEngine:(FlutterEngine*)flutterEngine; @end -@interface FlutterEngine () +@interface FlutterEngine () // Maintains a dictionary of plugin names that have registered with the engine. Used by // FlutterEngineRegistrar to implement a FlutterPluginRegistrar. @property(nonatomic, readonly) NSMutableDictionary* pluginPublications; @@ -331,6 +334,7 @@ - (void)setViewController:(FlutterViewController*)viewController { - (void)attachView { self.iosPlatformView->attachView(); + [_textInputPlugin.get() setupIndirectScribbleInteraction:self.viewController]; } - (void)setFlutterViewControllerWillDeallocObserver:(id)observer { @@ -355,6 +359,7 @@ - (void)notifyViewControllerDeallocated { platform_view->SetOwnerViewController({}); } } + [_textInputPlugin.get() resetViewResponder]; _viewController.reset(); } @@ -514,6 +519,8 @@ - (void)setupChannels { _textInputPlugin.reset([[FlutterTextInputPlugin alloc] init]); _textInputPlugin.get().textInputDelegate = self; + _textInputPlugin.get().indirectScribbleDelegate = self; + [_textInputPlugin.get() setupIndirectScribbleInteraction:self.viewController]; _platformPlugin.reset([[FlutterPlatformPlugin alloc] initWithEngine:[self getWeakPtr]]); @@ -720,22 +727,30 @@ - (void)notifyLowMemory { #pragma mark - Text input delegate -- (void)updateEditingClient:(int)client withState:(NSDictionary*)state { +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateEditingClient:(int)client + withState:(NSDictionary*)state { [_textInputChannel.get() invokeMethod:@"TextInputClient.updateEditingState" arguments:@[ @(client), state ]]; } -- (void)updateEditingClient:(int)client withState:(NSDictionary*)state withTag:(NSString*)tag { +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateEditingClient:(int)client + withState:(NSDictionary*)state + withTag:(NSString*)tag { [_textInputChannel.get() invokeMethod:@"TextInputClient.updateEditingStateWithTag" arguments:@[ @(client), @{tag : state} ]]; } -- (void)updateEditingClient:(int)client withDelta:(NSDictionary*)delta { +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateEditingClient:(int)client + withDelta:(NSDictionary*)delta { [_textInputChannel.get() invokeMethod:@"TextInputClient.updateEditingStateWithDeltas" arguments:@[ @(client), delta ]]; } -- (void)updateFloatingCursor:(FlutterFloatingCursorDragState)state +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateFloatingCursor:(FlutterFloatingCursorDragState)state withClient:(int)client withPosition:(NSDictionary*)position { NSString* stateString; @@ -754,7 +769,9 @@ - (void)updateFloatingCursor:(FlutterFloatingCursorDragState)state arguments:@[ @(client), stateString, position ]]; } -- (void)performAction:(FlutterTextInputAction)action withClient:(int)client { +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + performAction:(FlutterTextInputAction)action + withClient:(int)client { NSString* actionString; switch (action) { case FlutterTextInputActionUnspecified: @@ -799,15 +816,63 @@ - (void)performAction:(FlutterTextInputAction)action withClient:(int)client { arguments:@[ @(client), actionString ]]; } -- (void)showAutocorrectionPromptRectForStart:(NSUInteger)start - end:(NSUInteger)end - withClient:(int)client { +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + showAutocorrectionPromptRectForStart:(NSUInteger)start + end:(NSUInteger)end + withClient:(int)client { [_textInputChannel.get() invokeMethod:@"TextInputClient.showAutocorrectionPromptRect" arguments:@[ @(client), @(start), @(end) ]]; } #pragma mark - FlutterViewEngineDelegate +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client { + [_textInputChannel.get() invokeMethod:@"TextInputClient.showToolbar" arguments:@[ @(client) ]]; +} + +- (void)flutterTextInputPlugin:(FlutterTextInputPlugin*)textInputPlugin + focusElement:(UIScribbleElementIdentifier)elementIdentifier + atPoint:(CGPoint)referencePoint + result:(FlutterResult)callback { + [_textInputChannel.get() + invokeMethod:@"TextInputClient.focusElement" + arguments:@[ elementIdentifier, @(referencePoint.x), @(referencePoint.y) ] + result:callback]; +} + +- (void)flutterTextInputPlugin:(FlutterTextInputPlugin*)textInputPlugin + requestElementsInRect:(CGRect)rect + result:(FlutterResult)callback { + [_textInputChannel.get() + invokeMethod:@"TextInputClient.requestElementsInRect" + arguments:@[ @(rect.origin.x), @(rect.origin.y), @(rect.size.width), @(rect.size.height) ] + result:callback]; +} + +- (void)flutterTextInputViewScribbleInteractionBegan:(FlutterTextInputView*)textInputView { + [_textInputChannel.get() invokeMethod:@"TextInputClient.scribbleInteractionBegan" arguments:nil]; +} + +- (void)flutterTextInputViewScribbleInteractionFinished:(FlutterTextInputView*)textInputView { + [_textInputChannel.get() invokeMethod:@"TextInputClient.scribbleInteractionFinished" + arguments:nil]; +} + +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + insertTextPlaceholderWithSize:(CGSize)size + withClient:(int)client { + [_textInputChannel.get() invokeMethod:@"TextInputClient.insertTextPlaceholder" + arguments:@[ @(client), @(size.width), @(size.height) ]]; +} + +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + removeTextPlaceholder:(int)client { + [_textInputChannel.get() invokeMethod:@"TextInputClient.removeTextPlaceholder" + arguments:@[ @(client) ]]; +} + +#pragma mark - Screenshot Delegate + - (flutter::Rasterizer::Screenshot)takeScreenshot:(flutter::Rasterizer::ScreenshotType)type asBase64Encoded:(BOOL)base64Encode { FML_DCHECK(_shell) << "Cannot takeScreenshot without a shell"; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h index d81eaf6a6c2e7..f5a45952226c0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h @@ -21,6 +21,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject_Internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterRestorationPlugin.h" diff --git a/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h new file mode 100644 index 0000000000000..f5647e6173387 --- /dev/null +++ b/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h @@ -0,0 +1,24 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERINDIRECTSCRIBBLEDELEGATE_H_ +#define SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERINDIRECTSCRIBBLEDELEGATE_H_ + +#import + +NS_ASSUME_NONNULL_BEGIN +@class FlutterTextInputPlugin; + +@protocol FlutterIndirectScribbleDelegate +- (void)flutterTextInputPlugin:(FlutterTextInputPlugin*)textInputPlugin + focusElement:(UIScribbleElementIdentifier)elementIdentifier + atPoint:(CGPoint)referencePoint + result:(FlutterResult)callback; +- (void)flutterTextInputPlugin:(FlutterTextInputPlugin*)textInputPlugin + requestElementsInRect:(CGRect)rect + result:(FlutterResult)callback; +@end +NS_ASSUME_NONNULL_END + +#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERINDIRECTSCRIBBLEDELEGATE_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index c2bce5f65f7c1..7b33539446d63 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -7,6 +7,9 @@ #import +@class FlutterTextInputPlugin; +@class FlutterTextInputView; + typedef NS_ENUM(NSInteger, FlutterTextInputAction) { FlutterTextInputActionUnspecified, FlutterTextInputActionDone, @@ -28,16 +31,35 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { }; @protocol FlutterTextInputDelegate -- (void)updateEditingClient:(int)client withState:(NSDictionary*)state; -- (void)updateEditingClient:(int)client withState:(NSDictionary*)state withTag:(NSString*)tag; -- (void)updateEditingClient:(int)client withDelta:(NSDictionary*)state; -- (void)performAction:(FlutterTextInputAction)action withClient:(int)client; -- (void)updateFloatingCursor:(FlutterFloatingCursorDragState)state +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateEditingClient:(int)client + withState:(NSDictionary*)state; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateEditingClient:(int)client + withState:(NSDictionary*)state + withTag:(NSString*)tag; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateEditingClient:(int)client + withDelta:(NSDictionary*)state; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + performAction:(FlutterTextInputAction)action + withClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + updateFloatingCursor:(FlutterFloatingCursorDragState)state withClient:(int)client withPosition:(NSDictionary*)point; -- (void)showAutocorrectionPromptRectForStart:(NSUInteger)start - end:(NSUInteger)end - withClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + showAutocorrectionPromptRectForStart:(NSUInteger)start + end:(NSUInteger)end + withClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client; +- (void)flutterTextInputViewScribbleInteractionBegan:(FlutterTextInputView*)textInputView; +- (void)flutterTextInputViewScribbleInteractionFinished:(FlutterTextInputView*)textInputView; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + insertTextPlaceholderWithSize:(CGSize)size + withClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView removeTextPlaceholder:(int)client; + @end #endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTDELEGATE_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index 31c2071907e51..a5892a8ec956e 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -8,14 +8,32 @@ #import #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterKeySecondaryResponder.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextEditingDelta.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h" -@interface FlutterTextInputPlugin : NSObject +typedef NS_ENUM(NSInteger, FlutterScribbleFocusStatus) { + FlutterScribbleFocusStatusUnfocused, + FlutterScribbleFocusStatusFocusing, + FlutterScribbleFocusStatusFocused, +}; + +typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { + FlutterScribbleInteractionStatusNone, + FlutterScribbleInteractionStatusStarted, + FlutterScribbleInteractionStatusEnding, +}; + +@interface FlutterTextInputPlugin + : NSObject @property(nonatomic, assign) id textInputDelegate; @property(nonatomic, assign) UIViewController* viewController; +@property(nonatomic, assign) id indirectScribbleDelegate; +@property(nonatomic, strong) + NSMutableDictionary* scribbleElements; - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; /** @@ -26,6 +44,13 @@ */ - (UIView*)textInputView; +/** + * These are used by the UIIndirectScribbleInteractionDelegate methods to handle focusing on the + * correct element. + */ +- (void)setupIndirectScribbleInteraction:(id)viewResponder; +- (void)resetViewResponder; + @end /** An indexed position in the buffer of a Flutter text editing widget. */ @@ -51,10 +76,41 @@ @interface FlutterTokenizer : UITextInputStringTokenizer @end +@interface FlutterTextSelectionRect : UITextSelectionRect + +@property(nonatomic, assign) CGRect rect; +@property(nonatomic) NSUInteger position; +@property(nonatomic, assign) NSWritingDirection writingDirection; +@property(nonatomic) BOOL containsStart; +@property(nonatomic) BOOL containsEnd; +@property(nonatomic) BOOL isVertical; + ++ (instancetype)selectionRectWithRectAndInfo:(CGRect)rect + position:(NSUInteger)position + writingDirection:(NSWritingDirection)writingDirection + containsStart:(BOOL)containsStart + containsEnd:(BOOL)containsEnd + isVertical:(BOOL)isVertical; + ++ (instancetype)selectionRectWithRect:(CGRect)rect position:(NSUInteger)position; + +- (instancetype)initWithRectAndInfo:(CGRect)rect + position:(NSUInteger)position + writingDirection:(NSWritingDirection)writingDirection + containsStart:(BOOL)containsStart + containsEnd:(BOOL)containsEnd + isVertical:(BOOL)isVertical; + +- (instancetype)init NS_UNAVAILABLE; +@end + +API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder : UITextPlaceholder +@end + #if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG FLUTTER_DARWIN_EXPORT #endif -@interface FlutterTextInputView : UIView +@interface FlutterTextInputView : UIView // UITextInput @property(nonatomic, readonly) NSMutableString* text; @@ -81,5 +137,11 @@ FLUTTER_DARWIN_EXPORT @property(nonatomic, assign) id textInputDelegate; @property(nonatomic, assign) UIAccessibilityElement* backingTextInputAccessibilityObject; +// Scribble Support +@property(nonatomic, assign) id viewResponder; +@property(nonatomic) FlutterScribbleFocusStatus scribbleFocusStatus; +@property(nonatomic, strong) NSArray* selectionRects; +- (void)resetScribbleInteractionStatusIfEnding; + @end #endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index a65743026fd55..4a111d3aace6f 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -397,6 +397,64 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { return FlutterAutofillTypeNone; } +static BOOL isApproximatelyEqual(float x, float y, float delta) { + return fabsf(x - y) <= delta; +} + +// Checks whether point should be considered closer to selectionRect compared to +// otherSelectionRect. +// +// If checkRightBoundary is set, the right-center point on selectionRect and +// otherSelectionRect will be used instead of the left-center point. +// +// This uses special (empirically determined using a 1st gen iPad pro, 9.7" model running +// iOS 14.7.1) logic for determining the closer rect, rather than a simple distance calculation. +// First, the closer vertical distance is determined. Within the closest y distance, if the point is +// above the bottom of the closest rect, the x distance will be minimized; however, if the point is +// below the bottom of the rect, the x value will be maximized. +static BOOL isSelectionRectCloserToPoint(CGPoint point, + CGRect selectionRect, + CGRect otherSelectionRect, + BOOL checkRightBoundary) { + CGPoint pointForSelectionRect = + CGPointMake(selectionRect.origin.x + (checkRightBoundary ? selectionRect.size.width : 0), + selectionRect.origin.y + selectionRect.size.height * 0.5); + float yDist = fabs(pointForSelectionRect.y - point.y); + float xDist = fabs(pointForSelectionRect.x - point.x); + + CGPoint pointForOtherSelectionRect = + CGPointMake(otherSelectionRect.origin.x + (checkRightBoundary ? selectionRect.size.width : 0), + otherSelectionRect.origin.y + otherSelectionRect.size.height * 0.5); + float yDistOther = fabs(pointForOtherSelectionRect.y - point.y); + float xDistOther = fabs(pointForOtherSelectionRect.x - point.x); + + // This serves a similar purpose to isApproximatelyEqual, allowing a little buffer before + // declaring something closer vertically to account for the small variations in size and position + // of SelectionRects, especially when dealing with emoji. + BOOL isCloserVertically = yDist < yDistOther - 1; + BOOL isEqualVertically = isApproximatelyEqual(yDist, yDistOther, 1); + BOOL isAboveBottomOfLine = point.y <= selectionRect.origin.y + selectionRect.size.height; + BOOL isCloserHorizontally = xDist <= xDistOther; + BOOL isBelowBottomOfLine = point.y > selectionRect.origin.y + selectionRect.size.height; + BOOL isFartherToRight = + selectionRect.origin.x + (checkRightBoundary ? selectionRect.size.width : 0) > + otherSelectionRect.origin.x; + return (isCloserVertically || + (isEqualVertically && ((isAboveBottomOfLine && isCloserHorizontally) || + (isBelowBottomOfLine && isFartherToRight)))); +} + +// Checks whether Scribble features are possibly available – meaning this is an iPad running iOS +// 14 or higher. +static BOOL isScribbleAvailable() { + if (@available(iOS 14.0, *)) { + if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) { + return YES; + } + } + return NO; +} + #pragma mark - FlutterTextPosition @implementation FlutterTextPosition @@ -519,6 +577,71 @@ - (UITextRange*)lineEnclosingPosition:(UITextPosition*)position { @end +#pragma mark - FlutterTextSelectionRect + +@implementation FlutterTextSelectionRect + +@synthesize rect = _rect; +@synthesize writingDirection = _writingDirection; +@synthesize containsStart = _containsStart; +@synthesize containsEnd = _containsEnd; +@synthesize isVertical = _isVertical; + ++ (instancetype)selectionRectWithRectAndInfo:(CGRect)rect + position:(NSUInteger)position + writingDirection:(NSWritingDirection)writingDirection + containsStart:(BOOL)containsStart + containsEnd:(BOOL)containsEnd + isVertical:(BOOL)isVertical { + return [[[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect + position:position + writingDirection:writingDirection + containsStart:containsStart + containsEnd:containsEnd + isVertical:isVertical] autorelease]; +} + ++ (instancetype)selectionRectWithRect:(CGRect)rect position:(NSUInteger)position { + return [[[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect + position:position + writingDirection:UITextWritingDirectionNatural + containsStart:NO + containsEnd:NO + isVertical:NO] autorelease]; +} + +- (instancetype)initWithRectAndInfo:(CGRect)rect + position:(NSUInteger)position + writingDirection:(NSWritingDirection)writingDirection + containsStart:(BOOL)containsStart + containsEnd:(BOOL)containsEnd + isVertical:(BOOL)isVertical { + self = [super init]; + if (self) { + self.rect = rect; + self.position = position; + self.writingDirection = writingDirection; + self.containsStart = containsStart; + self.containsEnd = containsEnd; + self.isVertical = isVertical; + } + return self; +} + +@end + +#pragma mark - FlutterTextPlaceholder + +@implementation FlutterTextPlaceholder + +- (NSArray*)rects { + // Returning anything other than an empty array here seems to cause PencilKit to enter an + // infinite loop of allocating placeholders until the app crashes + return @[]; +} + +@end + // A FlutterTextInputView that masquerades as a UITextField, and forwards // selectors it can't respond to to a shared UITextField instance. // @@ -570,6 +693,7 @@ @interface FlutterTextInputView () @property(nonatomic, assign) CGRect markedRect; @property(nonatomic) BOOL isVisibleToAutofill; @property(nonatomic, assign) BOOL accessibilityEnabled; +@property(nonatomic, strong) UITextInteraction* textInteraction API_AVAILABLE(ios(13.0)); - (void)setEditableTransform:(NSArray*)matrix; @end @@ -580,6 +704,8 @@ @implementation FlutterTextInputView { FlutterTextRange* _selectedTextRange; UIInputViewController* _inputViewController; CGRect _cachedFirstRect; + FlutterScribbleInteractionStatus _scribbleInteractionStatus; + BOOL _hasPlaceholder; // Whether to show the system keyboard when this view // becomes the first responder. Typically set to false // when the app shows its own in-flutter keyboard. @@ -604,6 +730,7 @@ - (instancetype)init { _selectedTextRange = [[FlutterTextRange alloc] initWithNSRange:NSMakeRange(0, 0)]; _markedRect = kInvalidFirstRect; _cachedFirstRect = kInvalidFirstRect; + _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone; // Initialize with the zero matrix which is not // an affine transform. _editableTransform = CATransform3D(); @@ -625,14 +752,11 @@ - (instancetype)init { _smartQuotesType = UITextSmartQuotesTypeYes; _smartDashesType = UITextSmartDashesTypeYes; } + _selectionRects = [[NSArray alloc] init]; - // 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; + if (@available(iOS 14.0, *)) { + UIScribbleInteraction* interaction = + [[[UIScribbleInteraction alloc] initWithDelegate:self] autorelease]; [self addInteraction:interaction]; } } @@ -696,6 +820,21 @@ - (UITextContentType)textContentType { return _textContentType; } +// Prevent UIKit from showing selection handles or highlights. This is needed +// because Scribble interactions require the view to have it's actual frame on +// the screen. +- (UIColor*)insertionPointColor { + return [UIColor clearColor]; +} + +- (UIColor*)selectionBarColor { + return [UIColor clearColor]; +} + +- (UIColor*)selectionHighlightColor { + return [UIColor clearColor]; +} + - (UIInputViewController*)inputViewController { if (_isSystemKeyboardEnabled) { return nil; @@ -732,11 +871,13 @@ - (void)dealloc { [_tokenizer release]; [_autofillId release]; [_inputViewController release]; + [_selectionRects release]; [super dealloc]; } - (void)setTextInputClient:(int)client { _textInputClient = client; + _hasPlaceholder = NO; } - (void)setTextInputState:(NSDictionary*)state { @@ -776,6 +917,29 @@ - (void)setTextInputState:(NSDictionary*)state { } } +// Forward touches to the viewResponder to allow tapping inside the UITextField as normal. +- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event { + _scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; + [self resetScribbleInteractionStatusIfEnding]; + [self.viewResponder touchesBegan:touches withEvent:event]; +} + +- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event { + [self.viewResponder touchesMoved:touches withEvent:event]; +} + +- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event { + [self.viewResponder touchesEnded:touches withEvent:event]; +} + +- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event { + [self.viewResponder touchesCancelled:touches withEvent:event]; +} + +- (void)touchesEstimatedPropertiesUpdated:(NSSet*)touches { + [self.viewResponder touchesEstimatedPropertiesUpdated:touches]; +} + // Extracts the selection information from the editing state dictionary. // // The state may contain an invalid selection, such as when no selection was @@ -808,9 +972,35 @@ - (BOOL)isVisibleToAutofill { // their frames to CGRectZero prevents ios autofill from taking them into // account. - (void)setIsVisibleToAutofill:(BOOL)isVisibleToAutofill { + // This probably needs to change (think it is getting overwritten by the updateSizeAndTransform + // stuff for now). self.frame = isVisibleToAutofill ? CGRectMake(0, 0, 1, 1) : CGRectZero; } +#pragma mark UIScribbleInteractionDelegate + +- (void)scribbleInteractionWillBeginWriting:(UIScribbleInteraction*)interaction + API_AVAILABLE(ios(14.0)) { + _scribbleInteractionStatus = FlutterScribbleInteractionStatusStarted; + [_textInputDelegate flutterTextInputViewScribbleInteractionBegan:self]; +} + +- (void)scribbleInteractionDidFinishWriting:(UIScribbleInteraction*)interaction + API_AVAILABLE(ios(14.0)) { + _scribbleInteractionStatus = FlutterScribbleInteractionStatusEnding; + [_textInputDelegate flutterTextInputViewScribbleInteractionFinished:self]; +} + +- (BOOL)scribbleInteraction:(UIScribbleInteraction*)interaction + shouldBeginAtLocation:(CGPoint)location API_AVAILABLE(ios(14.0)) { + return YES; +} + +- (BOOL)scribbleInteractionShouldDelayFocus:(UIScribbleInteraction*)interaction + API_AVAILABLE(ios(14.0)) { + return NO; +} + #pragma mark - UIResponder Overrides - (BOOL)canBecomeFirstResponder { @@ -821,7 +1011,12 @@ - (BOOL)canBecomeFirstResponder { return _textInputClient != 0; } -- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender { +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender { + // When scribble is available, the FlutterTextInputView will display the native toolbar unless + // these text editing actions are disabled. + if (isScribbleAvailable()) { + return NO; + } if (action == @selector(paste:)) { // Forbid pasting images, memojis, or other non-string content. return [UIPasteboard generalPasteboard].string != nil; @@ -893,6 +1088,18 @@ - (void)setSelectedTextRange:(UITextRange*)selectedTextRange { } else { [self updateEditingState]; } + + if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone || + _scribbleFocusStatus == FlutterScribbleFocusStatusFocused) { + NSAssert([selectedTextRange isKindOfClass:[FlutterTextRange class]], + @"Expected a FlutterTextRange for range (got %@).", [selectedTextRange class]); + FlutterTextRange* flutterTextRange = (FlutterTextRange*)selectedTextRange; + if (flutterTextRange.range.length > 0) { + [_textInputDelegate flutterTextInputView:self showToolbar:_textInputClient]; + } + } + + [self resetScribbleInteractionStatusIfEnding]; } - (id)insertDictationResultPlaceholder { @@ -957,8 +1164,9 @@ - (void)replaceRange:(UITextRange*)range withText:(NSString*)text { - (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)text { if (self.returnKeyType == UIReturnKeyDefault && [text isEqualToString:@"\n"]) { - [self.textInputDelegate performAction:FlutterTextInputActionNewline - withClient:_textInputClient]; + [self.textInputDelegate flutterTextInputView:self + performAction:FlutterTextInputActionNewline + withClient:_textInputClient]; return YES; } @@ -999,7 +1207,9 @@ - (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)t break; } - [self.textInputDelegate performAction:action withClient:_textInputClient]; + [self.textInputDelegate flutterTextInputView:self + performAction:action + withClient:_textInputClient]; return NO; } @@ -1012,6 +1222,11 @@ - (void)setMarkedText:(NSString*)markedText selectedRange:(NSRange)markedSelecte NSRange markedTextRange = ((FlutterTextRange*)self.markedTextRange).range; NSRange actualReplacedRange; + if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone || + _scribbleFocusStatus != FlutterScribbleFocusStatusUnfocused) { + return; + } + if (markedText == nil) markedText = @""; @@ -1091,6 +1306,10 @@ - (UITextPosition*)positionFromPosition:(UITextPosition*)position offset:(NSInte return nil; } + if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone) { + return [FlutterTextPosition positionWithIndex:newLocation]; + } + if (offset >= 0) { for (NSInteger i = 0; i < offset && offsetPosition < self.text.length; ++i) offsetPosition = [self incrementOffsetPosition:offsetPosition]; @@ -1233,7 +1452,6 @@ - (CGRect)firstRectForRange:(UITextRange*)range { @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]); NSAssert([range.end isKindOfClass:[FlutterTextPosition class]], @"Expected a FlutterTextPosition for range.end (got %@).", [range.end class]); - NSUInteger start = ((FlutterTextPosition*)range.start).index; NSUInteger end = ((FlutterTextPosition*)range.end).index; if (_markedTextRange != nil) { @@ -1258,10 +1476,32 @@ - (CGRect)firstRectForRange:(UITextRange*)range { return _cachedFirstRect; } - [self.textInputDelegate showAutocorrectionPromptRectForStart:start - end:end - withClient:_textInputClient]; - // TODO(cbracken) Implement. + if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusNone && + _scribbleFocusStatus == FlutterScribbleFocusStatusUnfocused) { + [_textInputDelegate flutterTextInputView:self + showAutocorrectionPromptRectForStart:start + end:end + withClient:_textInputClient]; + } + + NSUInteger first = start; + if (end < start) { + first = end; + } + FlutterTextRange* textRange = [FlutterTextRange + rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))]; + for (NSUInteger i = 0; i < [_selectionRects count]; i++) { + BOOL startsOnOrBeforeStartOfRange = _selectionRects[i].position <= first; + BOOL isLastSelectionRect = i + 1 == [_selectionRects count]; + BOOL endOfTextIsAfterStartOfRange = isLastSelectionRect && textRange.range.length > first; + BOOL nextSelectionRectIsAfterStartOfRange = + !isLastSelectionRect && _selectionRects[i + 1].position > first; + if (startsOnOrBeforeStartOfRange && + (endOfTextIsAfterStartOfRange || nextSelectionRectIsAfterStartOfRange)) { + return _selectionRects[i].rect; + } + } + return CGRectZero; } @@ -1285,19 +1525,96 @@ - (CGRect)bounds { } - (UITextPosition*)closestPositionToPoint:(CGPoint)point { - // TODO(cbracken) Implement. - NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index; - return [FlutterTextPosition positionWithIndex:currentIndex]; + if ([_selectionRects count] == 0) { + NSAssert([_selectedTextRange.start isKindOfClass:[FlutterTextPosition class]], + @"Expected a FlutterTextPosition for position (got %@).", + [_selectedTextRange.start class]); + NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index; + return [FlutterTextPosition positionWithIndex:currentIndex]; + } + + FlutterTextRange* range = [FlutterTextRange + rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))]; + return [self closestPositionToPoint:point withinRange:range]; } - (NSArray*)selectionRectsForRange:(UITextRange*)range { - // TODO(cbracken) Implement. - return @[]; + // At least in the simulator, swapping to the Japanese keyboard crashes the app as this method + // is called immediately with a UITextRange with a UITextPosition rather than FlutterTextPosition + // for the start and end. + if (![range.start isKindOfClass:[FlutterTextPosition class]]) { + return @[]; + } + NSAssert([range.start isKindOfClass:[FlutterTextPosition class]], + @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]); + NSAssert([range.end isKindOfClass:[FlutterTextPosition class]], + @"Expected a FlutterTextPosition for range.end (got %@).", [range.end class]); + NSUInteger start = ((FlutterTextPosition*)range.start).index; + NSUInteger end = ((FlutterTextPosition*)range.end).index; + NSMutableArray* rects = [[[NSMutableArray alloc] init] autorelease]; + for (NSUInteger i = 0; i < [_selectionRects count]; i++) { + if (_selectionRects[i].position >= start && _selectionRects[i].position <= end) { + float width = _selectionRects[i].rect.size.width; + if (start == end) { + width = 0; + } + CGRect rect = CGRectMake(_selectionRects[i].rect.origin.x, _selectionRects[i].rect.origin.y, + width, _selectionRects[i].rect.size.height); + FlutterTextSelectionRect* selectionRect = [FlutterTextSelectionRect + selectionRectWithRectAndInfo:rect + position:_selectionRects[i].position + writingDirection:UITextWritingDirectionNatural + containsStart:(i == 0) + containsEnd:(i == fml::RangeForCharactersInRange( + self.text, NSMakeRange(0, self.text.length)) + .length) + isVertical:NO]; + [rects addObject:selectionRect]; + } + } + return rects; } - (UITextPosition*)closestPositionToPoint:(CGPoint)point withinRange:(UITextRange*)range { - // TODO(cbracken) Implement. - return range.start; + NSAssert([range.start isKindOfClass:[FlutterTextPosition class]], + @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]); + NSAssert([range.end isKindOfClass:[FlutterTextPosition class]], + @"Expected a FlutterTextPosition for range.end (got %@).", [range.end class]); + NSUInteger start = ((FlutterTextPosition*)range.start).index; + NSUInteger end = ((FlutterTextPosition*)range.end).index; + + NSUInteger _closestIndex = 0; + CGRect _closestRect = CGRectZero; + NSUInteger _closestPosition = 0; + for (NSUInteger i = 0; i < [_selectionRects count]; i++) { + NSUInteger position = _selectionRects[i].position; + if (position >= start && position <= end) { + BOOL isFirst = _closestIndex == 0; + if (isFirst || isSelectionRectCloserToPoint(point, _selectionRects[i].rect, _closestRect, + /*checkRightBoundary=*/NO)) { + _closestIndex = i; + _closestRect = _selectionRects[i].rect; + _closestPosition = position; + } + } + } + + FlutterTextRange* textRange = [FlutterTextRange + rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))]; + + if ([_selectionRects count] > 0 && textRange.range.length == end) { + NSUInteger i = [_selectionRects count] - 1; + NSUInteger position = _selectionRects[i].position + 1; + if (position <= end) { + if (isSelectionRectCloserToPoint(point, _selectionRects[i].rect, _closestRect, + /*checkRightBoundary=*/YES)) { + _closestIndex = [_selectionRects count]; + _closestPosition = position; + } + } + } + + return [FlutterTextPosition positionWithIndex:_closestPosition]; } - (UITextRange*)characterRangeAtPoint:(CGPoint)point { @@ -1315,36 +1632,53 @@ - (void)beginFloatingCursorAtPoint:(CGPoint)point { // ) // where // point = keyboardPanGestureRecognizer.translationInView(textInputView) + - // caretRectForPosition boundingBox = self.convertRect(bounds, fromView:textInputView) bounds - // = self._selectionClipRect ?? self.bounds + // 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. + // 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. + // 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). _isFloatingCursorActive = true; - [self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateStart + // 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, *)) { + self.textInteraction = [UITextInteraction textInteractionForMode:UITextInteractionModeEditable]; + self.textInteraction.textInput = self; + [self addInteraction:_textInteraction]; + } + [self.textInputDelegate flutterTextInputView:self + updateFloatingCursor:FlutterFloatingCursorDragStateStart withClient:_textInputClient withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; } - (void)updateFloatingCursorAtPoint:(CGPoint)point { _isFloatingCursorActive = true; - [self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateUpdate + [self.textInputDelegate flutterTextInputView:self + updateFloatingCursor:FlutterFloatingCursorDragStateUpdate withClient:_textInputClient withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; } - (void)endFloatingCursor { _isFloatingCursorActive = false; - [self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateEnd + if (@available(iOS 13.0, *)) { + if (_textInteraction != NULL) { + [self removeInteraction:_textInteraction]; + self.textInteraction = NULL; + } + } + [self.textInputDelegate flutterTextInputView:self + updateFloatingCursor:FlutterFloatingCursorDragStateEnd withClient:_textInputClient withPosition:@{@"X" : @(0), @"Y" : @(0)}]; } @@ -1374,11 +1708,14 @@ - (void)updateEditingState { }; if (_textInputClient == 0 && _autofillId != nil) { - [self.textInputDelegate updateEditingClient:_textInputClient - withState:state - withTag:_autofillId]; + [self.textInputDelegate flutterTextInputView:self + updateEditingClient:_textInputClient + withState:state + withTag:_autofillId]; } else { - [self.textInputDelegate updateEditingClient:_textInputClient withState:state]; + [self.textInputDelegate flutterTextInputView:self + updateEditingClient:_textInputClient + withState:state]; } } @@ -1411,7 +1748,9 @@ - (void)updateEditingStateWithDelta:(FlutterTextEditingDelta*)delta { @"deltas" : @[ deltaToFramework ], }; - [self.textInputDelegate updateEditingClient:_textInputClient withDelta:deltas]; + [self.textInputDelegate flutterTextInputView:self + updateEditingClient:_textInputClient + withDelta:deltas]; } - (BOOL)hasText { @@ -1419,12 +1758,54 @@ - (BOOL)hasText { } - (void)insertText:(NSString*)text { + NSMutableArray* copiedRects = + [[NSMutableArray alloc] initWithCapacity:[_selectionRects count]]; + NSAssert([_selectedTextRange.start isKindOfClass:[FlutterTextPosition class]], + @"Expected a FlutterTextPosition for position (got %@).", + [_selectedTextRange.start class]); + NSUInteger insertPosition = ((FlutterTextPosition*)_selectedTextRange.start).index; + for (NSUInteger i = 0; i < [_selectionRects count]; i++) { + NSUInteger rectPosition = _selectionRects[i].position; + if (rectPosition == insertPosition) { + for (NSUInteger j = 0; j <= text.length; j++) { + [copiedRects + addObject:[FlutterTextSelectionRect selectionRectWithRect:_selectionRects[i].rect + position:rectPosition + j]]; + } + } else { + if (rectPosition > insertPosition) { + rectPosition = rectPosition + text.length; + } + [copiedRects addObject:[FlutterTextSelectionRect selectionRectWithRect:_selectionRects[i].rect + position:rectPosition]]; + } + } + + _scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; + [self resetScribbleInteractionStatusIfEnding]; + self.selectionRects = copiedRects; + [copiedRects release]; _selectionAffinity = _kTextAffinityDownstream; [self replaceRange:_selectedTextRange withText:text]; } +- (UITextPlaceholder*)insertTextPlaceholderWithSize:(CGSize)size API_AVAILABLE(ios(13.0)) { + [_textInputDelegate flutterTextInputView:self + insertTextPlaceholderWithSize:size + withClient:_textInputClient]; + _hasPlaceholder = YES; + return [[[FlutterTextPlaceholder alloc] init] autorelease]; +} + +- (void)removeTextPlaceholder:(UITextPlaceholder*)textPlaceholder API_AVAILABLE(ios(13.0)) { + _hasPlaceholder = NO; + [_textInputDelegate flutterTextInputView:self removeTextPlaceholder:_textInputClient]; +} + - (void)deleteBackward { _selectionAffinity = _kTextAffinityDownstream; + _scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; + [self resetScribbleInteractionStatusIfEnding]; // When deleting Thai vowel, _selectedTextRange has location // but does not have length, so we have to manually set it. @@ -1471,6 +1852,12 @@ - (BOOL)accessibilityElementsHidden { return !_accessibilityEnabled; } +- (void)resetScribbleInteractionStatusIfEnding { + if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusEnding) { + _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone; + } +} + @end /** @@ -1533,6 +1920,7 @@ @interface FlutterTextInputPlugin () NSMutableDictionary* autofillContext; @property(nonatomic, strong) FlutterTextInputView* activeView; @property(nonatomic, strong) FlutterTextInputViewAccessibilityHider* inputHider; +@property(nonatomic, readonly) id viewResponder; @end @implementation FlutterTextInputPlugin { @@ -1547,6 +1935,7 @@ - (instancetype)init { if (self) { _autofillContext = [[NSMutableDictionary alloc] init]; _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init]; + _scribbleElements = [[NSMutableDictionary alloc] init]; // Initialize activeView with a dummy view to keep tests // passing. This dummy view needs to be replace once the // framework initializes an input connection, and thus @@ -1567,6 +1956,7 @@ - (void)dealloc { autofillView.textInputDelegate = nil; } [_autofillContext release]; + [_scribbleElements release]; [super dealloc]; } @@ -1609,6 +1999,9 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else if ([method isEqualToString:kFinishAutofillContextMethod]) { [self triggerAutofillSave:[args boolValue]]; result(nil); + } else if ([method isEqualToString:@"TextInput.setSelectionRects"]) { + [self setSelectionRects:args]; + result(nil); } else { result(FlutterMethodNotImplemented); } @@ -1616,6 +2009,18 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - (void)setEditableSizeAndTransform:(NSDictionary*)dictionary { [_activeView setEditableTransform:dictionary[@"transform"]]; + if (isScribbleAvailable()) { + // This is necessary to set up where the scribble interactable element will be. + int leftIndex = 12; + int topIndex = 13; + _inputHider.frame = + CGRectMake([dictionary[@"transform"][leftIndex] intValue], + [dictionary[@"transform"][topIndex] intValue], [dictionary[@"width"] intValue], + [dictionary[@"height"] intValue]); + _activeView.frame = + CGRectMake(0, 0, [dictionary[@"width"] intValue], [dictionary[@"height"] intValue]); + _activeView.tintColor = [UIColor clearColor]; + } } - (void)updateMarkedRect:(NSDictionary*)dictionary { @@ -1627,8 +2032,23 @@ - (void)updateMarkedRect:(NSDictionary*)dictionary { _activeView.markedRect = rect.size.width < 0 && rect.size.height < 0 ? kInvalidFirstRect : rect; } +- (void)setSelectionRects:(NSArray*)rects { + NSMutableArray* rectsAsRect = + [[[NSMutableArray alloc] initWithCapacity:[rects count]] autorelease]; + for (NSUInteger i = 0; i < [rects count]; i++) { + NSArray* rect = rects[i]; + [rectsAsRect + addObject:[FlutterTextSelectionRect + selectionRectWithRect:CGRectMake([rect[0] floatValue], [rect[1] floatValue], + [rect[2] floatValue], [rect[3] floatValue]) + position:[rect[4] unsignedIntegerValue]]]; + } + _activeView.selectionRects = rectsAsRect; +} + - (void)showTextInput { _activeView.textInputDelegate = _textInputDelegate; + _activeView.viewResponder = _viewResponder; [self addToInputParentViewIfNeeded:_activeView]; // Adds a delay to prevent the text view from receiving accessibility // focus in case it is activated during semantics updates. @@ -1896,6 +2316,105 @@ - (void)setTextInputEditingState:(NSDictionary*)state { - (void)clearTextInputClient { [_activeView setTextInputClient:0]; + _activeView.frame = CGRectZero; +} + +#pragma mark UIIndirectScribbleInteractionDelegate + +- (BOOL)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + isElementFocused:(UIScribbleElementIdentifier)elementIdentifier + API_AVAILABLE(ios(14.0)) { + return _activeView.scribbleFocusStatus == FlutterScribbleFocusStatusFocused; +} + +- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + focusElementIfNeeded:(UIScribbleElementIdentifier)elementIdentifier + referencePoint:(CGPoint)focusReferencePoint + completion:(void (^)(UIResponder* focusedInput))completion + API_AVAILABLE(ios(14.0)) { + _activeView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; + [_indirectScribbleDelegate flutterTextInputPlugin:self + focusElement:elementIdentifier + atPoint:focusReferencePoint + result:^(id _Nullable result) { + _activeView.scribbleFocusStatus = + FlutterScribbleFocusStatusFocused; + completion(_activeView); + }]; +} + +- (BOOL)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + shouldDelayFocusForElement:(UIScribbleElementIdentifier)elementIdentifier + API_AVAILABLE(ios(14.0)) { + return NO; +} + +- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + willBeginWritingInElement:(UIScribbleElementIdentifier)elementIdentifier + API_AVAILABLE(ios(14.0)) { +} + +- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + didFinishWritingInElement:(UIScribbleElementIdentifier)elementIdentifier + API_AVAILABLE(ios(14.0)) { +} + +- (CGRect)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + frameForElement:(UIScribbleElementIdentifier)elementIdentifier + API_AVAILABLE(ios(14.0)) { + NSValue* elementValue = [_scribbleElements objectForKey:elementIdentifier]; + if (elementValue == nil) { + return CGRectZero; + } + return [elementValue CGRectValue]; +} + +- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction + requestElementsInRect:(CGRect)rect + completion: + (void (^)(NSArray* elements))completion + API_AVAILABLE(ios(14.0)) { + [_indirectScribbleDelegate + flutterTextInputPlugin:self + requestElementsInRect:rect + result:^(id _Nullable result) { + NSMutableArray* elements = + [[[NSMutableArray alloc] init] autorelease]; + if ([result isKindOfClass:[NSArray class]]) { + for (NSArray* elementArray in result) { + [elements addObject:elementArray[0]]; + [_scribbleElements + setObject:[NSValue + valueWithCGRect:CGRectMake( + [elementArray[1] floatValue], + [elementArray[2] floatValue], + [elementArray[3] floatValue], + [elementArray[4] floatValue])] + forKey:elementArray[0]]; + } + } + completion(elements); + }]; +} + +#pragma mark - Methods related to Scribble support + +- (void)setupIndirectScribbleInteraction:(id)viewResponder { + if (_viewResponder != viewResponder) { + if (@available(iOS 14.0, *)) { + UIView* parentView = viewResponder.view; + if (parentView != nil) { + UIIndirectScribbleInteraction* scribbleInteraction = [[[UIIndirectScribbleInteraction alloc] + initWithDelegate:(id)self] autorelease]; + [parentView addInteraction:scribbleInteraction]; + } + } + } + _viewResponder = viewResponder; +} + +- (void)resetViewResponder { + _viewResponder = nil; } #pragma mark - diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 3f71c6ca6b0a0..6289c07d1e996 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -227,7 +227,62 @@ - (void)testAutocorrectionPromptRectAppears { [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; // Verify behavior. - OCMVerify([engine showAutocorrectionPromptRectForStart:0 end:1 withClient:0]); + OCMVerify([engine flutterTextInputView:inputView + showAutocorrectionPromptRectForStart:0 + end:1 + withClient:0]); +} + +- (void)testAutocorrectionPromptRectDoesNotAppearDuringScribble { + if (@available(iOS 14.0, *)) { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero]; + inputView.textInputDelegate = engine; + + __block int callCount = 0; + OCMStub([engine flutterTextInputView:inputView + showAutocorrectionPromptRectForStart:0 + end:1 + withClient:0]) + .andDo(^(NSInvocation* invocation) { + callCount++; + }); + + [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; + // showAutocorrectionPromptRectForStart fires in response to firstRectForRange + XCTAssertEqual(callCount, 1); + + UIScribbleInteraction* scribbleInteraction = + [[UIScribbleInteraction alloc] initWithDelegate:inputView]; + + [inputView scribbleInteractionWillBeginWriting:scribbleInteraction]; + [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; + // showAutocorrectionPromptRectForStart does not fire in response to setMarkedText during a + // scribble interaction.firstRectForRange + XCTAssertEqual(callCount, 1); + + [inputView scribbleInteractionDidFinishWriting:scribbleInteraction]; + [inputView resetScribbleInteractionStatusIfEnding]; + [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; + // showAutocorrectionPromptRectForStart fires in response to firstRectForRange. + XCTAssertEqual(callCount, 2); + + inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; + [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; + // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange during a + // scribble-initiated focus. + XCTAssertEqual(callCount, 2); + + inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused; + [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; + // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange after a + // scribble-initiated focus. + XCTAssertEqual(callCount, 2); + + inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; + [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; + // showAutocorrectionPromptRectForStart fires in response to firstRectForRange. + XCTAssertEqual(callCount, 3); + } } - (void)testTextRangeFromPositionMatchesUITextViewBehavior { @@ -379,7 +434,7 @@ - (void)testTextEditingDeltasAreGeneratedOnTextInput { inputView.enableDeltaModel = YES; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withDelta:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -390,82 +445,91 @@ - (void)testTextEditingDeltasAreGeneratedOnTextInput { // Verify correct delta is generated. OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] isEqualToString:@""]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] - isEqualToString:@"text to insert"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] isEqualToString:@""]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@"text to insert"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0); + }]]); [inputView deleteBackward]; XCTAssertEqual(updateCount, 2); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"text to insert"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] isEqualToString:@""]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 14); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"text to insert"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@""]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 14); + }]]); inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; XCTAssertEqual(updateCount, 3); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"text to inser"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] isEqualToString:@""]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == -1) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == -1); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"text to inser"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@""]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == -1) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == -1); + }]]); [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] withText:@"replace text"]; XCTAssertEqual(updateCount, 4); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"text to inser"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] - isEqualToString:@"replace text"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"text to inser"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@"replace text"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1); + }]]); [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; XCTAssertEqual(updateCount, 5); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"replace textext to inser"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] - isEqualToString:@"marked text"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 12) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 12); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"replace textext to inser"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@"marked text"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 12) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 12); + }]]); [inputView unmarkText]; XCTAssertEqual(updateCount, 6); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"replace textmarked textext to inser"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] isEqualToString:@""]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == -1) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == -1); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"replace textmarked textext to inser"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@""]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == -1) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == -1); + }]]); } - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement { @@ -474,7 +538,7 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement { inputView.enableDeltaModel = YES; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withDelta:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -491,15 +555,16 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement { XCTAssertEqual(updateCount, 2); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"Some initial text"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] - isEqualToString:@"new marked text."]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"Some initial text"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@"new marked text."]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); + }]]); } - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion { @@ -508,7 +573,7 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion { inputView.enableDeltaModel = YES; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withDelta:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -525,15 +590,16 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion { XCTAssertEqual(updateCount, 2); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"Some initial text"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] - isEqualToString:@"text."]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"Some initial text"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@"text."]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); + }]]); } - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion { @@ -542,7 +608,7 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion { inputView.enableDeltaModel = YES; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withDelta:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -559,15 +625,16 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion { XCTAssertEqual(updateCount, 2); OCMVerify([engine - updateEditingClient:0 - withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] - isEqualToString:@"Some initial text"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] - isEqualToString:@"tex"]) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && - ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); - }]]); + flutterTextInputView:inputView + updateEditingClient:0 + withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] + isEqualToString:@"Some initial text"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] + isEqualToString:@"tex"]) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && + ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); + }]]); } #pragma mark - EditingState tests @@ -577,7 +644,7 @@ - (void)testUITextInputCallsUpdateEditingStateOnce { inputView.textInputDelegate = engine; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -609,7 +676,7 @@ - (void)testUITextInputCallsUpdateEditingStateWithDeltaOnce { inputView.enableDeltaModel = YES; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withDelta:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -640,7 +707,7 @@ - (void)testTextChangesDoNotTriggerUpdateEditingClient { inputView.textInputDelegate = engine; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -682,7 +749,7 @@ - (void)testTextChangesDoNotTriggerUpdateEditingClientWithDelta { inputView.enableDeltaModel = YES; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withDelta:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -723,7 +790,7 @@ - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls { inputView.textInputDelegate = engine; __block int updateCount = 0; - OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]]) + OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) .andDo(^(NSInvocation* invocation) { updateCount++; }); @@ -741,6 +808,56 @@ - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls { XCTAssertEqual(updateCount, 2); } +- (void)testSetMarkedTextDuringScribbleDoesNotTriggerUpdateEditingClient { + if (@available(iOS 14.0, *)) { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; + inputView.textInputDelegate = engine; + + __block int updateCount = 0; + OCMStub([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg isNotNil]]) + .andDo(^(NSInvocation* invocation) { + updateCount++; + }); + + [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; + // updateEditingClient fires in response to setMarkedText. + XCTAssertEqual(updateCount, 1); + + UIScribbleInteraction* scribbleInteraction = + [[UIScribbleInteraction alloc] initWithDelegate:inputView]; + + [inputView scribbleInteractionWillBeginWriting:scribbleInteraction]; + [inputView setMarkedText:@"during writing" selectedRange:NSMakeRange(1, 2)]; + // updateEditingClient does not fire in response to setMarkedText during a scribble interaction. + XCTAssertEqual(updateCount, 1); + + [inputView scribbleInteractionDidFinishWriting:scribbleInteraction]; + [inputView resetScribbleInteractionStatusIfEnding]; + [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; + // updateEditingClient fires in response to setMarkedText. + XCTAssertEqual(updateCount, 2); + + inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; + [inputView setMarkedText:@"during focus" selectedRange:NSMakeRange(1, 2)]; + // updateEditingClient does not fire in response to setMarkedText during a scribble-initiated + // focus. + XCTAssertEqual(updateCount, 2); + + inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused; + [inputView setMarkedText:@"after focus" selectedRange:NSMakeRange(2, 3)]; + // updateEditingClient does not fire in response to setMarkedText after a scribble-initiated + // focus. + XCTAssertEqual(updateCount, 2); + + inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; + [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; + // updateEditingClient fires in response to setMarkedText. + XCTAssertEqual(updateCount, 3); + } +} + - (void)testUpdateEditingClientNegativeSelection { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; inputView.textInputDelegate = engine; @@ -755,30 +872,33 @@ - (void)testUpdateEditingClientNegativeSelection { @"selectionExtent" : @-1 }]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 0); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 0); + }]]); // Returns (0, 0) when either end goes below 0. [inputView setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 0); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 0); + }]]); [inputView setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 0); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 0); + }]]); } - (void)testUpdateEditingClientSelectionClamping { @@ -793,11 +913,12 @@ - (void)testUpdateEditingClientSelectionClamping { [inputView setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 0); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 0); + }]]); // Needs clamping. [inputView setTextInputState:@{ @@ -807,21 +928,23 @@ - (void)testUpdateEditingClientSelectionClamping { }]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 9); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 9); + }]]); // No clamping needed, but in reverse direction. [inputView setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 1); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 1); + }]]); // Both ends need clamping. [inputView setTextInputState:@{ @@ -830,11 +953,12 @@ - (void)testUpdateEditingClientSelectionClamping { @"selectionExtent" : @9999 }]; [inputView updateEditingState]; - OCMVerify([engine updateEditingClient:0 - withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 9 && - ([state[@"selectionExtent"] intValue] == 9); - }]]); + OCMVerify([engine flutterTextInputView:inputView + updateEditingClient:0 + withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"selectionBase"] intValue]) == 9 && + ([state[@"selectionExtent"] intValue] == 9); + }]]); } #pragma mark - UITextInput methods - Tests @@ -884,6 +1008,130 @@ - (void)testUpdateFirstRectForRange { XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); } +- (void)testFirstRectForRangeReturnsCorrectSelectionRect { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + CGRect testRect = CGRectMake(100, 100, 100, 100); + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:testRect position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U], + ]]; + XCTAssertTrue(CGRectEqualToRect(testRect, [inputView firstRectForRange:range])); + + [inputView setTextInputState:@{@"text" : @"COM"}]; + FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)]; + XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds])); +} + +- (void)testClosestPositionToPoint { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + // Minimize the vertical distance from the center of the rects first + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U], + ]]; + CGPoint point = CGPointMake(150, 150); + XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + + // Then, if the point is above the bottom of the closest rects vertically, get the closest x + // origin + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U], + ]]; + point = CGPointMake(125, 150); + XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + + // However, if the point is below the bottom of the closest rects vertically, get the position + // farthest to the right + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U], + ]]; + point = CGPointMake(125, 201); + XCTAssertEqual(3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + + // Also check a point at the right edge of the last selection rect + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], + ]]; + point = CGPointMake(125, 250); + XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); +} + +- (void)testSelectionRectsForRange { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + CGRect testRect0 = CGRectMake(100, 100, 100, 100); + CGRect testRect1 = CGRectMake(200, 200, 100, 100); + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:testRect0 position:1U], + [FlutterTextSelectionRect selectionRectWithRect:testRect1 position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U], + ]]; + + // Returns the matching rects within a range + FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + XCTAssertTrue(CGRectEqualToRect(testRect0, [inputView selectionRectsForRange:range][0].rect)); + XCTAssertTrue(CGRectEqualToRect(testRect1, [inputView selectionRectsForRange:range][1].rect)); + XCTAssertEqual(2U, [[inputView selectionRectsForRange:range] count]); + + // Returns a 0 width rect for a 0-length range + range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 0)]; + XCTAssertEqual(1U, [[inputView selectionRectsForRange:range] count]); + XCTAssertTrue(CGRectEqualToRect( + CGRectMake(testRect0.origin.x, testRect0.origin.y, 0, testRect0.size.height), + [inputView selectionRectsForRange:range][0].rect)); +} + +- (void)testClosestPositionToPointWithinRange { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + // Do not return a position before the start of the range + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U], + ]]; + CGPoint point = CGPointMake(125, 150); + FlutterTextRange* range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(3, 2)] copy]; + XCTAssertEqual( + 3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index); + + // Do not return a position after the end of the range + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U], + ]]; + point = CGPointMake(125, 150); + range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] copy]; + XCTAssertEqual( + 1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index); +} + #pragma mark - Floating Cursor - Tests - (void)testInputViewsHaveUIInteractions { @@ -941,6 +1189,52 @@ - (void)testBoundsForFloatingCursor { XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds)); } +#pragma mark - UIKeyInput Overrides - Tests + +- (void)testInsertTextAddsPlaceholderSelectionRects { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; + [inputView + setTextInputState:@{@"text" : @"test", @"selectionBase" : @1, @"selectionExtent" : @1}]; + + FlutterTextSelectionRect* first = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U]; + FlutterTextSelectionRect* second = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U]; + FlutterTextSelectionRect* third = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U]; + FlutterTextSelectionRect* fourth = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U]; + [inputView setSelectionRects:@[ first, second, third, fourth ]]; + + // Inserts additional selection rects at the selection start + [inputView insertText:@"in"]; + NSArray* selectionRects = + [inputView selectionRectsForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 6)]]; + XCTAssertEqual(6U, [selectionRects count]); + + XCTAssertEqual(first.position, ((FlutterTextSelectionRect*)selectionRects[0]).position); + XCTAssertTrue(CGRectEqualToRect(first.rect, ((FlutterTextSelectionRect*)selectionRects[0]).rect)); + + XCTAssertEqual(second.position, ((FlutterTextSelectionRect*)selectionRects[1]).position); + XCTAssertTrue( + CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[1]).rect)); + + XCTAssertEqual(second.position + 1, ((FlutterTextSelectionRect*)selectionRects[2]).position); + XCTAssertTrue( + CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[2]).rect)); + + XCTAssertEqual(second.position + 2, ((FlutterTextSelectionRect*)selectionRects[3]).position); + XCTAssertTrue( + CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[3]).rect)); + + XCTAssertEqual(third.position + 2, ((FlutterTextSelectionRect*)selectionRects[4]).position); + XCTAssertTrue(CGRectEqualToRect(third.rect, ((FlutterTextSelectionRect*)selectionRects[4]).rect)); + + XCTAssertEqual(fourth.position + 2, ((FlutterTextSelectionRect*)selectionRects[5]).position); + XCTAssertTrue( + CGRectEqualToRect(fourth.rect, ((FlutterTextSelectionRect*)selectionRects[5]).rect)); +} + #pragma mark - Autofill - Utilities - (NSMutableDictionary*)mutablePasswordTemplateCopy { @@ -1208,7 +1502,10 @@ - (void)testAutofillInputViews { [self ensureOnlyActiveViewCanBecomeFirstResponder]; // Verify behavior. - OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]); + OCMVerify([engine flutterTextInputView:inactiveView + updateEditingClient:0 + withState:[OCMArg isNotNil] + withTag:@"field2"]); } - (void)testPasswordAutofillHack { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h index b6b655026b463..16febb55f99ac 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h @@ -12,6 +12,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardManager.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterRestorationPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterUIPressProxy.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h" namespace flutter { class FlutterPlatformViewsController; @@ -26,7 +27,7 @@ extern NSNotificationName const FlutterViewControllerHideHomeIndicator; FLUTTER_DARWIN_EXPORT extern NSNotificationName const FlutterViewControllerShowHomeIndicator; -@interface FlutterViewController () +@interface FlutterViewController () @property(nonatomic, readonly) BOOL isPresentingViewController; @property(nonatomic, readonly) BOOL isVoiceOverRunning; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h b/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h new file mode 100644 index 0000000000000..a9bf602e86fef --- /dev/null +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h @@ -0,0 +1,24 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERVIEWRESPONDER_H_ +#define SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERVIEWRESPONDER_H_ + +#import + +NS_ASSUME_NONNULL_BEGIN +@protocol FlutterViewResponder + +@property(nonatomic, strong) UIView* view; + +- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event; +- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event; +- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event; +- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event; +- (void)touchesEstimatedPropertiesUpdated:(NSSet*)touches; + +@end +NS_ASSUME_NONNULL_END + +#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERVIEWRESPONDER_H_