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