Flutter Engine
The Flutter Engine
Loading...
Searching...
No Matches
FlutterTextInputPlugin.mm
Go to the documentation of this file.
1// Copyright 2013 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h"
6#import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h"
7
8#import <Foundation/Foundation.h>
9#import <UIKit/UIKit.h>
10
11#include "unicode/uchar.h"
12
13#include "flutter/fml/logging.h"
14#include "flutter/fml/platform/darwin/string_range_sanitization.h"
15
17
18static const char kTextAffinityDownstream[] = "TextAffinity.downstream";
19static const char kTextAffinityUpstream[] = "TextAffinity.upstream";
20// A delay before enabling the accessibility of FlutterTextInputView after
21// it is activated.
23
24// A delay before reenabling the UIView areAnimationsEnabled to YES
25// in order for becomeFirstResponder to receive the proper value.
26static const NSTimeInterval kKeyboardAnimationDelaySeconds = 0.1;
27
28// A time set for the screenshot to animate back to the assigned position.
29static const NSTimeInterval kKeyboardAnimationTimeToCompleteion = 0.3;
30
31// The "canonical" invalid CGRect, similar to CGRectNull, used to
32// indicate a CGRect involved in firstRectForRange calculation is
33// invalid. The specific value is chosen so that if firstRectForRange
34// returns kInvalidFirstRect, iOS will not show the IME candidates view.
35const CGRect kInvalidFirstRect = {{-1, -1}, {9999, 9999}};
36
37#pragma mark - TextInput channel method names.
38// See https://api.flutter.dev/flutter/services/SystemChannels/textInput-constant.html
39static NSString* const kShowMethod = @"TextInput.show";
40static NSString* const kHideMethod = @"TextInput.hide";
41static NSString* const kSetClientMethod = @"TextInput.setClient";
42static NSString* const kSetPlatformViewClientMethod = @"TextInput.setPlatformViewClient";
43static NSString* const kSetEditingStateMethod = @"TextInput.setEditingState";
44static NSString* const kClearClientMethod = @"TextInput.clearClient";
45static NSString* const kSetEditableSizeAndTransformMethod =
46 @"TextInput.setEditableSizeAndTransform";
47static NSString* const kSetMarkedTextRectMethod = @"TextInput.setMarkedTextRect";
48static NSString* const kFinishAutofillContextMethod = @"TextInput.finishAutofillContext";
49// TODO(justinmc): Remove the TextInput method constant when the framework has
50// finished transitioning to using the Scribble channel.
51// https://github.com/flutter/flutter/pull/104128
52static NSString* const kDeprecatedSetSelectionRectsMethod = @"TextInput.setSelectionRects";
53static NSString* const kSetSelectionRectsMethod = @"Scribble.setSelectionRects";
54static NSString* const kStartLiveTextInputMethod = @"TextInput.startLiveTextInput";
55static NSString* const kUpdateConfigMethod = @"TextInput.updateConfig";
57 @"TextInput.onPointerMoveForInteractiveKeyboard";
59 @"TextInput.onPointerUpForInteractiveKeyboard";
60
61#pragma mark - TextInputConfiguration Field Names
62static NSString* const kSecureTextEntry = @"obscureText";
63static NSString* const kKeyboardType = @"inputType";
64static NSString* const kKeyboardAppearance = @"keyboardAppearance";
65static NSString* const kInputAction = @"inputAction";
66static NSString* const kEnableDeltaModel = @"enableDeltaModel";
67static NSString* const kEnableInteractiveSelection = @"enableInteractiveSelection";
68
69static NSString* const kSmartDashesType = @"smartDashesType";
70static NSString* const kSmartQuotesType = @"smartQuotesType";
71
72static NSString* const kAssociatedAutofillFields = @"fields";
73
74// TextInputConfiguration.autofill and sub-field names
75static NSString* const kAutofillProperties = @"autofill";
76static NSString* const kAutofillId = @"uniqueIdentifier";
77static NSString* const kAutofillEditingValue = @"editingValue";
78static NSString* const kAutofillHints = @"hints";
79
80static NSString* const kAutocorrectionType = @"autocorrect";
81
82#pragma mark - Static Functions
83
84// Determine if the character at `range` of `text` is an emoji.
85static BOOL IsEmoji(NSString* text, NSRange charRange) {
86 UChar32 codePoint;
87 BOOL gotCodePoint = [text getBytes:&codePoint
88 maxLength:sizeof(codePoint)
89 usedLength:NULL
90 encoding:NSUTF32StringEncoding
91 options:kNilOptions
92 range:charRange
93 remainingRange:NULL];
94 return gotCodePoint && u_hasBinaryProperty(codePoint, UCHAR_EMOJI);
95}
96
97// "TextInputType.none" is a made-up input type that's typically
98// used when there's an in-app virtual keyboard. If
99// "TextInputType.none" is specified, disable the system
100// keyboard.
101static BOOL ShouldShowSystemKeyboard(NSDictionary* type) {
102 NSString* inputType = type[@"name"];
103 return ![inputType isEqualToString:@"TextInputType.none"];
104}
105static UIKeyboardType ToUIKeyboardType(NSDictionary* type) {
106 NSString* inputType = type[@"name"];
107 if ([inputType isEqualToString:@"TextInputType.address"]) {
108 return UIKeyboardTypeDefault;
109 }
110 if ([inputType isEqualToString:@"TextInputType.datetime"]) {
111 return UIKeyboardTypeNumbersAndPunctuation;
112 }
113 if ([inputType isEqualToString:@"TextInputType.emailAddress"]) {
114 return UIKeyboardTypeEmailAddress;
115 }
116 if ([inputType isEqualToString:@"TextInputType.multiline"]) {
117 return UIKeyboardTypeDefault;
118 }
119 if ([inputType isEqualToString:@"TextInputType.name"]) {
120 return UIKeyboardTypeNamePhonePad;
121 }
122 if ([inputType isEqualToString:@"TextInputType.number"]) {
123 if ([type[@"signed"] boolValue]) {
124 return UIKeyboardTypeNumbersAndPunctuation;
125 }
126 if ([type[@"decimal"] boolValue]) {
127 return UIKeyboardTypeDecimalPad;
128 }
129 return UIKeyboardTypeNumberPad;
130 }
131 if ([inputType isEqualToString:@"TextInputType.phone"]) {
132 return UIKeyboardTypePhonePad;
133 }
134 if ([inputType isEqualToString:@"TextInputType.text"]) {
135 return UIKeyboardTypeDefault;
136 }
137 if ([inputType isEqualToString:@"TextInputType.url"]) {
138 return UIKeyboardTypeURL;
139 }
140 if ([inputType isEqualToString:@"TextInputType.visiblePassword"]) {
141 return UIKeyboardTypeASCIICapable;
142 }
143 return UIKeyboardTypeDefault;
144}
145
146static UITextAutocapitalizationType ToUITextAutoCapitalizationType(NSDictionary* type) {
147 NSString* textCapitalization = type[@"textCapitalization"];
148 if ([textCapitalization isEqualToString:@"TextCapitalization.characters"]) {
149 return UITextAutocapitalizationTypeAllCharacters;
150 } else if ([textCapitalization isEqualToString:@"TextCapitalization.sentences"]) {
151 return UITextAutocapitalizationTypeSentences;
152 } else if ([textCapitalization isEqualToString:@"TextCapitalization.words"]) {
153 return UITextAutocapitalizationTypeWords;
154 }
155 return UITextAutocapitalizationTypeNone;
156}
157
158static UIReturnKeyType ToUIReturnKeyType(NSString* inputType) {
159 // Where did the term "unspecified" come from? iOS has a "default" and Android
160 // has "unspecified." These 2 terms seem to mean the same thing but we need
161 // to pick just one. "unspecified" was chosen because "default" is often a
162 // reserved word in languages with switch statements (dart, java, etc).
163 if ([inputType isEqualToString:@"TextInputAction.unspecified"]) {
164 return UIReturnKeyDefault;
165 }
166
167 if ([inputType isEqualToString:@"TextInputAction.done"]) {
168 return UIReturnKeyDone;
169 }
170
171 if ([inputType isEqualToString:@"TextInputAction.go"]) {
172 return UIReturnKeyGo;
173 }
174
175 if ([inputType isEqualToString:@"TextInputAction.send"]) {
176 return UIReturnKeySend;
177 }
178
179 if ([inputType isEqualToString:@"TextInputAction.search"]) {
180 return UIReturnKeySearch;
181 }
182
183 if ([inputType isEqualToString:@"TextInputAction.next"]) {
184 return UIReturnKeyNext;
185 }
186
187 if ([inputType isEqualToString:@"TextInputAction.continueAction"]) {
188 return UIReturnKeyContinue;
189 }
190
191 if ([inputType isEqualToString:@"TextInputAction.join"]) {
192 return UIReturnKeyJoin;
193 }
194
195 if ([inputType isEqualToString:@"TextInputAction.route"]) {
196 return UIReturnKeyRoute;
197 }
198
199 if ([inputType isEqualToString:@"TextInputAction.emergencyCall"]) {
200 return UIReturnKeyEmergencyCall;
201 }
202
203 if ([inputType isEqualToString:@"TextInputAction.newline"]) {
204 return UIReturnKeyDefault;
205 }
206
207 // Present default key if bad input type is given.
208 return UIReturnKeyDefault;
209}
210
211static UITextContentType ToUITextContentType(NSArray<NSString*>* hints) {
212 if (!hints || hints.count == 0) {
213 // If no hints are specified, use the default content type nil.
214 return nil;
215 }
216
217 NSString* hint = hints[0];
218 if ([hint isEqualToString:@"addressCityAndState"]) {
219 return UITextContentTypeAddressCityAndState;
220 }
221
222 if ([hint isEqualToString:@"addressState"]) {
223 return UITextContentTypeAddressState;
224 }
225
226 if ([hint isEqualToString:@"addressCity"]) {
227 return UITextContentTypeAddressCity;
228 }
229
230 if ([hint isEqualToString:@"sublocality"]) {
231 return UITextContentTypeSublocality;
232 }
233
234 if ([hint isEqualToString:@"streetAddressLine1"]) {
235 return UITextContentTypeStreetAddressLine1;
236 }
237
238 if ([hint isEqualToString:@"streetAddressLine2"]) {
239 return UITextContentTypeStreetAddressLine2;
240 }
241
242 if ([hint isEqualToString:@"countryName"]) {
243 return UITextContentTypeCountryName;
244 }
245
246 if ([hint isEqualToString:@"fullStreetAddress"]) {
247 return UITextContentTypeFullStreetAddress;
248 }
249
250 if ([hint isEqualToString:@"postalCode"]) {
251 return UITextContentTypePostalCode;
252 }
253
254 if ([hint isEqualToString:@"location"]) {
255 return UITextContentTypeLocation;
256 }
257
258 if ([hint isEqualToString:@"creditCardNumber"]) {
259 return UITextContentTypeCreditCardNumber;
260 }
261
262 if ([hint isEqualToString:@"email"]) {
263 return UITextContentTypeEmailAddress;
264 }
265
266 if ([hint isEqualToString:@"jobTitle"]) {
267 return UITextContentTypeJobTitle;
268 }
269
270 if ([hint isEqualToString:@"givenName"]) {
271 return UITextContentTypeGivenName;
272 }
273
274 if ([hint isEqualToString:@"middleName"]) {
275 return UITextContentTypeMiddleName;
276 }
277
278 if ([hint isEqualToString:@"familyName"]) {
279 return UITextContentTypeFamilyName;
280 }
281
282 if ([hint isEqualToString:@"name"]) {
283 return UITextContentTypeName;
284 }
285
286 if ([hint isEqualToString:@"namePrefix"]) {
287 return UITextContentTypeNamePrefix;
288 }
289
290 if ([hint isEqualToString:@"nameSuffix"]) {
291 return UITextContentTypeNameSuffix;
292 }
293
294 if ([hint isEqualToString:@"nickname"]) {
295 return UITextContentTypeNickname;
296 }
297
298 if ([hint isEqualToString:@"organizationName"]) {
299 return UITextContentTypeOrganizationName;
300 }
301
302 if ([hint isEqualToString:@"telephoneNumber"]) {
303 return UITextContentTypeTelephoneNumber;
304 }
305
306 if ([hint isEqualToString:@"password"]) {
307 return UITextContentTypePassword;
308 }
309
310 if ([hint isEqualToString:@"oneTimeCode"]) {
311 return UITextContentTypeOneTimeCode;
312 }
313
314 if ([hint isEqualToString:@"newPassword"]) {
315 return UITextContentTypeNewPassword;
316 }
317
318 return hints[0];
319}
320
321// Retrieves the autofillId from an input field's configuration. Returns
322// nil if the field is nil and the input field is not a password field.
323static NSString* AutofillIdFromDictionary(NSDictionary* dictionary) {
324 NSDictionary* autofill = dictionary[kAutofillProperties];
325 if (autofill) {
326 return autofill[kAutofillId];
327 }
328
329 // When autofill is nil, the field may still need an autofill id
330 // if the field is for password.
331 return [dictionary[kSecureTextEntry] boolValue] ? @"password" : nil;
332}
333
334// # Autofill Implementation Notes:
335//
336// Currently there're 2 types of autofills on iOS:
337// - Regular autofill, including contact information and one-time-code,
338// takes place in the form of predictive text in the quick type bar.
339// This type of autofill does not save user input, and the keyboard
340// currently only populates the focused field when a predictive text entry
341// is selected by the user.
342//
343// - Password autofill, includes automatic strong password and regular
344// password autofill. The former happens automatically when a
345// "new password" field is detected and focused, and only that password
346// field will be populated. The latter appears in the quick type bar when
347// an eligible input field (which either has a UITextContentTypePassword
348// contentType, or is a secure text entry) becomes the first responder, and may
349// fill both the username and the password fields. iOS will attempt
350// to save user input for both kinds of password fields. It's relatively
351// tricky to deal with password autofill since it can autofill more than one
352// field at a time and may employ heuristics based on what other text fields
353// are in the same view controller.
354//
355// When a flutter text field is focused, and autofill is not explicitly disabled
356// for it ("autofillable"), the framework collects its attributes and checks if
357// it's in an AutofillGroup, and collects the attributes of other autofillable
358// text fields in the same AutofillGroup if so. The attributes are sent to the
359// text input plugin via a "TextInput.setClient" platform channel message. If
360// autofill is disabled for a text field, its "autofill" field will be nil in
361// the configuration json.
362//
363// The text input plugin then tries to determine which kind of autofill the text
364// field needs. If the AutofillGroup the text field belongs to contains an
365// autofillable text field that's password related, this text 's autofill type
366// will be kFlutterAutofillTypePassword. If autofill is disabled for a text field,
367// then its type will be kFlutterAutofillTypeNone. Otherwise the text field will
368// have an autofill type of kFlutterAutofillTypeRegular.
369//
370// The text input plugin creates a new UIView for every kFlutterAutofillTypeNone
371// text field. The UIView instance is never reused for other flutter text fields
372// since the software keyboard often uses the identity of a UIView to distinguish
373// different views and provides the same predictive text suggestions or restore
374// the composing region if a UIView is reused for a different flutter text field.
375//
376// The text input plugin creates a new "autofill context" if the text field has
377// the type of kFlutterAutofillTypePassword, to represent the AutofillGroup of
378// the text field, and creates one FlutterTextInputView for every text field in
379// the AutofillGroup.
380//
381// The text input plugin will try to reuse a UIView if a flutter text field's
382// type is kFlutterAutofillTypeRegular, and has the same autofill id.
383typedef NS_ENUM(NSInteger, FlutterAutofillType) {
384 // The field does not have autofillable content. Additionally if
385 // the field is currently in the autofill context, it will be
386 // removed from the context without triggering autofill save.
387 kFlutterAutofillTypeNone,
388 kFlutterAutofillTypeRegular,
389 kFlutterAutofillTypePassword,
390};
391
392static BOOL IsFieldPasswordRelated(NSDictionary* configuration) {
393 // Autofill is explicitly disabled if the id isn't present.
394 if (!AutofillIdFromDictionary(configuration)) {
395 return NO;
396 }
397
398 BOOL isSecureTextEntry = [configuration[kSecureTextEntry] boolValue];
399 if (isSecureTextEntry) {
400 return YES;
401 }
402
403 NSDictionary* autofill = configuration[kAutofillProperties];
404 UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]);
405
406 if ([contentType isEqualToString:UITextContentTypePassword] ||
407 [contentType isEqualToString:UITextContentTypeUsername]) {
408 return YES;
409 }
410
411 if ([contentType isEqualToString:UITextContentTypeNewPassword]) {
412 return YES;
413 }
414
415 return NO;
416}
417
418static FlutterAutofillType AutofillTypeOf(NSDictionary* configuration) {
419 for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
420 if (IsFieldPasswordRelated(field)) {
421 return kFlutterAutofillTypePassword;
422 }
423 }
424
425 if (IsFieldPasswordRelated(configuration)) {
426 return kFlutterAutofillTypePassword;
427 }
428
429 NSDictionary* autofill = configuration[kAutofillProperties];
430 UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]);
431 return !autofill || [contentType isEqualToString:@""] ? kFlutterAutofillTypeNone
432 : kFlutterAutofillTypeRegular;
433}
434
435static BOOL IsApproximatelyEqual(float x, float y, float delta) {
436 return fabsf(x - y) <= delta;
437}
438
439// This is a helper function for floating cursor selection logic to determine which text
440// position is closer to a point.
441// Checks whether point should be considered closer to selectionRect compared to
442// otherSelectionRect.
443//
444// If `useTrailingBoundaryOfSelectionRect` is not set, it uses the leading-center point
445// on selectionRect and otherSelectionRect to compare.
446// For left-to-right text, this means the left-center point, and for right-to-left text,
447// this means the right-center point.
448//
449// If useTrailingBoundaryOfSelectionRect is set, the trailing-center point on selectionRect
450// will be used instead of the leading-center point, while leading-center point is still used
451// for otherSelectionRect.
452//
453// This uses special (empirically determined using a 1st gen iPad pro, 9.7" model running
454// iOS 14.7.1) logic for determining the closer rect, rather than a simple distance calculation.
455// - First, the rect with closer y distance wins.
456// - Otherwise (same y distance):
457// - If the point is above bottom of the rect, the rect boundary with closer x distance wins.
458// - Otherwise (point is below bottom of the rect), the rect boundary with farthest x wins.
459// This is because when the point is below the bottom line of text, we want to select the
460// whole line of text, so we mark the farthest rect as closest.
462 CGRect selectionRect,
463 BOOL selectionRectIsRTL,
464 BOOL useTrailingBoundaryOfSelectionRect,
465 CGRect otherSelectionRect,
466 BOOL otherSelectionRectIsRTL,
467 CGFloat verticalPrecision) {
468 // The point is inside the selectionRect's corresponding half-rect area.
469 if (CGRectContainsPoint(
470 CGRectMake(
471 selectionRect.origin.x + ((useTrailingBoundaryOfSelectionRect ^ selectionRectIsRTL)
472 ? 0.5 * selectionRect.size.width
473 : 0),
474 selectionRect.origin.y, 0.5 * selectionRect.size.width, selectionRect.size.height),
475 point)) {
476 return YES;
477 }
478 // pointForSelectionRect is either leading-center or trailing-center point of selectionRect.
479 CGPoint pointForSelectionRect = CGPointMake(
480 selectionRect.origin.x +
481 (selectionRectIsRTL ^ useTrailingBoundaryOfSelectionRect ? selectionRect.size.width : 0),
482 selectionRect.origin.y + selectionRect.size.height * 0.5);
483 float yDist = fabs(pointForSelectionRect.y - point.y);
484 float xDist = fabs(pointForSelectionRect.x - point.x);
485
486 // pointForOtherSelectionRect is the leading-center point of otherSelectionRect.
487 CGPoint pointForOtherSelectionRect = CGPointMake(
488 otherSelectionRect.origin.x + (otherSelectionRectIsRTL ? otherSelectionRect.size.width : 0),
489 otherSelectionRect.origin.y + otherSelectionRect.size.height * 0.5);
490 float yDistOther = fabs(pointForOtherSelectionRect.y - point.y);
491 float xDistOther = fabs(pointForOtherSelectionRect.x - point.x);
492
493 // This serves a similar purpose to IsApproximatelyEqual, allowing a little buffer before
494 // declaring something closer vertically to account for the small variations in size and position
495 // of SelectionRects, especially when dealing with emoji.
496 BOOL isCloserVertically = yDist < yDistOther - verticalPrecision;
497 BOOL isEqualVertically = IsApproximatelyEqual(yDist, yDistOther, verticalPrecision);
498 BOOL isAboveBottomOfLine = point.y <= selectionRect.origin.y + selectionRect.size.height;
499 BOOL isCloserHorizontally = xDist < xDistOther;
500 BOOL isBelowBottomOfLine = point.y > selectionRect.origin.y + selectionRect.size.height;
501 // Is "farther away", or is closer to the end of the text line.
502 BOOL isFarther;
503 if (selectionRectIsRTL) {
504 isFarther = selectionRect.origin.x < otherSelectionRect.origin.x;
505 } else {
506 isFarther = selectionRect.origin.x +
507 (useTrailingBoundaryOfSelectionRect ? selectionRect.size.width : 0) >
508 otherSelectionRect.origin.x;
509 }
510 return (isCloserVertically ||
511 (isEqualVertically &&
512 ((isAboveBottomOfLine && isCloserHorizontally) || (isBelowBottomOfLine && isFarther))));
513}
514
515#pragma mark - FlutterTextPosition
516
517@implementation FlutterTextPosition
518
519+ (instancetype)positionWithIndex:(NSUInteger)index {
520 return [[FlutterTextPosition alloc] initWithIndex:index affinity:UITextStorageDirectionForward];
521}
522
523+ (instancetype)positionWithIndex:(NSUInteger)index affinity:(UITextStorageDirection)affinity {
524 return [[FlutterTextPosition alloc] initWithIndex:index affinity:affinity];
525}
526
527- (instancetype)initWithIndex:(NSUInteger)index affinity:(UITextStorageDirection)affinity {
528 self = [super init];
529 if (self) {
530 _index = index;
531 _affinity = affinity;
532 }
533 return self;
534}
535
536@end
537
538#pragma mark - FlutterTextRange
539
540@implementation FlutterTextRange
541
542+ (instancetype)rangeWithNSRange:(NSRange)range {
543 return [[FlutterTextRange alloc] initWithNSRange:range];
544}
545
546- (instancetype)initWithNSRange:(NSRange)range {
547 self = [super init];
548 if (self) {
549 _range = range;
550 }
551 return self;
552}
553
554- (UITextPosition*)start {
555 return [FlutterTextPosition positionWithIndex:self.range.location
556 affinity:UITextStorageDirectionForward];
557}
558
559- (UITextPosition*)end {
560 return [FlutterTextPosition positionWithIndex:self.range.location + self.range.length
561 affinity:UITextStorageDirectionBackward];
562}
563
564- (BOOL)isEmpty {
565 return self.range.length == 0;
566}
567
568- (id)copyWithZone:(NSZone*)zone {
569 return [[FlutterTextRange allocWithZone:zone] initWithNSRange:self.range];
570}
571
572- (BOOL)isEqualTo:(FlutterTextRange*)other {
573 return NSEqualRanges(self.range, other.range);
574}
575@end
576
577#pragma mark - FlutterTokenizer
578
579@interface FlutterTokenizer ()
580
581@property(nonatomic, weak) FlutterTextInputView* textInputView;
582
583@end
584
585@implementation FlutterTokenizer
586
587- (instancetype)initWithTextInput:(UIResponder<UITextInput>*)textInput {
588 NSAssert([textInput isKindOfClass:[FlutterTextInputView class]],
589 @"The FlutterTokenizer can only be used in a FlutterTextInputView");
590 self = [super initWithTextInput:textInput];
591 if (self) {
592 _textInputView = (FlutterTextInputView*)textInput;
593 }
594 return self;
595}
596
597- (UITextRange*)rangeEnclosingPosition:(UITextPosition*)position
598 withGranularity:(UITextGranularity)granularity
599 inDirection:(UITextDirection)direction {
600 UITextRange* result;
601 switch (granularity) {
602 case UITextGranularityLine:
603 // The default UITextInputStringTokenizer does not handle line granularity
604 // correctly. We need to implement our own line tokenizer.
605 result = [self lineEnclosingPosition:position inDirection:direction];
606 break;
607 case UITextGranularityCharacter:
608 case UITextGranularityWord:
609 case UITextGranularitySentence:
610 case UITextGranularityParagraph:
611 case UITextGranularityDocument:
612 // The UITextInputStringTokenizer can handle all these cases correctly.
613 result = [super rangeEnclosingPosition:position
614 withGranularity:granularity
615 inDirection:direction];
616 break;
617 }
618 return result;
619}
620
621- (UITextRange*)lineEnclosingPosition:(UITextPosition*)position
622 inDirection:(UITextDirection)direction {
623 // TODO(hellohuanlin): remove iOS 17 check. The same logic should apply to older iOS version.
624 if (@available(iOS 17.0, *)) {
625 // According to the API doc if the text position is at a text-unit boundary, it is considered
626 // enclosed only if the next position in the given direction is entirely enclosed. Link:
627 // https://developer.apple.com/documentation/uikit/uitextinputtokenizer/1614464-rangeenclosingposition?language=objc
628 FlutterTextPosition* flutterPosition = (FlutterTextPosition*)position;
629 if (flutterPosition.index > _textInputView.text.length ||
630 (flutterPosition.index == _textInputView.text.length &&
631 direction == UITextStorageDirectionForward)) {
632 return nil;
633 }
634 }
635
636 // Gets the first line break position after the input position.
637 NSString* textAfter = [_textInputView
638 textInRange:[_textInputView textRangeFromPosition:position
639 toPosition:[_textInputView endOfDocument]]];
640 NSArray<NSString*>* linesAfter = [textAfter componentsSeparatedByString:@"\n"];
641 NSInteger offSetToLineBreak = [linesAfter firstObject].length;
642 UITextPosition* lineBreakAfter = [_textInputView positionFromPosition:position
643 offset:offSetToLineBreak];
644 // Gets the first line break position before the input position.
645 NSString* textBefore = [_textInputView
646 textInRange:[_textInputView textRangeFromPosition:[_textInputView beginningOfDocument]
647 toPosition:position]];
648 NSArray<NSString*>* linesBefore = [textBefore componentsSeparatedByString:@"\n"];
649 NSInteger offSetFromLineBreak = [linesBefore lastObject].length;
650 UITextPosition* lineBreakBefore = [_textInputView positionFromPosition:position
651 offset:-offSetFromLineBreak];
652
653 return [_textInputView textRangeFromPosition:lineBreakBefore toPosition:lineBreakAfter];
654}
655
656@end
657
658#pragma mark - FlutterTextSelectionRect
659
660@implementation FlutterTextSelectionRect
661
662@synthesize rect = _rect;
663@synthesize writingDirection = _writingDirection;
664@synthesize containsStart = _containsStart;
665@synthesize containsEnd = _containsEnd;
666@synthesize isVertical = _isVertical;
667
668+ (instancetype)selectionRectWithRectAndInfo:(CGRect)rect
669 position:(NSUInteger)position
670 writingDirection:(NSWritingDirection)writingDirection
671 containsStart:(BOOL)containsStart
672 containsEnd:(BOOL)containsEnd
673 isVertical:(BOOL)isVertical {
674 return [[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect
675 position:position
676 writingDirection:writingDirection
677 containsStart:containsStart
678 containsEnd:containsEnd
679 isVertical:isVertical];
680}
681
682+ (instancetype)selectionRectWithRect:(CGRect)rect position:(NSUInteger)position {
683 return [[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect
684 position:position
685 writingDirection:NSWritingDirectionNatural
686 containsStart:NO
687 containsEnd:NO
688 isVertical:NO];
689}
690
691+ (instancetype)selectionRectWithRect:(CGRect)rect
692 position:(NSUInteger)position
693 writingDirection:(NSWritingDirection)writingDirection {
694 return [[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect
695 position:position
696 writingDirection:writingDirection
697 containsStart:NO
698 containsEnd:NO
699 isVertical:NO];
700}
701
702- (instancetype)initWithRectAndInfo:(CGRect)rect
703 position:(NSUInteger)position
704 writingDirection:(NSWritingDirection)writingDirection
705 containsStart:(BOOL)containsStart
706 containsEnd:(BOOL)containsEnd
707 isVertical:(BOOL)isVertical {
708 self = [super init];
709 if (self) {
710 self.rect = rect;
711 self.position = position;
712 self.writingDirection = writingDirection;
713 self.containsStart = containsStart;
714 self.containsEnd = containsEnd;
715 self.isVertical = isVertical;
716 }
717 return self;
718}
719
720- (BOOL)isRTL {
721 return _writingDirection == NSWritingDirectionRightToLeft;
722}
723
724@end
725
726#pragma mark - FlutterTextPlaceholder
727
729
730- (NSArray<UITextSelectionRect*>*)rects {
731 // Returning anything other than an empty array here seems to cause PencilKit to enter an
732 // infinite loop of allocating placeholders until the app crashes
733 return @[];
734}
735
736@end
737
738// A FlutterTextInputView that masquerades as a UITextField, and forwards
739// selectors it can't respond to a shared UITextField instance.
740//
741// Relevant API docs claim that password autofill supports any custom view
742// that adopts the UITextInput protocol, automatic strong password seems to
743// currently only support UITextFields, and password saving only supports
744// UITextFields and UITextViews, as of iOS 13.5.
746@property(nonatomic, retain, readonly) UITextField* textField;
747@end
748
749@implementation FlutterSecureTextInputView {
750 UITextField* _textField;
751}
752
753- (UITextField*)textField {
754 if (!_textField) {
755 _textField = [[UITextField alloc] init];
756 }
757 return _textField;
758}
759
760- (BOOL)isKindOfClass:(Class)aClass {
761 return [super isKindOfClass:aClass] || (aClass == [UITextField class]);
762}
763
764- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector {
765 NSMethodSignature* signature = [super methodSignatureForSelector:aSelector];
766 if (!signature) {
767 signature = [self.textField methodSignatureForSelector:aSelector];
768 }
769 return signature;
770}
771
772- (void)forwardInvocation:(NSInvocation*)anInvocation {
773 [anInvocation invokeWithTarget:self.textField];
774}
775
776@end
777
778@interface FlutterTextInputPlugin ()
779@property(nonatomic, readonly, weak) id<FlutterTextInputDelegate> textInputDelegate;
780@property(nonatomic, readonly) UIView* hostView;
781@end
782
783@interface FlutterTextInputView ()
784@property(nonatomic, readonly, weak) FlutterTextInputPlugin* textInputPlugin;
785@property(nonatomic, copy) NSString* autofillId;
786@property(nonatomic, readonly) CATransform3D editableTransform;
787@property(nonatomic, assign) CGRect markedRect;
788// Disables the cursor from dismissing when firstResponder is resigned
789@property(nonatomic, assign) BOOL preventCursorDismissWhenResignFirstResponder;
790@property(nonatomic) BOOL isVisibleToAutofill;
791@property(nonatomic, assign) BOOL accessibilityEnabled;
792@property(nonatomic, assign) int textInputClient;
793// The composed character that is temporarily removed by the keyboard API.
794// This is cleared at the start of each keyboard interaction. (Enter a character, delete a character
795// etc)
796@property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter;
797@property(nonatomic, assign) CGRect editMenuTargetRect;
798
799- (void)setEditableTransform:(NSArray*)matrix;
800@end
801
802@implementation FlutterTextInputView {
803 int _textInputClient;
806 UIInputViewController* _inputViewController;
808 FlutterScribbleInteractionStatus _scribbleInteractionStatus;
810 // Whether to show the system keyboard when this view
811 // becomes the first responder. Typically set to false
812 // when the app shows its own in-flutter keyboard.
817 UITextInteraction* _textInteraction API_AVAILABLE(ios(13.0));
818}
819
820@synthesize tokenizer = _tokenizer;
821
822- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin {
823 self = [super initWithFrame:CGRectZero];
824 if (self) {
826 _textInputClient = 0;
828 _preventCursorDismissWhenResignFirstResponder = NO;
829
830 // UITextInput
831 _text = [[NSMutableString alloc] init];
832 _selectedTextRange = [[FlutterTextRange alloc] initWithNSRange:NSMakeRange(0, 0)];
833 _markedRect = kInvalidFirstRect;
835 _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone;
836 _pendingDeltas = [[NSMutableArray alloc] init];
837 // Initialize with the zero matrix which is not
838 // an affine transform.
839 _editableTransform = CATransform3D();
840
841 // UITextInputTraits
842 _autocapitalizationType = UITextAutocapitalizationTypeSentences;
843 _autocorrectionType = UITextAutocorrectionTypeDefault;
844 _spellCheckingType = UITextSpellCheckingTypeDefault;
845 _enablesReturnKeyAutomatically = NO;
846 _keyboardAppearance = UIKeyboardAppearanceDefault;
847 _keyboardType = UIKeyboardTypeDefault;
848 _returnKeyType = UIReturnKeyDone;
849 _secureTextEntry = NO;
850 _enableDeltaModel = NO;
852 _accessibilityEnabled = NO;
853 _smartQuotesType = UITextSmartQuotesTypeYes;
854 _smartDashesType = UITextSmartDashesTypeYes;
855 _selectionRects = [[NSArray alloc] init];
856
857 if (@available(iOS 14.0, *)) {
858 UIScribbleInteraction* interaction = [[UIScribbleInteraction alloc] initWithDelegate:self];
859 [self addInteraction:interaction];
860 }
861 }
862
863 if (@available(iOS 16.0, *)) {
864 _editMenuInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self];
865 [self addInteraction:_editMenuInteraction];
866 }
867
868 return self;
869}
870
871- (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction
872 menuForConfiguration:(UIEditMenuConfiguration*)configuration
873 suggestedActions:(NSArray<UIMenuElement*>*)suggestedActions API_AVAILABLE(ios(16.0)) {
874 return [UIMenu menuWithChildren:suggestedActions];
875}
876
877- (void)editMenuInteraction:(UIEditMenuInteraction*)interaction
878 willDismissMenuForConfiguration:(UIEditMenuConfiguration*)configuration
879 animator:(id<UIEditMenuInteractionAnimating>)animator
880 API_AVAILABLE(ios(16.0)) {
881 [self.textInputDelegate flutterTextInputView:self
882 willDismissEditMenuWithTextInputClient:_textInputClient];
883}
884
885- (CGRect)editMenuInteraction:(UIEditMenuInteraction*)interaction
886 targetRectForConfiguration:(UIEditMenuConfiguration*)configuration API_AVAILABLE(ios(16.0)) {
887 return _editMenuTargetRect;
888}
889
890- (void)showEditMenuWithTargetRect:(CGRect)targetRect API_AVAILABLE(ios(16.0)) {
891 _editMenuTargetRect = targetRect;
892 UIEditMenuConfiguration* config =
893 [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:CGPointZero];
894 [self.editMenuInteraction presentEditMenuWithConfiguration:config];
895}
896
897- (void)hideEditMenu API_AVAILABLE(ios(16.0)) {
898 [self.editMenuInteraction dismissMenu];
899}
900
901- (void)configureWithDictionary:(NSDictionary*)configuration {
902 NSDictionary* inputType = configuration[kKeyboardType];
903 NSString* keyboardAppearance = configuration[kKeyboardAppearance];
904 NSDictionary* autofill = configuration[kAutofillProperties];
905
906 self.secureTextEntry = [configuration[kSecureTextEntry] boolValue];
907 self.enableDeltaModel = [configuration[kEnableDeltaModel] boolValue];
908
910 self.keyboardType = ToUIKeyboardType(inputType);
911 self.returnKeyType = ToUIReturnKeyType(configuration[kInputAction]);
912 self.autocapitalizationType = ToUITextAutoCapitalizationType(configuration);
913 _enableInteractiveSelection = [configuration[kEnableInteractiveSelection] boolValue];
914 NSString* smartDashesType = configuration[kSmartDashesType];
915 // This index comes from the SmartDashesType enum in the framework.
916 bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"];
917 self.smartDashesType = smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes;
918 NSString* smartQuotesType = configuration[kSmartQuotesType];
919 // This index comes from the SmartQuotesType enum in the framework.
920 bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"];
921 self.smartQuotesType = smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes;
922 if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) {
923 self.keyboardAppearance = UIKeyboardAppearanceDark;
924 } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) {
925 self.keyboardAppearance = UIKeyboardAppearanceLight;
926 } else {
927 self.keyboardAppearance = UIKeyboardAppearanceDefault;
928 }
929 NSString* autocorrect = configuration[kAutocorrectionType];
930 bool autocorrectIsDisabled = autocorrect && ![autocorrect boolValue];
931 self.autocorrectionType =
932 autocorrectIsDisabled ? UITextAutocorrectionTypeNo : UITextAutocorrectionTypeDefault;
933 self.spellCheckingType =
934 autocorrectIsDisabled ? UITextSpellCheckingTypeNo : UITextSpellCheckingTypeDefault;
935 self.autofillId = AutofillIdFromDictionary(configuration);
936 if (autofill == nil) {
937 self.textContentType = @"";
938 } else {
939 self.textContentType = ToUITextContentType(autofill[kAutofillHints]);
940 [self setTextInputState:autofill[kAutofillEditingValue]];
941 NSAssert(_autofillId, @"The autofill configuration must contain an autofill id");
942 }
943 // The input field needs to be visible for the system autofill
944 // to find it.
945 self.isVisibleToAutofill = autofill || _secureTextEntry;
946}
947
948- (UITextContentType)textContentType {
949 return _textContentType;
950}
951
952// Prevent UIKit from showing selection handles or highlights. This is needed
953// because Scribble interactions require the view to have it's actual frame on
954// the screen. They're not needed on iOS 17 with the new
955// UITextSelectionDisplayInteraction API.
956//
957// These are undocumented methods. On iOS 17, the insertion point color is also
958// used as the highlighted background of the selected IME candidate:
959// https://github.com/flutter/flutter/issues/132548
960// So the respondsToSelector method is overridden to return NO for this method
961// on iOS 17+.
962- (UIColor*)insertionPointColor {
963 return [UIColor clearColor];
964}
965
966- (UIColor*)selectionBarColor {
967 return [UIColor clearColor];
968}
969
970- (UIColor*)selectionHighlightColor {
971 return [UIColor clearColor];
972}
973
974- (UIInputViewController*)inputViewController {
976 return nil;
977 }
978
980 _inputViewController = [[UIInputViewController alloc] init];
981 }
983}
984
985- (id<FlutterTextInputDelegate>)textInputDelegate {
986 return _textInputPlugin.textInputDelegate;
987}
988
989- (BOOL)respondsToSelector:(SEL)selector {
990 if (@available(iOS 17.0, *)) {
991 // See the comment on this method.
992 if (selector == @selector(insertionPointColor)) {
993 return NO;
994 }
995 }
996 return [super respondsToSelector:selector];
997}
998
999- (void)setTextInputClient:(int)client {
1000 _textInputClient = client;
1001 _hasPlaceholder = NO;
1002}
1003
1004- (UITextInteraction*)textInteraction API_AVAILABLE(ios(13.0)) {
1005 if (!_textInteraction) {
1006 _textInteraction = [UITextInteraction textInteractionForMode:UITextInteractionModeEditable];
1007 _textInteraction.textInput = self;
1008 }
1009 return _textInteraction;
1010}
1011
1012- (void)setTextInputState:(NSDictionary*)state {
1013 if (@available(iOS 13.0, *)) {
1014 // [UITextInteraction willMoveToView:] sometimes sets the textInput's inputDelegate
1015 // to nil. This is likely a bug in UIKit. In order to inform the keyboard of text
1016 // and selection changes when that happens, add a dummy UITextInteraction to this
1017 // view so it sets a valid inputDelegate that we can call textWillChange et al. on.
1018 // See https://github.com/flutter/engine/pull/32881.
1019 if (!self.inputDelegate && self.isFirstResponder) {
1020 [self addInteraction:self.textInteraction];
1021 }
1022 }
1023
1024 NSString* newText = state[@"text"];
1025 BOOL textChanged = ![self.text isEqualToString:newText];
1026 if (textChanged) {
1027 [self.inputDelegate textWillChange:self];
1028 [self.text setString:newText];
1029 }
1030 NSInteger composingBase = [state[@"composingBase"] intValue];
1031 NSInteger composingExtent = [state[@"composingExtent"] intValue];
1032 NSRange composingRange = [self clampSelection:NSMakeRange(MIN(composingBase, composingExtent),
1033 ABS(composingBase - composingExtent))
1034 forText:self.text];
1035
1036 self.markedTextRange =
1037 composingRange.length > 0 ? [FlutterTextRange rangeWithNSRange:composingRange] : nil;
1038
1039 NSRange selectedRange = [self clampSelectionFromBase:[state[@"selectionBase"] intValue]
1040 extent:[state[@"selectionExtent"] intValue]
1041 forText:self.text];
1042
1043 NSRange oldSelectedRange = [(FlutterTextRange*)self.selectedTextRange range];
1044 if (!NSEqualRanges(selectedRange, oldSelectedRange)) {
1045 [self.inputDelegate selectionWillChange:self];
1046
1047 [self setSelectedTextRangeLocal:[FlutterTextRange rangeWithNSRange:selectedRange]];
1048
1050 if ([state[@"selectionAffinity"] isEqualToString:@(kTextAffinityUpstream)]) {
1052 }
1053 [self.inputDelegate selectionDidChange:self];
1054 }
1055
1056 if (textChanged) {
1057 [self.inputDelegate textDidChange:self];
1058 }
1059
1060 if (@available(iOS 13.0, *)) {
1061 if (_textInteraction) {
1062 [self removeInteraction:_textInteraction];
1063 }
1064 }
1065}
1066
1067// Forward touches to the viewResponder to allow tapping inside the UITextField as normal.
1068- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event {
1069 _scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
1070 [self resetScribbleInteractionStatusIfEnding];
1071 [self.viewResponder touchesBegan:touches withEvent:event];
1072}
1073
1074- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event {
1075 [self.viewResponder touchesMoved:touches withEvent:event];
1076}
1077
1078- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event {
1079 [self.viewResponder touchesEnded:touches withEvent:event];
1080}
1081
1082- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event {
1083 [self.viewResponder touchesCancelled:touches withEvent:event];
1084}
1085
1086- (void)touchesEstimatedPropertiesUpdated:(NSSet*)touches {
1087 [self.viewResponder touchesEstimatedPropertiesUpdated:touches];
1088}
1089
1090// Extracts the selection information from the editing state dictionary.
1091//
1092// The state may contain an invalid selection, such as when no selection was
1093// explicitly set in the framework. This is handled here by setting the
1094// selection to (0,0). In contrast, Android handles this situation by
1095// clearing the selection, but the result in both cases is that the cursor
1096// is placed at the beginning of the field.
1097- (NSRange)clampSelectionFromBase:(int)selectionBase
1098 extent:(int)selectionExtent
1099 forText:(NSString*)text {
1100 int loc = MIN(selectionBase, selectionExtent);
1101 int len = ABS(selectionExtent - selectionBase);
1102 return loc < 0 ? NSMakeRange(0, 0)
1103 : [self clampSelection:NSMakeRange(loc, len) forText:self.text];
1104}
1105
1106- (NSRange)clampSelection:(NSRange)range forText:(NSString*)text {
1107 NSUInteger start = MIN(MAX(range.location, 0), text.length);
1108 NSUInteger length = MIN(range.length, text.length - start);
1109 return NSMakeRange(start, length);
1110}
1111
1112- (BOOL)isVisibleToAutofill {
1113 return self.frame.size.width > 0 && self.frame.size.height > 0;
1114}
1115
1116// An input view is generally ignored by password autofill attempts, if it's
1117// not the first responder and is zero-sized. For input fields that are in the
1118// autofill context but do not belong to the current autofill group, setting
1119// their frames to CGRectZero prevents ios autofill from taking them into
1120// account.
1121- (void)setIsVisibleToAutofill:(BOOL)isVisibleToAutofill {
1122 // This probably needs to change (think it is getting overwritten by the updateSizeAndTransform
1123 // stuff for now).
1124 self.frame = isVisibleToAutofill ? CGRectMake(0, 0, 1, 1) : CGRectZero;
1125}
1126
1127#pragma mark UIScribbleInteractionDelegate
1128
1129// Checks whether Scribble features are possibly available – meaning this is an iPad running iOS
1130// 14 or higher.
1132 if (@available(iOS 14.0, *)) {
1133 if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
1134 return YES;
1135 }
1136 }
1137 return NO;
1138}
1139
1140- (void)scribbleInteractionWillBeginWriting:(UIScribbleInteraction*)interaction
1141 API_AVAILABLE(ios(14.0)) {
1142 _scribbleInteractionStatus = FlutterScribbleInteractionStatusStarted;
1143 [self.textInputDelegate flutterTextInputViewScribbleInteractionBegan:self];
1144}
1145
1146- (void)scribbleInteractionDidFinishWriting:(UIScribbleInteraction*)interaction
1147 API_AVAILABLE(ios(14.0)) {
1148 _scribbleInteractionStatus = FlutterScribbleInteractionStatusEnding;
1149 [self.textInputDelegate flutterTextInputViewScribbleInteractionFinished:self];
1150}
1151
1152- (BOOL)scribbleInteraction:(UIScribbleInteraction*)interaction
1153 shouldBeginAtLocation:(CGPoint)location API_AVAILABLE(ios(14.0)) {
1154 return YES;
1155}
1156
1157- (BOOL)scribbleInteractionShouldDelayFocus:(UIScribbleInteraction*)interaction
1158 API_AVAILABLE(ios(14.0)) {
1159 return NO;
1160}
1161
1162#pragma mark - UIResponder Overrides
1163
1164- (BOOL)canBecomeFirstResponder {
1165 // Only the currently focused input field can
1166 // become the first responder. This prevents iOS
1167 // from changing focus by itself (the framework
1168 // focus will be out of sync if that happens).
1169 return _textInputClient != 0;
1170}
1171
1172- (BOOL)resignFirstResponder {
1173 BOOL success = [super resignFirstResponder];
1174 if (success) {
1175 if (!_preventCursorDismissWhenResignFirstResponder) {
1176 [self.textInputDelegate flutterTextInputView:self
1177 didResignFirstResponderWithTextInputClient:_textInputClient];
1178 }
1179 }
1180 return success;
1181}
1182
1183- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
1184 if (action == @selector(paste:)) {
1185 // Forbid pasting images, memojis, or other non-string content.
1186 return [UIPasteboard generalPasteboard].hasStrings;
1187 } else if (action == @selector(copy:) || action == @selector(cut:) ||
1188 action == @selector(delete:)) {
1189 return [self textInRange:_selectedTextRange].length > 0;
1190 }
1191 return [super canPerformAction:action withSender:sender];
1192}
1193
1194#pragma mark - UIResponderStandardEditActions Overrides
1195
1196- (void)cut:(id)sender {
1197 [UIPasteboard generalPasteboard].string = [self textInRange:_selectedTextRange];
1198 [self replaceRange:_selectedTextRange withText:@""];
1199}
1200
1201- (void)copy:(id)sender {
1202 [UIPasteboard generalPasteboard].string = [self textInRange:_selectedTextRange];
1203}
1204
1205- (void)paste:(id)sender {
1206 NSString* pasteboardString = [UIPasteboard generalPasteboard].string;
1207 if (pasteboardString != nil) {
1208 [self insertText:pasteboardString];
1209 }
1210}
1211
1212- (void)delete:(id)sender {
1213 [self replaceRange:_selectedTextRange withText:@""];
1214}
1215
1216- (void)selectAll:(id)sender {
1217 [self setSelectedTextRange:[self textRangeFromPosition:[self beginningOfDocument]
1218 toPosition:[self endOfDocument]]];
1219}
1220
1221#pragma mark - UITextInput Overrides
1222
1223- (id<UITextInputTokenizer>)tokenizer {
1224 if (_tokenizer == nil) {
1225 _tokenizer = [[FlutterTokenizer alloc] initWithTextInput:self];
1226 }
1227 return _tokenizer;
1228}
1229
1230- (UITextRange*)selectedTextRange {
1231 return [_selectedTextRange copy];
1232}
1233
1234// Change the range of selected text, without notifying the framework.
1235- (void)setSelectedTextRangeLocal:(UITextRange*)selectedTextRange {
1237 if (self.hasText) {
1240 rangeWithNSRange:fml::RangeForCharactersInRange(self.text, flutterTextRange.range)] copy];
1241 } else {
1242 _selectedTextRange = [selectedTextRange copy];
1243 }
1244 }
1245}
1246
1247- (void)setSelectedTextRange:(UITextRange*)selectedTextRange {
1249 return;
1250 }
1251
1252 [self setSelectedTextRangeLocal:selectedTextRange];
1253
1254 if (_enableDeltaModel) {
1255 [self updateEditingStateWithDelta:flutter::TextEditingDelta([self.text UTF8String])];
1256 } else {
1257 [self updateEditingState];
1258 }
1259
1260 if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone ||
1261 _scribbleFocusStatus == FlutterScribbleFocusStatusFocused) {
1262 NSAssert([selectedTextRange isKindOfClass:[FlutterTextRange class]],
1263 @"Expected a FlutterTextRange for range (got %@).", [selectedTextRange class]);
1265 if (flutterTextRange.range.length > 0) {
1266 [self.textInputDelegate flutterTextInputView:self showToolbar:_textInputClient];
1267 }
1268 }
1269
1270 [self resetScribbleInteractionStatusIfEnding];
1271}
1272
1273- (id)insertDictationResultPlaceholder {
1274 return @"";
1275}
1276
1277- (void)removeDictationResultPlaceholder:(id)placeholder willInsertResult:(BOOL)willInsertResult {
1278}
1279
1280- (NSString*)textInRange:(UITextRange*)range {
1281 if (!range) {
1282 return nil;
1283 }
1284 NSAssert([range isKindOfClass:[FlutterTextRange class]],
1285 @"Expected a FlutterTextRange for range (got %@).", [range class]);
1286 NSRange textRange = ((FlutterTextRange*)range).range;
1287 NSAssert(textRange.location != NSNotFound, @"Expected a valid text range.");
1288 // Sanitize the range to prevent going out of bounds.
1289 NSUInteger location = MIN(textRange.location, self.text.length);
1290 NSUInteger length = MIN(self.text.length - location, textRange.length);
1291 NSRange safeRange = NSMakeRange(location, length);
1292 return [self.text substringWithRange:safeRange];
1293}
1294
1295// Replace the text within the specified range with the given text,
1296// without notifying the framework.
1297- (void)replaceRangeLocal:(NSRange)range withText:(NSString*)text {
1298 [self.text replaceCharactersInRange:[self clampSelection:range forText:self.text]
1299 withString:text];
1300
1301 // Adjust the selected range and the marked text range. There's no
1302 // documentation but UITextField always sets markedTextRange to nil,
1303 // and collapses the selection to the end of the new replacement text.
1304 const NSRange newSelectionRange =
1305 [self clampSelection:NSMakeRange(range.location + text.length, 0) forText:self.text];
1306
1307 [self setSelectedTextRangeLocal:[FlutterTextRange rangeWithNSRange:newSelectionRange]];
1308 self.markedTextRange = nil;
1309}
1310
1311- (void)replaceRange:(UITextRange*)range withText:(NSString*)text {
1312 NSString* textBeforeChange = [self.text copy];
1313 NSRange replaceRange = ((FlutterTextRange*)range).range;
1314 [self replaceRangeLocal:replaceRange withText:text];
1315 if (_enableDeltaModel) {
1316 NSRange nextReplaceRange = [self clampSelection:replaceRange forText:textBeforeChange];
1317 [self updateEditingStateWithDelta:flutter::TextEditingDelta(
1318 [textBeforeChange UTF8String],
1319 flutter::TextRange(
1320 nextReplaceRange.location,
1321 nextReplaceRange.location + nextReplaceRange.length),
1322 [text UTF8String])];
1323 } else {
1324 [self updateEditingState];
1325 }
1326}
1327
1328- (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)text {
1329 // `temporarilyDeletedComposedCharacter` should only be used during a single text change session.
1330 // So it needs to be cleared at the start of each text editing session.
1331 self.temporarilyDeletedComposedCharacter = nil;
1332
1333 if (self.returnKeyType == UIReturnKeyDefault && [text isEqualToString:@"\n"]) {
1334 [self.textInputDelegate flutterTextInputView:self
1335 performAction:FlutterTextInputActionNewline
1336 withClient:_textInputClient];
1337 return YES;
1338 }
1339
1340 if ([text isEqualToString:@"\n"]) {
1341 FlutterTextInputAction action;
1342 switch (self.returnKeyType) {
1343 case UIReturnKeyDefault:
1344 action = FlutterTextInputActionUnspecified;
1345 break;
1346 case UIReturnKeyDone:
1347 action = FlutterTextInputActionDone;
1348 break;
1349 case UIReturnKeyGo:
1350 action = FlutterTextInputActionGo;
1351 break;
1352 case UIReturnKeySend:
1353 action = FlutterTextInputActionSend;
1354 break;
1355 case UIReturnKeySearch:
1356 case UIReturnKeyGoogle:
1357 case UIReturnKeyYahoo:
1358 action = FlutterTextInputActionSearch;
1359 break;
1360 case UIReturnKeyNext:
1361 action = FlutterTextInputActionNext;
1362 break;
1363 case UIReturnKeyContinue:
1364 action = FlutterTextInputActionContinue;
1365 break;
1366 case UIReturnKeyJoin:
1367 action = FlutterTextInputActionJoin;
1368 break;
1369 case UIReturnKeyRoute:
1370 action = FlutterTextInputActionRoute;
1371 break;
1372 case UIReturnKeyEmergencyCall:
1373 action = FlutterTextInputActionEmergencyCall;
1374 break;
1375 }
1376
1377 [self.textInputDelegate flutterTextInputView:self
1378 performAction:action
1379 withClient:_textInputClient];
1380 return NO;
1381 }
1382
1383 return YES;
1384}
1385
1386// Either replaces the existing marked text or, if none is present, inserts it in
1387// place of the current selection.
1388- (void)setMarkedText:(NSString*)markedText selectedRange:(NSRange)markedSelectedRange {
1389 NSString* textBeforeChange = [self.text copy];
1390
1391 if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone ||
1392 _scribbleFocusStatus != FlutterScribbleFocusStatusUnfocused) {
1393 return;
1394 }
1395
1396 if (markedText == nil) {
1397 markedText = @"";
1398 }
1399
1400 const FlutterTextRange* currentMarkedTextRange = (FlutterTextRange*)self.markedTextRange;
1401 const NSRange& actualReplacedRange = currentMarkedTextRange && !currentMarkedTextRange.isEmpty
1402 ? currentMarkedTextRange.range
1404 // No need to call replaceRangeLocal as this method always adjusts the
1405 // selected/marked text ranges anyways.
1406 [self.text replaceCharactersInRange:actualReplacedRange withString:markedText];
1407
1408 const NSRange newMarkedRange = NSMakeRange(actualReplacedRange.location, markedText.length);
1409 self.markedTextRange =
1410 newMarkedRange.length > 0 ? [FlutterTextRange rangeWithNSRange:newMarkedRange] : nil;
1411
1412 [self setSelectedTextRangeLocal:
1414 rangeWithNSRange:[self clampSelection:NSMakeRange(markedSelectedRange.location +
1415 newMarkedRange.location,
1416 markedSelectedRange.length)
1417 forText:self.text]]];
1418 if (_enableDeltaModel) {
1419 NSRange nextReplaceRange = [self clampSelection:actualReplacedRange forText:textBeforeChange];
1420 [self updateEditingStateWithDelta:flutter::TextEditingDelta(
1421 [textBeforeChange UTF8String],
1422 flutter::TextRange(
1423 nextReplaceRange.location,
1424 nextReplaceRange.location + nextReplaceRange.length),
1425 [markedText UTF8String])];
1426 } else {
1427 [self updateEditingState];
1428 }
1429}
1430
1431- (void)unmarkText {
1432 if (!self.markedTextRange) {
1433 return;
1434 }
1435 self.markedTextRange = nil;
1436 if (_enableDeltaModel) {
1437 [self updateEditingStateWithDelta:flutter::TextEditingDelta([self.text UTF8String])];
1438 } else {
1439 [self updateEditingState];
1440 }
1441}
1442
1443- (UITextRange*)textRangeFromPosition:(UITextPosition*)fromPosition
1444 toPosition:(UITextPosition*)toPosition {
1445 NSUInteger fromIndex = ((FlutterTextPosition*)fromPosition).index;
1446 NSUInteger toIndex = ((FlutterTextPosition*)toPosition).index;
1447 if (toIndex >= fromIndex) {
1448 return [FlutterTextRange rangeWithNSRange:NSMakeRange(fromIndex, toIndex - fromIndex)];
1449 } else {
1450 // toIndex can be smaller than fromIndex, because
1451 // UITextInputStringTokenizer does not handle CJK characters
1452 // well in some cases. See:
1453 // https://github.com/flutter/flutter/issues/58750#issuecomment-644469521
1454 // Swap fromPosition and toPosition to match the behavior of native
1455 // UITextViews.
1456 return [FlutterTextRange rangeWithNSRange:NSMakeRange(toIndex, fromIndex - toIndex)];
1457 }
1458}
1459
1460- (NSUInteger)decrementOffsetPosition:(NSUInteger)position {
1461 return fml::RangeForCharacterAtIndex(self.text, MAX(0, position - 1)).location;
1462}
1463
1464- (NSUInteger)incrementOffsetPosition:(NSUInteger)position {
1465 NSRange charRange = fml::RangeForCharacterAtIndex(self.text, position);
1466 return MIN(position + charRange.length, self.text.length);
1467}
1468
1469- (UITextPosition*)positionFromPosition:(UITextPosition*)position offset:(NSInteger)offset {
1470 NSUInteger offsetPosition = ((FlutterTextPosition*)position).index;
1471
1472 NSInteger newLocation = (NSInteger)offsetPosition + offset;
1473 if (newLocation < 0 || newLocation > (NSInteger)self.text.length) {
1474 return nil;
1475 }
1476
1477 if (_scribbleInteractionStatus != FlutterScribbleInteractionStatusNone) {
1478 return [FlutterTextPosition positionWithIndex:newLocation];
1479 }
1480
1481 if (offset >= 0) {
1482 for (NSInteger i = 0; i < offset && offsetPosition < self.text.length; ++i) {
1483 offsetPosition = [self incrementOffsetPosition:offsetPosition];
1484 }
1485 } else {
1486 for (NSInteger i = 0; i < ABS(offset) && offsetPosition > 0; ++i) {
1487 offsetPosition = [self decrementOffsetPosition:offsetPosition];
1488 }
1489 }
1490 return [FlutterTextPosition positionWithIndex:offsetPosition];
1491}
1492
1493- (UITextPosition*)positionFromPosition:(UITextPosition*)position
1494 inDirection:(UITextLayoutDirection)direction
1495 offset:(NSInteger)offset {
1496 // TODO(cbracken) Add RTL handling.
1497 switch (direction) {
1498 case UITextLayoutDirectionLeft:
1499 case UITextLayoutDirectionUp:
1500 return [self positionFromPosition:position offset:offset * -1];
1501 case UITextLayoutDirectionRight:
1502 case UITextLayoutDirectionDown:
1503 return [self positionFromPosition:position offset:1];
1504 }
1505}
1506
1507- (UITextPosition*)beginningOfDocument {
1508 return [FlutterTextPosition positionWithIndex:0 affinity:UITextStorageDirectionForward];
1509}
1510
1511- (UITextPosition*)endOfDocument {
1512 return [FlutterTextPosition positionWithIndex:self.text.length
1513 affinity:UITextStorageDirectionBackward];
1514}
1515
1516- (NSComparisonResult)comparePosition:(UITextPosition*)position toPosition:(UITextPosition*)other {
1517 NSUInteger positionIndex = ((FlutterTextPosition*)position).index;
1518 NSUInteger otherIndex = ((FlutterTextPosition*)other).index;
1519 if (positionIndex < otherIndex) {
1520 return NSOrderedAscending;
1521 }
1522 if (positionIndex > otherIndex) {
1523 return NSOrderedDescending;
1524 }
1525 UITextStorageDirection positionAffinity = ((FlutterTextPosition*)position).affinity;
1526 UITextStorageDirection otherAffinity = ((FlutterTextPosition*)other).affinity;
1527 if (positionAffinity == otherAffinity) {
1528 return NSOrderedSame;
1529 }
1530 if (positionAffinity == UITextStorageDirectionBackward) {
1531 // positionAffinity points backwards, otherAffinity points forwards
1532 return NSOrderedAscending;
1533 }
1534 // positionAffinity points forwards, otherAffinity points backwards
1535 return NSOrderedDescending;
1536}
1537
1538- (NSInteger)offsetFromPosition:(UITextPosition*)from toPosition:(UITextPosition*)toPosition {
1539 return ((FlutterTextPosition*)toPosition).index - ((FlutterTextPosition*)from).index;
1540}
1541
1542- (UITextPosition*)positionWithinRange:(UITextRange*)range
1543 farthestInDirection:(UITextLayoutDirection)direction {
1544 NSUInteger index;
1545 UITextStorageDirection affinity;
1546 switch (direction) {
1547 case UITextLayoutDirectionLeft:
1548 case UITextLayoutDirectionUp:
1549 index = ((FlutterTextPosition*)range.start).index;
1550 affinity = UITextStorageDirectionForward;
1551 break;
1552 case UITextLayoutDirectionRight:
1553 case UITextLayoutDirectionDown:
1554 index = ((FlutterTextPosition*)range.end).index;
1555 affinity = UITextStorageDirectionBackward;
1556 break;
1557 }
1558 return [FlutterTextPosition positionWithIndex:index affinity:affinity];
1559}
1560
1561- (UITextRange*)characterRangeByExtendingPosition:(UITextPosition*)position
1562 inDirection:(UITextLayoutDirection)direction {
1563 NSUInteger positionIndex = ((FlutterTextPosition*)position).index;
1564 NSUInteger startIndex;
1565 NSUInteger endIndex;
1566 switch (direction) {
1567 case UITextLayoutDirectionLeft:
1568 case UITextLayoutDirectionUp:
1569 startIndex = [self decrementOffsetPosition:positionIndex];
1570 endIndex = positionIndex;
1571 break;
1572 case UITextLayoutDirectionRight:
1573 case UITextLayoutDirectionDown:
1574 startIndex = positionIndex;
1575 endIndex = [self incrementOffsetPosition:positionIndex];
1576 break;
1577 }
1578 return [FlutterTextRange rangeWithNSRange:NSMakeRange(startIndex, endIndex - startIndex)];
1579}
1580
1581#pragma mark - UITextInput text direction handling
1582
1583- (UITextWritingDirection)baseWritingDirectionForPosition:(UITextPosition*)position
1584 inDirection:(UITextStorageDirection)direction {
1585 // TODO(cbracken) Add RTL handling.
1586 return UITextWritingDirectionNatural;
1587}
1588
1589- (void)setBaseWritingDirection:(UITextWritingDirection)writingDirection
1590 forRange:(UITextRange*)range {
1591 // TODO(cbracken) Add RTL handling.
1592}
1593
1594#pragma mark - UITextInput cursor, selection rect handling
1595
1596- (void)setMarkedRect:(CGRect)markedRect {
1597 _markedRect = markedRect;
1598 // Invalidate the cache.
1600}
1601
1602// This method expects a 4x4 perspective matrix
1603// stored in a NSArray in column-major order.
1604- (void)setEditableTransform:(NSArray*)matrix {
1605 CATransform3D* transform = &_editableTransform;
1606
1607 transform->m11 = [matrix[0] doubleValue];
1608 transform->m12 = [matrix[1] doubleValue];
1609 transform->m13 = [matrix[2] doubleValue];
1610 transform->m14 = [matrix[3] doubleValue];
1611
1612 transform->m21 = [matrix[4] doubleValue];
1613 transform->m22 = [matrix[5] doubleValue];
1614 transform->m23 = [matrix[6] doubleValue];
1615 transform->m24 = [matrix[7] doubleValue];
1616
1617 transform->m31 = [matrix[8] doubleValue];
1618 transform->m32 = [matrix[9] doubleValue];
1619 transform->m33 = [matrix[10] doubleValue];
1620 transform->m34 = [matrix[11] doubleValue];
1621
1622 transform->m41 = [matrix[12] doubleValue];
1623 transform->m42 = [matrix[13] doubleValue];
1624 transform->m43 = [matrix[14] doubleValue];
1625 transform->m44 = [matrix[15] doubleValue];
1626
1627 // Invalidate the cache.
1629}
1630
1631// Returns the bounding CGRect of the transformed incomingRect, in the view's
1632// coordinates.
1633- (CGRect)localRectFromFrameworkTransform:(CGRect)incomingRect {
1634 CGPoint points[] = {
1635 incomingRect.origin,
1636 CGPointMake(incomingRect.origin.x, incomingRect.origin.y + incomingRect.size.height),
1637 CGPointMake(incomingRect.origin.x + incomingRect.size.width, incomingRect.origin.y),
1638 CGPointMake(incomingRect.origin.x + incomingRect.size.width,
1639 incomingRect.origin.y + incomingRect.size.height)};
1640
1641 CGPoint origin = CGPointMake(CGFLOAT_MAX, CGFLOAT_MAX);
1642 CGPoint farthest = CGPointMake(-CGFLOAT_MAX, -CGFLOAT_MAX);
1643
1644 for (int i = 0; i < 4; i++) {
1645 const CGPoint point = points[i];
1646
1647 CGFloat x = _editableTransform.m11 * point.x + _editableTransform.m21 * point.y +
1649 CGFloat y = _editableTransform.m12 * point.x + _editableTransform.m22 * point.y +
1651
1652 const CGFloat w = _editableTransform.m14 * point.x + _editableTransform.m24 * point.y +
1654
1655 if (w == 0.0) {
1656 return kInvalidFirstRect;
1657 } else if (w != 1.0) {
1658 x /= w;
1659 y /= w;
1660 }
1661
1662 origin.x = MIN(origin.x, x);
1663 origin.y = MIN(origin.y, y);
1664 farthest.x = MAX(farthest.x, x);
1665 farthest.y = MAX(farthest.y, y);
1666 }
1667 return CGRectMake(origin.x, origin.y, farthest.x - origin.x, farthest.y - origin.y);
1668}
1669
1670// The following methods are required to support force-touch cursor positioning
1671// and to position the
1672// candidates view for multi-stage input methods (e.g., Japanese) when using a
1673// physical keyboard.
1674// Returns the rect for the queried range, or a subrange through the end of line, if
1675// the range encompasses multiple lines.
1676- (CGRect)firstRectForRange:(UITextRange*)range {
1677 NSAssert([range.start isKindOfClass:[FlutterTextPosition class]],
1678 @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]);
1679 NSAssert([range.end isKindOfClass:[FlutterTextPosition class]],
1680 @"Expected a FlutterTextPosition for range.end (got %@).", [range.end class]);
1681 NSUInteger start = ((FlutterTextPosition*)range.start).index;
1682 NSUInteger end = ((FlutterTextPosition*)range.end).index;
1683 if (_markedTextRange != nil) {
1684 // The candidates view can't be shown if the framework has not sent the
1685 // first caret rect.
1686 if (CGRectEqualToRect(kInvalidFirstRect, _markedRect)) {
1687 return kInvalidFirstRect;
1688 }
1689
1690 if (CGRectEqualToRect(_cachedFirstRect, kInvalidFirstRect)) {
1691 // If the width returned is too small, that means the framework sent us
1692 // the caret rect instead of the marked text rect. Expand it to 0.2 so
1693 // the IME candidates view would show up.
1694 CGRect rect = _markedRect;
1695 if (CGRectIsEmpty(rect)) {
1696 rect = CGRectInset(rect, -0.1, 0);
1697 }
1698 _cachedFirstRect = [self localRectFromFrameworkTransform:rect];
1699 }
1700
1701 UIView* hostView = _textInputPlugin.hostView;
1702 NSAssert(hostView == nil || [self isDescendantOfView:hostView], @"%@ is not a descendant of %@",
1703 self, hostView);
1704 return hostView ? [hostView convertRect:_cachedFirstRect toView:self] : _cachedFirstRect;
1705 }
1706
1707 if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusNone &&
1708 _scribbleFocusStatus == FlutterScribbleFocusStatusUnfocused) {
1709 if (@available(iOS 17.0, *)) {
1710 // Disable auto-correction highlight feature for iOS 17+.
1711 // In iOS 17+, whenever a character is inserted or deleted, the system will always query
1712 // the rect for every single character of the current word.
1713 // GitHub Issue: https://github.com/flutter/flutter/issues/128406
1714 } else {
1715 // This tells the framework to show the highlight for incorrectly spelled word that is
1716 // about to be auto-corrected.
1717 // There is no other UITextInput API that informs about the auto-correction highlight.
1718 // So we simply add the call here as a workaround.
1719 [self.textInputDelegate flutterTextInputView:self
1720 showAutocorrectionPromptRectForStart:start
1721 end:end
1722 withClient:_textInputClient];
1723 }
1724 }
1725
1726 // The iOS 16 system highlight does not repect the height returned by `firstRectForRange`
1727 // API (unlike iOS 17). So we return CGRectZero to hide it (unless if scribble is enabled).
1728 // To support scribble's advanced gestures (e.g. insert a space with a vertical bar),
1729 // at least 1 character's width is required.
1730 if (@available(iOS 17, *)) {
1731 // No-op
1732 } else if (![self isScribbleAvailable]) {
1733 return CGRectZero;
1734 }
1735
1736 NSUInteger first = start;
1737 if (end < start) {
1738 first = end;
1739 }
1740
1741 CGRect startSelectionRect = CGRectNull;
1742 CGRect endSelectionRect = CGRectNull;
1743 // Selection rects from different langauges may have different minY/maxY.
1744 // So we need to iterate through each rects to update minY/maxY.
1745 CGFloat minY = CGFLOAT_MAX;
1746 CGFloat maxY = CGFLOAT_MIN;
1747
1749 rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))];
1750 for (NSUInteger i = 0; i < [_selectionRects count]; i++) {
1751 BOOL startsOnOrBeforeStartOfRange = _selectionRects[i].position <= first;
1752 BOOL isLastSelectionRect = i + 1 == [_selectionRects count];
1753 BOOL endOfTextIsAfterStartOfRange = isLastSelectionRect && textRange.range.length > first;
1754 BOOL nextSelectionRectIsAfterStartOfRange =
1755 !isLastSelectionRect && _selectionRects[i + 1].position > first;
1756 if (startsOnOrBeforeStartOfRange &&
1757 (endOfTextIsAfterStartOfRange || nextSelectionRectIsAfterStartOfRange)) {
1758 // TODO(hellohaunlin): Remove iOS 17 check. The logic should also work for older versions.
1759 if (@available(iOS 17, *)) {
1760 startSelectionRect = _selectionRects[i].rect;
1761 } else {
1762 return _selectionRects[i].rect;
1763 }
1764 }
1765 if (!CGRectIsNull(startSelectionRect)) {
1766 minY = fmin(minY, CGRectGetMinY(_selectionRects[i].rect));
1767 maxY = fmax(maxY, CGRectGetMaxY(_selectionRects[i].rect));
1768 BOOL endsOnOrAfterEndOfRange = _selectionRects[i].position >= end - 1; // end is exclusive
1769 BOOL nextSelectionRectIsOnNextLine =
1770 !isLastSelectionRect &&
1771 // Selection rects from different langauges in 2 lines may overlap with each other.
1772 // A good approximation is to check if the center of next rect is below the bottom of
1773 // current rect.
1774 // TODO(hellohuanlin): Consider passing the line break info from framework.
1775 CGRectGetMidY(_selectionRects[i + 1].rect) > CGRectGetMaxY(_selectionRects[i].rect);
1776 if (endsOnOrAfterEndOfRange || isLastSelectionRect || nextSelectionRectIsOnNextLine) {
1777 endSelectionRect = _selectionRects[i].rect;
1778 break;
1779 }
1780 }
1781 }
1782 if (CGRectIsNull(startSelectionRect) || CGRectIsNull(endSelectionRect)) {
1783 return CGRectZero;
1784 } else {
1785 // fmin/fmax to support both LTR and RTL languages.
1786 CGFloat minX = fmin(CGRectGetMinX(startSelectionRect), CGRectGetMinX(endSelectionRect));
1787 CGFloat maxX = fmax(CGRectGetMaxX(startSelectionRect), CGRectGetMaxX(endSelectionRect));
1788 return CGRectMake(minX, minY, maxX - minX, maxY - minY);
1789 }
1790}
1791
1792- (CGRect)caretRectForPosition:(UITextPosition*)position {
1793 NSInteger index = ((FlutterTextPosition*)position).index;
1794 UITextStorageDirection affinity = ((FlutterTextPosition*)position).affinity;
1795 // Get the selectionRect of the characters before and after the requested caret position.
1796 NSArray<UITextSelectionRect*>* rects = [self
1797 selectionRectsForRange:[FlutterTextRange
1798 rangeWithNSRange:fml::RangeForCharactersInRange(
1799 self.text,
1800 NSMakeRange(
1801 MAX(0, index - 1),
1802 (index >= (NSInteger)self.text.length)
1803 ? 1
1804 : 2))]];
1805 if (rects.count == 0) {
1806 return CGRectZero;
1807 }
1808 if (index == 0) {
1809 // There is no character before the caret, so this will be the bounds of the character after the
1810 // caret position.
1811 CGRect characterAfterCaret = rects[0].rect;
1812 // Return a zero-width rectangle along the upstream edge of the character after the caret
1813 // position.
1814 if ([rects[0] isKindOfClass:[FlutterTextSelectionRect class]] &&
1815 ((FlutterTextSelectionRect*)rects[0]).isRTL) {
1816 return CGRectMake(characterAfterCaret.origin.x + characterAfterCaret.size.width,
1817 characterAfterCaret.origin.y, 0, characterAfterCaret.size.height);
1818 } else {
1819 return CGRectMake(characterAfterCaret.origin.x, characterAfterCaret.origin.y, 0,
1820 characterAfterCaret.size.height);
1821 }
1822 } else if (rects.count == 2 && affinity == UITextStorageDirectionForward) {
1823 // There are characters before and after the caret, with forward direction affinity.
1824 // It's better to use the character after the caret.
1825 CGRect characterAfterCaret = rects[1].rect;
1826 // Return a zero-width rectangle along the upstream edge of the character after the caret
1827 // position.
1828 if ([rects[1] isKindOfClass:[FlutterTextSelectionRect class]] &&
1829 ((FlutterTextSelectionRect*)rects[1]).isRTL) {
1830 return CGRectMake(characterAfterCaret.origin.x + characterAfterCaret.size.width,
1831 characterAfterCaret.origin.y, 0, characterAfterCaret.size.height);
1832 } else {
1833 return CGRectMake(characterAfterCaret.origin.x, characterAfterCaret.origin.y, 0,
1834 characterAfterCaret.size.height);
1835 }
1836 }
1837
1838 // Covers 2 remaining cases:
1839 // 1. there are characters before and after the caret, with backward direction affinity.
1840 // 2. there is only 1 character before the caret (caret is at the end of text).
1841 // For both cases, return a zero-width rectangle along the downstream edge of the character
1842 // before the caret position.
1843 CGRect characterBeforeCaret = rects[0].rect;
1844 if ([rects[0] isKindOfClass:[FlutterTextSelectionRect class]] &&
1845 ((FlutterTextSelectionRect*)rects[0]).isRTL) {
1846 return CGRectMake(characterBeforeCaret.origin.x, characterBeforeCaret.origin.y, 0,
1847 characterBeforeCaret.size.height);
1848 } else {
1849 return CGRectMake(characterBeforeCaret.origin.x + characterBeforeCaret.size.width,
1850 characterBeforeCaret.origin.y, 0, characterBeforeCaret.size.height);
1851 }
1852}
1853
1854- (UITextPosition*)closestPositionToPoint:(CGPoint)point {
1855 if ([_selectionRects count] == 0) {
1856 NSAssert([_selectedTextRange.start isKindOfClass:[FlutterTextPosition class]],
1857 @"Expected a FlutterTextPosition for position (got %@).",
1858 [_selectedTextRange.start class]);
1859 NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index;
1860 UITextStorageDirection currentAffinity =
1862 return [FlutterTextPosition positionWithIndex:currentIndex affinity:currentAffinity];
1863 }
1864
1866 rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))];
1867 return [self closestPositionToPoint:point withinRange:range];
1868}
1869
1870- (NSArray*)selectionRectsForRange:(UITextRange*)range {
1871 // At least in the simulator, swapping to the Japanese keyboard crashes the app as this method
1872 // is called immediately with a UITextRange with a UITextPosition rather than FlutterTextPosition
1873 // for the start and end.
1874 if (![range.start isKindOfClass:[FlutterTextPosition class]]) {
1875 return @[];
1876 }
1877 NSAssert([range.start isKindOfClass:[FlutterTextPosition class]],
1878 @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]);
1879 NSAssert([range.end isKindOfClass:[FlutterTextPosition class]],
1880 @"Expected a FlutterTextPosition for range.end (got %@).", [range.end class]);
1881 NSUInteger start = ((FlutterTextPosition*)range.start).index;
1882 NSUInteger end = ((FlutterTextPosition*)range.end).index;
1883 NSMutableArray* rects = [[NSMutableArray alloc] init];
1884 for (NSUInteger i = 0; i < [_selectionRects count]; i++) {
1885 if (_selectionRects[i].position >= start &&
1886 (_selectionRects[i].position < end ||
1887 (start == end && _selectionRects[i].position <= end))) {
1888 float width = _selectionRects[i].rect.size.width;
1889 if (start == end) {
1890 width = 0;
1891 }
1892 CGRect rect = CGRectMake(_selectionRects[i].rect.origin.x, _selectionRects[i].rect.origin.y,
1893 width, _selectionRects[i].rect.size.height);
1896 position:_selectionRects[i].position
1897 writingDirection:NSWritingDirectionNatural
1898 containsStart:(i == 0)
1899 containsEnd:(i == fml::RangeForCharactersInRange(
1900 self.text, NSMakeRange(0, self.text.length))
1901 .length)
1902 isVertical:NO];
1903 [rects addObject:selectionRect];
1904 }
1905 }
1906 return rects;
1907}
1908
1909- (UITextPosition*)closestPositionToPoint:(CGPoint)point withinRange:(UITextRange*)range {
1910 NSAssert([range.start isKindOfClass:[FlutterTextPosition class]],
1911 @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]);
1912 NSAssert([range.end isKindOfClass:[FlutterTextPosition class]],
1913 @"Expected a FlutterTextPosition for range.end (got %@).", [range.end class]);
1914 NSUInteger start = ((FlutterTextPosition*)range.start).index;
1915 NSUInteger end = ((FlutterTextPosition*)range.end).index;
1916
1917 // Selecting text using the floating cursor is not as precise as the pencil.
1918 // Allow further vertical deviation and base more of the decision on horizontal comparison.
1919 CGFloat verticalPrecision = _isFloatingCursorActive ? 10 : 1;
1920
1921 // Find the selectionRect with a leading-center point that is closest to a given point.
1922 BOOL isFirst = YES;
1923 NSUInteger _closestRectIndex = 0;
1924 for (NSUInteger i = 0; i < [_selectionRects count]; i++) {
1925 NSUInteger position = _selectionRects[i].position;
1926 if (position >= start && position <= end) {
1927 if (isFirst ||
1929 point, _selectionRects[i].rect, _selectionRects[i].isRTL,
1930 /*useTrailingBoundaryOfSelectionRect=*/NO, _selectionRects[_closestRectIndex].rect,
1931 _selectionRects[_closestRectIndex].isRTL, verticalPrecision)) {
1932 isFirst = NO;
1933 _closestRectIndex = i;
1934 }
1935 }
1936 }
1937
1938 FlutterTextPosition* closestPosition =
1939 [FlutterTextPosition positionWithIndex:_selectionRects[_closestRectIndex].position
1940 affinity:UITextStorageDirectionForward];
1941
1942 // Check if the far side of the closest rect is a better fit (e.g. tapping end of line)
1943 // Cannot simply check the _closestRectIndex result from the previous for loop due to RTL
1944 // writing direction and the gaps between selectionRects. So we also need to consider
1945 // the adjacent selectionRects to refine _closestRectIndex.
1946 for (NSUInteger i = MAX(0, _closestRectIndex - 1);
1947 i < MIN(_closestRectIndex + 2, [_selectionRects count]); i++) {
1948 NSUInteger position = _selectionRects[i].position + 1;
1949 if (position >= start && position <= end) {
1951 point, _selectionRects[i].rect, _selectionRects[i].isRTL,
1952 /*useTrailingBoundaryOfSelectionRect=*/YES, _selectionRects[_closestRectIndex].rect,
1953 _selectionRects[_closestRectIndex].isRTL, verticalPrecision)) {
1954 // This is an upstream position
1955 closestPosition = [FlutterTextPosition positionWithIndex:position
1956 affinity:UITextStorageDirectionBackward];
1957 }
1958 }
1959 }
1960
1961 return closestPosition;
1962}
1963
1964- (UITextRange*)characterRangeAtPoint:(CGPoint)point {
1965 // TODO(cbracken) Implement.
1966 NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index;
1967 return [FlutterTextRange rangeWithNSRange:fml::RangeForCharacterAtIndex(self.text, currentIndex)];
1968}
1969
1970// Overall logic for floating cursor's "move" gesture and "selection" gesture:
1971//
1972// Floating cursor's "move" gesture takes 1 finger to force press the space bar, and then move the
1973// cursor. The process starts with `beginFloatingCursorAtPoint`. When the finger is moved,
1974// `updateFloatingCursorAtPoint` will be called. When the finger is released, `endFloatingCursor`
1975// will be called. In all cases, we send the point (relative to the initial point registered in
1976// beginFloatingCursorAtPoint) to the framework, so that framework can animate the floating cursor.
1977//
1978// During the move gesture, the framework only animate the cursor visually. It's only
1979// after the gesture is complete, will the framework update the selection to the cursor's
1980// new position (with zero selection length). This means during the animation, the visual effect
1981// of the cursor is temporarily out of sync with the selection state in both framework and engine.
1982// But it will be in sync again after the animation is complete.
1983//
1984// Floating cursor's "selection" gesture also starts with 1 finger to force press the space bar,
1985// so exactly the same functions as the "move gesture" discussed above will be called. When the
1986// second finger is pressed, `setSelectedText` will be called. This mechanism requires
1987// `closestPositionFromPoint` to be implemented, to allow UIKit to translate the finger touch
1988// location displacement to the text range to select. When the selection is completed
1989// (i.e. when both of the 2 fingers are released), similar to "move" gesture,
1990// the `endFloatingCursor` will be called.
1991//
1992// When the 2nd finger is pressed, it does not trigger another startFloatingCursor call. So
1993// floating cursor move/selection logic has to be implemented in iOS embedder rather than
1994// just the framework side.
1995//
1996// Whenever a selection is updated, the engine sends the new selection to the framework. So unlike
1997// the move gesture, the selections in the framework and the engine are always kept in sync.
1998- (void)beginFloatingCursorAtPoint:(CGPoint)point {
1999 // For "beginFloatingCursorAtPoint" and "updateFloatingCursorAtPoint", "point" is roughly:
2000 //
2001 // CGPoint(
2002 // width >= 0 ? point.x.clamp(boundingBox.left, boundingBox.right) : point.x,
2003 // height >= 0 ? point.y.clamp(boundingBox.top, boundingBox.bottom) : point.y,
2004 // )
2005 // where
2006 // point = keyboardPanGestureRecognizer.translationInView(textInputView) + caretRectForPosition
2007 // boundingBox = self.convertRect(bounds, fromView:textInputView)
2008 // bounds = self._selectionClipRect ?? self.bounds
2009 //
2010 // It seems impossible to use a negative "width" or "height", as the "convertRect"
2011 // call always turns a CGRect's negative dimensions into non-negative values, e.g.,
2012 // (1, 2, -3, -4) would become (-2, -2, 3, 4).
2014 _floatingCursorOffset = point;
2015 [self.textInputDelegate flutterTextInputView:self
2016 updateFloatingCursor:FlutterFloatingCursorDragStateStart
2017 withClient:_textInputClient
2018 withPosition:@{@"X" : @0, @"Y" : @0}];
2019}
2020
2021- (void)updateFloatingCursorAtPoint:(CGPoint)point {
2022 [self.textInputDelegate flutterTextInputView:self
2023 updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
2024 withClient:_textInputClient
2025 withPosition:@{
2026 @"X" : @(point.x - _floatingCursorOffset.x),
2027 @"Y" : @(point.y - _floatingCursorOffset.y)
2028 }];
2029}
2030
2031- (void)endFloatingCursor {
2033 [self.textInputDelegate flutterTextInputView:self
2034 updateFloatingCursor:FlutterFloatingCursorDragStateEnd
2035 withClient:_textInputClient
2036 withPosition:@{@"X" : @0, @"Y" : @0}];
2037}
2038
2039#pragma mark - UIKeyInput Overrides
2040
2041- (void)updateEditingState {
2042 NSUInteger selectionBase = ((FlutterTextPosition*)_selectedTextRange.start).index;
2043 NSUInteger selectionExtent = ((FlutterTextPosition*)_selectedTextRange.end).index;
2044
2045 // Empty compositing range is represented by the framework's TextRange.empty.
2046 NSInteger composingBase = -1;
2047 NSInteger composingExtent = -1;
2048 if (self.markedTextRange != nil) {
2049 composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index;
2050 composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index;
2051 }
2052 NSDictionary* state = @{
2053 @"selectionBase" : @(selectionBase),
2054 @"selectionExtent" : @(selectionExtent),
2055 @"selectionAffinity" : @(_selectionAffinity),
2056 @"selectionIsDirectional" : @(false),
2057 @"composingBase" : @(composingBase),
2058 @"composingExtent" : @(composingExtent),
2059 @"text" : [NSString stringWithString:self.text],
2060 };
2061
2062 if (_textInputClient == 0 && _autofillId != nil) {
2063 [self.textInputDelegate flutterTextInputView:self
2064 updateEditingClient:_textInputClient
2065 withState:state
2066 withTag:_autofillId];
2067 } else {
2068 [self.textInputDelegate flutterTextInputView:self
2069 updateEditingClient:_textInputClient
2070 withState:state];
2071 }
2072}
2073
2074- (void)updateEditingStateWithDelta:(flutter::TextEditingDelta)delta {
2075 NSUInteger selectionBase = ((FlutterTextPosition*)_selectedTextRange.start).index;
2076 NSUInteger selectionExtent = ((FlutterTextPosition*)_selectedTextRange.end).index;
2077
2078 // Empty compositing range is represented by the framework's TextRange.empty.
2079 NSInteger composingBase = -1;
2080 NSInteger composingExtent = -1;
2081 if (self.markedTextRange != nil) {
2082 composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index;
2083 composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index;
2084 }
2085
2086 NSDictionary* deltaToFramework = @{
2087 @"oldText" : @(delta.old_text().c_str()),
2088 @"deltaText" : @(delta.delta_text().c_str()),
2089 @"deltaStart" : @(delta.delta_start()),
2090 @"deltaEnd" : @(delta.delta_end()),
2091 @"selectionBase" : @(selectionBase),
2092 @"selectionExtent" : @(selectionExtent),
2093 @"selectionAffinity" : @(_selectionAffinity),
2094 @"selectionIsDirectional" : @(false),
2095 @"composingBase" : @(composingBase),
2096 @"composingExtent" : @(composingExtent),
2097 };
2098
2099 [_pendingDeltas addObject:deltaToFramework];
2100
2101 if (_pendingDeltas.count == 1) {
2102 __weak FlutterTextInputView* weakSelf = self;
2103 dispatch_async(dispatch_get_main_queue(), ^{
2104 __strong FlutterTextInputView* strongSelf = weakSelf;
2105 if (strongSelf && strongSelf.pendingDeltas.count > 0) {
2106 NSDictionary* deltas = @{
2107 @"deltas" : strongSelf.pendingDeltas,
2108 };
2109
2110 [strongSelf.textInputDelegate flutterTextInputView:strongSelf
2111 updateEditingClient:strongSelf->_textInputClient
2112 withDelta:deltas];
2113 [strongSelf.pendingDeltas removeAllObjects];
2114 }
2115 });
2116 }
2117}
2118
2119- (BOOL)hasText {
2120 return self.text.length > 0;
2121}
2122
2123- (void)insertText:(NSString*)text {
2124 if (self.temporarilyDeletedComposedCharacter.length > 0 && text.length == 1 && !text.UTF8String &&
2125 [text characterAtIndex:0] == [self.temporarilyDeletedComposedCharacter characterAtIndex:0]) {
2126 // Workaround for https://github.com/flutter/flutter/issues/111494
2127 // TODO(cyanglaz): revert this workaround if when flutter supports a minimum iOS version which
2128 // this bug is fixed by Apple.
2130 self.temporarilyDeletedComposedCharacter = nil;
2131 }
2132
2133 NSMutableArray<FlutterTextSelectionRect*>* copiedRects =
2134 [[NSMutableArray alloc] initWithCapacity:[_selectionRects count]];
2135 NSAssert([_selectedTextRange.start isKindOfClass:[FlutterTextPosition class]],
2136 @"Expected a FlutterTextPosition for position (got %@).",
2137 [_selectedTextRange.start class]);
2138 NSUInteger insertPosition = ((FlutterTextPosition*)_selectedTextRange.start).index;
2139 for (NSUInteger i = 0; i < [_selectionRects count]; i++) {
2140 NSUInteger rectPosition = _selectionRects[i].position;
2141 if (rectPosition == insertPosition) {
2142 for (NSUInteger j = 0; j <= text.length; j++) {
2143 [copiedRects addObject:[FlutterTextSelectionRect
2144 selectionRectWithRect:_selectionRects[i].rect
2145 position:rectPosition + j
2146 writingDirection:_selectionRects[i].writingDirection]];
2147 }
2148 } else {
2149 if (rectPosition > insertPosition) {
2150 rectPosition = rectPosition + text.length;
2151 }
2152 [copiedRects addObject:[FlutterTextSelectionRect
2153 selectionRectWithRect:_selectionRects[i].rect
2154 position:rectPosition
2155 writingDirection:_selectionRects[i].writingDirection]];
2156 }
2157 }
2158
2159 _scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
2160 [self resetScribbleInteractionStatusIfEnding];
2161 self.selectionRects = copiedRects;
2163 [self replaceRange:_selectedTextRange withText:text];
2164}
2165
2166- (UITextPlaceholder*)insertTextPlaceholderWithSize:(CGSize)size API_AVAILABLE(ios(13.0)) {
2167 [self.textInputDelegate flutterTextInputView:self
2168 insertTextPlaceholderWithSize:size
2169 withClient:_textInputClient];
2170 _hasPlaceholder = YES;
2171 return [[FlutterTextPlaceholder alloc] init];
2172}
2173
2174- (void)removeTextPlaceholder:(UITextPlaceholder*)textPlaceholder API_AVAILABLE(ios(13.0)) {
2175 _hasPlaceholder = NO;
2176 [self.textInputDelegate flutterTextInputView:self removeTextPlaceholder:_textInputClient];
2177}
2178
2179- (void)deleteBackward {
2181 _scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
2182 [self resetScribbleInteractionStatusIfEnding];
2183
2184 // When deleting Thai vowel, _selectedTextRange has location
2185 // but does not have length, so we have to manually set it.
2186 // In addition, we needed to delete only a part of grapheme cluster
2187 // because it is the expected behavior of Thai input.
2188 // https://github.com/flutter/flutter/issues/24203
2189 // https://github.com/flutter/flutter/issues/21745
2190 // https://github.com/flutter/flutter/issues/39399
2191 //
2192 // This is needed for correct handling of the deletion of Thai vowel input.
2193 // TODO(cbracken): Get a good understanding of expected behavior of Thai
2194 // input and ensure that this is the correct solution.
2195 // https://github.com/flutter/flutter/issues/28962
2196 if (_selectedTextRange.isEmpty && [self hasText]) {
2197 UITextRange* oldSelectedRange = _selectedTextRange;
2198 NSRange oldRange = ((FlutterTextRange*)oldSelectedRange).range;
2199 if (oldRange.location > 0) {
2200 NSRange newRange = NSMakeRange(oldRange.location - 1, 1);
2201
2202 // We should check if the last character is a part of emoji.
2203 // If so, we must delete the entire emoji to prevent the text from being malformed.
2204 NSRange charRange = fml::RangeForCharacterAtIndex(self.text, oldRange.location - 1);
2205 if (IsEmoji(self.text, charRange)) {
2206 newRange = NSMakeRange(charRange.location, oldRange.location - charRange.location);
2207 }
2208
2210 }
2211 }
2212
2214 // Cache the last deleted emoji to use for an iOS bug where the next
2215 // insertion corrupts the emoji characters.
2216 // See: https://github.com/flutter/flutter/issues/111494#issuecomment-1248441346
2217 if (IsEmoji(self.text, _selectedTextRange.range)) {
2218 NSString* deletedText = [self.text substringWithRange:_selectedTextRange.range];
2219 NSRange deleteFirstCharacterRange = fml::RangeForCharacterAtIndex(deletedText, 0);
2220 self.temporarilyDeletedComposedCharacter =
2221 [deletedText substringWithRange:deleteFirstCharacterRange];
2222 }
2223 [self replaceRange:_selectedTextRange withText:@""];
2224 }
2225}
2226
2227- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
2228 UIAccessibilityPostNotification(notification, target);
2229}
2230
2231- (void)accessibilityElementDidBecomeFocused {
2232 if ([self accessibilityElementIsFocused]) {
2233 // For most of the cases, this flutter text input view should never
2234 // receive the focus. If we do receive the focus, we make the best effort
2235 // to send the focus back to the real text field.
2236 FML_DCHECK(_backingTextInputAccessibilityObject);
2237 [self postAccessibilityNotification:UIAccessibilityScreenChangedNotification
2238 target:_backingTextInputAccessibilityObject];
2239 }
2240}
2241
2242- (BOOL)accessibilityElementsHidden {
2243 return !_accessibilityEnabled;
2244}
2245
2247 if (_scribbleInteractionStatus == FlutterScribbleInteractionStatusEnding) {
2248 _scribbleInteractionStatus = FlutterScribbleInteractionStatusNone;
2249 }
2250}
2251
2252#pragma mark - Key Events Handling
2253- (void)pressesBegan:(NSSet<UIPress*>*)presses
2254 withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) {
2255 [_textInputPlugin.viewController pressesBegan:presses withEvent:event];
2256}
2257
2258- (void)pressesChanged:(NSSet<UIPress*>*)presses
2259 withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) {
2260 [_textInputPlugin.viewController pressesChanged:presses withEvent:event];
2261}
2262
2263- (void)pressesEnded:(NSSet<UIPress*>*)presses
2264 withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) {
2265 [_textInputPlugin.viewController pressesEnded:presses withEvent:event];
2266}
2267
2268- (void)pressesCancelled:(NSSet<UIPress*>*)presses
2269 withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) {
2270 [_textInputPlugin.viewController pressesCancelled:presses withEvent:event];
2271}
2272
2273@end
2274
2275/**
2276 * Hides `FlutterTextInputView` from iOS accessibility system so it
2277 * does not show up twice, once where it is in the `UIView` hierarchy,
2278 * and a second time as part of the `SemanticsObject` hierarchy.
2279 *
2280 * This prevents the `FlutterTextInputView` from receiving the focus
2281 * due to swiping gesture.
2282 *
2283 * There are other cases the `FlutterTextInputView` may receive
2284 * focus. One example is during screen changes, the accessibility
2285 * tree will undergo a dramatic structural update. The Voiceover may
2286 * decide to focus the `FlutterTextInputView` that is not involved
2287 * in the structural update instead. If that happens, the
2288 * `FlutterTextInputView` will make a best effort to direct the
2289 * focus back to the `SemanticsObject`.
2290 */
2292}
2293
2294@end
2295
2297}
2298
2299- (BOOL)accessibilityElementsHidden {
2300 return YES;
2301}
2302
2303@end
2304
2305@interface FlutterTextInputPlugin ()
2307@end
2308
2309@interface FlutterTimerProxy : NSObject
2310@property(nonatomic, weak) FlutterTextInputPlugin* target;
2311@end
2312
2313@implementation FlutterTimerProxy
2314
2315+ (instancetype)proxyWithTarget:(FlutterTextInputPlugin*)target {
2316 FlutterTimerProxy* proxy = [[self alloc] init];
2317 if (proxy) {
2318 proxy.target = target;
2319 }
2320 return proxy;
2321}
2322
2324 [self.target enableActiveViewAccessibility];
2325}
2326
2327@end
2328
2329@interface FlutterTextInputPlugin ()
2330// The current password-autofillable input fields that have yet to be saved.
2331@property(nonatomic, readonly)
2332 NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
2333@property(nonatomic, retain) FlutterTextInputView* activeView;
2334@property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider;
2335@property(nonatomic, readonly, weak) id<FlutterViewResponder> viewResponder;
2336
2337@property(nonatomic, strong) UIView* keyboardViewContainer;
2338@property(nonatomic, strong) UIView* keyboardView;
2339@property(nonatomic, strong) UIView* cachedFirstResponder;
2340@property(nonatomic, assign) CGRect keyboardRect;
2341@property(nonatomic, assign) CGFloat previousPointerYPosition;
2342@property(nonatomic, assign) CGFloat pointerYVelocity;
2343@end
2344
2345@implementation FlutterTextInputPlugin {
2346 NSTimer* _enableFlutterTextInputViewAccessibilityTimer;
2347}
2348
2349- (instancetype)initWithDelegate:(id<FlutterTextInputDelegate>)textInputDelegate {
2350 self = [super init];
2351 if (self) {
2352 // `_textInputDelegate` is a weak reference because it should retain FlutterTextInputPlugin.
2353 _textInputDelegate = textInputDelegate;
2354 _autofillContext = [[NSMutableDictionary alloc] init];
2355 _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
2356 _scribbleElements = [[NSMutableDictionary alloc] init];
2357 _keyboardViewContainer = [[UIView alloc] init];
2358
2359 [[NSNotificationCenter defaultCenter] addObserver:self
2360 selector:@selector(handleKeyboardWillShow:)
2361 name:UIKeyboardWillShowNotification
2362 object:nil];
2363 }
2364
2365 return self;
2366}
2367
2368- (void)handleKeyboardWillShow:(NSNotification*)notification {
2369 NSDictionary* keyboardInfo = [notification userInfo];
2370 NSValue* keyboardFrameEnd = [keyboardInfo valueForKey:UIKeyboardFrameEndUserInfoKey];
2371 _keyboardRect = [keyboardFrameEnd CGRectValue];
2372}
2373
2374- (void)dealloc {
2375 [self hideTextInput];
2376}
2377
2378- (void)removeEnableFlutterTextInputViewAccessibilityTimer {
2379 if (_enableFlutterTextInputViewAccessibilityTimer) {
2380 [_enableFlutterTextInputViewAccessibilityTimer invalidate];
2381 _enableFlutterTextInputViewAccessibilityTimer = nil;
2382 }
2383}
2384
2385- (UIView<UITextInput>*)textInputView {
2386 return _activeView;
2387}
2388
2389- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
2390 NSString* method = call.method;
2391 id args = call.arguments;
2392 if ([method isEqualToString:kShowMethod]) {
2393 [self showTextInput];
2394 result(nil);
2395 } else if ([method isEqualToString:kHideMethod]) {
2396 [self hideTextInput];
2397 result(nil);
2398 } else if ([method isEqualToString:kSetClientMethod]) {
2399 [self setTextInputClient:[args[0] intValue] withConfiguration:args[1]];
2400 result(nil);
2401 } else if ([method isEqualToString:kSetPlatformViewClientMethod]) {
2402 // This method call has a `platformViewId` argument, but we do not need it for iOS for now.
2403 [self setPlatformViewTextInputClient];
2404 result(nil);
2405 } else if ([method isEqualToString:kSetEditingStateMethod]) {
2406 [self setTextInputEditingState:args];
2407 result(nil);
2408 } else if ([method isEqualToString:kClearClientMethod]) {
2409 [self clearTextInputClient];
2410 result(nil);
2411 } else if ([method isEqualToString:kSetEditableSizeAndTransformMethod]) {
2412 [self setEditableSizeAndTransform:args];
2413 result(nil);
2414 } else if ([method isEqualToString:kSetMarkedTextRectMethod]) {
2415 [self updateMarkedRect:args];
2416 result(nil);
2417 } else if ([method isEqualToString:kFinishAutofillContextMethod]) {
2418 [self triggerAutofillSave:[args boolValue]];
2419 result(nil);
2420 // TODO(justinmc): Remove the TextInput method constant when the framework has
2421 // finished transitioning to using the Scribble channel.
2422 // https://github.com/flutter/flutter/pull/104128
2423 } else if ([method isEqualToString:kDeprecatedSetSelectionRectsMethod]) {
2424 [self setSelectionRects:args];
2425 result(nil);
2426 } else if ([method isEqualToString:kSetSelectionRectsMethod]) {
2427 [self setSelectionRects:args];
2428 result(nil);
2429 } else if ([method isEqualToString:kStartLiveTextInputMethod]) {
2430 [self startLiveTextInput];
2431 result(nil);
2432 } else if ([method isEqualToString:kUpdateConfigMethod]) {
2433 [self updateConfig:args];
2434 result(nil);
2435 } else if ([method isEqualToString:kOnInteractiveKeyboardPointerMoveMethod]) {
2436 CGFloat pointerY = (CGFloat)[args[@"pointerY"] doubleValue];
2437 [self handlePointerMove:pointerY];
2438 result(nil);
2439 } else if ([method isEqualToString:kOnInteractiveKeyboardPointerUpMethod]) {
2440 CGFloat pointerY = (CGFloat)[args[@"pointerY"] doubleValue];
2441 [self handlePointerUp:pointerY];
2442 result(nil);
2443 } else {
2445 }
2446}
2447
2448- (void)handlePointerUp:(CGFloat)pointerY {
2449 if (_keyboardView.superview != nil) {
2450 // Done to avoid the issue of a pointer up done without a screenshot
2451 // View must be loaded at this point.
2452 UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
2453 CGFloat screenHeight = screen.bounds.size.height;
2454 CGFloat keyboardHeight = _keyboardRect.size.height;
2455 // Negative velocity indicates a downward movement
2456 BOOL shouldDismissKeyboardBasedOnVelocity = _pointerYVelocity < 0;
2457 [UIView animateWithDuration:kKeyboardAnimationTimeToCompleteion
2458 animations:^{
2459 double keyboardDestination =
2460 shouldDismissKeyboardBasedOnVelocity ? screenHeight : screenHeight - keyboardHeight;
2461 _keyboardViewContainer.frame = CGRectMake(
2462 0, keyboardDestination, _viewController.flutterScreenIfViewLoaded.bounds.size.width,
2463 _keyboardViewContainer.frame.size.height);
2464 }
2465 completion:^(BOOL finished) {
2466 if (shouldDismissKeyboardBasedOnVelocity) {
2467 [self.textInputDelegate flutterTextInputView:self.activeView
2468 didResignFirstResponderWithTextInputClient:self.activeView.textInputClient];
2469 [self dismissKeyboardScreenshot];
2470 } else {
2471 [self showKeyboardAndRemoveScreenshot];
2472 }
2473 }];
2474 }
2475}
2476
2477- (void)dismissKeyboardScreenshot {
2478 for (UIView* subView in _keyboardViewContainer.subviews) {
2479 [subView removeFromSuperview];
2480 }
2481}
2482
2483- (void)showKeyboardAndRemoveScreenshot {
2484 [UIView setAnimationsEnabled:NO];
2485 [_cachedFirstResponder becomeFirstResponder];
2486 // UIKit does not immediately access the areAnimationsEnabled Boolean so a delay is needed before
2487 // returned
2488 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kKeyboardAnimationDelaySeconds * NSEC_PER_SEC),
2489 dispatch_get_main_queue(), ^{
2490 [UIView setAnimationsEnabled:YES];
2491 [self dismissKeyboardScreenshot];
2492 });
2493}
2494
2495- (void)handlePointerMove:(CGFloat)pointerY {
2496 // View must be loaded at this point.
2497 UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
2498 CGFloat screenHeight = screen.bounds.size.height;
2499 CGFloat keyboardHeight = _keyboardRect.size.height;
2500 if (screenHeight - keyboardHeight <= pointerY) {
2501 // If the pointer is within the bounds of the keyboard.
2502 if (_keyboardView.superview == nil) {
2503 // If no screenshot has been taken.
2504 [self takeKeyboardScreenshotAndDisplay];
2505 [self hideKeyboardWithoutAnimationAndAvoidCursorDismissUpdate];
2506 } else {
2507 [self setKeyboardContainerHeight:pointerY];
2508 _pointerYVelocity = _previousPointerYPosition - pointerY;
2509 }
2510 } else {
2511 if (_keyboardView.superview != nil) {
2512 // Keeps keyboard at proper height.
2513 _keyboardViewContainer.frame = _keyboardRect;
2514 _pointerYVelocity = _previousPointerYPosition - pointerY;
2515 }
2516 }
2517 _previousPointerYPosition = pointerY;
2518}
2519
2520- (void)setKeyboardContainerHeight:(CGFloat)pointerY {
2521 CGRect frameRect = _keyboardRect;
2522 frameRect.origin.y = pointerY;
2523 _keyboardViewContainer.frame = frameRect;
2524}
2525
2526- (void)hideKeyboardWithoutAnimationAndAvoidCursorDismissUpdate {
2527 [UIView setAnimationsEnabled:NO];
2528 _cachedFirstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
2529 _activeView.preventCursorDismissWhenResignFirstResponder = YES;
2530 [_cachedFirstResponder resignFirstResponder];
2531 _activeView.preventCursorDismissWhenResignFirstResponder = NO;
2532 [UIView setAnimationsEnabled:YES];
2533}
2534
2535- (void)takeKeyboardScreenshotAndDisplay {
2536 // View must be loaded at this point
2537 UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
2538 UIView* keyboardSnap = [screen snapshotViewAfterScreenUpdates:YES];
2539 keyboardSnap = [keyboardSnap resizableSnapshotViewFromRect:_keyboardRect
2540 afterScreenUpdates:YES
2541 withCapInsets:UIEdgeInsetsZero];
2542 _keyboardView = keyboardSnap;
2543 [_keyboardViewContainer addSubview:_keyboardView];
2544 if (_keyboardViewContainer.superview == nil) {
2545 [UIApplication.sharedApplication.delegate.window.rootViewController.view
2546 addSubview:_keyboardViewContainer];
2547 }
2548 _keyboardViewContainer.layer.zPosition = NSIntegerMax;
2549 _keyboardViewContainer.frame = _keyboardRect;
2550}
2551
2552- (BOOL)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)) {
2553 if (!self.activeView.isFirstResponder) {
2554 return NO;
2555 }
2556 NSDictionary<NSString*, NSNumber*>* encodedTargetRect = args[@"targetRect"];
2557 CGRect globalTargetRect = CGRectMake(
2558 [encodedTargetRect[@"x"] doubleValue], [encodedTargetRect[@"y"] doubleValue],
2559 [encodedTargetRect[@"width"] doubleValue], [encodedTargetRect[@"height"] doubleValue]);
2560 CGRect localTargetRect = [self.hostView convertRect:globalTargetRect toView:self.activeView];
2561 [self.activeView showEditMenuWithTargetRect:localTargetRect];
2562 return YES;
2563}
2564
2565- (void)hideEditMenu {
2566 [self.activeView hideEditMenu];
2567}
2568
2569- (void)setEditableSizeAndTransform:(NSDictionary*)dictionary {
2570 NSArray* transform = dictionary[@"transform"];
2571 [_activeView setEditableTransform:transform];
2572 const int leftIndex = 12;
2573 const int topIndex = 13;
2574 if ([_activeView isScribbleAvailable]) {
2575 // This is necessary to set up where the scribble interactable element will be.
2576 _inputHider.frame =
2577 CGRectMake([transform[leftIndex] intValue], [transform[topIndex] intValue],
2578 [dictionary[@"width"] intValue], [dictionary[@"height"] intValue]);
2579 _activeView.frame =
2580 CGRectMake(0, 0, [dictionary[@"width"] intValue], [dictionary[@"height"] intValue]);
2581 _activeView.tintColor = [UIColor clearColor];
2582 } else {
2583 // TODO(hellohuanlin): Also need to handle iOS 16 case, where the auto-correction highlight does
2584 // not match the size of text.
2585 // See https://github.com/flutter/flutter/issues/131695
2586 if (@available(iOS 17, *)) {
2587 // Move auto-correction highlight to overlap with the actual text.
2588 // This is to fix an issue where the system auto-correction highlight is displayed at
2589 // the top left corner of the screen on iOS 17+.
2590 // This problem also happens on iOS 16, but the size of highlight does not match the text.
2591 // See https://github.com/flutter/flutter/issues/131695
2592 // TODO(hellohuanlin): Investigate if we can use non-zero size.
2593 _inputHider.frame =
2594 CGRectMake([transform[leftIndex] intValue], [transform[topIndex] intValue], 0, 0);
2595 }
2596 }
2597}
2598
2599- (void)updateMarkedRect:(NSDictionary*)dictionary {
2600 NSAssert(dictionary[@"x"] != nil && dictionary[@"y"] != nil && dictionary[@"width"] != nil &&
2601 dictionary[@"height"] != nil,
2602 @"Expected a dictionary representing a CGRect, got %@", dictionary);
2603 CGRect rect = CGRectMake([dictionary[@"x"] doubleValue], [dictionary[@"y"] doubleValue],
2604 [dictionary[@"width"] doubleValue], [dictionary[@"height"] doubleValue]);
2605 _activeView.markedRect = rect.size.width < 0 && rect.size.height < 0 ? kInvalidFirstRect : rect;
2606}
2607
2608- (void)setSelectionRects:(NSArray*)encodedRects {
2609 NSMutableArray<FlutterTextSelectionRect*>* rectsAsRect =
2610 [[NSMutableArray alloc] initWithCapacity:[encodedRects count]];
2611 for (NSUInteger i = 0; i < [encodedRects count]; i++) {
2612 NSArray<NSNumber*>* encodedRect = encodedRects[i];
2613 [rectsAsRect addObject:[FlutterTextSelectionRect
2614 selectionRectWithRect:CGRectMake([encodedRect[0] floatValue],
2615 [encodedRect[1] floatValue],
2616 [encodedRect[2] floatValue],
2617 [encodedRect[3] floatValue])
2618 position:[encodedRect[4] unsignedIntegerValue]
2619 writingDirection:[encodedRect[5] unsignedIntegerValue] == 1
2620 ? NSWritingDirectionLeftToRight
2621 : NSWritingDirectionRightToLeft]];
2622 }
2623
2624 // TODO(hellohuanlin): Investigate why notifying the text input system about text changes (via
2625 // textWillChange and textDidChange APIs) causes a bug where we cannot enter text with IME
2626 // keyboards. Issue: https://github.com/flutter/flutter/issues/133908
2627 _activeView.selectionRects = rectsAsRect;
2628}
2629
2630- (void)startLiveTextInput {
2631 if (@available(iOS 15.0, *)) {
2632 if (_activeView == nil || !_activeView.isFirstResponder) {
2633 return;
2634 }
2635 [_activeView captureTextFromCamera:nil];
2636 }
2637}
2638
2639- (void)showTextInput {
2640 _activeView.viewResponder = _viewResponder;
2641 [self addToInputParentViewIfNeeded:_activeView];
2642 // Adds a delay to prevent the text view from receiving accessibility
2643 // focus in case it is activated during semantics updates.
2644 //
2645 // One common case is when the app navigates to a page with an auto
2646 // focused text field. The text field will activate the FlutterTextInputView
2647 // with a semantics update sent to the engine. The voiceover will focus
2648 // the newly attached active view while performing accessibility update.
2649 // This results in accessibility focus stuck at the FlutterTextInputView.
2650 if (!_enableFlutterTextInputViewAccessibilityTimer) {
2651 _enableFlutterTextInputViewAccessibilityTimer =
2652 [NSTimer scheduledTimerWithTimeInterval:kUITextInputAccessibilityEnablingDelaySeconds
2654 selector:@selector(enableActiveViewAccessibility)
2655 userInfo:nil
2656 repeats:NO];
2657 }
2658 [_activeView becomeFirstResponder];
2659}
2660
2661- (void)enableActiveViewAccessibility {
2662 if (_activeView.isFirstResponder) {
2663 _activeView.accessibilityEnabled = YES;
2664 }
2665 [self removeEnableFlutterTextInputViewAccessibilityTimer];
2666}
2667
2668- (void)hideTextInput {
2669 [self removeEnableFlutterTextInputViewAccessibilityTimer];
2670 _activeView.accessibilityEnabled = NO;
2671 [_activeView resignFirstResponder];
2672 [_activeView removeFromSuperview];
2673 [_inputHider removeFromSuperview];
2674}
2675
2676- (void)triggerAutofillSave:(BOOL)saveEntries {
2677 [_activeView resignFirstResponder];
2678
2679 if (saveEntries) {
2680 // Make all the input fields in the autofill context visible,
2681 // then remove them to trigger autofill save.
2682 [self cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
2683 [_autofillContext removeAllObjects];
2684 [self changeInputViewsAutofillVisibility:YES];
2685 } else {
2686 [_autofillContext removeAllObjects];
2687 }
2688
2689 [self cleanUpViewHierarchy:YES clearText:!saveEntries delayRemoval:NO];
2690 [self addToInputParentViewIfNeeded:_activeView];
2691}
2692
2693- (void)setPlatformViewTextInputClient {
2694 // No need to track the platformViewID (unlike in Android). When a platform view
2695 // becomes the first responder, simply hide this dummy text input view (`_activeView`)
2696 // for the previously focused widget.
2697 [self removeEnableFlutterTextInputViewAccessibilityTimer];
2698 _activeView.accessibilityEnabled = NO;
2699 [_activeView removeFromSuperview];
2700 [_inputHider removeFromSuperview];
2701}
2702
2703- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
2704 [self resetAllClientIds];
2705 // Hide all input views from autofill, only make those in the new configuration visible
2706 // to autofill.
2707 [self changeInputViewsAutofillVisibility:NO];
2708
2709 // Update the current active view.
2710 switch (AutofillTypeOf(configuration)) {
2711 case kFlutterAutofillTypeNone:
2712 self.activeView = [self createInputViewWith:configuration];
2713 break;
2714 case kFlutterAutofillTypeRegular:
2715 // If the group does not involve password autofill, only install the
2716 // input view that's being focused.
2717 self.activeView = [self updateAndShowAutofillViews:nil
2718 focusedField:configuration
2719 isPasswordRelated:NO];
2720 break;
2721 case kFlutterAutofillTypePassword:
2722 self.activeView = [self updateAndShowAutofillViews:configuration[kAssociatedAutofillFields]
2723 focusedField:configuration
2724 isPasswordRelated:YES];
2725 break;
2726 }
2727 [_activeView setTextInputClient:client];
2728 [_activeView reloadInputViews];
2729
2730 // Clean up views that no longer need to be in the view hierarchy, according to
2731 // the current autofill context. The "garbage" input views are already made
2732 // invisible to autofill and they can't `becomeFirstResponder`, we only remove
2733 // them to free up resources and reduce the number of input views in the view
2734 // hierarchy.
2735 //
2736 // The garbage views are decommissioned immediately, but the removeFromSuperview
2737 // call is scheduled on the runloop and delayed by 0.1s so we don't remove the
2738 // text fields immediately (which seems to make the keyboard flicker).
2739 // See: https://github.com/flutter/flutter/issues/64628.
2740 [self cleanUpViewHierarchy:NO clearText:YES delayRemoval:YES];
2741}
2742
2743// Creates and shows an input field that is not password related and has no autofill
2744// info. This method returns a new FlutterTextInputView instance when called, since
2745// UIKit uses the identity of `UITextInput` instances (or the identity of the input
2746// views) to decide whether the IME's internal states should be reset. See:
2747// https://github.com/flutter/flutter/issues/79031 .
2748- (FlutterTextInputView*)createInputViewWith:(NSDictionary*)configuration {
2749 NSString* autofillId = AutofillIdFromDictionary(configuration);
2750 if (autofillId) {
2751 [_autofillContext removeObjectForKey:autofillId];
2752 }
2753 FlutterTextInputView* newView = [[FlutterTextInputView alloc] initWithOwner:self];
2754 [newView configureWithDictionary:configuration];
2755 [self addToInputParentViewIfNeeded:newView];
2756
2757 for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
2758 NSString* autofillId = AutofillIdFromDictionary(field);
2759 if (autofillId && AutofillTypeOf(field) == kFlutterAutofillTypeNone) {
2760 [_autofillContext removeObjectForKey:autofillId];
2761 }
2762 }
2763 return newView;
2764}
2765
2766- (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields
2767 focusedField:(NSDictionary*)focusedField
2768 isPasswordRelated:(BOOL)isPassword {
2769 FlutterTextInputView* focused = nil;
2770 NSString* focusedId = AutofillIdFromDictionary(focusedField);
2771 NSAssert(focusedId, @"autofillId must not be null for the focused field: %@", focusedField);
2772
2773 if (!fields) {
2774 // DO NOT push the current autofillable input fields to the context even
2775 // if it's password-related, because it is not in an autofill group.
2776 focused = [self getOrCreateAutofillableView:focusedField isPasswordAutofill:isPassword];
2777 [_autofillContext removeObjectForKey:focusedId];
2778 }
2779
2780 for (NSDictionary* field in fields) {
2781 NSString* autofillId = AutofillIdFromDictionary(field);
2782 NSAssert(autofillId, @"autofillId must not be null for field: %@", field);
2783
2784 BOOL hasHints = AutofillTypeOf(field) != kFlutterAutofillTypeNone;
2785 BOOL isFocused = [focusedId isEqualToString:autofillId];
2786
2787 if (isFocused) {
2788 focused = [self getOrCreateAutofillableView:field isPasswordAutofill:isPassword];
2789 }
2790
2791 if (hasHints) {
2792 // Push the current input field to the context if it has hints.
2793 _autofillContext[autofillId] = isFocused ? focused
2794 : [self getOrCreateAutofillableView:field
2795 isPasswordAutofill:isPassword];
2796 } else {
2797 // Mark for deletion.
2798 [_autofillContext removeObjectForKey:autofillId];
2799 }
2800 }
2801
2802 NSAssert(focused, @"The current focused input view must not be nil.");
2803 return focused;
2804}
2805
2806// Returns a new non-reusable input view (and put it into the view hierarchy), or get the
2807// view from the current autofill context, if an input view with the same autofill id
2808// already exists in the context.
2809// This is generally used for input fields that are autofillable (UIKit tracks these veiws
2810// for autofill purposes so they should not be reused for a different type of views).
2811- (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field
2812 isPasswordAutofill:(BOOL)needsPasswordAutofill {
2813 NSString* autofillId = AutofillIdFromDictionary(field);
2814 FlutterTextInputView* inputView = _autofillContext[autofillId];
2815 if (!inputView) {
2816 inputView =
2817 needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc];
2818 inputView = [inputView initWithOwner:self];
2819 [self addToInputParentViewIfNeeded:inputView];
2820 }
2821
2822 [inputView configureWithDictionary:field];
2823 return inputView;
2824}
2825
2826// The UIView to add FlutterTextInputViews to.
2827- (UIView*)hostView {
2828 UIView* host = _viewController.view;
2829 NSAssert(host != nullptr,
2830 @"The application must have a host view since the keyboard client "
2831 @"must be part of the responder chain to function. The host view controller is %@",
2833 return host;
2834}
2835
2836// The UIView to add FlutterTextInputViews to.
2837- (NSArray<UIView*>*)textInputViews {
2838 return _inputHider.subviews;
2839}
2840
2841// Removes every installed input field, unless it's in the current autofill context.
2842//
2843// The active view will be removed from its superview too, if includeActiveView is YES.
2844// When clearText is YES, the text on the input fields will be set to empty before
2845// they are removed from the view hierarchy, to avoid triggering autofill save.
2846// If delayRemoval is true, removeFromSuperview will be scheduled on the runloop and
2847// will be delayed by 0.1s so we don't remove the text fields immediately (which seems
2848// to make the keyboard flicker).
2849// See: https://github.com/flutter/flutter/issues/64628.
2850
2851- (void)cleanUpViewHierarchy:(BOOL)includeActiveView
2852 clearText:(BOOL)clearText
2853 delayRemoval:(BOOL)delayRemoval {
2854 for (UIView* view in self.textInputViews) {
2855 if ([view isKindOfClass:[FlutterTextInputView class]] &&
2856 (includeActiveView || view != _activeView)) {
2857 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
2858 if (_autofillContext[inputView.autofillId] != view) {
2859 if (clearText) {
2860 [inputView replaceRangeLocal:NSMakeRange(0, inputView.text.length) withText:@""];
2861 }
2862 if (delayRemoval) {
2863 [inputView performSelector:@selector(removeFromSuperview) withObject:nil afterDelay:0.1];
2864 } else {
2865 [inputView removeFromSuperview];
2866 }
2867 }
2868 }
2869 }
2870}
2871
2872// Changes the visibility of every FlutterTextInputView currently in the
2873// view hierarchy.
2874- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
2875 for (UIView* view in self.textInputViews) {
2876 if ([view isKindOfClass:[FlutterTextInputView class]]) {
2877 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
2878 inputView.isVisibleToAutofill = newVisibility;
2879 }
2880 }
2881}
2882
2883// Resets the client id of every FlutterTextInputView in the view hierarchy
2884// to 0.
2885// Called before establishing a new text input connection.
2886// For views in the current autofill context, they need to
2887// stay in the view hierachy but should not be allowed to
2888// send messages (other than autofill related ones) to the
2889// framework.
2890- (void)resetAllClientIds {
2891 for (UIView* view in self.textInputViews) {
2892 if ([view isKindOfClass:[FlutterTextInputView class]]) {
2893 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
2894 [inputView setTextInputClient:0];
2895 }
2896 }
2897}
2898
2899- (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView {
2900 if (![inputView isDescendantOfView:_inputHider]) {
2901 [_inputHider addSubview:inputView];
2902 }
2903
2904 if (_viewController.view == nil) {
2905 // If view controller's view has detached from flutter engine, we don't add _inputHider
2906 // in parent view to fallback and avoid crash.
2907 // https://github.com/flutter/flutter/issues/106404.
2908 return;
2909 }
2910 UIView* parentView = self.hostView;
2911 if (_inputHider.superview != parentView) {
2912 [parentView addSubview:_inputHider];
2913 }
2914}
2915
2916- (void)setTextInputEditingState:(NSDictionary*)state {
2917 [_activeView setTextInputState:state];
2918}
2919
2920- (void)clearTextInputClient {
2921 [_activeView setTextInputClient:0];
2922 _activeView.frame = CGRectZero;
2923}
2924
2925- (void)updateConfig:(NSDictionary*)dictionary {
2926 BOOL isSecureTextEntry = [dictionary[kSecureTextEntry] boolValue];
2927 for (UIView* view in self.textInputViews) {
2928 if ([view isKindOfClass:[FlutterTextInputView class]]) {
2929 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
2930 // The feature of holding and draging spacebar to move cursor is affected by
2931 // secureTextEntry, so when obscureText is updated, we need to update secureTextEntry
2932 // and call reloadInputViews.
2933 // https://github.com/flutter/flutter/issues/122139
2934 if (inputView.isSecureTextEntry != isSecureTextEntry) {
2935 inputView.secureTextEntry = isSecureTextEntry;
2936 [inputView reloadInputViews];
2937 }
2938 }
2939 }
2940}
2941
2942#pragma mark UIIndirectScribbleInteractionDelegate
2943
2944- (BOOL)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2945 isElementFocused:(UIScribbleElementIdentifier)elementIdentifier
2946 API_AVAILABLE(ios(14.0)) {
2947 return _activeView.scribbleFocusStatus == FlutterScribbleFocusStatusFocused;
2948}
2949
2950- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2951 focusElementIfNeeded:(UIScribbleElementIdentifier)elementIdentifier
2952 referencePoint:(CGPoint)focusReferencePoint
2953 completion:(void (^)(UIResponder<UITextInput>* focusedInput))completion
2954 API_AVAILABLE(ios(14.0)) {
2955 _activeView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
2956 [_indirectScribbleDelegate flutterTextInputPlugin:self
2957 focusElement:elementIdentifier
2958 atPoint:focusReferencePoint
2959 result:^(id _Nullable result) {
2960 _activeView.scribbleFocusStatus =
2961 FlutterScribbleFocusStatusFocused;
2962 completion(_activeView);
2963 }];
2964}
2965
2966- (BOOL)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2967 shouldDelayFocusForElement:(UIScribbleElementIdentifier)elementIdentifier
2968 API_AVAILABLE(ios(14.0)) {
2969 return NO;
2970}
2971
2972- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2973 willBeginWritingInElement:(UIScribbleElementIdentifier)elementIdentifier
2974 API_AVAILABLE(ios(14.0)) {
2975}
2976
2977- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2978 didFinishWritingInElement:(UIScribbleElementIdentifier)elementIdentifier
2979 API_AVAILABLE(ios(14.0)) {
2980}
2981
2982- (CGRect)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2983 frameForElement:(UIScribbleElementIdentifier)elementIdentifier
2984 API_AVAILABLE(ios(14.0)) {
2985 NSValue* elementValue = [_scribbleElements objectForKey:elementIdentifier];
2986 if (elementValue == nil) {
2987 return CGRectZero;
2988 }
2989 return [elementValue CGRectValue];
2990}
2991
2992- (void)indirectScribbleInteraction:(UIIndirectScribbleInteraction*)interaction
2993 requestElementsInRect:(CGRect)rect
2994 completion:
2995 (void (^)(NSArray<UIScribbleElementIdentifier>* elements))completion
2996 API_AVAILABLE(ios(14.0)) {
2997 [_indirectScribbleDelegate
2998 flutterTextInputPlugin:self
2999 requestElementsInRect:rect
3000 result:^(id _Nullable result) {
3001 NSMutableArray<UIScribbleElementIdentifier>* elements =
3002 [[NSMutableArray alloc] init];
3003 if ([result isKindOfClass:[NSArray class]]) {
3004 for (NSArray* elementArray in result) {
3005 [elements addObject:elementArray[0]];
3006 [_scribbleElements
3007 setObject:[NSValue
3008 valueWithCGRect:CGRectMake(
3009 [elementArray[1] floatValue],
3010 [elementArray[2] floatValue],
3011 [elementArray[3] floatValue],
3012 [elementArray[4] floatValue])]
3013 forKey:elementArray[0]];
3014 }
3015 }
3016 completion(elements);
3017 }];
3018}
3019
3020#pragma mark - Methods related to Scribble support
3021
3022- (void)setUpIndirectScribbleInteraction:(id<FlutterViewResponder>)viewResponder {
3023 if (_viewResponder != viewResponder) {
3024 if (@available(iOS 14.0, *)) {
3025 UIView* parentView = viewResponder.view;
3026 if (parentView != nil) {
3027 UIIndirectScribbleInteraction* scribbleInteraction = [[UIIndirectScribbleInteraction alloc]
3028 initWithDelegate:(id<UIIndirectScribbleInteractionDelegate>)self];
3029 [parentView addInteraction:scribbleInteraction];
3030 }
3031 }
3032 }
3033 _viewResponder = viewResponder;
3034}
3035
3036- (void)resetViewResponder {
3037 _viewResponder = nil;
3038}
3039
3040#pragma mark -
3041#pragma mark FlutterKeySecondaryResponder
3042
3043/**
3044 * Handles key down events received from the view controller, responding YES if
3045 * the event was handled.
3046 */
3047- (BOOL)handlePress:(nonnull FlutterUIPressProxy*)press API_AVAILABLE(ios(13.4)) {
3048 return NO;
3049}
3050@end
3051
3052/**
3053 * Recursively searches the UIView's subviews to locate the First Responder
3054 */
3055@implementation UIView (FindFirstResponder)
3057 if (self.isFirstResponder) {
3058 return self;
3059 }
3060 for (UIView* subView in self.subviews) {
3061 UIView* firstResponder = subView.flutterFirstResponder;
3062 if (firstResponder) {
3063 return firstResponder;
3064 }
3065 }
3066 return nil;
3067}
3068@end
const char * options
void(^ FlutterResult)(id _Nullable result)
FLUTTER_DARWIN_EXPORT NSObject const * FlutterMethodNotImplemented
UITextField * _textField
NSRange _range
int count
static const int points[]
instancetype initWithOwner:(FlutterTextInputPlugin *textInputPlugin)
id< UITextInputTokenizer > tokenizer()
void configureWithDictionary:(NSDictionary *configuration)
void replaceRangeLocal:withText:(NSRange range, [withText] NSString *text)
id< FlutterTextInputDelegate > textInputDelegate()
NSArray< UITextSelectionRect * > * rects()
AtkStateType state
glong glong end
G_BEGIN_DECLS G_MODULE_EXPORT FlValue * args
GAsyncResult * result
uint32_t * target
#define FML_DCHECK(condition)
Definition logging.h:103
NSMethodSignature * methodSignatureForSelector:(SEL aSelector)
instancetype positionWithIndex:(NSUInteger index)
instancetype positionWithIndex:affinity:(NSUInteger index,[affinity] UITextStorageDirection affinity)
UITextStorageDirection affinity
instancetype rangeWithNSRange:(NSRange range)
NSWritingDirection writingDirection
instancetype selectionRectWithRect:position:writingDirection:(CGRect rect,[position] NSUInteger position,[writingDirection] NSWritingDirection writingDirection)
instancetype selectionRectWithRectAndInfo:position:writingDirection:containsStart:containsEnd:isVertical:(CGRect rect,[position] NSUInteger position,[writingDirection] NSWritingDirection writingDirection,[containsStart] BOOL containsStart,[containsEnd] BOOL containsEnd,[isVertical] BOOL isVertical)
FlutterTextInputPlugin * target
instancetype proxyWithTarget:(FlutterTextInputPlugin *target)
fml::WeakNSObject< FlutterViewController > _viewController
fml::scoped_nsobject< FlutterTextInputPlugin > _textInputPlugin
FlutterTextInputPlugin * textInputPlugin
BOOL isScribbleAvailable
CGRect localRectFromFrameworkTransform
void resetScribbleInteractionStatusIfEnding
API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder UITextRange * selectedTextRange
instancetype initWithOwner
id< FlutterViewResponder > viewResponder
UITextSmartQuotesType smartQuotesType API_AVAILABLE(ios(11.0))
CGRect caretRectForPosition
UIKeyboardAppearance keyboardAppearance
static NSString * AutofillIdFromDictionary(NSDictionary *dictionary)
static UITextAutocapitalizationType ToUITextAutoCapitalizationType(NSDictionary *type)
static NSString *const kSetMarkedTextRectMethod
static NSString *const kFinishAutofillContextMethod
CGRect _cachedFirstRect
bool _isFloatingCursorActive
static BOOL IsEmoji(NSString *text, NSRange charRange)
static NSString *const kAutofillHints
static NSString *const kAutofillEditingValue
FlutterTextRange * _selectedTextRange
static NSString *const kSecureTextEntry
bool _enableInteractiveSelection
static BOOL ShouldShowSystemKeyboard(NSDictionary *type)
static NSString *const kShowMethod
static NSString *const kUpdateConfigMethod
static UIKeyboardType ToUIKeyboardType(NSDictionary *type)
CGPoint _floatingCursorOffset
static NSString *const kSmartDashesType
static FLUTTER_ASSERT_ARC const char kTextAffinityDownstream[]
static constexpr double kUITextInputAccessibilityEnablingDelaySeconds
static NSString *const kSetSelectionRectsMethod
typedef NS_ENUM(NSInteger, FlutterAutofillType)
static NSString *const kAutocorrectionType
static NSString *const kSetPlatformViewClientMethod
static NSString *const kAssociatedAutofillFields
static NSString *const kKeyboardType
static const NSTimeInterval kKeyboardAnimationDelaySeconds
static NSString *const kSetEditingStateMethod
UIInputViewController * _inputViewController
static NSString *const kAutofillId
static NSString *const kOnInteractiveKeyboardPointerUpMethod
bool _isSystemKeyboardEnabled
static NSString *const kClearClientMethod
static NSString *const kKeyboardAppearance
const CGRect kInvalidFirstRect
static FlutterAutofillType AutofillTypeOf(NSDictionary *configuration)
static NSString *const kSmartQuotesType
static NSString *const kEnableDeltaModel
FlutterScribbleInteractionStatus _scribbleInteractionStatus
static NSString *const kSetClientMethod
static NSString *const kEnableInteractiveSelection
static NSString *const kInputAction
static NSString *const kStartLiveTextInputMethod
static NSString *const kAutofillProperties
static BOOL IsFieldPasswordRelated(NSDictionary *configuration)
static const NSTimeInterval kKeyboardAnimationTimeToCompleteion
static BOOL IsApproximatelyEqual(float x, float y, float delta)
static NSString *const kDeprecatedSetSelectionRectsMethod
static const char kTextAffinityUpstream[]
static UIReturnKeyType ToUIReturnKeyType(NSString *inputType)
static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, CGRect selectionRect, BOOL selectionRectIsRTL, BOOL useTrailingBoundaryOfSelectionRect, CGRect otherSelectionRect, BOOL otherSelectionRectIsRTL, CGFloat verticalPrecision)
static NSString *const kHideMethod
static UITextContentType ToUITextContentType(NSArray< NSString * > *hints)
static NSString *const kSetEditableSizeAndTransformMethod
static NSString *const kOnInteractiveKeyboardPointerMoveMethod
BOOL _hasPlaceholder
const char * _selectionAffinity
size_t length
std::u16string text
CATransform3D _editableTransform
double y
double x
sk_sp< SkBlender > blender SkRect rect
Definition SkRecords.h:350
SK_API sk_sp< SkSurface > ios(9.0)
Definition copy.py:1
call(args)
Definition dom.py:159
DEF_SWITCHES_START aot vmservice shared library Name of the *so containing AOT compiled Dart assets for launching the service isolate vm snapshot The VM snapshot data that will be memory mapped as read only SnapshotAssetPath must be present isolate snapshot The isolate snapshot data that will be memory mapped as read only SnapshotAssetPath must be present cache dir Path to the cache directory This is different from the persistent_cache_path in embedder which is used for Skia shader cache icu native lib Path to the library file that exports the ICU data vm service host
Definition switches.h:74
NSRange RangeForCharacterAtIndex(NSString *text, NSUInteger index)
static SkColor4f transform(SkColor4f c, SkColorSpace *src, SkColorSpace *dst)
Definition p3.cpp:47
SkScalar w
int32_t width
Point offset
const uintptr_t id
#define NSEC_PER_SEC
Definition timerfd.cc:35
int BOOL