diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index b91f747c8f3fb..b1d3bd9a6765b 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -2491,6 +2491,8 @@ ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSeman ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient_UITextInput.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm + ../../../flutter/LICENSE @@ -4947,6 +4949,8 @@ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanti FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient_UITextInput.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm diff --git a/shell/platform/darwin/ios/BUILD.gn b/shell/platform/darwin/ios/BUILD.gn index 63ca777abfbd9..a14646fbf11ba 100644 --- a/shell/platform/darwin/ios/BUILD.gn +++ b/shell/platform/darwin/ios/BUILD.gn @@ -56,6 +56,8 @@ source_set("flutter_framework_source_arc") { public_configs = [ "//flutter:config" ] sources = [ + "framework/Source/FlutterTextInputClient.h", + "framework/Source/FlutterTextInputClient_UITextInput.mm", "framework/Source/FlutterTextInputDelegate.h", "framework/Source/FlutterTextInputPlugin.h", "framework/Source/FlutterTextInputPlugin.mm", diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 9a0352a360604..99e68e9c486f3 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -875,14 +875,14 @@ - (void)notifyLowMemory { #pragma mark - Text input delegate -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView updateEditingClient:(int)client withState:(NSDictionary*)state { [_textInputChannel.get() invokeMethod:@"TextInputClient.updateEditingState" arguments:@[ @(client), state ]]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView updateEditingClient:(int)client withState:(NSDictionary*)state withTag:(NSString*)tag { @@ -890,14 +890,14 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView arguments:@[ @(client), @{tag : state} ]]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView updateEditingClient:(int)client withDelta:(NSDictionary*)delta { [_textInputChannel.get() invokeMethod:@"TextInputClient.updateEditingStateWithDeltas" arguments:@[ @(client), delta ]]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView updateFloatingCursor:(FlutterFloatingCursorDragState)state withClient:(int)client withPosition:(NSDictionary*)position { @@ -917,7 +917,7 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView arguments:@[ @(client), stateString, position ]]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView performAction:(FlutterTextInputAction)action withClient:(int)client { NSString* actionString; @@ -964,7 +964,7 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView arguments:@[ @(client), actionString ]]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView showAutocorrectionPromptRectForStart:(NSUInteger)start end:(NSUInteger)end withClient:(int)client { @@ -974,7 +974,8 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView #pragma mark - FlutterViewEngineDelegate -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client { +- (void)flutterTextInputView:(UIView*)textInputView + showToolbar:(int)client { [_scribbleChannel.get() invokeMethod:@"Scribble.showToolbar" arguments:@[ @(client) ]]; } @@ -997,27 +998,29 @@ - (void)flutterTextInputPlugin:(FlutterTextInputPlugin*)textInputPlugin result:callback]; } -- (void)flutterTextInputViewScribbleInteractionBegan:(FlutterTextInputView*)textInputView { +- (void)flutterTextInputViewScribbleInteractionBegan: + (UIView*)textInputView { [_scribbleChannel.get() invokeMethod:@"Scribble.scribbleInteractionBegan" arguments:nil]; } -- (void)flutterTextInputViewScribbleInteractionFinished:(FlutterTextInputView*)textInputView { +- (void)flutterTextInputViewScribbleInteractionFinished: + (UIView*)textInputView { [_scribbleChannel.get() invokeMethod:@"Scribble.scribbleInteractionFinished" arguments:nil]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView insertTextPlaceholderWithSize:(CGSize)size withClient:(int)client { [_scribbleChannel.get() invokeMethod:@"Scribble.insertTextPlaceholder" arguments:@[ @(client), @(size.width), @(size.height) ]]; } -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView removeTextPlaceholder:(int)client { [_scribbleChannel.get() invokeMethod:@"Scribble.removeTextPlaceholder" arguments:@[ @(client) ]]; } -- (void)flutterTextInputViewDidResignFirstResponder:(FlutterTextInputView*)textInputView { +- (void)flutterTextInputViewDidResignFirstResponder:(UIView*)textInputView { // Platform view's first responder detection logic: // // All text input widgets (e.g. EditableText) are backed by a dummy UITextInput view diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h new file mode 100644 index 0000000000000..ae133046b9113 --- /dev/null +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h @@ -0,0 +1,112 @@ +// 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_FLUTTERTEXTINPUTCLIENT_H_ +#define SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTCLIENT_H_ + +#import +#import "FlutterMacros.h" + +#define RegularInputClient FlutterTextInputView +#define SecureInputClient FlutterSecureTextInputView + +@class FlutterTextInputPlugin; +@class FlutterTextSelectionRect; + +@protocol FlutterViewResponder; + +typedef NS_ENUM(NSInteger, FlutterScribbleFocusStatus) { + FlutterScribbleFocusStatusUnfocused, + FlutterScribbleFocusStatusFocusing, + FlutterScribbleFocusStatusFocused, +}; + +typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { + FlutterScribbleInteractionStatusNone, + FlutterScribbleInteractionStatusStarted, + FlutterScribbleInteractionStatusEnding, +}; + +/** An indexed position in the buffer of a Flutter text editing widget. */ +@interface FlutterTextPosition : UITextPosition + +@property(nonatomic, readonly) NSUInteger index; + ++ (instancetype)positionWithIndex:(NSUInteger)index; +- (instancetype)initWithIndex:(NSUInteger)index; + +@end + +/** A range of text in the buffer of a Flutter text editing widget. */ +@interface FlutterTextRange : UITextRange + +@property(nonatomic, readonly) NSRange range; + ++ (instancetype)rangeWithNSRange:(NSRange)range; + +@end + +/** An object that represents a framework text editing widget and interacts with the iOS text input + * system on behalf of that widget. + * A FlutterTextInputClient can receive editing state updates from the setTextInputState: method, + * and it should typically relay editing state changes made by the iOS text input system to the + * framework, via the textInputPlugin.textInputDelegate method. */ +@protocol FlutterTextInputClient +/** The framework issued id of this client. */ +@property(nonatomic, assign) int clientID; +@property(nonatomic, assign) BOOL accessibilityEnabled; +@property(nonatomic, assign) FlutterScribbleFocusStatus scribbleFocusStatus; +@property(nonatomic, weak) UIAccessibilityElement* backingTextInputAccessibilityObject; + +- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin; +/** Updates the rect that describes the bounding box of the framework blinking cursor, in the + * framework widget's coordinates. + * See the setEditableSize:transform: method. */ +- (void)setMarkedRect:(CGRect)rect; +- (void)setViewResponder:(id)viewResponder; +/** Updates the visible glyph boxes in the framework, in the framework widget's coordinates. + * See the setEditableSize:transform: method. */ +- (void)setSelectionRects:(NSArray*)rects; +/** Called by the framework to update the editing state (text, selection, composing region). */ +- (void)setTextInputState:(NSDictionary*)state; +/** Updates the transform and the size of the framework text editing widget's text editing region. + * The information describes the paint transform and paint bounds of the framework widget. */ +- (void)setEditableSize:(CGSize)size transform:(NSArray*)matrix; +- (void)setEnableDeltaModel:(BOOL)enableDeltaModel; +- (void)setEnableSoftwareKeyboard:(BOOL)enabled; +- (void)setEnableInteractiveSelection:(BOOL)enabled; +@end + +@protocol FlutterTextAutofillClient + +/** A framework issued id used to uniquely identify an autofill client. The ID is guaranteed to be + * unique at any given time. */ +@property(nonatomic, copy) NSString* autofillID; +- (void)setIsVisibleToAutofill:(BOOL)visibility; +@end + +#if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG +FLUTTER_DARWIN_EXPORT +#endif +@interface FlutterTextInputView + : UIView + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder*)aDecoder NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin NS_DESIGNATED_INITIALIZER; +@end + +// A FlutterTextInputView that masquerades as a UITextField, and forwards +// selectors it can't respond to to a shared UITextField instance. +// +// Relevant API docs claim that password autofill supports any custom view +// that adopts the UITextInput protocol, automatic strong password seems to +// currently only support UITextFields, and password saving only supports +// UITextFields and UITextViews, as of iOS 13.5. +@interface FlutterSecureTextInputView : FlutterTextInputView +@end + +#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTCLIENT_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient_UITextInput.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient_UITextInput.mm new file mode 100644 index 0000000000000..fdee6d0fdfd7e --- /dev/null +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient_UITextInput.mm @@ -0,0 +1,1375 @@ +// 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. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" + +#include "flutter/fml/platform/darwin/string_range_sanitization.h" +#include "unicode/uchar.h" + +FLUTTER_ASSERT_ARC + +static const char kTextAffinityDownstream[] = "TextAffinity.downstream"; +static const char kTextAffinityUpstream[] = "TextAffinity.upstream"; +// 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}}; + +// The "canonical" invalid CGRect, similar to CGRectNull, used to +// indicate a CGRect involved in firstRectForRange calculation is +// invalid. The specific value is chosen so that if firstRectForRange +// returns kInvalidFirstRect, iOS will not show the IME candidates view. +const CGRect kInvalidFirstRect = {{-1, -1}, {9999, 9999}}; + +#pragma mark - Static Functions + +// Determine if the character at `range` of `text` is an emoji. +static BOOL IsEmoji(NSString* text, NSRange charRange) { + UChar32 codePoint; + BOOL gotCodePoint = [text getBytes:&codePoint + maxLength:sizeof(codePoint) + usedLength:NULL + encoding:NSUTF32StringEncoding + options:kNilOptions + range:charRange + remainingRange:NULL]; + return gotCodePoint && u_hasBinaryProperty(codePoint, UCHAR_EMOJI); +} + +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)))); +} + +@interface FlutterTextInputView () +// UITextInput +@property(nonatomic, readonly) NSMutableString* markedText; +@property(nonatomic, strong) FlutterTextRange* markedTextRange; + +// Other Configurations +@property(nonatomic, readonly) NSMutableString* text; +@property(nonatomic, getter=isEnableDeltaModel) BOOL enableDeltaModel; + +// Scribble Support +@property(nonatomic, weak) id viewResponder; +@property(nonatomic, strong) NSArray* selectionRects; + +@property(nonatomic, weak) FlutterTextInputPlugin* textInputPlugin; +@property(nonatomic, readonly) CATransform3D editableTransform; +@property(nonatomic, assign) CGRect markedRect; +@property(nonatomic) BOOL isVisibleToAutofill; +// The composed character that is temporarily removed by the keyboard API. +// This is cleared at the start of each keyboard interaction. (Enter a character, delete a character +// etc) +@property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter; + +@end + +@implementation FlutterTextInputView { + int _clientID; + const char* _selectionAffinity; + 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. + bool _isSystemKeyboardEnabled; + bool _isFloatingCursorActive; + bool _enableInteractiveSelection; + UITextInteraction* _textInteraction API_AVAILABLE(ios(13.0)); +} + +@synthesize tokenizer = _tokenizer; +@synthesize markedTextStyle = _markedTextStyle; +@synthesize inputDelegate = _inputDelegate; +@synthesize autocorrectionType = _autocorrectionType; +@synthesize autocapitalizationType = _autocapitalizationType; +@synthesize spellCheckingType = _spellCheckingType; +@synthesize keyboardAppearance = _keyboardAppearance; +@synthesize keyboardType = _keyboardType; +@synthesize returnKeyType = _returnKeyType; +@synthesize enablesReturnKeyAutomatically = _enablesReturnKeyAutomatically; +@synthesize secureTextEntry = _secureTextEntry; +@synthesize smartQuotesType = _smartQuotesType; +@synthesize smartDashesType = _smartDashesType; +@synthesize textContentType = _textContentType; +@synthesize accessibilityEnabled = _accessibilityEnabled; +@synthesize scribbleFocusStatus = _scribbleFocusStatus; +@synthesize autofillID = _autofillID; +@synthesize backingTextInputAccessibilityObject = _backingTextInputAccessibilityObject; + +- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin { + self = [super initWithFrame:CGRectZero]; + if (self) { + _textInputPlugin = textInputPlugin; + _clientID = 0; + _selectionAffinity = kTextAffinityUpstream; + + // UITextInput + _text = [[NSMutableString alloc] init]; + _markedText = [[NSMutableString alloc] init]; + _selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]; + _markedRect = kInvalidFirstRect; + _cachedFirstRect = kInvalidFirstRect; + _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone; + // Initialize with the zero matrix which is not + // an affine transform. + _editableTransform = CATransform3D(); + _isFloatingCursorActive = false; + + // UITextInputTraits + _autocapitalizationType = UITextAutocapitalizationTypeSentences; + _autocorrectionType = UITextAutocorrectionTypeDefault; + _spellCheckingType = UITextSpellCheckingTypeDefault; + _enablesReturnKeyAutomatically = NO; + _keyboardAppearance = UIKeyboardAppearanceDefault; + _keyboardType = UIKeyboardTypeDefault; + _returnKeyType = UIReturnKeyDone; + _secureTextEntry = NO; + _enableDeltaModel = NO; + _enableInteractiveSelection = YES; + _accessibilityEnabled = NO; + _smartQuotesType = UITextSmartQuotesTypeYes; + _smartDashesType = UITextSmartDashesTypeYes; + _selectionRects = [[NSArray alloc] init]; + + if (@available(iOS 14.0, *)) { + UIScribbleInteraction* interaction = [[UIScribbleInteraction alloc] initWithDelegate:self]; + [self addInteraction:interaction]; + } + } + + return self; +} + +- (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]; +} + +- (void)setEnableSoftwareKeyboard:(BOOL)enableSoftwareKeyboard { + _isSystemKeyboardEnabled = enableSoftwareKeyboard; +} +- (UIInputViewController*)inputViewController { + if (_isSystemKeyboardEnabled) { + return nil; + } + + if (!_inputViewController) { + _inputViewController = [[UIInputViewController alloc] init]; + } + return _inputViewController; +} + +- (id)textInputDelegate { + return _textInputPlugin.textInputDelegate; +} + +- (int)clientID { + return _clientID; +} +- (void)setClientID:(int)client { + _clientID = client; + _hasPlaceholder = NO; +} + +- (UITextInteraction*)textInteraction API_AVAILABLE(ios(13.0)) { + if (!_textInteraction) { + _textInteraction = [UITextInteraction textInteractionForMode:UITextInteractionModeEditable]; + _textInteraction.textInput = self; + } + return _textInteraction; +} + +- (void)setTextInputState:(NSDictionary*)state { + if (@available(iOS 13.0, *)) { + // [UITextInteraction willMoveToView:] sometimes sets the textInput's inputDelegate + // to nil. This is likely a bug in UIKit. In order to inform the keyboard of text + // and selection changes when that happens, add a dummy UITextInteraction to this + // view so it sets a valid inputDelegate that we can call textWillChange et al. on. + // See https://github.com/flutter/engine/pull/32881. + if (!self.inputDelegate && self.isFirstResponder) { + [self addInteraction:self.textInteraction]; + } + } + + NSString* newText = state[@"text"]; + BOOL textChanged = ![self.text isEqualToString:newText]; + if (textChanged) { + [self.inputDelegate textWillChange:self]; + [self.text setString:newText]; + } + NSInteger composingBase = [state[@"composingBase"] intValue]; + NSInteger composingExtent = [state[@"composingExtent"] intValue]; + NSRange composingRange = [self clampSelection:NSMakeRange(MIN(composingBase, composingExtent), + ABS(composingBase - composingExtent)) + forText:self.text]; + + self.markedTextRange = + composingRange.length > 0 ? [FlutterTextRange rangeWithNSRange:composingRange] : nil; + + NSRange selectedRange = [self clampSelectionFromBase:[state[@"selectionBase"] intValue] + extent:[state[@"selectionExtent"] intValue] + forText:self.text]; + + NSRange oldSelectedRange = [(FlutterTextRange*)self.selectedTextRange range]; + if (!NSEqualRanges(selectedRange, oldSelectedRange)) { + [self.inputDelegate selectionWillChange:self]; + + [self setSelectedTextRangeLocal:[FlutterTextRange rangeWithNSRange:selectedRange]]; + + _selectionAffinity = kTextAffinityDownstream; + if ([state[@"selectionAffinity"] isEqualToString:@(kTextAffinityUpstream)]) { + _selectionAffinity = kTextAffinityUpstream; + } + [self.inputDelegate selectionDidChange:self]; + } + + if (textChanged) { + [self.inputDelegate textDidChange:self]; + } + + if (@available(iOS 13.0, *)) { + if (_textInteraction) { + [self removeInteraction:_textInteraction]; + } + } +} + +// 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 +// explicitly set in the framework. This is handled here by setting the +// selection to (0,0). In contrast, Android handles this situation by +// clearing the selection, but the result in both cases is that the cursor +// is placed at the beginning of the field. +- (NSRange)clampSelectionFromBase:(int)selectionBase + extent:(int)selectionExtent + forText:(NSString*)text { + int loc = MIN(selectionBase, selectionExtent); + int len = ABS(selectionExtent - selectionBase); + return loc < 0 ? NSMakeRange(0, 0) + : [self clampSelection:NSMakeRange(loc, len) forText:self.text]; +} + +- (NSRange)clampSelection:(NSRange)range forText:(NSString*)text { + NSUInteger start = MIN(MAX(range.location, 0), text.length); + NSUInteger length = MIN(range.length, text.length - start); + return NSMakeRange(start, length); +} + +- (BOOL)isVisibleToAutofill { + return self.frame.size.width > 0 && self.frame.size.height > 0; +} + +// An input view is generally ignored by password autofill attempts, if it's +// not the first responder and is zero-sized. For input fields that are in the +// autofill context but do not belong to the current autofill group, setting +// 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; + [self.textInputDelegate flutterTextInputViewScribbleInteractionBegan:self]; +} + +- (void)scribbleInteractionDidFinishWriting:(UIScribbleInteraction*)interaction + API_AVAILABLE(ios(14.0)) { + _scribbleInteractionStatus = FlutterScribbleInteractionStatusEnding; + [self.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 { + // Only the currently focused input field can + // become the first responder. This prevents iOS + // from changing focus by itself (the framework + // focus will be out of sync if that happens). + return _clientID != 0; +} + +- (BOOL)resignFirstResponder { + BOOL success = [super resignFirstResponder]; + if (success) { + [self.textInputDelegate flutterTextInputViewDidResignFirstResponder:self]; + } + return success; +} + +- (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 (FlutterTextInputPlugin.isScribbleAvailable && sender == NULL) { + return NO; + } + if (action == @selector(paste:)) { + // Forbid pasting images, memojis, or other non-string content. + return [UIPasteboard generalPasteboard].string != nil; + } + + return [super canPerformAction:action withSender:sender]; +} + +#pragma mark - UIResponderStandardEditActions Overrides + +- (void)cut:(id)sender { + [UIPasteboard generalPasteboard].string = [self textInRange:_selectedTextRange]; + [self replaceRange:_selectedTextRange withText:@""]; +} + +- (void)copy:(id)sender { + [UIPasteboard generalPasteboard].string = [self textInRange:_selectedTextRange]; +} + +- (void)paste:(id)sender { + NSString* pasteboardString = [UIPasteboard generalPasteboard].string; + if (pasteboardString != nil) { + [self insertText:pasteboardString]; + } +} + +- (void)delete:(id)sender { + [self replaceRange:_selectedTextRange withText:@""]; +} + +- (void)selectAll:(id)sender { + [self setSelectedTextRange:[self textRangeFromPosition:[self beginningOfDocument] + toPosition:[self endOfDocument]]]; +} + +#pragma mark - UITextInput Overrides + +- (id)tokenizer { + if (_tokenizer == nil) { + _tokenizer = [[FlutterTokenizer alloc] initWithTextInput:self]; + } + return _tokenizer; +} + +- (UITextRange*)selectedTextRange { + return [_selectedTextRange copy]; +} + +// Change the range of selected text, without notifying the framework. +- (void)setSelectedTextRangeLocal:(UITextRange*)selectedTextRange { + if (_selectedTextRange == selectedTextRange) { + return; + } + if (self.hasText) { + FlutterTextRange* flutterTextRange = (FlutterTextRange*)selectedTextRange; + _selectedTextRange = [[FlutterTextRange + rangeWithNSRange:fml::RangeForCharactersInRange(self.text, flutterTextRange.range)] copy]; + } else { + _selectedTextRange = [selectedTextRange copy]; + } +} +- (void)setEnableInteractiveSelection:(BOOL)enableInteractiveSelection { + _enableInteractiveSelection = enableInteractiveSelection; +} +- (void)setSelectedTextRange:(UITextRange*)selectedTextRange { + if (!_enableInteractiveSelection) { + return; + } + + [self setSelectedTextRangeLocal:selectedTextRange]; + + if (_enableDeltaModel) { + [self updateEditingStateWithDelta:flutter::TextEditingDelta([self.text UTF8String])]; + } 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) { + [self.textInputDelegate flutterTextInputView:self showToolbar:_clientID]; + } + } + + [self resetScribbleInteractionStatusIfEnding]; +} + +- (id)insertDictationResultPlaceholder { + return @""; +} + +- (void)removeDictationResultPlaceholder:(id)placeholder willInsertResult:(BOOL)willInsertResult { +} + +- (NSString*)textInRange:(UITextRange*)range { + if (!range) { + return nil; + } + NSAssert([range isKindOfClass:[FlutterTextRange class]], + @"Expected a FlutterTextRange for range (got %@).", [range class]); + NSRange textRange = ((FlutterTextRange*)range).range; + NSAssert(textRange.location != NSNotFound, @"Expected a valid text range."); + // Sanitize the range to prevent going out of bounds. + NSUInteger location = MIN(textRange.location, self.text.length); + NSUInteger length = MIN(self.text.length - location, textRange.length); + NSRange safeRange = NSMakeRange(location, length); + return [self.text substringWithRange:safeRange]; +} + +// Replace the text within the specified range with the given text, +// without notifying the framework. +- (void)replaceRangeLocal:(NSRange)range withText:(NSString*)text { + NSRange selectedRange = _selectedTextRange.range; + + // Adjust the text selection: + // * reduce the length by the intersection length + // * adjust the location by newLength - oldLength + intersectionLength + NSRange intersectionRange = NSIntersectionRange(range, selectedRange); + if (range.location <= selectedRange.location) { + selectedRange.location += text.length - range.length; + } + if (intersectionRange.location != NSNotFound) { + selectedRange.location += intersectionRange.length; + selectedRange.length -= intersectionRange.length; + } + + [self.text replaceCharactersInRange:[self clampSelection:range forText:self.text] + withString:text]; + [self setSelectedTextRangeLocal:[FlutterTextRange + rangeWithNSRange:[self clampSelection:selectedRange + forText:self.text]]]; +} + +- (void)replaceRange:(UITextRange*)range withText:(NSString*)text { + NSString* textBeforeChange = [self.text copy]; + NSRange replaceRange = ((FlutterTextRange*)range).range; + [self replaceRangeLocal:replaceRange withText:text]; + if (_enableDeltaModel) { + NSRange nextReplaceRange = [self clampSelection:replaceRange forText:textBeforeChange]; + [self updateEditingStateWithDelta:flutter::TextEditingDelta( + [textBeforeChange UTF8String], + flutter::TextRange( + nextReplaceRange.location, + nextReplaceRange.location + nextReplaceRange.length), + [text UTF8String])]; + } else { + [self updateEditingState]; + } +} + +- (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)text { + // `temporarilyDeletedComposedCharacter` should only be used during a single text change session. + // So it needs to be cleared at the start of each text editting session. + self.temporarilyDeletedComposedCharacter = nil; + + if (self.returnKeyType == UIReturnKeyDefault && [text isEqualToString:@"\n"]) { + [self.textInputDelegate flutterTextInputView:self + performAction:FlutterTextInputActionNewline + withClient:_clientID]; + return YES; + } + + if ([text isEqualToString:@"\n"]) { + FlutterTextInputAction action; + switch (self.returnKeyType) { + case UIReturnKeyDefault: + action = FlutterTextInputActionUnspecified; + break; + case UIReturnKeyDone: + action = FlutterTextInputActionDone; + break; + case UIReturnKeyGo: + action = FlutterTextInputActionGo; + break; + case UIReturnKeySend: + action = FlutterTextInputActionSend; + break; + case UIReturnKeySearch: + case UIReturnKeyGoogle: + case UIReturnKeyYahoo: + action = FlutterTextInputActionSearch; + break; + case UIReturnKeyNext: + action = FlutterTextInputActionNext; + break; + case UIReturnKeyContinue: + action = FlutterTextInputActionContinue; + break; + case UIReturnKeyJoin: + action = FlutterTextInputActionJoin; + break; + case UIReturnKeyRoute: + action = FlutterTextInputActionRoute; + break; + case UIReturnKeyEmergencyCall: + action = FlutterTextInputActionEmergencyCall; + break; + } + + [self.textInputDelegate flutterTextInputView:self performAction:action withClient:_clientID]; + return NO; + } + + return YES; +} + +- (void)setMarkedText:(NSString*)markedText selectedRange:(NSRange)markedSelectedRange { + NSString* textBeforeChange = [self.text copy]; + NSRange selectedRange = _selectedTextRange.range; + NSRange markedTextRange = ((FlutterTextRange*)self.markedTextRange).range; + NSRange actualReplacedRange; + + if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone || + _scribbleFocusStatus != FlutterScribbleFocusStatusUnfocused) { + return; + } + + if (markedText == nil) { + markedText = @""; + } + + if (markedTextRange.length > 0) { + // Replace text in the marked range with the new text. + [self replaceRangeLocal:markedTextRange withText:markedText]; + actualReplacedRange = markedTextRange; + markedTextRange.length = markedText.length; + } else { + // Replace text in the selected range with the new text. + actualReplacedRange = selectedRange; + [self replaceRangeLocal:selectedRange withText:markedText]; + markedTextRange = NSMakeRange(selectedRange.location, markedText.length); + } + + self.markedTextRange = + markedTextRange.length > 0 ? [FlutterTextRange rangeWithNSRange:markedTextRange] : nil; + + NSUInteger selectionLocation = markedSelectedRange.location + markedTextRange.location; + selectedRange = NSMakeRange(selectionLocation, markedSelectedRange.length); + [self setSelectedTextRangeLocal:[FlutterTextRange + rangeWithNSRange:[self clampSelection:selectedRange + forText:self.text]]]; + if (_enableDeltaModel) { + NSRange nextReplaceRange = [self clampSelection:actualReplacedRange forText:textBeforeChange]; + [self updateEditingStateWithDelta:flutter::TextEditingDelta( + [textBeforeChange UTF8String], + flutter::TextRange( + nextReplaceRange.location, + nextReplaceRange.location + nextReplaceRange.length), + [markedText UTF8String])]; + } else { + [self updateEditingState]; + } +} + +- (void)unmarkText { + if (!self.markedTextRange) { + return; + } + self.markedTextRange = nil; + if (_enableDeltaModel) { + [self updateEditingStateWithDelta:flutter::TextEditingDelta([self.text UTF8String])]; + } else { + [self updateEditingState]; + } +} + +- (UITextRange*)textRangeFromPosition:(UITextPosition*)fromPosition + toPosition:(UITextPosition*)toPosition { + NSUInteger fromIndex = ((FlutterTextPosition*)fromPosition).index; + NSUInteger toIndex = ((FlutterTextPosition*)toPosition).index; + if (toIndex >= fromIndex) { + return [FlutterTextRange rangeWithNSRange:NSMakeRange(fromIndex, toIndex - fromIndex)]; + } else { + // toIndex can be smaller than fromIndex, because + // UITextInputStringTokenizer does not handle CJK characters + // well in some cases. See: + // https://github.com/flutter/flutter/issues/58750#issuecomment-644469521 + // Swap fromPosition and toPosition to match the behavior of native + // UITextViews. + return [FlutterTextRange rangeWithNSRange:NSMakeRange(toIndex, fromIndex - toIndex)]; + } +} + +- (NSUInteger)decrementOffsetPosition:(NSUInteger)position { + return fml::RangeForCharacterAtIndex(self.text, MAX(0, position - 1)).location; +} + +- (NSUInteger)incrementOffsetPosition:(NSUInteger)position { + NSRange charRange = fml::RangeForCharacterAtIndex(self.text, position); + return MIN(position + charRange.length, self.text.length); +} + +- (UITextPosition*)positionFromPosition:(UITextPosition*)position offset:(NSInteger)offset { + NSUInteger offsetPosition = ((FlutterTextPosition*)position).index; + + NSInteger newLocation = (NSInteger)offsetPosition + offset; + if (newLocation < 0 || newLocation > (NSInteger)self.text.length) { + 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]; + } + } else { + for (NSInteger i = 0; i < ABS(offset) && offsetPosition > 0; ++i) { + offsetPosition = [self decrementOffsetPosition:offsetPosition]; + } + } + return [FlutterTextPosition positionWithIndex:offsetPosition]; +} + +- (UITextPosition*)positionFromPosition:(UITextPosition*)position + inDirection:(UITextLayoutDirection)direction + offset:(NSInteger)offset { + // TODO(cbracken) Add RTL handling. + switch (direction) { + case UITextLayoutDirectionLeft: + case UITextLayoutDirectionUp: + return [self positionFromPosition:position offset:offset * -1]; + case UITextLayoutDirectionRight: + case UITextLayoutDirectionDown: + return [self positionFromPosition:position offset:1]; + } +} + +- (UITextPosition*)beginningOfDocument { + return [FlutterTextPosition positionWithIndex:0]; +} + +- (UITextPosition*)endOfDocument { + return [FlutterTextPosition positionWithIndex:self.text.length]; +} + +- (NSComparisonResult)comparePosition:(UITextPosition*)position toPosition:(UITextPosition*)other { + NSUInteger positionIndex = ((FlutterTextPosition*)position).index; + NSUInteger otherIndex = ((FlutterTextPosition*)other).index; + if (positionIndex < otherIndex) { + return NSOrderedAscending; + } + if (positionIndex > otherIndex) { + return NSOrderedDescending; + } + return NSOrderedSame; +} + +- (NSInteger)offsetFromPosition:(UITextPosition*)from toPosition:(UITextPosition*)toPosition { + return ((FlutterTextPosition*)toPosition).index - ((FlutterTextPosition*)from).index; +} + +- (UITextPosition*)positionWithinRange:(UITextRange*)range + farthestInDirection:(UITextLayoutDirection)direction { + NSUInteger index; + switch (direction) { + case UITextLayoutDirectionLeft: + case UITextLayoutDirectionUp: + index = ((FlutterTextPosition*)range.start).index; + break; + case UITextLayoutDirectionRight: + case UITextLayoutDirectionDown: + index = ((FlutterTextPosition*)range.end).index; + break; + } + return [FlutterTextPosition positionWithIndex:index]; +} + +- (UITextRange*)characterRangeByExtendingPosition:(UITextPosition*)position + inDirection:(UITextLayoutDirection)direction { + NSUInteger positionIndex = ((FlutterTextPosition*)position).index; + NSUInteger startIndex; + NSUInteger endIndex; + switch (direction) { + case UITextLayoutDirectionLeft: + case UITextLayoutDirectionUp: + startIndex = [self decrementOffsetPosition:positionIndex]; + endIndex = positionIndex; + break; + case UITextLayoutDirectionRight: + case UITextLayoutDirectionDown: + startIndex = positionIndex; + endIndex = [self incrementOffsetPosition:positionIndex]; + break; + } + return [FlutterTextRange rangeWithNSRange:NSMakeRange(startIndex, endIndex - startIndex)]; +} + +#pragma mark - UITextInput text direction handling + +- (UITextWritingDirection)baseWritingDirectionForPosition:(UITextPosition*)position + inDirection:(UITextStorageDirection)direction { + // TODO(cbracken) Add RTL handling. + return UITextWritingDirectionNatural; +} + +- (void)setBaseWritingDirection:(UITextWritingDirection)writingDirection + forRange:(UITextRange*)range { + // TODO(cbracken) Add RTL handling. +} + +#pragma mark - UITextInput cursor, selection rect handling + +- (void)setMarkedRect:(CGRect)markedRect { + _markedRect = markedRect; + // Invalidate the cache. + _cachedFirstRect = kInvalidFirstRect; +} + +// This method expects a 4x4 perspective matrix +// stored in a NSArray in column-major order. +- (void)setEditableSize:(CGSize)size transform:(NSArray*)matrix { + CATransform3D* transform = &_editableTransform; + + transform->m11 = [matrix[0] doubleValue]; + transform->m12 = [matrix[1] doubleValue]; + transform->m13 = [matrix[2] doubleValue]; + transform->m14 = [matrix[3] doubleValue]; + + transform->m21 = [matrix[4] doubleValue]; + transform->m22 = [matrix[5] doubleValue]; + transform->m23 = [matrix[6] doubleValue]; + transform->m24 = [matrix[7] doubleValue]; + + transform->m31 = [matrix[8] doubleValue]; + transform->m32 = [matrix[9] doubleValue]; + transform->m33 = [matrix[10] doubleValue]; + transform->m34 = [matrix[11] doubleValue]; + + transform->m41 = [matrix[12] doubleValue]; + transform->m42 = [matrix[13] doubleValue]; + transform->m43 = [matrix[14] doubleValue]; + transform->m44 = [matrix[15] doubleValue]; + + // Invalidate the cache. + _cachedFirstRect = kInvalidFirstRect; +} + +// Returns the bounding CGRect of the transformed incomingRect, in the view's +// coordinates. +- (CGRect)localRectFromFrameworkTransform:(CGRect)incomingRect { + CGPoint points[] = { + incomingRect.origin, + CGPointMake(incomingRect.origin.x, incomingRect.origin.y + incomingRect.size.height), + CGPointMake(incomingRect.origin.x + incomingRect.size.width, incomingRect.origin.y), + CGPointMake(incomingRect.origin.x + incomingRect.size.width, + incomingRect.origin.y + incomingRect.size.height)}; + + CGPoint origin = CGPointMake(CGFLOAT_MAX, CGFLOAT_MAX); + CGPoint farthest = CGPointMake(-CGFLOAT_MAX, -CGFLOAT_MAX); + + for (int i = 0; i < 4; i++) { + const CGPoint point = points[i]; + + CGFloat x = _editableTransform.m11 * point.x + _editableTransform.m21 * point.y + + _editableTransform.m41; + CGFloat y = _editableTransform.m12 * point.x + _editableTransform.m22 * point.y + + _editableTransform.m42; + + const CGFloat w = _editableTransform.m14 * point.x + _editableTransform.m24 * point.y + + _editableTransform.m44; + + if (w == 0.0) { + return kInvalidFirstRect; + } else if (w != 1.0) { + x /= w; + y /= w; + } + + origin.x = MIN(origin.x, x); + origin.y = MIN(origin.y, y); + farthest.x = MAX(farthest.x, x); + farthest.y = MAX(farthest.y, y); + } + return CGRectMake(origin.x, origin.y, farthest.x - origin.x, farthest.y - origin.y); +} + +// The following methods are required to support force-touch cursor positioning +// and to position the +// candidates view for multi-stage input methods (e.g., Japanese) when using a +// physical keyboard. +- (CGRect)firstRectForRange:(UITextRange*)range { + 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; + if (_markedTextRange != nil) { + // The candidates view can't be shown if the framework has not sent the + // first caret rect. + if (CGRectEqualToRect(kInvalidFirstRect, _markedRect)) { + return kInvalidFirstRect; + } + + if (CGRectEqualToRect(_cachedFirstRect, kInvalidFirstRect)) { + // If the width returned is too small, that means the framework sent us + // the caret rect instead of the marked text rect. Expand it to 0.2 so + // the IME candidates view would show up. + CGRect rect = _markedRect; + if (CGRectIsEmpty(rect)) { + rect = CGRectInset(rect, -0.1, 0); + } + _cachedFirstRect = [self localRectFromFrameworkTransform:rect]; + } + + UIView* hostView = _textInputPlugin.hostView; + NSAssert(hostView == nil || [self isDescendantOfView:hostView], @"%@ is not a descendant of %@", + self, hostView); + return hostView ? [hostView convertRect:_cachedFirstRect toView:self] : _cachedFirstRect; + } + + if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusNone && + _scribbleFocusStatus == FlutterScribbleFocusStatusUnfocused) { + [self.textInputDelegate flutterTextInputView:self + showAutocorrectionPromptRectForStart:start + end:end + withClient:_clientID]; + } + + 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; +} + +- (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 { + 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 { + // 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]; + 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 { + 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)) { + _closestPosition = position; + } + } + } + + return [FlutterTextPosition positionWithIndex:_closestPosition]; +} + +- (UITextRange*)characterRangeAtPoint:(CGPoint)point { + // TODO(cbracken) Implement. + NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index; + return [FlutterTextRange rangeWithNSRange:fml::RangeForCharacterAtIndex(self.text, currentIndex)]; +} + +// 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). +- (void)beginFloatingCursorAtPoint:(CGPoint)point { + _isFloatingCursorActive = true; + [self.textInputDelegate flutterTextInputView:self + updateFloatingCursor:FlutterFloatingCursorDragStateStart + withClient:_clientID + withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; +} + +- (void)updateFloatingCursorAtPoint:(CGPoint)point { + _isFloatingCursorActive = true; + [self.textInputDelegate flutterTextInputView:self + updateFloatingCursor:FlutterFloatingCursorDragStateUpdate + withClient:_clientID + withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; +} + +- (void)endFloatingCursor { + _isFloatingCursorActive = false; + [self.textInputDelegate flutterTextInputView:self + updateFloatingCursor:FlutterFloatingCursorDragStateEnd + withClient:_clientID + withPosition:@{@"X" : @(0), @"Y" : @(0)}]; +} + +#pragma mark - UIKeyInput Overrides + +- (void)updateEditingState { + NSUInteger selectionBase = ((FlutterTextPosition*)_selectedTextRange.start).index; + NSUInteger selectionExtent = ((FlutterTextPosition*)_selectedTextRange.end).index; + + // Empty compositing range is represented by the framework's TextRange.empty. + NSInteger composingBase = -1; + NSInteger composingExtent = -1; + if (self.markedTextRange != nil) { + composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index; + composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index; + } + NSDictionary* state = @{ + @"selectionBase" : @(selectionBase), + @"selectionExtent" : @(selectionExtent), + @"selectionAffinity" : @(_selectionAffinity), + @"selectionIsDirectional" : @(false), + @"composingBase" : @(composingBase), + @"composingExtent" : @(composingExtent), + @"text" : [NSString stringWithString:self.text], + }; + + if (_clientID == 0 && _autofillID != nil) { + [self.textInputDelegate flutterTextInputView:self + updateEditingClient:_clientID + withState:state + withTag:_autofillID]; + } else { + [self.textInputDelegate flutterTextInputView:self + updateEditingClient:_clientID + withState:state]; + } +} + +- (void)updateEditingStateWithDelta:(flutter::TextEditingDelta)delta { + NSUInteger selectionBase = ((FlutterTextPosition*)_selectedTextRange.start).index; + NSUInteger selectionExtent = ((FlutterTextPosition*)_selectedTextRange.end).index; + + // Empty compositing range is represented by the framework's TextRange.empty. + NSInteger composingBase = -1; + NSInteger composingExtent = -1; + if (self.markedTextRange != nil) { + composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index; + composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index; + } + + NSDictionary* deltaToFramework = @{ + @"oldText" : @(delta.old_text().c_str()), + @"deltaText" : @(delta.delta_text().c_str()), + @"deltaStart" : @(delta.delta_start()), + @"deltaEnd" : @(delta.delta_end()), + @"selectionBase" : @(selectionBase), + @"selectionExtent" : @(selectionExtent), + @"selectionAffinity" : @(_selectionAffinity), + @"selectionIsDirectional" : @(false), + @"composingBase" : @(composingBase), + @"composingExtent" : @(composingExtent), + }; + + NSDictionary* deltas = @{ + @"deltas" : @[ deltaToFramework ], + }; + + [self.textInputDelegate flutterTextInputView:self updateEditingClient:_clientID withDelta:deltas]; +} + +- (BOOL)hasText { + return self.text.length > 0; +} + +- (void)insertText:(NSString*)text { + if (self.temporarilyDeletedComposedCharacter.length > 0 && text.length == 1 && !text.UTF8String && + [text characterAtIndex:0] == [self.temporarilyDeletedComposedCharacter characterAtIndex:0]) { + // Workaround for https://github.com/flutter/flutter/issues/111494 + // TODO(cyanglaz): revert this workaround if when flutter supports a minimum iOS version which + // this bug is fixed by Apple. + text = self.temporarilyDeletedComposedCharacter; + self.temporarilyDeletedComposedCharacter = nil; + } + + 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; + _selectionAffinity = kTextAffinityDownstream; + [self replaceRange:_selectedTextRange withText:text]; +} + +- (UITextPlaceholder*)insertTextPlaceholderWithSize:(CGSize)size API_AVAILABLE(ios(13.0)) { + [self.textInputDelegate flutterTextInputView:self + insertTextPlaceholderWithSize:size + withClient:_clientID]; + _hasPlaceholder = YES; + return [[FlutterTextPlaceholder alloc] init]; +} + +- (void)removeTextPlaceholder:(UITextPlaceholder*)textPlaceholder API_AVAILABLE(ios(13.0)) { + _hasPlaceholder = NO; + [self.textInputDelegate flutterTextInputView:self removeTextPlaceholder:_clientID]; +} + +- (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. + // In addition, we needed to delete only a part of grapheme cluster + // because it is the expected behavior of Thai input. + // https://github.com/flutter/flutter/issues/24203 + // https://github.com/flutter/flutter/issues/21745 + // https://github.com/flutter/flutter/issues/39399 + // + // This is needed for correct handling of the deletion of Thai vowel input. + // TODO(cbracken): Get a good understanding of expected behavior of Thai + // input and ensure that this is the correct solution. + // https://github.com/flutter/flutter/issues/28962 + if (_selectedTextRange.isEmpty && [self hasText]) { + UITextRange* oldSelectedRange = _selectedTextRange; + NSRange oldRange = ((FlutterTextRange*)oldSelectedRange).range; + if (oldRange.location > 0) { + NSRange newRange = NSMakeRange(oldRange.location - 1, 1); + + // We should check if the last character is a part of emoji. + // If so, we must delete the entire emoji to prevent the text from being malformed. + NSRange charRange = fml::RangeForCharacterAtIndex(self.text, oldRange.location - 1); + if (IsEmoji(self.text, charRange)) { + newRange = NSMakeRange(charRange.location, oldRange.location - charRange.location); + } + + _selectedTextRange = [[FlutterTextRange rangeWithNSRange:newRange] copy]; + } + } + + if (!_selectedTextRange.isEmpty) { + // Cache the last deleted emoji to use for an iOS bug where the next + // insertion corrupts the emoji characters. + // See: https://github.com/flutter/flutter/issues/111494#issuecomment-1248441346 + if (IsEmoji(self.text, _selectedTextRange.range)) { + NSString* deletedText = [self.text substringWithRange:_selectedTextRange.range]; + NSRange deleteFirstCharacterRange = fml::RangeForCharacterAtIndex(deletedText, 0); + self.temporarilyDeletedComposedCharacter = + [deletedText substringWithRange:deleteFirstCharacterRange]; + } + [self replaceRange:_selectedTextRange withText:@""]; + } +} + +- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target { + UIAccessibilityPostNotification(notification, target); +} + +- (void)accessibilityElementDidBecomeFocused { + if ([self accessibilityElementIsFocused]) { + // For most of the cases, this flutter text input view should never + // receive the focus. If we do receive the focus, we make the best effort + // to send the focus back to the real text field. + FML_DCHECK(_backingTextInputAccessibilityObject); + [self postAccessibilityNotification:UIAccessibilityScreenChangedNotification + target:_backingTextInputAccessibilityObject]; + } +} + +- (BOOL)accessibilityElementsHidden { + return !_accessibilityEnabled; +} + +- (void)resetScribbleInteractionStatusIfEnding { + if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusEnding) { + _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone; + } +} + +#pragma mark - Key Events Handling +- (void)pressesBegan:(NSSet*)presses + withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { + [_textInputPlugin.viewController pressesBegan:presses withEvent:event]; +} + +- (void)pressesChanged:(NSSet*)presses + withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { + [_textInputPlugin.viewController pressesChanged:presses withEvent:event]; +} + +- (void)pressesEnded:(NSSet*)presses + withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { + [_textInputPlugin.viewController pressesEnded:presses withEvent:event]; +} + +- (void)pressesCancelled:(NSSet*)presses + withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { + [_textInputPlugin.viewController pressesCancelled:presses withEvent:event]; +} + +@end + +@interface FlutterSecureTextInputView () +@property(nonatomic, retain, readonly) UITextField* textField; +@end + +@implementation FlutterSecureTextInputView { + UITextField* _textField; +} + +- (UITextField*)textField { + if (!_textField) { + _textField = [[UITextField alloc] init]; + } + return _textField; +} + +- (BOOL)isKindOfClass:(Class)aClass { + return [super isKindOfClass:aClass] || (aClass == [UITextField class]); +} + +- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector { + NSMethodSignature* signature = [super methodSignatureForSelector:aSelector]; + if (!signature) { + signature = [self.textField methodSignatureForSelector:aSelector]; + } + return signature; +} + +- (void)forwardInvocation:(NSInvocation*)anInvocation { + [anInvocation invokeWithTarget:self.textField]; +} + +@end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index 7ecda6a23bb93..87ea303fd62fc 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -8,7 +8,7 @@ #import @class FlutterTextInputPlugin; -@class FlutterTextInputView; +@protocol FlutterTextInputClient; typedef NS_ENUM(NSInteger, FlutterTextInputAction) { FlutterTextInputActionUnspecified, @@ -31,35 +31,39 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { }; @protocol FlutterTextInputDelegate -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputClient updateEditingClient:(int)client withState:(NSDictionary*)state; -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputClient updateEditingClient:(int)client withState:(NSDictionary*)state withTag:(NSString*)tag; -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputClient updateEditingClient:(int)client withDelta:(NSDictionary*)state; -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputClient performAction:(FlutterTextInputAction)action withClient:(int)client; -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputView updateFloatingCursor:(FlutterFloatingCursorDragState)state withClient:(int)client withPosition:(NSDictionary*)point; -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView +- (void)flutterTextInputView:(UIView*)textInputClient 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 +- (void)flutterTextInputView:(UIView*)textInputClient + showToolbar:(int)client; +- (void)flutterTextInputViewScribbleInteractionBegan:(UIView*)textInputView; +- (void)flutterTextInputViewScribbleInteractionFinished: + (UIView*)textInputView; +- (void)flutterTextInputView:(UIView*)textInputClient insertTextPlaceholderWithSize:(CGSize)size withClient:(int)client; -- (void)flutterTextInputView:(FlutterTextInputView*)textInputView removeTextPlaceholder:(int)client; -- (void)flutterTextInputViewDidResignFirstResponder:(FlutterTextInputView*)textInputView; +- (void)flutterTextInputView:(UIView*)textInputClient + removeTextPlaceholder:(int)client; +- (void)flutterTextInputViewDidResignFirstResponder: + (UIView*)textInputClient; @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index 51acd0e41085e..9718c4959b7e0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -14,25 +14,15 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewResponder.h" -typedef NS_ENUM(NSInteger, FlutterScribbleFocusStatus) { - FlutterScribbleFocusStatusUnfocused, - FlutterScribbleFocusStatusFocusing, - FlutterScribbleFocusStatusFocused, -}; - -typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { - FlutterScribbleInteractionStatusNone, - FlutterScribbleInteractionStatusStarted, - FlutterScribbleInteractionStatusEnding, -}; - @interface FlutterTextInputPlugin : NSObject @property(nonatomic, weak) UIViewController* viewController; +@property(nonatomic, readonly) UIView* hostView; @property(nonatomic, weak) id indirectScribbleDelegate; @property(nonatomic, strong) NSMutableDictionary* scribbleElements; +@property(nonatomic, readonly, weak) id textInputDelegate; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; @@ -50,6 +40,7 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { */ - (UIView*)textInputView; ++ (BOOL)isScribbleAvailable; /** * These are used by the UIIndirectScribbleInteractionDelegate methods to handle focusing on the * correct element. @@ -59,25 +50,6 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { @end -/** An indexed position in the buffer of a Flutter text editing widget. */ -@interface FlutterTextPosition : UITextPosition - -@property(nonatomic, readonly) NSUInteger index; - -+ (instancetype)positionWithIndex:(NSUInteger)index; -- (instancetype)initWithIndex:(NSUInteger)index; - -@end - -/** A range of text in the buffer of a Flutter text editing widget. */ -@interface FlutterTextRange : UITextRange - -@property(nonatomic, readonly) NSRange range; - -+ (instancetype)rangeWithNSRange:(NSRange)range; - -@end - /** A tokenizer used by `FlutterTextInputView` to customize string parsing. */ @interface FlutterTokenizer : UITextInputStringTokenizer @end @@ -113,47 +85,4 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder : UITextPlaceholder @end -#if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG -FLUTTER_DARWIN_EXPORT -#endif -@interface FlutterTextInputView : UIView - -// UITextInput -@property(nonatomic, readonly) NSMutableString* text; -@property(nonatomic, readonly) NSMutableString* markedText; -@property(readwrite, copy) UITextRange* selectedTextRange; -@property(nonatomic, strong) UITextRange* markedTextRange; -@property(nonatomic, copy) NSDictionary* markedTextStyle; -@property(nonatomic, weak) id inputDelegate; - -// UITextInputTraits -@property(nonatomic) UITextAutocapitalizationType autocapitalizationType; -@property(nonatomic) UITextAutocorrectionType autocorrectionType; -@property(nonatomic) UITextSpellCheckingType spellCheckingType; -@property(nonatomic) BOOL enablesReturnKeyAutomatically; -@property(nonatomic) UIKeyboardAppearance keyboardAppearance; -@property(nonatomic) UIKeyboardType keyboardType; -@property(nonatomic) UIReturnKeyType returnKeyType; -@property(nonatomic, getter=isSecureTextEntry) BOOL secureTextEntry; -@property(nonatomic, getter=isEnableDeltaModel) BOOL enableDeltaModel; -@property(nonatomic) UITextSmartQuotesType smartQuotesType API_AVAILABLE(ios(11.0)); -@property(nonatomic) UITextSmartDashesType smartDashesType API_AVAILABLE(ios(11.0)); -@property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0)); - -@property(nonatomic, weak) UIAccessibilityElement* backingTextInputAccessibilityObject; - -// Scribble Support -@property(nonatomic, weak) id viewResponder; -@property(nonatomic) FlutterScribbleFocusStatus scribbleFocusStatus; -@property(nonatomic, strong) NSArray* selectionRects; -- (void)resetScribbleInteractionStatusIfEnding; -- (BOOL)isScribbleAvailable; - -- (instancetype)init NS_UNAVAILABLE; -+ (instancetype)new NS_UNAVAILABLE; -- (instancetype)initWithCoder:(NSCoder*)aDecoder NS_UNAVAILABLE; -- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; -- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin NS_DESIGNATED_INITIALIZER; - -@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 edd6a594edfc0..5b0fcbdfc0130 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -3,19 +3,12 @@ // found in the LICENSE file. #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h" #import -#import - -#include "unicode/uchar.h" - -#include "flutter/fml/logging.h" -#include "flutter/fml/platform/darwin/string_range_sanitization.h" FLUTTER_ASSERT_ARC -static const char kTextAffinityDownstream[] = "TextAffinity.downstream"; -static const char kTextAffinityUpstream[] = "TextAffinity.upstream"; // A delay before enabling the accessibility of FlutterTextInputView after // it is activated. static constexpr double kUITextInputAccessibilityEnablingDelaySeconds = 0.5; @@ -26,18 +19,6 @@ // 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 - TextInput channel method names. // See https://api.flutter.dev/flutter/services/SystemChannels/textInput-constant.html static NSString* const kShowMethod = @"TextInput.show"; @@ -68,27 +49,13 @@ // TextInputConfiguration.autofill and sub-field names static NSString* const kAutofillProperties = @"autofill"; -static NSString* const kAutofillId = @"uniqueIdentifier"; +static NSString* const kAutofillID = @"uniqueIdentifier"; static NSString* const kAutofillEditingValue = @"editingValue"; static NSString* const kAutofillHints = @"hints"; static NSString* const kAutocorrectionType = @"autocorrect"; #pragma mark - Static Functions - -// Determine if the character at `range` of `text` is an emoji. -static BOOL IsEmoji(NSString* text, NSRange charRange) { - UChar32 codePoint; - BOOL gotCodePoint = [text getBytes:&codePoint - maxLength:sizeof(codePoint) - usedLength:NULL - encoding:NSUTF32StringEncoding - options:kNilOptions - range:charRange - remainingRange:NULL]; - return gotCodePoint && u_hasBinaryProperty(codePoint, UCHAR_EMOJI); -} - // "TextInputType.none" is a made-up input type that's typically // used when there's an in-app virtual keyboard. If // "TextInputType.none" is specified, disable the system @@ -312,12 +279,12 @@ static UITextContentType ToUITextContentType(NSArray* hints) { return hints[0]; } -// Retrieves the autofillId from an input field's configuration. Returns +// Retrieves the autofillID from an input field's configuration. Returns // nil if the field is nil and the input field is not a password field. -static NSString* AutofillIdFromDictionary(NSDictionary* dictionary) { +static NSString* AutofillIDFromDictionary(NSDictionary* dictionary) { NSDictionary* autofill = dictionary[kAutofillProperties]; if (autofill) { - return autofill[kAutofillId]; + return autofill[kAutofillID]; } // When autofill is nil, the field may still need an autofill id @@ -385,7 +352,7 @@ typedef NS_ENUM(NSInteger, FlutterAutofillType) { static BOOL IsFieldPasswordRelated(NSDictionary* configuration) { // Autofill is explicitly disabled if the id isn't present. - if (!AutofillIdFromDictionary(configuration)) { + if (!AutofillIDFromDictionary(configuration)) { return NO; } @@ -427,53 +394,6 @@ static FlutterAutofillType AutofillTypeOf(NSDictionary* configuration) { : kFlutterAutofillTypeRegular; } -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)))); -} - #pragma mark - FlutterTextPosition @implementation FlutterTextPosition @@ -533,18 +453,18 @@ - (BOOL)isEqualTo:(FlutterTextRange*)other { @interface FlutterTokenizer () -@property(nonatomic, weak) FlutterTextInputView* textInputView; +@property(nonatomic, weak) UIView* textInputView; @end @implementation FlutterTokenizer - (instancetype)initWithTextInput:(UIResponder*)textInput { - NSAssert([textInput isKindOfClass:[FlutterTextInputView class]], - @"The FlutterTokenizer can only be used in a FlutterTextInputView"); + NSAssert([textInput conformsToProtocol:@protocol(FlutterTextInputClient)], + @"The FlutterTokenizer can only be used in a FlutterTextInputClient"); self = [super initWithTextInput:textInput]; if (self) { - _textInputView = (FlutterTextInputView*)textInput; + _textInputView = (UIView*)textInput; } return self; } @@ -661,1325 +581,58 @@ @implementation FlutterTextPlaceholder @end -// A FlutterTextInputView that masquerades as a UITextField, and forwards -// selectors it can't respond to to a shared UITextField instance. -// -// Relevant API docs claim that password autofill supports any custom view -// that adopts the UITextInput protocol, automatic strong password seems to -// currently only support UITextFields, and password saving only supports -// UITextFields and UITextViews, as of iOS 13.5. -@interface FlutterSecureTextInputView : FlutterTextInputView -@property(nonatomic, retain, readonly) UITextField* textField; -@end - -@implementation FlutterSecureTextInputView { - UITextField* _textField; -} - -- (UITextField*)textField { - if (!_textField) { - _textField = [[UITextField alloc] init]; - } - return _textField; -} - -- (BOOL)isKindOfClass:(Class)aClass { - return [super isKindOfClass:aClass] || (aClass == [UITextField class]); -} - -- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector { - NSMethodSignature* signature = [super methodSignatureForSelector:aSelector]; - if (!signature) { - signature = [self.textField methodSignatureForSelector:aSelector]; - } - return signature; -} - -- (void)forwardInvocation:(NSInvocation*)anInvocation { - [anInvocation invokeWithTarget:self.textField]; -} - -@end - -@interface FlutterTextInputPlugin () -@property(nonatomic, readonly, weak) id textInputDelegate; -@property(nonatomic, readonly) UIView* hostView; -@end - -@interface FlutterTextInputView () -@property(nonatomic, readonly, weak) FlutterTextInputPlugin* textInputPlugin; -@property(nonatomic, copy) NSString* autofillId; -@property(nonatomic, readonly) CATransform3D editableTransform; -@property(nonatomic, assign) CGRect markedRect; -@property(nonatomic) BOOL isVisibleToAutofill; -@property(nonatomic, assign) BOOL accessibilityEnabled; -// The composed character that is temporarily removed by the keyboard API. -// This is cleared at the start of each keyboard interaction. (Enter a character, delete a character -// etc) -@property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter; - -- (void)setEditableTransform:(NSArray*)matrix; -@end - -@implementation FlutterTextInputView { - int _textInputClient; - const char* _selectionAffinity; - 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. - bool _isSystemKeyboardEnabled; - bool _isFloatingCursorActive; - bool _enableInteractiveSelection; - UITextInteraction* _textInteraction API_AVAILABLE(ios(13.0)); -} - -@synthesize tokenizer = _tokenizer; - -- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin { - self = [super initWithFrame:CGRectZero]; - if (self) { - _textInputPlugin = textInputPlugin; - _textInputClient = 0; - _selectionAffinity = kTextAffinityUpstream; - - // UITextInput - _text = [[NSMutableString alloc] init]; - _markedText = [[NSMutableString alloc] 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(); - _isFloatingCursorActive = false; - - // UITextInputTraits - _autocapitalizationType = UITextAutocapitalizationTypeSentences; - _autocorrectionType = UITextAutocorrectionTypeDefault; - _spellCheckingType = UITextSpellCheckingTypeDefault; - _enablesReturnKeyAutomatically = NO; - _keyboardAppearance = UIKeyboardAppearanceDefault; - _keyboardType = UIKeyboardTypeDefault; - _returnKeyType = UIReturnKeyDone; - _secureTextEntry = NO; - _enableDeltaModel = NO; - _enableInteractiveSelection = YES; - _accessibilityEnabled = NO; - _smartQuotesType = UITextSmartQuotesTypeYes; - _smartDashesType = UITextSmartDashesTypeYes; - _selectionRects = [[NSArray alloc] init]; - - if (@available(iOS 14.0, *)) { - UIScribbleInteraction* interaction = [[UIScribbleInteraction alloc] initWithDelegate:self]; - [self addInteraction:interaction]; - } - } - - return self; -} - -- (void)configureWithDictionary:(NSDictionary*)configuration { +static void ConfigureInputClientWithDictionary(UIView* client, + NSDictionary* configuration) { NSDictionary* inputType = configuration[kKeyboardType]; NSString* keyboardAppearance = configuration[kKeyboardAppearance]; NSDictionary* autofill = configuration[kAutofillProperties]; - self.secureTextEntry = [configuration[kSecureTextEntry] boolValue]; - self.enableDeltaModel = [configuration[kEnableDeltaModel] boolValue]; + client.secureTextEntry = [configuration[kSecureTextEntry] boolValue]; + client.enableDeltaModel = [configuration[kEnableDeltaModel] boolValue]; - _isSystemKeyboardEnabled = ShouldShowSystemKeyboard(inputType); - self.keyboardType = ToUIKeyboardType(inputType); - self.returnKeyType = ToUIReturnKeyType(configuration[kInputAction]); - self.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); - _enableInteractiveSelection = [configuration[kEnableInteractiveSelection] boolValue]; + client.enableSoftwareKeyboard = ShouldShowSystemKeyboard(inputType); + client.keyboardType = ToUIKeyboardType(inputType); + client.returnKeyType = ToUIReturnKeyType(configuration[kInputAction]); + client.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); + client.enableInteractiveSelection = [configuration[kEnableInteractiveSelection] boolValue]; NSString* smartDashesType = configuration[kSmartDashesType]; // This index comes from the SmartDashesType enum in the framework. bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"]; - self.smartDashesType = smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; + client.smartDashesType = + smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; NSString* smartQuotesType = configuration[kSmartQuotesType]; // This index comes from the SmartQuotesType enum in the framework. bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"]; - self.smartQuotesType = smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes; + client.smartQuotesType = + smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes; if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) { - self.keyboardAppearance = UIKeyboardAppearanceDark; + client.keyboardAppearance = UIKeyboardAppearanceDark; } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) { - self.keyboardAppearance = UIKeyboardAppearanceLight; + client.keyboardAppearance = UIKeyboardAppearanceLight; } else { - self.keyboardAppearance = UIKeyboardAppearanceDefault; + client.keyboardAppearance = UIKeyboardAppearanceDefault; } NSString* autocorrect = configuration[kAutocorrectionType]; - self.autocorrectionType = autocorrect && ![autocorrect boolValue] - ? UITextAutocorrectionTypeNo - : UITextAutocorrectionTypeDefault; - self.autofillId = AutofillIdFromDictionary(configuration); - if (autofill == nil) { - self.textContentType = @""; - } else { - self.textContentType = ToUITextContentType(autofill[kAutofillHints]); - [self setTextInputState:autofill[kAutofillEditingValue]]; - NSAssert(_autofillId, @"The autofill configuration must contain an autofill id"); + client.autocorrectionType = autocorrect && ![autocorrect boolValue] + ? UITextAutocorrectionTypeNo + : UITextAutocorrectionTypeDefault; + if ([client conformsToProtocol:@protocol(FlutterTextAutofillClient)]) { + UIView* autofillClient = + (UIView*)client; + autofillClient.autofillID = AutofillIDFromDictionary(configuration); + // The input field needs to be visible for the system autofill + // to find it. + autofillClient.isVisibleToAutofill = autofill || client.isSecureTextEntry; } - // The input field needs to be visible for the system autofill - // to find it. - self.isVisibleToAutofill = autofill || _secureTextEntry; -} - -- (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; - } - - if (!_inputViewController) { - _inputViewController = [[UIInputViewController alloc] init]; - } - return _inputViewController; -} - -- (id)textInputDelegate { - return _textInputPlugin.textInputDelegate; -} - -- (void)setTextInputClient:(int)client { - _textInputClient = client; - _hasPlaceholder = NO; -} - -- (UITextInteraction*)textInteraction API_AVAILABLE(ios(13.0)) { - if (!_textInteraction) { - _textInteraction = [UITextInteraction textInteractionForMode:UITextInteractionModeEditable]; - _textInteraction.textInput = self; - } - return _textInteraction; -} - -- (void)setTextInputState:(NSDictionary*)state { - if (@available(iOS 13.0, *)) { - // [UITextInteraction willMoveToView:] sometimes sets the textInput's inputDelegate - // to nil. This is likely a bug in UIKit. In order to inform the keyboard of text - // and selection changes when that happens, add a dummy UITextInteraction to this - // view so it sets a valid inputDelegate that we can call textWillChange et al. on. - // See https://github.com/flutter/engine/pull/32881. - if (!self.inputDelegate && self.isFirstResponder) { - [self addInteraction:self.textInteraction]; - } - } - - NSString* newText = state[@"text"]; - BOOL textChanged = ![self.text isEqualToString:newText]; - if (textChanged) { - [self.inputDelegate textWillChange:self]; - [self.text setString:newText]; - } - NSInteger composingBase = [state[@"composingBase"] intValue]; - NSInteger composingExtent = [state[@"composingExtent"] intValue]; - NSRange composingRange = [self clampSelection:NSMakeRange(MIN(composingBase, composingExtent), - ABS(composingBase - composingExtent)) - forText:self.text]; - - self.markedTextRange = - composingRange.length > 0 ? [FlutterTextRange rangeWithNSRange:composingRange] : nil; - - NSRange selectedRange = [self clampSelectionFromBase:[state[@"selectionBase"] intValue] - extent:[state[@"selectionExtent"] intValue] - forText:self.text]; - - NSRange oldSelectedRange = [(FlutterTextRange*)self.selectedTextRange range]; - if (!NSEqualRanges(selectedRange, oldSelectedRange)) { - [self.inputDelegate selectionWillChange:self]; - - [self setSelectedTextRangeLocal:[FlutterTextRange rangeWithNSRange:selectedRange]]; - - _selectionAffinity = kTextAffinityDownstream; - if ([state[@"selectionAffinity"] isEqualToString:@(kTextAffinityUpstream)]) { - _selectionAffinity = kTextAffinityUpstream; - } - [self.inputDelegate selectionDidChange:self]; - } - - if (textChanged) { - [self.inputDelegate textDidChange:self]; - } - - if (@available(iOS 13.0, *)) { - if (_textInteraction) { - [self removeInteraction:_textInteraction]; - } - } -} - -// 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 -// explicitly set in the framework. This is handled here by setting the -// selection to (0,0). In contrast, Android handles this situation by -// clearing the selection, but the result in both cases is that the cursor -// is placed at the beginning of the field. -- (NSRange)clampSelectionFromBase:(int)selectionBase - extent:(int)selectionExtent - forText:(NSString*)text { - int loc = MIN(selectionBase, selectionExtent); - int len = ABS(selectionExtent - selectionBase); - return loc < 0 ? NSMakeRange(0, 0) - : [self clampSelection:NSMakeRange(loc, len) forText:self.text]; -} - -- (NSRange)clampSelection:(NSRange)range forText:(NSString*)text { - NSUInteger start = MIN(MAX(range.location, 0), text.length); - NSUInteger length = MIN(range.length, text.length - start); - return NSMakeRange(start, length); -} - -- (BOOL)isVisibleToAutofill { - return self.frame.size.width > 0 && self.frame.size.height > 0; -} - -// An input view is generally ignored by password autofill attempts, if it's -// not the first responder and is zero-sized. For input fields that are in the -// autofill context but do not belong to the current autofill group, setting -// 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 - -// Checks whether Scribble features are possibly available – meaning this is an iPad running iOS -// 14 or higher. -- (BOOL)isScribbleAvailable { - if (@available(iOS 14.0, *)) { - if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) { - return YES; - } - } - return NO; -} - -- (void)scribbleInteractionWillBeginWriting:(UIScribbleInteraction*)interaction - API_AVAILABLE(ios(14.0)) { - _scribbleInteractionStatus = FlutterScribbleInteractionStatusStarted; - [self.textInputDelegate flutterTextInputViewScribbleInteractionBegan:self]; -} - -- (void)scribbleInteractionDidFinishWriting:(UIScribbleInteraction*)interaction - API_AVAILABLE(ios(14.0)) { - _scribbleInteractionStatus = FlutterScribbleInteractionStatusEnding; - [self.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 { - // Only the currently focused input field can - // become the first responder. This prevents iOS - // from changing focus by itself (the framework - // focus will be out of sync if that happens). - return _textInputClient != 0; -} - -- (BOOL)resignFirstResponder { - BOOL success = [super resignFirstResponder]; - if (success) { - [self.textInputDelegate flutterTextInputViewDidResignFirstResponder:self]; - } - return success; -} - -- (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 ([self isScribbleAvailable] && sender == NULL) { - return NO; - } - if (action == @selector(paste:)) { - // Forbid pasting images, memojis, or other non-string content. - return [UIPasteboard generalPasteboard].string != nil; - } - - return [super canPerformAction:action withSender:sender]; -} - -#pragma mark - UIResponderStandardEditActions Overrides - -- (void)cut:(id)sender { - [UIPasteboard generalPasteboard].string = [self textInRange:_selectedTextRange]; - [self replaceRange:_selectedTextRange withText:@""]; -} - -- (void)copy:(id)sender { - [UIPasteboard generalPasteboard].string = [self textInRange:_selectedTextRange]; -} - -- (void)paste:(id)sender { - NSString* pasteboardString = [UIPasteboard generalPasteboard].string; - if (pasteboardString != nil) { - [self insertText:pasteboardString]; - } -} - -- (void)delete:(id)sender { - [self replaceRange:_selectedTextRange withText:@""]; -} - -- (void)selectAll:(id)sender { - [self setSelectedTextRange:[self textRangeFromPosition:[self beginningOfDocument] - toPosition:[self endOfDocument]]]; -} - -#pragma mark - UITextInput Overrides - -- (id)tokenizer { - if (_tokenizer == nil) { - _tokenizer = [[FlutterTokenizer alloc] initWithTextInput:self]; - } - return _tokenizer; -} - -- (UITextRange*)selectedTextRange { - return [_selectedTextRange copy]; -} - -// Change the range of selected text, without notifying the framework. -- (void)setSelectedTextRangeLocal:(UITextRange*)selectedTextRange { - if (_selectedTextRange != selectedTextRange) { - if (self.hasText) { - FlutterTextRange* flutterTextRange = (FlutterTextRange*)selectedTextRange; - _selectedTextRange = [[FlutterTextRange - rangeWithNSRange:fml::RangeForCharactersInRange(self.text, flutterTextRange.range)] copy]; - } else { - _selectedTextRange = [selectedTextRange copy]; - } - } -} - -- (void)setSelectedTextRange:(UITextRange*)selectedTextRange { - if (!_enableInteractiveSelection) { - return; - } - - [self setSelectedTextRangeLocal:selectedTextRange]; - - if (_enableDeltaModel) { - [self updateEditingStateWithDelta:flutter::TextEditingDelta([self.text UTF8String])]; - } 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) { - [self.textInputDelegate flutterTextInputView:self showToolbar:_textInputClient]; - } - } - - [self resetScribbleInteractionStatusIfEnding]; -} - -- (id)insertDictationResultPlaceholder { - return @""; -} - -- (void)removeDictationResultPlaceholder:(id)placeholder willInsertResult:(BOOL)willInsertResult { -} - -- (NSString*)textInRange:(UITextRange*)range { - if (!range) { - return nil; - } - NSAssert([range isKindOfClass:[FlutterTextRange class]], - @"Expected a FlutterTextRange for range (got %@).", [range class]); - NSRange textRange = ((FlutterTextRange*)range).range; - NSAssert(textRange.location != NSNotFound, @"Expected a valid text range."); - // Sanitize the range to prevent going out of bounds. - NSUInteger location = MIN(textRange.location, self.text.length); - NSUInteger length = MIN(self.text.length - location, textRange.length); - NSRange safeRange = NSMakeRange(location, length); - return [self.text substringWithRange:safeRange]; -} - -// Replace the text within the specified range with the given text, -// without notifying the framework. -- (void)replaceRangeLocal:(NSRange)range withText:(NSString*)text { - NSRange selectedRange = _selectedTextRange.range; - - // Adjust the text selection: - // * reduce the length by the intersection length - // * adjust the location by newLength - oldLength + intersectionLength - NSRange intersectionRange = NSIntersectionRange(range, selectedRange); - if (range.location <= selectedRange.location) { - selectedRange.location += text.length - range.length; - } - if (intersectionRange.location != NSNotFound) { - selectedRange.location += intersectionRange.length; - selectedRange.length -= intersectionRange.length; - } - - [self.text replaceCharactersInRange:[self clampSelection:range forText:self.text] - withString:text]; - [self setSelectedTextRangeLocal:[FlutterTextRange - rangeWithNSRange:[self clampSelection:selectedRange - forText:self.text]]]; -} - -- (void)replaceRange:(UITextRange*)range withText:(NSString*)text { - NSString* textBeforeChange = [self.text copy]; - NSRange replaceRange = ((FlutterTextRange*)range).range; - [self replaceRangeLocal:replaceRange withText:text]; - if (_enableDeltaModel) { - NSRange nextReplaceRange = [self clampSelection:replaceRange forText:textBeforeChange]; - [self updateEditingStateWithDelta:flutter::TextEditingDelta( - [textBeforeChange UTF8String], - flutter::TextRange( - nextReplaceRange.location, - nextReplaceRange.location + nextReplaceRange.length), - [text UTF8String])]; - } else { - [self updateEditingState]; - } -} - -- (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)text { - // `temporarilyDeletedComposedCharacter` should only be used during a single text change session. - // So it needs to be cleared at the start of each text editting session. - self.temporarilyDeletedComposedCharacter = nil; - - if (self.returnKeyType == UIReturnKeyDefault && [text isEqualToString:@"\n"]) { - [self.textInputDelegate flutterTextInputView:self - performAction:FlutterTextInputActionNewline - withClient:_textInputClient]; - return YES; - } - - if ([text isEqualToString:@"\n"]) { - FlutterTextInputAction action; - switch (self.returnKeyType) { - case UIReturnKeyDefault: - action = FlutterTextInputActionUnspecified; - break; - case UIReturnKeyDone: - action = FlutterTextInputActionDone; - break; - case UIReturnKeyGo: - action = FlutterTextInputActionGo; - break; - case UIReturnKeySend: - action = FlutterTextInputActionSend; - break; - case UIReturnKeySearch: - case UIReturnKeyGoogle: - case UIReturnKeyYahoo: - action = FlutterTextInputActionSearch; - break; - case UIReturnKeyNext: - action = FlutterTextInputActionNext; - break; - case UIReturnKeyContinue: - action = FlutterTextInputActionContinue; - break; - case UIReturnKeyJoin: - action = FlutterTextInputActionJoin; - break; - case UIReturnKeyRoute: - action = FlutterTextInputActionRoute; - break; - case UIReturnKeyEmergencyCall: - action = FlutterTextInputActionEmergencyCall; - break; - } - - [self.textInputDelegate flutterTextInputView:self - performAction:action - withClient:_textInputClient]; - return NO; - } - - return YES; -} - -- (void)setMarkedText:(NSString*)markedText selectedRange:(NSRange)markedSelectedRange { - NSString* textBeforeChange = [self.text copy]; - NSRange selectedRange = _selectedTextRange.range; - NSRange markedTextRange = ((FlutterTextRange*)self.markedTextRange).range; - NSRange actualReplacedRange; - - if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone || - _scribbleFocusStatus != FlutterScribbleFocusStatusUnfocused) { - return; - } - - if (markedText == nil) { - markedText = @""; - } - - if (markedTextRange.length > 0) { - // Replace text in the marked range with the new text. - [self replaceRangeLocal:markedTextRange withText:markedText]; - actualReplacedRange = markedTextRange; - markedTextRange.length = markedText.length; - } else { - // Replace text in the selected range with the new text. - actualReplacedRange = selectedRange; - [self replaceRangeLocal:selectedRange withText:markedText]; - markedTextRange = NSMakeRange(selectedRange.location, markedText.length); - } - - self.markedTextRange = - markedTextRange.length > 0 ? [FlutterTextRange rangeWithNSRange:markedTextRange] : nil; - - NSUInteger selectionLocation = markedSelectedRange.location + markedTextRange.location; - selectedRange = NSMakeRange(selectionLocation, markedSelectedRange.length); - [self setSelectedTextRangeLocal:[FlutterTextRange - rangeWithNSRange:[self clampSelection:selectedRange - forText:self.text]]]; - if (_enableDeltaModel) { - NSRange nextReplaceRange = [self clampSelection:actualReplacedRange forText:textBeforeChange]; - [self updateEditingStateWithDelta:flutter::TextEditingDelta( - [textBeforeChange UTF8String], - flutter::TextRange( - nextReplaceRange.location, - nextReplaceRange.location + nextReplaceRange.length), - [markedText UTF8String])]; - } else { - [self updateEditingState]; - } -} - -- (void)unmarkText { - if (!self.markedTextRange) { - return; - } - self.markedTextRange = nil; - if (_enableDeltaModel) { - [self updateEditingStateWithDelta:flutter::TextEditingDelta([self.text UTF8String])]; - } else { - [self updateEditingState]; - } -} - -- (UITextRange*)textRangeFromPosition:(UITextPosition*)fromPosition - toPosition:(UITextPosition*)toPosition { - NSUInteger fromIndex = ((FlutterTextPosition*)fromPosition).index; - NSUInteger toIndex = ((FlutterTextPosition*)toPosition).index; - if (toIndex >= fromIndex) { - return [FlutterTextRange rangeWithNSRange:NSMakeRange(fromIndex, toIndex - fromIndex)]; - } else { - // toIndex can be smaller than fromIndex, because - // UITextInputStringTokenizer does not handle CJK characters - // well in some cases. See: - // https://github.com/flutter/flutter/issues/58750#issuecomment-644469521 - // Swap fromPosition and toPosition to match the behavior of native - // UITextViews. - return [FlutterTextRange rangeWithNSRange:NSMakeRange(toIndex, fromIndex - toIndex)]; - } -} - -- (NSUInteger)decrementOffsetPosition:(NSUInteger)position { - return fml::RangeForCharacterAtIndex(self.text, MAX(0, position - 1)).location; -} - -- (NSUInteger)incrementOffsetPosition:(NSUInteger)position { - NSRange charRange = fml::RangeForCharacterAtIndex(self.text, position); - return MIN(position + charRange.length, self.text.length); -} - -- (UITextPosition*)positionFromPosition:(UITextPosition*)position offset:(NSInteger)offset { - NSUInteger offsetPosition = ((FlutterTextPosition*)position).index; - - NSInteger newLocation = (NSInteger)offsetPosition + offset; - if (newLocation < 0 || newLocation > (NSInteger)self.text.length) { - 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]; - } - } else { - for (NSInteger i = 0; i < ABS(offset) && offsetPosition > 0; ++i) { - offsetPosition = [self decrementOffsetPosition:offsetPosition]; - } - } - return [FlutterTextPosition positionWithIndex:offsetPosition]; -} - -- (UITextPosition*)positionFromPosition:(UITextPosition*)position - inDirection:(UITextLayoutDirection)direction - offset:(NSInteger)offset { - // TODO(cbracken) Add RTL handling. - switch (direction) { - case UITextLayoutDirectionLeft: - case UITextLayoutDirectionUp: - return [self positionFromPosition:position offset:offset * -1]; - case UITextLayoutDirectionRight: - case UITextLayoutDirectionDown: - return [self positionFromPosition:position offset:1]; - } -} - -- (UITextPosition*)beginningOfDocument { - return [FlutterTextPosition positionWithIndex:0]; -} - -- (UITextPosition*)endOfDocument { - return [FlutterTextPosition positionWithIndex:self.text.length]; -} - -- (NSComparisonResult)comparePosition:(UITextPosition*)position toPosition:(UITextPosition*)other { - NSUInteger positionIndex = ((FlutterTextPosition*)position).index; - NSUInteger otherIndex = ((FlutterTextPosition*)other).index; - if (positionIndex < otherIndex) { - return NSOrderedAscending; - } - if (positionIndex > otherIndex) { - return NSOrderedDescending; - } - return NSOrderedSame; -} - -- (NSInteger)offsetFromPosition:(UITextPosition*)from toPosition:(UITextPosition*)toPosition { - return ((FlutterTextPosition*)toPosition).index - ((FlutterTextPosition*)from).index; -} - -- (UITextPosition*)positionWithinRange:(UITextRange*)range - farthestInDirection:(UITextLayoutDirection)direction { - NSUInteger index; - switch (direction) { - case UITextLayoutDirectionLeft: - case UITextLayoutDirectionUp: - index = ((FlutterTextPosition*)range.start).index; - break; - case UITextLayoutDirectionRight: - case UITextLayoutDirectionDown: - index = ((FlutterTextPosition*)range.end).index; - break; - } - return [FlutterTextPosition positionWithIndex:index]; -} - -- (UITextRange*)characterRangeByExtendingPosition:(UITextPosition*)position - inDirection:(UITextLayoutDirection)direction { - NSUInteger positionIndex = ((FlutterTextPosition*)position).index; - NSUInteger startIndex; - NSUInteger endIndex; - switch (direction) { - case UITextLayoutDirectionLeft: - case UITextLayoutDirectionUp: - startIndex = [self decrementOffsetPosition:positionIndex]; - endIndex = positionIndex; - break; - case UITextLayoutDirectionRight: - case UITextLayoutDirectionDown: - startIndex = positionIndex; - endIndex = [self incrementOffsetPosition:positionIndex]; - break; - } - return [FlutterTextRange rangeWithNSRange:NSMakeRange(startIndex, endIndex - startIndex)]; -} - -#pragma mark - UITextInput text direction handling - -- (UITextWritingDirection)baseWritingDirectionForPosition:(UITextPosition*)position - inDirection:(UITextStorageDirection)direction { - // TODO(cbracken) Add RTL handling. - return UITextWritingDirectionNatural; -} - -- (void)setBaseWritingDirection:(UITextWritingDirection)writingDirection - forRange:(UITextRange*)range { - // TODO(cbracken) Add RTL handling. -} - -#pragma mark - UITextInput cursor, selection rect handling - -- (void)setMarkedRect:(CGRect)markedRect { - _markedRect = markedRect; - // Invalidate the cache. - _cachedFirstRect = kInvalidFirstRect; -} - -// This method expects a 4x4 perspective matrix -// stored in a NSArray in column-major order. -- (void)setEditableTransform:(NSArray*)matrix { - CATransform3D* transform = &_editableTransform; - - transform->m11 = [matrix[0] doubleValue]; - transform->m12 = [matrix[1] doubleValue]; - transform->m13 = [matrix[2] doubleValue]; - transform->m14 = [matrix[3] doubleValue]; - - transform->m21 = [matrix[4] doubleValue]; - transform->m22 = [matrix[5] doubleValue]; - transform->m23 = [matrix[6] doubleValue]; - transform->m24 = [matrix[7] doubleValue]; - - transform->m31 = [matrix[8] doubleValue]; - transform->m32 = [matrix[9] doubleValue]; - transform->m33 = [matrix[10] doubleValue]; - transform->m34 = [matrix[11] doubleValue]; - - transform->m41 = [matrix[12] doubleValue]; - transform->m42 = [matrix[13] doubleValue]; - transform->m43 = [matrix[14] doubleValue]; - transform->m44 = [matrix[15] doubleValue]; - - // Invalidate the cache. - _cachedFirstRect = kInvalidFirstRect; -} - -// Returns the bounding CGRect of the transformed incomingRect, in the view's -// coordinates. -- (CGRect)localRectFromFrameworkTransform:(CGRect)incomingRect { - CGPoint points[] = { - incomingRect.origin, - CGPointMake(incomingRect.origin.x, incomingRect.origin.y + incomingRect.size.height), - CGPointMake(incomingRect.origin.x + incomingRect.size.width, incomingRect.origin.y), - CGPointMake(incomingRect.origin.x + incomingRect.size.width, - incomingRect.origin.y + incomingRect.size.height)}; - - CGPoint origin = CGPointMake(CGFLOAT_MAX, CGFLOAT_MAX); - CGPoint farthest = CGPointMake(-CGFLOAT_MAX, -CGFLOAT_MAX); - - for (int i = 0; i < 4; i++) { - const CGPoint point = points[i]; - - CGFloat x = _editableTransform.m11 * point.x + _editableTransform.m21 * point.y + - _editableTransform.m41; - CGFloat y = _editableTransform.m12 * point.x + _editableTransform.m22 * point.y + - _editableTransform.m42; - - const CGFloat w = _editableTransform.m14 * point.x + _editableTransform.m24 * point.y + - _editableTransform.m44; - - if (w == 0.0) { - return kInvalidFirstRect; - } else if (w != 1.0) { - x /= w; - y /= w; - } - - origin.x = MIN(origin.x, x); - origin.y = MIN(origin.y, y); - farthest.x = MAX(farthest.x, x); - farthest.y = MAX(farthest.y, y); - } - return CGRectMake(origin.x, origin.y, farthest.x - origin.x, farthest.y - origin.y); -} - -// The following methods are required to support force-touch cursor positioning -// and to position the -// candidates view for multi-stage input methods (e.g., Japanese) when using a -// physical keyboard. -- (CGRect)firstRectForRange:(UITextRange*)range { - 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; - if (_markedTextRange != nil) { - // The candidates view can't be shown if the framework has not sent the - // first caret rect. - if (CGRectEqualToRect(kInvalidFirstRect, _markedRect)) { - return kInvalidFirstRect; - } - - if (CGRectEqualToRect(_cachedFirstRect, kInvalidFirstRect)) { - // If the width returned is too small, that means the framework sent us - // the caret rect instead of the marked text rect. Expand it to 0.2 so - // the IME candidates view would show up. - CGRect rect = _markedRect; - if (CGRectIsEmpty(rect)) { - rect = CGRectInset(rect, -0.1, 0); - } - _cachedFirstRect = [self localRectFromFrameworkTransform:rect]; - } - - UIView* hostView = _textInputPlugin.hostView; - NSAssert(hostView == nil || [self isDescendantOfView:hostView], @"%@ is not a descendant of %@", - self, hostView); - return hostView ? [hostView convertRect:_cachedFirstRect toView:self] : _cachedFirstRect; - } - - if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusNone && - _scribbleFocusStatus == FlutterScribbleFocusStatusUnfocused) { - [self.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; -} - -- (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 { - 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 { - // 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]; - 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 { - 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)) { - _closestPosition = position; - } - } - } - - return [FlutterTextPosition positionWithIndex:_closestPosition]; -} - -- (UITextRange*)characterRangeAtPoint:(CGPoint)point { - // TODO(cbracken) Implement. - NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index; - return [FlutterTextRange rangeWithNSRange:fml::RangeForCharacterAtIndex(self.text, currentIndex)]; -} - -- (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). - _isFloatingCursorActive = true; - [self.textInputDelegate flutterTextInputView:self - updateFloatingCursor:FlutterFloatingCursorDragStateStart - withClient:_textInputClient - withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; -} - -- (void)updateFloatingCursorAtPoint:(CGPoint)point { - _isFloatingCursorActive = true; - [self.textInputDelegate flutterTextInputView:self - updateFloatingCursor:FlutterFloatingCursorDragStateUpdate - withClient:_textInputClient - withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; -} - -- (void)endFloatingCursor { - _isFloatingCursorActive = false; - [self.textInputDelegate flutterTextInputView:self - updateFloatingCursor:FlutterFloatingCursorDragStateEnd - withClient:_textInputClient - withPosition:@{@"X" : @(0), @"Y" : @(0)}]; -} - -#pragma mark - UIKeyInput Overrides - -- (void)updateEditingState { - NSUInteger selectionBase = ((FlutterTextPosition*)_selectedTextRange.start).index; - NSUInteger selectionExtent = ((FlutterTextPosition*)_selectedTextRange.end).index; - - // Empty compositing range is represented by the framework's TextRange.empty. - NSInteger composingBase = -1; - NSInteger composingExtent = -1; - if (self.markedTextRange != nil) { - composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index; - composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index; - } - NSDictionary* state = @{ - @"selectionBase" : @(selectionBase), - @"selectionExtent" : @(selectionExtent), - @"selectionAffinity" : @(_selectionAffinity), - @"selectionIsDirectional" : @(false), - @"composingBase" : @(composingBase), - @"composingExtent" : @(composingExtent), - @"text" : [NSString stringWithString:self.text], - }; - - if (_textInputClient == 0 && _autofillId != nil) { - [self.textInputDelegate flutterTextInputView:self - updateEditingClient:_textInputClient - withState:state - withTag:_autofillId]; + if (autofill == nil) { + client.textContentType = @""; } else { - [self.textInputDelegate flutterTextInputView:self - updateEditingClient:_textInputClient - withState:state]; - } -} - -- (void)updateEditingStateWithDelta:(flutter::TextEditingDelta)delta { - NSUInteger selectionBase = ((FlutterTextPosition*)_selectedTextRange.start).index; - NSUInteger selectionExtent = ((FlutterTextPosition*)_selectedTextRange.end).index; - - // Empty compositing range is represented by the framework's TextRange.empty. - NSInteger composingBase = -1; - NSInteger composingExtent = -1; - if (self.markedTextRange != nil) { - composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index; - composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index; - } - - NSDictionary* deltaToFramework = @{ - @"oldText" : @(delta.old_text().c_str()), - @"deltaText" : @(delta.delta_text().c_str()), - @"deltaStart" : @(delta.delta_start()), - @"deltaEnd" : @(delta.delta_end()), - @"selectionBase" : @(selectionBase), - @"selectionExtent" : @(selectionExtent), - @"selectionAffinity" : @(_selectionAffinity), - @"selectionIsDirectional" : @(false), - @"composingBase" : @(composingBase), - @"composingExtent" : @(composingExtent), - }; - - NSDictionary* deltas = @{ - @"deltas" : @[ deltaToFramework ], - }; - - [self.textInputDelegate flutterTextInputView:self - updateEditingClient:_textInputClient - withDelta:deltas]; -} - -- (BOOL)hasText { - return self.text.length > 0; -} - -- (void)insertText:(NSString*)text { - if (self.temporarilyDeletedComposedCharacter.length > 0 && text.length == 1 && !text.UTF8String && - [text characterAtIndex:0] == [self.temporarilyDeletedComposedCharacter characterAtIndex:0]) { - // Workaround for https://github.com/flutter/flutter/issues/111494 - // TODO(cyanglaz): revert this workaround if when flutter supports a minimum iOS version which - // this bug is fixed by Apple. - text = self.temporarilyDeletedComposedCharacter; - self.temporarilyDeletedComposedCharacter = nil; - } - - 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; - _selectionAffinity = kTextAffinityDownstream; - [self replaceRange:_selectedTextRange withText:text]; -} - -- (UITextPlaceholder*)insertTextPlaceholderWithSize:(CGSize)size API_AVAILABLE(ios(13.0)) { - [self.textInputDelegate flutterTextInputView:self - insertTextPlaceholderWithSize:size - withClient:_textInputClient]; - _hasPlaceholder = YES; - return [[FlutterTextPlaceholder alloc] init]; -} - -- (void)removeTextPlaceholder:(UITextPlaceholder*)textPlaceholder API_AVAILABLE(ios(13.0)) { - _hasPlaceholder = NO; - [self.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. - // In addition, we needed to delete only a part of grapheme cluster - // because it is the expected behavior of Thai input. - // https://github.com/flutter/flutter/issues/24203 - // https://github.com/flutter/flutter/issues/21745 - // https://github.com/flutter/flutter/issues/39399 - // - // This is needed for correct handling of the deletion of Thai vowel input. - // TODO(cbracken): Get a good understanding of expected behavior of Thai - // input and ensure that this is the correct solution. - // https://github.com/flutter/flutter/issues/28962 - if (_selectedTextRange.isEmpty && [self hasText]) { - UITextRange* oldSelectedRange = _selectedTextRange; - NSRange oldRange = ((FlutterTextRange*)oldSelectedRange).range; - if (oldRange.location > 0) { - NSRange newRange = NSMakeRange(oldRange.location - 1, 1); - - // We should check if the last character is a part of emoji. - // If so, we must delete the entire emoji to prevent the text from being malformed. - NSRange charRange = fml::RangeForCharacterAtIndex(self.text, oldRange.location - 1); - if (IsEmoji(self.text, charRange)) { - newRange = NSMakeRange(charRange.location, oldRange.location - charRange.location); - } - - _selectedTextRange = [[FlutterTextRange rangeWithNSRange:newRange] copy]; - } - } - - if (!_selectedTextRange.isEmpty) { - // Cache the last deleted emoji to use for an iOS bug where the next - // insertion corrupts the emoji characters. - // See: https://github.com/flutter/flutter/issues/111494#issuecomment-1248441346 - if (IsEmoji(self.text, _selectedTextRange.range)) { - NSString* deletedText = [self.text substringWithRange:_selectedTextRange.range]; - NSRange deleteFirstCharacterRange = fml::RangeForCharacterAtIndex(deletedText, 0); - self.temporarilyDeletedComposedCharacter = - [deletedText substringWithRange:deleteFirstCharacterRange]; - } - [self replaceRange:_selectedTextRange withText:@""]; + client.textContentType = ToUITextContentType(autofill[kAutofillHints]); + [client setTextInputState:autofill[kAutofillEditingValue]]; } } -- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target { - UIAccessibilityPostNotification(notification, target); -} - -- (void)accessibilityElementDidBecomeFocused { - if ([self accessibilityElementIsFocused]) { - // For most of the cases, this flutter text input view should never - // receive the focus. If we do receive the focus, we make the best effort - // to send the focus back to the real text field. - FML_DCHECK(_backingTextInputAccessibilityObject); - [self postAccessibilityNotification:UIAccessibilityScreenChangedNotification - target:_backingTextInputAccessibilityObject]; - } -} - -- (BOOL)accessibilityElementsHidden { - return !_accessibilityEnabled; -} - -- (void)resetScribbleInteractionStatusIfEnding { - if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusEnding) { - _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone; - } -} - -#pragma mark - Key Events Handling -- (void)pressesBegan:(NSSet*)presses - withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { - [_textInputPlugin.viewController pressesBegan:presses withEvent:event]; -} - -- (void)pressesChanged:(NSSet*)presses - withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { - [_textInputPlugin.viewController pressesChanged:presses withEvent:event]; -} - -- (void)pressesEnded:(NSSet*)presses - withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { - [_textInputPlugin.viewController pressesEnded:presses withEvent:event]; -} - -- (void)pressesCancelled:(NSSet*)presses - withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { - [_textInputPlugin.viewController pressesCancelled:presses withEvent:event]; -} - -@end - /** * Hides `FlutterTextInputView` from iOS accessibility system so it * does not show up twice, once where it is in the `UIView` hierarchy, @@ -2015,17 +668,20 @@ - (void)enableActiveViewAccessibility; @end @interface FlutterTimerProxy : NSObject -@property(nonatomic, weak) FlutterTextInputPlugin* target; +@property(nonatomic, readonly, weak) FlutterTextInputPlugin* target; @end @implementation FlutterTimerProxy -+ (instancetype)proxyWithTarget:(FlutterTextInputPlugin*)target { - FlutterTimerProxy* proxy = [[self alloc] init]; - if (proxy) { - proxy.target = target; +- (instancetype)initWithTarget:(FlutterTextInputPlugin*)target { + self = [[FlutterTimerProxy alloc] init]; + if (self) { + _target = target; } - return proxy; + return self; +} ++ (instancetype)proxyWithTarget:(FlutterTextInputPlugin*)target { + return [[FlutterTimerProxy alloc] initWithTarget:target]; } - (void)enableActiveViewAccessibility { @@ -2037,10 +693,12 @@ - (void)enableActiveViewAccessibility { @interface FlutterTextInputPlugin () // The current password-autofillable input fields that have yet to be saved. @property(nonatomic, readonly) - NSMutableDictionary* autofillContext; -@property(nonatomic, retain) FlutterTextInputView* activeView; + NSMutableDictionary*>* + autofillContext; +@property(nonatomic, retain) UIView* activeClient; @property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider; @property(nonatomic, readonly) id viewResponder; + @end @implementation FlutterTextInputPlugin { @@ -2051,7 +709,6 @@ - (instancetype)initWithDelegate:(id)textInputDelegate self = [super init]; if (self) { - // `_textInputDelegate` is a weak reference because it should retain FlutterTextInputPlugin. _textInputDelegate = textInputDelegate; _autofillContext = [[NSMutableDictionary alloc] init]; _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init]; @@ -2073,7 +730,7 @@ - (void)removeEnableFlutterTextInputViewAccessibilityTimer { } - (UIView*)textInputView { - return _activeView; + return _activeClient; } - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @@ -2119,8 +776,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } - (void)setEditableSizeAndTransform:(NSDictionary*)dictionary { - [_activeView setEditableTransform:dictionary[@"transform"]]; - if ([_activeView isScribbleAvailable]) { + CGSize size = CGSizeZero; + if (FlutterTextInputPlugin.isScribbleAvailable) { // This is necessary to set up where the scribble interactable element will be. int leftIndex = 12; int topIndex = 13; @@ -2128,10 +785,10 @@ - (void)setEditableSizeAndTransform:(NSDictionary*)dictionary { 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]; + size = CGSizeMake([dictionary[@"width"] intValue], [dictionary[@"height"] intValue]); + _activeClient.tintColor = [UIColor clearColor]; } + [_activeClient setEditableSize:size transform:dictionary[@"transform"]]; } - (void)updateMarkedRect:(NSDictionary*)dictionary { @@ -2140,7 +797,7 @@ - (void)updateMarkedRect:(NSDictionary*)dictionary { @"Expected a dictionary representing a CGRect, got %@", dictionary); CGRect rect = CGRectMake([dictionary[@"x"] doubleValue], [dictionary[@"y"] doubleValue], [dictionary[@"width"] doubleValue], [dictionary[@"height"] doubleValue]); - _activeView.markedRect = rect.size.width < 0 && rect.size.height < 0 ? kInvalidFirstRect : rect; + _activeClient.markedRect = rect.size.width < 0 && rect.size.height < 0 ? kInvalidFirstRect : rect; } - (void)setSelectionRects:(NSArray*)rects { @@ -2154,21 +811,21 @@ - (void)setSelectionRects:(NSArray*)rects { [rect[2] floatValue], [rect[3] floatValue]) position:[rect[4] unsignedIntegerValue]]]; } - _activeView.selectionRects = rectsAsRect; + [_activeClient setSelectionRects:rectsAsRect]; } - (void)startLiveTextInput { if (@available(iOS 15.0, *)) { - if (_activeView == nil || !_activeView.isFirstResponder) { + if (_activeClient == nil || !_activeClient.isFirstResponder) { return; } - [_activeView captureTextFromCamera:nil]; + [_activeClient captureTextFromCamera:nil]; } } - (void)showTextInput { - _activeView.viewResponder = _viewResponder; - [self addToInputParentViewIfNeeded:_activeView]; + [_activeClient setViewResponder:_viewResponder]; + [self addToInputParentViewIfNeeded:_activeClient]; // Adds a delay to prevent the text view from receiving accessibility // focus in case it is activated during semantics updates. // @@ -2185,26 +842,26 @@ - (void)showTextInput { userInfo:nil repeats:NO]; } - [_activeView becomeFirstResponder]; + [_activeClient becomeFirstResponder]; } - (void)enableActiveViewAccessibility { - if (_activeView.isFirstResponder) { - _activeView.accessibilityEnabled = YES; + if (_activeClient.isFirstResponder) { + _activeClient.accessibilityEnabled = YES; } [self removeEnableFlutterTextInputViewAccessibilityTimer]; } - (void)hideTextInput { [self removeEnableFlutterTextInputViewAccessibilityTimer]; - _activeView.accessibilityEnabled = NO; - [_activeView resignFirstResponder]; - [_activeView removeFromSuperview]; + _activeClient.accessibilityEnabled = NO; + [_activeClient resignFirstResponder]; + [_activeClient removeFromSuperview]; [_inputHider removeFromSuperview]; } - (void)triggerAutofillSave:(BOOL)saveEntries { - [_activeView resignFirstResponder]; + [_activeClient resignFirstResponder]; if (saveEntries) { // Make all the input fields in the autofill context visible, @@ -2217,7 +874,7 @@ - (void)triggerAutofillSave:(BOOL)saveEntries { } [self cleanUpViewHierarchy:YES clearText:!saveEntries delayRemoval:NO]; - [self addToInputParentViewIfNeeded:_activeView]; + [self addToInputParentViewIfNeeded:_activeClient]; } - (void)setPlatformViewTextInputClient { @@ -2225,8 +882,8 @@ - (void)setPlatformViewTextInputClient { // becomes the first responder, simply hide this dummy text input view (`_activeView`) // for the previously focused widget. [self removeEnableFlutterTextInputViewAccessibilityTimer]; - _activeView.accessibilityEnabled = NO; - [_activeView removeFromSuperview]; + _activeClient.accessibilityEnabled = NO; + [_activeClient removeFromSuperview]; [_inputHider removeFromSuperview]; } @@ -2239,23 +896,23 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur // Update the current active view. switch (AutofillTypeOf(configuration)) { case kFlutterAutofillTypeNone: - self.activeView = [self createInputViewWith:configuration]; + self.activeClient = [self createInputViewWith:configuration]; break; case kFlutterAutofillTypeRegular: // If the group does not involve password autofill, only install the // input view that's being focused. - self.activeView = [self updateAndShowAutofillViews:nil - focusedField:configuration - isPasswordRelated:NO]; + self.activeClient = [self updateAndShowAutofillViews:nil + focusedField:configuration + isPasswordRelated:NO]; break; case kFlutterAutofillTypePassword: - self.activeView = [self updateAndShowAutofillViews:configuration[kAssociatedAutofillFields] - focusedField:configuration - isPasswordRelated:YES]; + self.activeClient = [self updateAndShowAutofillViews:configuration[kAssociatedAutofillFields] + focusedField:configuration + isPasswordRelated:YES]; break; } - [_activeView setTextInputClient:client]; - [_activeView reloadInputViews]; + _activeClient.clientID = client; + [_activeClient reloadInputViews]; // Clean up views that no longer need to be in the view hierarchy, according to // the current autofill context. The "garbage" input views are already made @@ -2263,42 +920,41 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur // them to free up resources and reduce the number of input views in the view // hierarchy. // - // The garbage views are decommissioned immediately, but the removeFromSuperview - // call is scheduled on the runloop and delayed by 0.1s so we don't remove the - // text fields immediately (which seems to make the keyboard flicker). + // The removeFromSuperview call is scheduled on the runloop and delayed by 0.1s + // so we don't remove the text fields immediately (which seems to make the keyboard flicker). // See: https://github.com/flutter/flutter/issues/64628. [self cleanUpViewHierarchy:NO clearText:YES delayRemoval:YES]; } // Creates and shows an input field that is not password related and has no autofill -// info. This method returns a new FlutterTextInputView instance when called, since +// info. This method returns a new FlutterTextInputClient instance when called, since // UIKit uses the identity of `UITextInput` instances (or the identity of the input // views) to decide whether the IME's internal states should be reset. See: // https://github.com/flutter/flutter/issues/79031 . -- (FlutterTextInputView*)createInputViewWith:(NSDictionary*)configuration { - NSString* autofillId = AutofillIdFromDictionary(configuration); - if (autofillId) { - [_autofillContext removeObjectForKey:autofillId]; +- (UIView*)createInputViewWith:(NSDictionary*)configuration { + NSString* autofillID = AutofillIDFromDictionary(configuration); + if (autofillID) { + [_autofillContext removeObjectForKey:autofillID]; } - FlutterTextInputView* newView = [[FlutterTextInputView alloc] initWithOwner:self]; - [newView configureWithDictionary:configuration]; + UIView* newView = [[RegularInputClient alloc] initWithOwner:self]; + ConfigureInputClientWithDictionary(newView, configuration); [self addToInputParentViewIfNeeded:newView]; for (NSDictionary* field in configuration[kAssociatedAutofillFields]) { - NSString* autofillId = AutofillIdFromDictionary(field); - if (autofillId && AutofillTypeOf(field) == kFlutterAutofillTypeNone) { - [_autofillContext removeObjectForKey:autofillId]; + NSString* autofillID = AutofillIDFromDictionary(field); + if (autofillID && AutofillTypeOf(field) == kFlutterAutofillTypeNone) { + [_autofillContext removeObjectForKey:autofillID]; } } return newView; } -- (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields - focusedField:(NSDictionary*)focusedField - isPasswordRelated:(BOOL)isPassword { - FlutterTextInputView* focused = nil; - NSString* focusedId = AutofillIdFromDictionary(focusedField); - NSAssert(focusedId, @"autofillId must not be null for the focused field: %@", focusedField); +- (UIView*)updateAndShowAutofillViews:(NSArray*)fields + focusedField:(NSDictionary*)focusedField + isPasswordRelated:(BOOL)isPassword { + UIView* focused = nil; + NSString* focusedId = AutofillIDFromDictionary(focusedField); + NSAssert(focusedId, @"autofillID must not be null for the focused field: %@", focusedField); if (!fields) { // DO NOT push the current autofillable input fields to the context even @@ -2308,11 +964,11 @@ - (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields } for (NSDictionary* field in fields) { - NSString* autofillId = AutofillIdFromDictionary(field); - NSAssert(autofillId, @"autofillId must not be null for field: %@", field); + NSString* autofillID = AutofillIDFromDictionary(field); + NSAssert(autofillID, @"autofillID must not be null for field: %@", field); BOOL hasHints = AutofillTypeOf(field) != kFlutterAutofillTypeNone; - BOOL isFocused = [focusedId isEqualToString:autofillId]; + BOOL isFocused = [focusedId isEqualToString:autofillID]; if (isFocused) { focused = [self getOrCreateAutofillableView:field isPasswordAutofill:isPassword]; @@ -2320,12 +976,12 @@ - (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields if (hasHints) { // Push the current input field to the context if it has hints. - _autofillContext[autofillId] = isFocused ? focused + _autofillContext[autofillID] = isFocused ? focused : [self getOrCreateAutofillableView:field isPasswordAutofill:isPassword]; } else { // Mark for deletion. - [_autofillContext removeObjectForKey:autofillId]; + [_autofillContext removeObjectForKey:autofillID]; } } @@ -2338,18 +994,19 @@ - (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields // already exists in the context. // This is generally used for input fields that are autofillable (UIKit tracks these veiws // for autofill purposes so they should not be reused for a different type of views). -- (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field - isPasswordAutofill:(BOOL)needsPasswordAutofill { - NSString* autofillId = AutofillIdFromDictionary(field); - FlutterTextInputView* inputView = _autofillContext[autofillId]; +- (UIView*) + getOrCreateAutofillableView:(NSDictionary*)field + isPasswordAutofill:(BOOL)needsPasswordAutofill { + NSString* autofillID = AutofillIDFromDictionary(field); + UIView* inputView = + _autofillContext[autofillID]; if (!inputView) { - inputView = - needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc]; + inputView = needsPasswordAutofill ? [SecureInputClient alloc] : [RegularInputClient alloc]; inputView = [inputView initWithOwner:self]; [self addToInputParentViewIfNeeded:inputView]; } - [inputView configureWithDictionary:field]; + ConfigureInputClientWithDictionary(inputView, field); return inputView; } @@ -2363,14 +1020,14 @@ - (UIView*)hostView { return host; } -// The UIView to add FlutterTextInputViews to. +// The UIView to add FlutterTextInputClients to. - (NSArray*)textInputViews { return _inputHider.subviews; } // Removes every installed input field, unless it's in the current autofill context. // -// The active view will be removed from its superview too, if includeActiveView is YES. +// The active view will be removed from its superview, 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. // If delayRemoval is true, removeFromSuperview will be scheduled on the runloop and @@ -2382,12 +1039,17 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText delayRemoval:(BOOL)delayRemoval { for (UIView* view in self.textInputViews) { - if ([view isKindOfClass:[FlutterTextInputView class]] && - (includeActiveView || view != _activeView)) { - FlutterTextInputView* inputView = (FlutterTextInputView*)view; - if (_autofillContext[inputView.autofillId] != view) { + if ([view conformsToProtocol:@protocol(FlutterTextInputClient)] && + [view conformsToProtocol:@protocol(FlutterTextAutofillClient)] && + (includeActiveView || view != _activeClient)) { + UIView* inputView = + (UIView*)view; + if (_autofillContext[inputView.autofillID] != view) { if (clearText) { - [inputView replaceRangeLocal:NSMakeRange(0, inputView.text.length) withText:@""]; + inputView.selectedTextRange = + [inputView textRangeFromPosition:inputView.beginningOfDocument + toPosition:inputView.endOfDocument]; + [inputView deleteBackward]; } if (delayRemoval) { [inputView performSelector:@selector(removeFromSuperview) withObject:nil afterDelay:0.1]; @@ -2403,8 +1065,8 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView // view hierarchy. - (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility { for (UIView* view in self.textInputViews) { - if ([view isKindOfClass:[FlutterTextInputView class]]) { - FlutterTextInputView* inputView = (FlutterTextInputView*)view; + if ([view conformsToProtocol:@protocol(FlutterTextAutofillClient)]) { + UIView* inputView = (UIView*)view; inputView.isVisibleToAutofill = newVisibility; } } @@ -2419,14 +1081,14 @@ - (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility { // framework. - (void)resetAllClientIds { for (UIView* view in self.textInputViews) { - if ([view isKindOfClass:[FlutterTextInputView class]]) { - FlutterTextInputView* inputView = (FlutterTextInputView*)view; - [inputView setTextInputClient:0]; + if ([view conformsToProtocol:@protocol(FlutterTextInputClient)]) { + UIView* inputView = (UIView*)view; + inputView.clientID = 0; } } } -- (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView { +- (void)addToInputParentViewIfNeeded:(UIView*)inputView { if (![inputView isDescendantOfView:_inputHider]) { [_inputHider addSubview:inputView]; } @@ -2437,12 +1099,12 @@ - (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView { } - (void)setTextInputEditingState:(NSDictionary*)state { - [_activeView setTextInputState:state]; + _activeClient.textInputState = state; } - (void)clearTextInputClient { - [_activeView setTextInputClient:0]; - _activeView.frame = CGRectZero; + _activeClient.clientID = 0; + _activeClient.frame = CGRectZero; } #pragma mark UIIndirectScribbleInteractionDelegate @@ -2450,7 +1112,7 @@ - (void)clearTextInputClient { - (BOOL)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction isElementFocused:(UIScribbleElementIdentifier)elementIdentifier API_AVAILABLE(ios(14.0)) { - return _activeView.scribbleFocusStatus == FlutterScribbleFocusStatusFocused; + return _activeClient.scribbleFocusStatus == FlutterScribbleFocusStatusFocused; } - (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction @@ -2458,14 +1120,14 @@ - (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction referencePoint:(CGPoint)focusReferencePoint completion:(void (^)(UIResponder* focusedInput))completion API_AVAILABLE(ios(14.0)) { - _activeView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; + _activeClient.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; [_indirectScribbleDelegate flutterTextInputPlugin:self focusElement:elementIdentifier atPoint:focusReferencePoint result:^(id _Nullable result) { - _activeView.scribbleFocusStatus = + _activeClient.scribbleFocusStatus = FlutterScribbleFocusStatusFocused; - completion(_activeView); + completion(_activeClient); }]; } @@ -2525,6 +1187,17 @@ - (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction #pragma mark - Methods related to Scribble support +// Checks whether Scribble features are possibly available – meaning this is an iPad running iOS +// 14 or higher. ++ (BOOL)isScribbleAvailable { + if (@available(iOS 14.0, *)) { + if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) { + return YES; + } + } + return NO; +} + - (void)setupIndirectScribbleInteraction:(id)viewResponder { if (_viewResponder != viewResponder) { if (@available(iOS 14.0, *)) { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 459a74335bcfa..e736612175478 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" #import @@ -18,15 +19,20 @@ - (nonnull FlutterTextInputPlugin*)textInputPlugin; @end @interface FlutterTextInputView () -@property(nonatomic, copy) NSString* autofillId; -- (void)setEditableTransform:(NSArray*)matrix; -- (void)setTextInputClient:(int)client; +@property(nonatomic, readonly) NSMutableString* text; +@property(nonatomic, strong) FlutterTextRange* markedTextRange; +@property(nonatomic, strong) NSArray* selectionRects; + +- (void)setEditableSize:(CGSize)size transform:(NSArray*)matrix; +- (void)setClientID:(int)client; - (void)setTextInputState:(NSDictionary*)state; - (void)setMarkedRect:(CGRect)markedRect; - (void)updateEditingState; - (BOOL)isVisibleToAutofill; - (id)textInputDelegate; -- (void)configureWithDictionary:(NSDictionary*)configuration; + +- (void)resetScribbleInteractionStatusIfEnding; + @end @interface FlutterTextInputViewSpy : FlutterTextInputView @@ -52,16 +58,16 @@ - (BOOL)accessibilityElementIsFocused { @end -@interface FlutterSecureTextInputView : FlutterTextInputView +@interface FlutterSecureTextInputView () @property(nonatomic, strong) UITextField* textField; @end @interface FlutterTextInputPlugin () -@property(nonatomic, assign) FlutterTextInputView* activeView; +@property(nonatomic, strong) FlutterTextInputView* activeClient; @property(nonatomic, readonly) NSMutableDictionary* autofillContext; -- (void)cleanUpViewHierarchy:(BOOL)includeActiveView +- (void)cleanUpViewHierarchy:(BOOL)includeactiveClient clearText:(BOOL)clearText delayRemoval:(BOOL)delayRemoval; - (NSArray*)textInputViews; @@ -201,7 +207,7 @@ - (void)testNoDanglingEnginePointer { [flutterTextInputPlugin handleMethodCall:setClientCall result:^(id _Nullable result){ }]; - currentView = flutterTextInputPlugin.activeView; + currentView = flutterTextInputPlugin.activeClient; } NSAssert(!weakFlutterEngine, @"flutter engine must be nil"); @@ -240,7 +246,7 @@ - (void)testSecureInput { // Despite not given an id in configuration, inputView has // an autofill id. - XCTAssert(inputView.autofillId.length > 0); + XCTAssert(inputView.autofillID.length > 0); } - (void)testKeyboardType { @@ -263,12 +269,12 @@ - (void)testSettingKeyboardTypeNoneDisablesSystemKeyboard { [self setClientId:123 configuration:config]; // Verify the view's inputViewController is not nil; - XCTAssertNotNil(textInputPlugin.activeView.inputViewController); + XCTAssertNotNil(textInputPlugin.activeClient.inputViewController); [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"]; [self setClientId:124 configuration:config]; - XCTAssertNotNil(textInputPlugin.activeView); - XCTAssertNil(textInputPlugin.activeView.inputViewController); + XCTAssertNotNil(textInputPlugin.activeClient); + XCTAssertNil(textInputPlugin.activeClient.inputViewController); } - (void)testAutocorrectionPromptRectAppears { @@ -298,11 +304,7 @@ - (void)testIngoresSelectionChangeIfSelectionIsDisabled { XCTAssertEqual(updateCount, 1); // Disable the interactive selection. - NSDictionary* config = self.mutableTemplateCopy; - [config setValue:@(NO) forKey:@"enableInteractiveSelection"]; - [config setValue:@(NO) forKey:@"obscureText"]; - [config setValue:@(NO) forKey:@"enableDeltaModel"]; - [inputView configureWithDictionary:config]; + inputView.enableInteractiveSelection = NO; textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(2, 3)]; [inputView setSelectedTextRange:textRange]; @@ -538,30 +540,30 @@ - (void)testNoZombies { } - (void)testInputViewCrash { - FlutterTextInputView* activeView = nil; + FlutterTextInputView* activeClient = nil; @autoreleasepool { FlutterEngine* flutterEngine = [[FlutterEngine alloc] init]; FlutterTextInputPlugin* inputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:(id)flutterEngine]; - activeView = inputPlugin.activeView; + activeClient = inputPlugin.activeClient; } - [activeView updateEditingState]; + [activeClient updateEditingState]; } - (void)testDoNotReuseInputViews { NSDictionary* config = self.mutableTemplateCopy; [self setClientId:123 configuration:config]; - FlutterTextInputView* currentView = textInputPlugin.activeView; + FlutterTextInputView* currentView = textInputPlugin.activeClient; [self setClientId:456 configuration:config]; XCTAssertNotNil(currentView); - XCTAssertNotNil(textInputPlugin.activeView); - XCTAssertNotEqual(currentView, textInputPlugin.activeView); + XCTAssertNotNil(textInputPlugin.activeClient); + XCTAssertNotEqual(currentView, textInputPlugin.activeClient); } -- (void)ensureOnlyActiveViewCanBecomeFirstResponder { +- (void)ensureOnlyactiveClientCanBecomeFirstResponder { for (FlutterTextInputView* inputView in self.installedInputViews) { - XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView); + XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeClient); } } @@ -574,7 +576,7 @@ - (void)testPropagatePressEventsToViewController { NSDictionary* config = self.mutableTemplateCopy; [self setClientId:123 configuration:config]; - FlutterTextInputView* currentView = textInputPlugin.activeView; + FlutterTextInputView* currentView = textInputPlugin.activeClient; [self setTextInputShow]; [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] @@ -604,7 +606,7 @@ - (void)testPropagatePressEventsToViewController2 { NSDictionary* config = self.mutableTemplateCopy; [self setClientId:123 configuration:config]; [self setTextInputShow]; - FlutterTextInputView* currentView = textInputPlugin.activeView; + FlutterTextInputView* currentView = textInputPlugin.activeClient; [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] withEvent:OCMClassMock([UIPressesEvent class])]; @@ -617,9 +619,9 @@ - (void)testPropagatePressEventsToViewController2 { // Switch focus to a different view. [self setClientId:321 configuration:config]; [self setTextInputShow]; - NSAssert(textInputPlugin.activeView, @"active view must not be nil"); - NSAssert(textInputPlugin.activeView != currentView, @"active view must change"); - currentView = textInputPlugin.activeView; + NSAssert(textInputPlugin.activeClient, @"active view must not be nil"); + NSAssert(textInputPlugin.activeClient != currentView, @"active view must change"); + currentView = textInputPlugin.activeClient; [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] withEvent:OCMClassMock([UIPressesEvent class])]; @@ -746,7 +748,7 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement { [inputView.text setString:@"Some initial text"]; XCTAssertEqual(updateCount, 0); - UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; + FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; inputView.markedTextRange = range; inputView.selectedTextRange = nil; XCTAssertEqual(updateCount, 1); @@ -780,7 +782,7 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion { [inputView.text setString:@"Some initial text"]; XCTAssertEqual(updateCount, 0); - UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; + FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; inputView.markedTextRange = range; inputView.selectedTextRange = nil; XCTAssertEqual(updateCount, 1); @@ -814,7 +816,7 @@ - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion { [inputView.text setString:@"Some initial text"]; XCTAssertEqual(updateCount, 0); - UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; + FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; inputView.markedTextRange = range; inputView.selectedTextRange = nil; XCTAssertEqual(updateCount, 1); @@ -1009,7 +1011,8 @@ - (void)testCanCopyPasteWithScribbleEnabled { FlutterTextInputView* inputView = inputFields[0]; FlutterTextInputView* mockInputView = OCMPartialMock(inputView); - OCMStub([mockInputView isScribbleAvailable]).andReturn(YES); + id inputPluginClassMock = OCMClassMock([FlutterTextInputPlugin class]); + OCMStub([inputPluginClassMock isScribbleAvailable]).andReturn(YES); [mockInputView insertText:@"aaaa"]; [mockInputView selectAll:nil]; @@ -1182,7 +1185,7 @@ - (void)testInputViewsHasNonNilInputDelegate { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; [UIApplication.sharedApplication.keyWindow addSubview:inputView]; - [inputView setTextInputClient:123]; + [inputView setClientID:123]; [inputView reloadInputViews]; [inputView becomeFirstResponder]; NSAssert(inputView.isFirstResponder, @"inputView is not first responder"); @@ -1218,7 +1221,7 @@ - (void)testInputViewsDoNotHaveUITextInteractions { - (void)testUpdateFirstRectForRange { [self setClientId:123 configuration:self.mutableTemplateCopy]; - FlutterTextInputView* inputView = textInputPlugin.activeView; + FlutterTextInputView* inputView = textInputPlugin.activeClient; textInputPlugin.viewController.view.frame = CGRectMake(0, 0, 0, 0); [inputView @@ -1240,7 +1243,7 @@ - (void)testUpdateFirstRectForRange { // Invalid since we don't have the transform or the rect. XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); - [inputView setEditableTransform:yOffsetMatrix]; + [inputView setEditableSize:CGSizeZero transform:yOffsetMatrix]; // Invalid since we don't have the rect. XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); @@ -1254,13 +1257,13 @@ - (void)testUpdateFirstRectForRange { XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range])); // Use an invalid matrix: - [inputView setEditableTransform:zeroMatrix]; + [inputView setEditableSize:CGSizeZero transform:zeroMatrix]; // Invalid matrix is invalid. XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); // Revert the invalid matrix change. - [inputView setEditableTransform:yOffsetMatrix]; + [inputView setEditableSize:CGSizeZero transform:yOffsetMatrix]; [inputView setMarkedRect:testRect]; XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range])); @@ -1271,7 +1274,7 @@ - (void)testUpdateFirstRectForRange { XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); // Use a 3d affine transform that does 3d-scaling, z-index rotating and 3d translation. - [inputView setEditableTransform:affineMatrix]; + [inputView setEditableSize:CGSizeZero transform:affineMatrix]; [inputView setMarkedRect:testRect]; XCTAssertTrue( CGRectEqualToRect(CGRectMake(-306, 3, 300, 300), [inputView firstRectForRange:range])); @@ -1539,7 +1542,7 @@ - (void)commitAutofillContextAndVerify { }]; XCTAssertEqual(self.viewsVisibleToAutofill.count, - [textInputPlugin.activeView isVisibleToAutofill] ? 1ul : 0ul); + [textInputPlugin.activeClient isVisibleToAutofill] ? 1ul : 0ul); XCTAssertNotEqual(textInputPlugin.textInputView, nil); // The active view should still be installed so it doesn't get // deallocated. @@ -1600,7 +1603,7 @@ - (void)testAutofillContext { [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; XCTAssertEqual(self.installedInputViews.count, 2ul); XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // The configuration changes. NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy; @@ -1623,7 +1626,7 @@ - (void)testAutofillContext { [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; XCTAssertEqual(self.installedInputViews.count, 3ul); XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // Old autofill input fields are still installed and reused. for (NSString* key in oldContext.allKeys) { @@ -1635,7 +1638,7 @@ - (void)testAutofillContext { oldContext = textInputPlugin.autofillContext; [self setClientId:124 configuration:config]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul); XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul); @@ -1649,7 +1652,7 @@ - (void)testAutofillContext { } // The active view should change. XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // Switch to a similar password field, the previous field should be reused. oldContext = textInputPlugin.autofillContext; @@ -1667,7 +1670,7 @@ - (void)testAutofillContext { XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); } XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; } - (void)testCommitAutofillContext { @@ -1701,10 +1704,10 @@ - (void)testCommitAutofillContext { [self setClientId:123 configuration:config]; XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul); XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; [self commitAutofillContextAndVerify]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // Install the password field again. [self setClientId:123 configuration:config]; @@ -1716,10 +1719,10 @@ - (void)testCommitAutofillContext { XCTAssertEqual(self.installedInputViews.count, 3ul); XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul); XCTAssertNotEqual(textInputPlugin.textInputView, nil); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; [self commitAutofillContextAndVerify]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // Now switch to an input field that does not autofill. [self setClientId:125 configuration:self.mutableTemplateCopy]; @@ -1731,10 +1734,10 @@ - (void)testCommitAutofillContext { [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; XCTAssertEqual(self.installedInputViews.count, 1ul); XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul); - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; [self commitAutofillContextAndVerify]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; } - (void)testAutofillInputViews { @@ -1758,7 +1761,7 @@ - (void)testAutofillInputViews { [config setValue:@[ field1, field2 ] forKey:@"fields"]; [self setClientId:123 configuration:config]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // Find all the FlutterTextInputViews we created. NSArray* inputFields = self.installedInputViews; @@ -1768,13 +1771,13 @@ - (void)testAutofillInputViews { XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul); // Find the inactive autofillable input field. - FlutterTextInputView* inactiveView = inputFields[1]; - [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)] - withText:@"Autofilled!"]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + FlutterTextInputView* inactiveClient = inputFields[1]; + [inactiveClient replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)] + withText:@"Autofilled!"]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; // Verify behavior. - OCMVerify([engine flutterTextInputView:inactiveView + OCMVerify([engine flutterTextInputView:inactiveClient updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]); @@ -1813,7 +1816,7 @@ - (void)testClearAutofillContextClearsSelection { forKey:@"autofill"]; [regularField addEntriesFromDictionary:editingValue]; [self setClientId:123 configuration:regularField]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; XCTAssertEqual(self.installedInputViews.count, 1ul); FlutterTextInputView* oldInputView = self.installedInputViews[0]; @@ -1824,7 +1827,7 @@ - (void)testClearAutofillContextClearsSelection { // Replace the original password field with new one. This should remove // the old password field, but not immediately. [self setClientId:124 configuration:self.mutablePasswordTemplateCopy]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; XCTAssertEqual(self.installedInputViews.count, 2ul); @@ -1840,13 +1843,13 @@ - (void)testClearAutofillContextClearsSelection { - (void)testGarbageInputViewsAreNotRemovedImmediately { // Add a password field that should autofill. [self setClientId:123 configuration:self.mutablePasswordTemplateCopy]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; XCTAssertEqual(self.installedInputViews.count, 1ul); // Add an input field that doesn't autofill. This should remove the password // field, but not immediately. [self setClientId:124 configuration:self.mutableTemplateCopy]; - [self ensureOnlyActiveViewCanBecomeFirstResponder]; + [self ensureOnlyactiveClientCanBecomeFirstResponder]; XCTAssertEqual(self.installedInputViews.count, 2ul); @@ -1871,7 +1874,7 @@ - (void)testScribbleSetSelectionRects { [regularField addEntriesFromDictionary:editingValue]; [self setClientId:123 configuration:regularField]; XCTAssertEqual(self.installedInputViews.count, 1ul); - XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 0u); + XCTAssertEqual([textInputPlugin.activeClient.selectionRects count], 0u); NSArray* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, nil]; NSArray* selectionRects = [NSArray arrayWithObjects:selectionRect, nil]; @@ -1882,7 +1885,7 @@ - (void)testScribbleSetSelectionRects { result:^(id _Nullable result){ }]; - XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 1u); + XCTAssertEqual([textInputPlugin.activeClient.selectionRects count], 1u); } - (void)testDecommissionedViewAreNotReusedByAutofill { @@ -1899,21 +1902,21 @@ - (void)testDecommissionedViewAreNotReusedByAutofill { [self setClientId:123 configuration:configuration]; [self setTextInputHide]; - UIView* previousActiveView = textInputPlugin.activeView; + UIView* previousactiveClient = textInputPlugin.activeClient; [self setClientId:124 configuration:configuration]; // Make sure the autofillable view is reused. - XCTAssertEqual(previousActiveView, textInputPlugin.activeView); - XCTAssertNotNil(previousActiveView); + XCTAssertEqual(previousactiveClient, textInputPlugin.activeClient); + XCTAssertNotNil(previousactiveClient); // Does not crash. } -- (void)testInitialActiveViewCantAccessTextInputDelegate { +- (void)testInitialactiveClientCantAccessTextInputDelegate { // Before the framework sends the first text input configuration, - // the dummy "activeView" we use should never have access to + // the dummy "activeClient" we use should never have access to // its textInputDelegate. - XCTAssertNil(textInputPlugin.activeView.textInputDelegate); + XCTAssertNil(textInputPlugin.activeClient.textInputDelegate); } #pragma mark - Accessibility - Tests @@ -1994,7 +1997,7 @@ - (void)testFlutterTextInputPluginRetainsFlutterTextInputView { FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; myInputPlugin.viewController = flutterViewController; - __weak UIView* activeView; + __weak UIView* activeClient; @autoreleasepool { FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" @@ -2004,16 +2007,16 @@ - (void)testFlutterTextInputPluginRetainsFlutterTextInputView { [myInputPlugin handleMethodCall:setClientCall result:^(id _Nullable result){ }]; - activeView = myInputPlugin.textInputView; + activeClient = myInputPlugin.textInputView; FlutterMethodCall* hideCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide" arguments:@[]]; [myInputPlugin handleMethodCall:hideCall result:^(id _Nullable result){ }]; - XCTAssertNotNil(activeView); + XCTAssertNotNil(activeClient); } // This assert proves the myInputPlugin.textInputView is not deallocated. - XCTAssertNotNil(activeView); + XCTAssertNotNil(activeClient); } - (void)testFlutterTextInputPluginHostViewNilCrash { @@ -2042,15 +2045,15 @@ - (void)testSetPlatformViewClient { [myInputPlugin handleMethodCall:setClientCall result:^(id _Nullable result){ }]; - UIView* activeView = myInputPlugin.textInputView; - XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy."); + UIView* activeClient = myInputPlugin.textInputView; + XCTAssertNotNil(activeClient.superview, @"activeClient must be added to the view hierarchy."); FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setPlatformViewClient" arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}]; [myInputPlugin handleMethodCall:setPlatformViewClientCall result:^(id _Nullable result){ }]; - XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy."); + XCTAssertNil(activeClient.superview, @"activeClient must be removed from view hierarchy."); } @end diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm b/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm index 92820ea8e522f..0755761fade20 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm +++ b/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm @@ -4,6 +4,7 @@ #import +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputClient.h" #import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h" #import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.h" @@ -203,7 +204,8 @@ - (void)dealloc { - (void)setSemanticsNode:(const flutter::SemanticsNode*)node { [super setSemanticsNode:node]; _inactive_text_input.text = @(node->value.data()); - FlutterTextInputView* textInput = (FlutterTextInputView*)[self bridge]->textInputView(); + UIView* textInput = + (UIView*)[self bridge]->textInputView(); if ([self node].HasFlag(flutter::SemanticsFlags::kIsFocused)) { textInput.backingTextInputAccessibilityObject = self; // The text input view must have a non-trivial size for the accessibility