Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qiostextinputoverlay.mm
Go to the documentation of this file.
1// Copyright (C) 2017 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3// Qt-Security score:significant reason:default
4
5#import <UIKit/UIGestureRecognizerSubclass.h>
6#import <UIKit/UITextView.h>
7
8#include <QtGui/QGuiApplication>
9#include <QtGui/QInputMethod>
10#include <QtGui/QStyleHints>
11
12#include <QtGui/private/qinputmethod_p.h>
13#include <QtCore/private/qobject_p.h>
14#include <QtCore/private/qcore_mac_p.h>
15
16#include "qiosglobal.h"
18#include "qioswindow.h"
19#include "quiview.h"
20
21#include <utility> // for std::pair
22
23typedef std::pair<int, int> SelectionPair;
24typedef void (^Block)(void);
25
26static const CGFloat kKnobWidth = 10;
27
29{
30 return static_cast<QInputMethodPrivate *>(QObjectPrivate::get(QGuiApplication::inputMethod()))->platformInputContext();
31}
32
34{
35 QInputMethodQueryEvent query(Qt::ImAnchorPosition | Qt::ImCursorPosition);
36 QGuiApplication::sendEvent(QGuiApplication::focusObject(), &query);
37 int anchorPos = query.value(Qt::ImAnchorPosition).toInt();
38 int cursorPos = query.value(Qt::ImCursorPosition).toInt();
39 return {anchorPos, cursorPos};
40}
41
42static bool hasSelection()
43{
44 SelectionPair selection = querySelection();
45 return selection.first != selection.second;
46}
47
49{
50 [CATransaction begin];
51 [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
52 block();
53 [CATransaction commit];
54}
55
56// -------------------------------------------------------------------------
57/**
58 QIOSEditMenu is just a wrapper class around UIMenuController to
59 ease showing and hiding it correctly.
60 */
61@interface QIOSEditMenu : NSObject
62@property (nonatomic, assign) BOOL visible;
63@property (nonatomic, readonly) BOOL isHiding;
64@property (nonatomic, readonly) BOOL shownByUs;
65@property (nonatomic, assign) BOOL reshowAfterHidden;
66@end
67
68@implementation QIOSEditMenu
69
70- (instancetype)init
71{
72 if (self = [super init]) {
73 NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
74
75 [center addObserverForName:UIMenuControllerWillHideMenuNotification
76 object:nil queue:nil usingBlock:^(NSNotification *) {
77 _isHiding = YES;
78 }];
79
80 [center addObserverForName:UIMenuControllerDidHideMenuNotification
81 object:nil queue:nil usingBlock:^(NSNotification *) {
82 _isHiding = NO;
83 _shownByUs = NO;
84 if (self.reshowAfterHidden) {
85 // To not abort an ongoing hide transition when showing the menu, you can set
86 // reshowAfterHidden to wait until the transition finishes before reshowing it.
87 self.reshowAfterHidden = NO;
88 dispatch_async(dispatch_get_main_queue (), ^{ self.visible = YES; });
89 }
90 }];
91 [center addObserverForName:UIKeyboardDidHideNotification object:nil queue:nil
92 usingBlock:^(NSNotification *) {
93 self.visible = NO;
94 }];
95
96 }
97
98 return self;
99}
100
101- (void)dealloc
102{
103 [[NSNotificationCenter defaultCenter] removeObserver:self name:nil object:nil];
104 [super dealloc];
105}
106
107- (BOOL)visible
108{
109 return [UIMenuController sharedMenuController].menuVisible;
110}
111
112- (void)setVisible:(BOOL)visible
113{
114 if (visible == self.visible)
115 return;
116
117 if (visible) {
118 // UIMenuController is a singleton that can be shown (and hidden) from anywhere.
119 // Try to keep track of whether or not is was shown by us (the gesture recognizers
120 // in this file) to avoid closing it if it was opened from elsewhere.
121 _shownByUs = YES;
122 // Note that the contents of the edit menu is decided by
123 // first responder, which is normally QIOSTextResponder.
124 QRectF cr = QPlatformInputContext::cursorRectangle();
125 QRectF ar = QPlatformInputContext::anchorRectangle();
126
127 CGRect targetRect = cr.united(ar).toCGRect();
128 UIView *focusView = reinterpret_cast<UIView *>(qApp->focusWindow()->winId());
129 [[UIMenuController sharedMenuController] setTargetRect:targetRect inView:focusView];
130 [[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
131 } else {
132 [[UIMenuController sharedMenuController] setMenuVisible:NO animated:YES];
133 }
134}
135
136@end
137
138void showEditMenu(UIView *focusView, QPoint touchPos)
139{
140 const bool mouseTriggered = false;
141 const Qt::KeyboardModifiers keyboardModifiers = Qt::NoModifier;
142 QWindow *qtWindow = quiview_cast(focusView).platformWindow->window();
143 const auto globalTouchPos = qtWindow->mapToGlobal(touchPos);
144 const bool contextMenuEventAccepted = QWindowSystemInterface::handleContextMenuEvent<
145 QWindowSystemInterface::SynchronousDelivery>(qtWindow, mouseTriggered, touchPos,
146 globalTouchPos, keyboardModifiers);
147
148 if (!contextMenuEventAccepted) {
149 // Fall back to show the default platform menu, like we did
150 // before we started sending context menu events. This is
151 // to be backwards compatible with Widgets and Quick items.
152 QIOSTextInputOverlay::s_editMenu.visible = YES;
153 }
154}
155
156// -------------------------------------------------------------------------
157
158@interface QIOSLoupeLayer : CALayer
159@property (nonatomic, retain) UIView *targetView;
160@property (nonatomic, assign) CGPoint focalPoint;
161@property (nonatomic, assign) BOOL visible;
162@end
163
164@implementation QIOSLoupeLayer {
165 UIView *_snapshotView;
166 BOOL _pendingSnapshotUpdate;
167 UIView *_loupeImageView;
168 CALayer *_containerLayer;
169 CGFloat _loupeOffset;
170 QTimer _updateTimer;
171}
172
173- (instancetype)initWithSize:(CGSize)size cornerRadius:(CGFloat)cornerRadius bottomOffset:(CGFloat)bottomOffset
174{
175 if (self = [super init]) {
176 _loupeOffset = bottomOffset + (size.height / 2);
177 _snapshotView = nil;
178 _pendingSnapshotUpdate = YES;
179 _updateTimer.setInterval(100);
180 _updateTimer.setSingleShot(true);
181 QObject::connect(&_updateTimer, &QTimer::timeout, [self](){ [self updateSnapshot]; });
182
183 // Create own geometry and outer shadow
184 self.frame = CGRectMake(0, 0, size.width, size.height);
185 self.cornerRadius = cornerRadius;
186 self.shadowColor = [[UIColor grayColor] CGColor];
187 self.shadowOffset = CGSizeMake(0, 1);
188 self.shadowRadius = 2.0;
189 self.shadowOpacity = 0.75;
190 self.transform = CATransform3DMakeScale(0, 0, 0);
191
192 // Create container view for the snapshots
193 _containerLayer = [[CALayer new] autorelease];
194 _containerLayer.frame = self.bounds;
195 _containerLayer.cornerRadius = cornerRadius;
196 _containerLayer.masksToBounds = YES;
197 [self addSublayer:_containerLayer];
198
199 // Create inner loupe shadow
200 const CGFloat inset = 30;
201 CALayer *topShadeLayer = [[CALayer new] autorelease];
202 topShadeLayer.frame = CGRectOffset(CGRectInset(self.bounds, -inset, -inset), 0, inset / 2);
203 topShadeLayer.borderWidth = inset / 2;
204 topShadeLayer.cornerRadius = cornerRadius;
205 topShadeLayer.borderColor = [[UIColor blackColor] CGColor];
206 topShadeLayer.shadowColor = [[UIColor blackColor] CGColor];
207 topShadeLayer.shadowOffset = CGSizeMake(0, 0);
208 topShadeLayer.shadowRadius = 15.0;
209 topShadeLayer.shadowOpacity = 0.6;
210 // Keep the shadow inside the loupe
211 CALayer *mask = [[CALayer new] autorelease];
212 mask.frame = CGRectOffset(self.bounds, inset, inset / 2);
213 mask.backgroundColor = [[UIColor blackColor] CGColor];
214 mask.cornerRadius = cornerRadius;
215 topShadeLayer.mask = mask;
216 [self addSublayer:topShadeLayer];
217
218 // Create border around the loupe. We need to do this in a separate
219 // layer (as opposed to on self) to not draw the border on top of
220 // overlapping external children (arrow).
221 CALayer *borderLayer = [[CALayer new] autorelease];
222 borderLayer.frame = self.bounds;
223 borderLayer.borderWidth = 0.75;
224 borderLayer.cornerRadius = cornerRadius;
225 borderLayer.borderColor = [[UIColor lightGrayColor] CGColor];
226 [self addSublayer:borderLayer];
227 }
228
229 return self;
230}
231
232- (void)dealloc
233{
234 _targetView = nil;
235 [super dealloc];
236}
237
238- (void)setVisible:(BOOL)visible
239{
240 if (_visible == visible)
241 return;
242
243 _visible = visible;
244
245 dispatch_async(dispatch_get_main_queue (), ^{
246 // Setting transform later, since CA will not perform an animation if
247 // changing values directly after init, and if the scale ends up empty.
248 self.transform = _visible ? CATransform3DMakeScale(1, 1, 1) : CATransform3DMakeScale(0.0, 0.0, 1);
249 });
250}
251
252- (void)updateSnapshot
253{
254 _pendingSnapshotUpdate = YES;
255 [self setNeedsDisplay];
256}
257
258- (void)setFocalPoint:(CGPoint)point
259{
260 _focalPoint = point;
261 [self updateSnapshot];
262
263 // Schedule a delayed update as well to ensure that we end up with a correct
264 // snapshot of the cursor, since QQuickRenderThread lags a bit behind
265 _updateTimer.start();
266}
267
268- (void)display
269{
270 // Take a snapshow of the target view, magnify the area around the focal
271 // point, and add the snapshow layer as a child of the container layer
272 // to make it look like a loupe. Then place this layer at the position of
273 // the focal point with the requested offset.
274 executeBlockWithoutAnimation(^{
275 if (_pendingSnapshotUpdate) {
276 UIView *newSnapshot = [_targetView snapshotViewAfterScreenUpdates:NO];
277 [_snapshotView.layer removeFromSuperlayer];
278 [_snapshotView release];
279 _snapshotView = [newSnapshot retain];
280 [_containerLayer addSublayer:_snapshotView.layer];
281 _pendingSnapshotUpdate = NO;
282 }
283
284 self.position = CGPointMake(_focalPoint.x, _focalPoint.y - _loupeOffset);
285
286 const CGFloat loupeScale = 1.5;
287 CGFloat x = -(_focalPoint.x * loupeScale) + self.frame.size.width / 2;
288 CGFloat y = -(_focalPoint.y * loupeScale) + self.frame.size.height / 2;
289 CGFloat w = _targetView.frame.size.width * loupeScale;
290 CGFloat h = _targetView.frame.size.height * loupeScale;
291 _snapshotView.layer.frame = CGRectMake(x, y, w, h);
292 });
293}
294
295@end
296
297// -------------------------------------------------------------------------
298
299@interface QIOSHandleLayer : CALayer <CAAnimationDelegate>
300@property (nonatomic, assign) CGRect cursorRectangle;
301@property (nonatomic, assign) CGFloat handleScale;
302@property (nonatomic, assign) BOOL visible;
303@property (nonatomic, copy) Block onAnimationDidStop;
304@end
305
306@implementation QIOSHandleLayer {
307 CALayer *_handleCursorLayer;
308 CALayer *_handleKnobLayer;
309 Qt::Edge _selectionEdge;
310}
311
312@dynamic handleScale;
313
314- (instancetype)initWithKnobAtEdge:(Qt::Edge)selectionEdge
315{
316 if (self = [super init]) {
317 CGColorRef bgColor = [UIColor colorWithRed:0.1 green:0.4 blue:0.9 alpha:1].CGColor;
318 _selectionEdge = selectionEdge;
319 self.handleScale = 0;
320
321 _handleCursorLayer = [[CALayer new] autorelease];
322 _handleCursorLayer.masksToBounds = YES;
323 _handleCursorLayer.backgroundColor = bgColor;
324 [self addSublayer:_handleCursorLayer];
325
326 _handleKnobLayer = [[CALayer new] autorelease];
327 _handleKnobLayer.masksToBounds = YES;
328 _handleKnobLayer.backgroundColor = bgColor;
329 _handleKnobLayer.cornerRadius = kKnobWidth / 2;
330 [self addSublayer:_handleKnobLayer];
331 }
332 return self;
333}
334
335+ (BOOL)needsDisplayForKey:(NSString *)key
336{
337 if ([key isEqualToString:@"handleScale"])
338 return YES;
339 return [super needsDisplayForKey:key];
340}
341
342- (id<CAAction>)actionForKey:(NSString *)key
343{
344 if ([key isEqualToString:@"handleScale"]) {
345 if (_visible) {
346 // The handle should "bounce" in when becoming visible
347 CAKeyframeAnimation * animation = [CAKeyframeAnimation animationWithKeyPath:key];
348 [animation setDuration:0.5];
349 animation.values = @[@(0.0f), @(1.3f), @(1.3f), @(1.0f)];
350 animation.keyTimes = @[@(0.0f), @(0.3f), @(0.9f), @(1.0f)];
351 return animation;
352 } else {
353 CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
354 [animation setDelegate:self];
355 animation.fromValue = [self valueForKey:key];
356 [animation setDuration:0.2];
357 return animation;
358 }
359 }
360 return [super actionForKey:key];
361}
362
363- (void)animationDidStop:(CAAnimation *)animation finished:(BOOL)flag
364{
365 Q_UNUSED(animation);
366 Q_UNUSED(flag);
367 if (self.onAnimationDidStop)
368 self.onAnimationDidStop();
369}
370
371- (void)setVisible:(BOOL)visible
372{
373 if (visible == _visible)
374 return;
375
376 _visible = visible;
377
378 self.handleScale = visible ? 1 : 0;
379}
380
381- (void)setCursorRectangle:(CGRect)cursorRect
382{
383 if (CGRectEqualToRect(_cursorRectangle, cursorRect))
384 return;
385
386 _cursorRectangle = cursorRect;
387
388 executeBlockWithoutAnimation(^{
389 [self setNeedsDisplay];
390 [self displayIfNeeded];
391 });
392}
393
394- (void)display
395{
396 CGFloat cursorWidth = 2;
397 CGPoint origin = _cursorRectangle.origin;
398 CGSize size = _cursorRectangle.size;
399 CGFloat scale = ((QIOSHandleLayer *)[self presentationLayer]).handleScale;
400 CGFloat edgeAdjustment = (_selectionEdge == Qt::LeftEdge) ? 0.5 - cursorWidth : -0.5;
401
402 CGFloat cursorX = origin.x + (size.width / 2) + edgeAdjustment;
403 CGFloat cursorY = origin.y;
404 CGFloat knobX = cursorX - (kKnobWidth - cursorWidth) / 2;
405 CGFloat knobY = origin.y + ((_selectionEdge == Qt::LeftEdge) ? -kKnobWidth : size.height);
406
407 _handleCursorLayer.frame = CGRectMake(cursorX, cursorY, cursorWidth, size.height);
408 _handleKnobLayer.frame = CGRectMake(knobX, knobY, kKnobWidth, kKnobWidth);
409 _handleCursorLayer.transform = CATransform3DMakeScale(1, scale, scale);
410 _handleKnobLayer.transform = CATransform3DMakeScale(scale, scale, scale);
411}
412
413@end
414
415// -------------------------------------------------------------------------
416
417/**
418 QIOSLoupeRecognizer is only a base class from which other recognisers
419 below will inherit. It takes care of creating and showing a magnifier
420 glass depending on the current gesture state.
421 */
422@interface QIOSLoupeRecognizer : UIGestureRecognizer <UIGestureRecognizerDelegate>
423@property (nonatomic, assign) QPointF focalPoint;
424@property (nonatomic, assign) BOOL dragTriggersGesture;
425@property (nonatomic, readonly) UIView *focusView;
426@end
427
428@implementation QIOSLoupeRecognizer {
429 QIOSLoupeLayer *_loupeLayer;
430 UIView *_desktopView;
431 CGPoint _firstTouchPoint;
432 CGPoint _lastTouchPoint;
433 QTimer _triggerStateBeganTimer;
434 int _originalCursorFlashTime;
435}
436
437- (instancetype)init
438{
439 if (self = [super initWithTarget:self action:@selector(gestureStateChanged)]) {
440 self.enabled = NO;
441 _triggerStateBeganTimer.setInterval(QGuiApplication::styleHints()->startDragTime());
442 _triggerStateBeganTimer.setSingleShot(true);
443 QObject::connect(&_triggerStateBeganTimer, &QTimer::timeout, [=](){
444 self.state = UIGestureRecognizerStateBegan;
445 });
446 }
447
448 return self;
449}
450
451- (void)setEnabled:(BOOL)enabled
452{
453 if (enabled == self.enabled)
454 return;
455
456 [super setEnabled:enabled];
457
458 if (enabled) {
459 _focusView = [reinterpret_cast<UIView *>(qApp->focusWindow()->winId()) retain];
460 _desktopView = [presentationWindow(nullptr).rootViewController.view retain];
461 Q_ASSERT(_focusView && _desktopView && _desktopView.superview);
462 [_desktopView addGestureRecognizer:self];
463 } else {
464 [_desktopView removeGestureRecognizer:self];
465 [_desktopView release];
466 _desktopView = nil;
467 [_focusView release];
468 _focusView = nil;
469 _triggerStateBeganTimer.stop();
470 if (_loupeLayer) {
471 [_loupeLayer removeFromSuperlayer];
472 [_loupeLayer release];
473 _loupeLayer = nil;
474 }
475 }
476}
477
478- (void)gestureStateChanged
479{
480 switch (self.state) {
481 case UIGestureRecognizerStateBegan:
482 // Stop cursor blinking, and show the loupe
483 _originalCursorFlashTime = QGuiApplication::styleHints()->cursorFlashTime();
484 QGuiApplication::styleHints()->setCursorFlashTime(0);
485 if (!_loupeLayer)
486 [self createLoupe];
487 [self updateFocalPoint:QPointF::fromCGPoint(_lastTouchPoint)];
488 _loupeLayer.visible = YES;
489 QIOSTextInputOverlay::s_editMenu.visible = NO;
490 break;
491 case UIGestureRecognizerStateChanged:
492 // Tell the sub class to move the loupe to the correct position
493 [self updateFocalPoint:QPointF::fromCGPoint(_lastTouchPoint)];
494 break;
495 case UIGestureRecognizerStateEnded: {
496 // Restore cursor blinking, and hide the loupe
497 QGuiApplication::styleHints()->setCursorFlashTime(_originalCursorFlashTime);
498 const QPoint touchPos = QPointF::fromCGPoint(_lastTouchPoint).toPoint();
499 showEditMenu(_focusView, touchPos);
500 _loupeLayer.visible = NO;
501 break;
502 }
503 default:
504 _loupeLayer.visible = NO;
505 break;
506 }
507}
508
509- (void)createLoupe
510{
511 // We magnify the desktop view. But the loupe itself will be added as a child
512 // of the desktop view's parent, so it doesn't become a part of what we magnify.
513 _loupeLayer = [[self createLoupeLayer] retain];
514 _loupeLayer.targetView = _desktopView;
515 [_desktopView.superview.layer addSublayer:_loupeLayer];
516}
517
518- (QPointF)focalPoint
519{
520 return QPointF::fromCGPoint([_loupeLayer.targetView convertPoint:_loupeLayer.focalPoint toView:_focusView]);
521}
522
523- (void)setFocalPoint:(QPointF)point
524{
525 _loupeLayer.focalPoint = [_loupeLayer.targetView convertPoint:point.toCGPoint() fromView:_focusView];
526}
527
528- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
529{
530 [super touchesBegan:touches withEvent:event];
531 if ([event allTouches].count > 1) {
532 // We only support text selection with one finger
533 self.state = UIGestureRecognizerStateFailed;
534 return;
535 }
536
537 _firstTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
538 _lastTouchPoint = _firstTouchPoint;
539
540 // If the touch point is accepted by the sub class (e.g touch on cursor), we start a
541 // press'n'hold timer that eventually will move the state to UIGestureRecognizerStateBegan.
542 if ([self acceptTouchesBegan:QPointF::fromCGPoint(_firstTouchPoint)])
543 _triggerStateBeganTimer.start();
544 else
545 self.state = UIGestureRecognizerStateFailed;
546}
547
548- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
549{
550 [super touchesMoved:touches withEvent:event];
551 _lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
552
553 if (self.state == UIGestureRecognizerStatePossible) {
554 // If the touch was moved too far before the timer triggered (meaning that this
555 // is a drag, not a press'n'hold), we should either fail, or trigger the gesture
556 // immediately, depending on self.dragTriggersGesture.
557 int startDragDistance = QGuiApplication::styleHints()->startDragDistance();
558 int dragDistance = hypot(_firstTouchPoint.x - _lastTouchPoint.x, _firstTouchPoint.y - _lastTouchPoint.y);
559 if (dragDistance > startDragDistance) {
560 _triggerStateBeganTimer.stop();
561 self.state = self.dragTriggersGesture ? UIGestureRecognizerStateBegan : UIGestureRecognizerStateFailed;
562 }
563 } else {
564 self.state = UIGestureRecognizerStateChanged;
565 }
566}
567
568- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
569{
570 [super touchesEnded:touches withEvent:event];
571 _triggerStateBeganTimer.stop();
572 _lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
573 self.state = self.state == UIGestureRecognizerStatePossible ? UIGestureRecognizerStateFailed : UIGestureRecognizerStateEnded;
574}
575
576- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
577{
578 [super touchesCancelled:touches withEvent:event];
579 _triggerStateBeganTimer.stop();
580 _lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
581 self.state = UIGestureRecognizerStateCancelled;
582}
583
584// Methods implemented by subclasses:
585
586- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
587{
588 Q_UNUSED(touchPoint);
589 Q_UNREACHABLE();
590 return NO;
591}
592
593- (QIOSLoupeLayer *)createLoupeLayer
594{
595 Q_UNREACHABLE();
596 return nullptr;
597}
598
599- (void)updateFocalPoint:(QPointF)touchPoint
600{
601 Q_UNUSED(touchPoint);
602 Q_UNREACHABLE();
603}
604
605@end
606
607// -------------------------------------------------------------------------
608
609/**
610 This recognizer will be active when there's no selection. It will trigger if
611 the user does a press and hold, which will start a session where the user can move
612 the cursor around with his finger together with a magnifier glass.
613 */
614@interface QIOSCursorRecognizer : QIOSLoupeRecognizer
615@end
616
617@implementation QIOSCursorRecognizer
618
619- (QIOSLoupeLayer *)createLoupeLayer
620{
621 return [[[QIOSLoupeLayer alloc] initWithSize:CGSizeMake(120, 120) cornerRadius:60 bottomOffset:4] autorelease];
622}
623
624- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
625{
626 QRectF inputRect = QPlatformInputContext::inputItemRectangle();
627 return !hasSelection() && inputRect.contains(touchPoint);
628}
629
630- (void)updateFocalPoint:(QPointF)touchPoint
631{
632 self.focalPoint = touchPoint;
633
634 const int currentCursorPos = QInputMethod::queryFocusObject(Qt::ImCursorPosition, QVariant()).toInt();
635 const int newCursorPos = QPlatformInputContext::queryFocusObject(Qt::ImCursorPosition, touchPoint).toInt();
636 if (newCursorPos != currentCursorPos)
637 QPlatformInputContext::setSelectionOnFocusObject(touchPoint, touchPoint);
638}
639
640@end
641
642// -------------------------------------------------------------------------
643
644/**
645 This recognizer will watch for selections, and draw handles as overlay
646 on the sides. If the user starts dragging on a handle (or do a press and
647 hold), it will show a magnifier glass that follows the handle as it moves.
648 */
649@interface QIOSSelectionRecognizer : QIOSLoupeRecognizer
650@end
651
652@implementation QIOSSelectionRecognizer {
653 CALayer *_clipRectLayer;
654 QIOSHandleLayer *_cursorLayer;
655 QIOSHandleLayer *_anchorLayer;
656 QPointF _touchOffset;
657 bool _dragOnCursor;
658 bool _dragOnAnchor;
659 bool _multiLine;
660 QTimer _updateSelectionTimer;
661 QMetaObject::Connection _cursorConnection;
662 QMetaObject::Connection _anchorConnection;
663 QMetaObject::Connection _clipRectConnection;
664}
665
666- (instancetype)init
667{
668 if (self = [super init]) {
669 self.delaysTouchesBegan = YES;
670 self.dragTriggersGesture = YES;
671 _multiLine = QInputMethod::queryFocusObject(Qt::ImHints, QVariant()).toUInt() & Qt::ImhMultiLine;
672 _updateSelectionTimer.setInterval(1);
673 _updateSelectionTimer.setSingleShot(true);
674 QObject::connect(&_updateSelectionTimer, &QTimer::timeout, [self](){ [self updateSelection]; });
675 }
676
677 return self;
678}
679
680- (void)setEnabled:(BOOL)enabled
681{
682 if (enabled == self.enabled)
683 return;
684
685 [super setEnabled:enabled];
686
687 if (enabled) {
688 // Create a layer that clips the handles inside the input field
689 _clipRectLayer = [CALayer new];
690 _clipRectLayer.masksToBounds = YES;
691 [self.focusView.layer addSublayer:_clipRectLayer];
692
693 // Create the handle layers, and add them to the clipped input rect layer
694 _cursorLayer = [[[QIOSHandleLayer alloc] initWithKnobAtEdge:Qt::RightEdge] autorelease];
695 _anchorLayer = [[[QIOSHandleLayer alloc] initWithKnobAtEdge:Qt::LeftEdge] autorelease];
696 bool selection = hasSelection();
697 _cursorLayer.visible = selection;
698 _anchorLayer.visible = selection;
699 [_clipRectLayer addSublayer:_cursorLayer];
700 [_clipRectLayer addSublayer:_anchorLayer];
701
702 // iOS text input will sometimes set a temporary text selection to perform operations
703 // such as backspace (select last character + cut selection). To avoid briefly showing
704 // the selection handles for such cases, and to avoid calling updateSelection when
705 // both handles and clip rectangle change, we use a timer to wait a cycle before we update.
706 // (Note that since QTimer::start is overloaded, we need some extra syntax for the connections).
707 QInputMethod *im = QGuiApplication::inputMethod();
708 void(QTimer::*start)(void) = &QTimer::start;
709 _cursorConnection = QObject::connect(im, &QInputMethod::cursorRectangleChanged, &_updateSelectionTimer, start);
710 _anchorConnection = QObject::connect(im, &QInputMethod::anchorRectangleChanged, &_updateSelectionTimer, start);
711 _clipRectConnection = QObject::connect(im, &QInputMethod::inputItemClipRectangleChanged, &_updateSelectionTimer, start);
712
713 [self updateSelection];
714 } else {
715 // Fade out the handles by setting visible to NO, and wait for the animations
716 // to finish before removing the clip rect layer, including the handles.
717 // Create a local variable to hold the clipRectLayer while the animation is
718 // ongoing to ensure that any subsequent calls to setEnabled does not interfere.
719 // Also, declare it as __block to stop it from being automatically retained, which
720 // would cause a cyclic dependency between clipRectLayer and the block.
721 __block CALayer *clipRectLayer = _clipRectLayer;
722 __block int handleCount = 2;
723 Block block = ^{
724 if (--handleCount == 0) {
725 [clipRectLayer removeFromSuperlayer];
726 [clipRectLayer release];
727 }
728 };
729
730 _cursorLayer.onAnimationDidStop = block;
731 _anchorLayer.onAnimationDidStop = block;
732 _cursorLayer.visible = NO;
733 _anchorLayer.visible = NO;
734
735 _clipRectLayer = 0;
736 _cursorLayer = 0;
737 _anchorLayer = 0;
738 _updateSelectionTimer.stop();
739
740 QObject::disconnect(_cursorConnection);
741 QObject::disconnect(_anchorConnection);
742 QObject::disconnect(_clipRectConnection);
743
744 if (QIOSTextInputOverlay::s_editMenu.shownByUs)
745 QIOSTextInputOverlay::s_editMenu.visible = NO;
746 }
747}
748
749- (QIOSLoupeLayer *)createLoupeLayer
750{
751 CGSize loupeSize = CGSizeMake(123, 33);
752 CGSize arrowSize = CGSizeMake(25, 12);
753 CGFloat loupeOffset = arrowSize.height + 20;
754
755 // Create loupe and arrow layers
756 QIOSLoupeLayer *loupeLayer = [[[QIOSLoupeLayer alloc] initWithSize:loupeSize cornerRadius:5 bottomOffset:loupeOffset] autorelease];
757 CAShapeLayer *arrowLayer = [[[CAShapeLayer alloc] init] autorelease];
758
759 // Build a triangular path to both draw and mask the arrow layer as a triangle
760 UIBezierPath *path = [[UIBezierPath new] autorelease];
761 [path moveToPoint:CGPointMake(0, 0)];
762 [path addLineToPoint:CGPointMake(arrowSize.width / 2, arrowSize.height)];
763 [path addLineToPoint:CGPointMake(arrowSize.width, 0)];
764
765 arrowLayer.frame = CGRectMake((loupeSize.width - arrowSize.width) / 2, loupeSize.height - 1, arrowSize.width, arrowSize.height);
766 arrowLayer.path = path.CGPath;
767 arrowLayer.backgroundColor = [[UIColor whiteColor] CGColor];
768 arrowLayer.strokeColor = [[UIColor lightGrayColor] CGColor];
769 arrowLayer.lineWidth = 0.75 * 2;
770 arrowLayer.fillColor = nil;
771
772 CAShapeLayer *mask = [[CAShapeLayer new] autorelease];
773 mask.frame = arrowLayer.bounds;
774 mask.path = path.CGPath;
775 arrowLayer.mask = mask;
776
777 [loupeLayer addSublayer:arrowLayer];
778
779 return loupeLayer;
780}
781
782- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
783{
784 if (!hasSelection())
785 return NO;
786
787 // Accept the touch if it "overlaps" with any of the handles
788 const int handleRadius = 50;
789 QPointF cursorCenter = QPlatformInputContext::cursorRectangle().center();
790 QPointF anchorCenter = QPlatformInputContext::anchorRectangle().center();
791 QPointF cursorOffset = QPointF(cursorCenter.x() - touchPoint.x(), cursorCenter.y() - touchPoint.y());
792 QPointF anchorOffset = QPointF(anchorCenter.x() - touchPoint.x(), anchorCenter.y() - touchPoint.y());
793 double cursorDist = hypot(cursorOffset.x(), cursorOffset.y());
794 double anchorDist = hypot(anchorOffset.x(), anchorOffset.y());
795
796 if (cursorDist > handleRadius && anchorDist > handleRadius)
797 return NO;
798
799 if (cursorDist < anchorDist) {
800 _touchOffset = cursorOffset;
801 _dragOnCursor = YES;
802 _dragOnAnchor = NO;
803 } else {
804 _touchOffset = anchorOffset;
805 _dragOnCursor = NO;
806 _dragOnAnchor = YES;
807 }
808
809 return YES;
810}
811
812- (void)updateFocalPoint:(QPointF)touchPoint
813{
814 touchPoint += _touchOffset;
815
816 // Get the text position under the touch
817 SelectionPair selection = querySelection();
818 int touchTextPos = QPlatformInputContext::queryFocusObject(Qt::ImCursorPosition, touchPoint).toInt();
819
820 // Ensure that the handles cannot be dragged past each other
821 if (_dragOnCursor)
822 selection.second = (touchTextPos > selection.first) ? touchTextPos : selection.first + 1;
823 else
824 selection.first = (touchTextPos < selection.second) ? touchTextPos : selection.second - 1;
825
826 // Set new selection
827 QList<QInputMethodEvent::Attribute> imAttributes;
828 imAttributes.append(QInputMethodEvent::Attribute(
829 QInputMethodEvent::Selection, selection.first, selection.second - selection.first, QVariant()));
830 QInputMethodEvent event(QString(), imAttributes);
831 QGuiApplication::sendEvent(qApp->focusObject(), &event);
832
833 // Move loupe to new position
834 QRectF handleRect = _dragOnCursor ?
835 QPlatformInputContext::cursorRectangle() :
836 QPlatformInputContext::anchorRectangle();
837 self.focalPoint = QPointF(touchPoint.x(), handleRect.center().y());
838}
839
840- (void)updateSelection
841{
842 if (!hasSelection()) {
843 if (_cursorLayer.visible) {
844 _cursorLayer.visible = NO;
845 _anchorLayer.visible = NO;
846 }
847 if (QIOSTextInputOverlay::s_editMenu.shownByUs)
848 QIOSTextInputOverlay::s_editMenu.visible = NO;
849 return;
850 }
851
852 if (!_cursorLayer.visible && QIOSTextInputOverlay::s_editMenu.isHiding) {
853 // Since the edit menu is hiding and this is the first selection thereafter, we
854 // assume that the selection came from the user tapping on a menu item. In that
855 // case, we reshow the menu after it has closed (but then with selection based
856 // menu items, as specified by first responder).
857 QIOSTextInputOverlay::s_editMenu.reshowAfterHidden = YES;
858 }
859
860 // Adjust handles and input rect to match the new selection
861 QRectF inputRect = QPlatformInputContext::inputItemClipRectangle();
862 CGRect cursorRect = QPlatformInputContext::cursorRectangle().toCGRect();
863 CGRect anchorRect = QPlatformInputContext::anchorRectangle().toCGRect();
864
865 if (!_multiLine) {
866 // Resize the layer a bit bigger to ensure that the handles are
867 // not cut if if they are otherwise visible inside the clip rect.
868 int margin = kKnobWidth + 5;
869 inputRect.adjust(-margin / 2, -margin, margin / 2, margin);
870 }
871
872 executeBlockWithoutAnimation(^{ _clipRectLayer.frame = inputRect.toCGRect(); });
873 _cursorLayer.cursorRectangle = [self.focusView.layer convertRect:cursorRect toLayer:_clipRectLayer];
874 _anchorLayer.cursorRectangle = [self.focusView.layer convertRect:anchorRect toLayer:_clipRectLayer];
875 _cursorLayer.visible = YES;
876 _anchorLayer.visible = YES;
877}
878
879@end
880
881// -------------------------------------------------------------------------
882
883/**
884 This recognizer will show the edit menu if the user taps inside the input
885 item without changing the cursor position, or hide it if it's already visible
886 and the user taps anywhere on the screen.
887 */
888@interface QIOSTapRecognizer : UITapGestureRecognizer
889@end
890
891@implementation QIOSTapRecognizer {
892 int _cursorPosOnPress;
893 bool _menuShouldBeVisible;
894 UIView *_focusView;
895}
896
897- (instancetype)init
898{
899 if (self = [super initWithTarget:self action:@selector(gestureStateChanged)]) {
900 self.enabled = NO;
901 }
902
903 return self;
904}
905
906- (void)setEnabled:(BOOL)enabled
907{
908 if (enabled == self.enabled)
909 return;
910
911 [super setEnabled:enabled];
912
913 if (enabled) {
914 _focusView = [reinterpret_cast<UIView *>(qApp->focusWindow()->winId()) retain];
915 [_focusView addGestureRecognizer:self];
916 } else {
917 [_focusView removeGestureRecognizer:self];
918 [_focusView release];
919 _focusView = nil;
920 }
921}
922
923- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
924{
925 [super touchesBegan:touches withEvent:event];
926
927 QRectF inputRect = QPlatformInputContext::inputItemClipRectangle();
928 QPointF touchPos = QPointF::fromCGPoint([static_cast<UITouch *>([touches anyObject]) locationInView:_focusView]);
929 const bool touchInsideInputArea = inputRect.contains(touchPos);
930
931 if (touchInsideInputArea && hasSelection()) {
932 // When we have a selection and the user taps inside the input area, we stop
933 // tracking, and let Qt handle the event like normal. Unless the selection
934 // recogniser is triggered instead (if the touch is on top of the selection
935 // handles) this will typically result in Qt clearing the selection, which in
936 // turn will make the selection recogniser hide the menu.
937 self.state = UIGestureRecognizerStateFailed;
938 return;
939 }
940
941 if (QIOSTextInputOverlay::s_editMenu.visible) {
942 // When the menu is visible and there is no selection, we should always
943 // hide it, regardless of where the user tapped on the screen. We achieve
944 // this by continue tracking so that we receive a touchesEnded call.
945 // But note, we only want to hide the menu, and not clear the selection.
946 // Only when the user taps inside the input area do we want to clear the
947 // selection as well. This is different from native behavior, but done so
948 // deliberately for cross-platform consistency. This will let the user click on
949 // e.g "Bold" and "Italic" buttons elsewhere in the UI to modify the selected text.
950 return;
951 }
952
953 if (!touchInsideInputArea) {
954 // If the menu is not showing, and the touch is outside the input
955 // area, there is nothing left for this recogniser to do.
956 self.state = UIGestureRecognizerStateFailed;
957 return;
958 }
959
960 // When no menu is showing, and the touch is inside the input
961 // area, we check if we should show it. We want to do so if
962 // the tap doesn't result in the cursor changing position.
963 _cursorPosOnPress = QInputMethod::queryFocusObject(Qt::ImCursorPosition, QVariant()).toInt();
964}
965
966- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
967{
968 if (QIOSTextInputOverlay::s_editMenu.visible) {
969 _menuShouldBeVisible = false;
970 } else {
971 QPointF touchPos = QPointF::fromCGPoint([static_cast<UITouch *>([touches anyObject]) locationInView:_focusView]);
972 int cursorPosOnRelease = QPlatformInputContext::queryFocusObject(Qt::ImCursorPosition, touchPos).toInt();
973
974 if (cursorPosOnRelease == _cursorPosOnPress) {
975 // We've recognized a gesture to open the menu, but we don't know
976 // whether the user tapped a control that was overlaid our input
977 // area, since we don't do any granular hit-testing in touchesBegan.
978 // To ensure that the gesture doesn't eat touch events that should
979 // have reached another UI control we report the gesture as failed
980 // here, and then manually show the menu at the next runloop pass.
981 _menuShouldBeVisible = true;
982 self.state = UIGestureRecognizerStateFailed;
983 dispatch_async(dispatch_get_main_queue(), ^{
984 if (_menuShouldBeVisible)
985 showEditMenu(_focusView, touchPos.toPoint());
986 else
987 QIOSTextInputOverlay::s_editMenu.visible = false;
988 });
989 } else {
990 // The menu is hidden, and the cursor will change position once
991 // Qt receive the touch release. We therefore fail so that we
992 // don't block the touch event from further processing.
993 self.state = UIGestureRecognizerStateFailed;
994 }
995 }
996
997 [super touchesEnded:touches withEvent:event];
998}
999
1000- (void)gestureStateChanged
1001{
1002 if (self.state != UIGestureRecognizerStateEnded)
1003 return;
1004
1005 QIOSTextInputOverlay::s_editMenu.visible = _menuShouldBeVisible;
1006}
1007
1008@end
1009
1010// -------------------------------------------------------------------------
1011
1012QT_BEGIN_NAMESPACE
1013
1014QIOSEditMenu *QIOSTextInputOverlay::s_editMenu = nullptr;
1015
1016QIOSTextInputOverlay::QIOSTextInputOverlay()
1017 : m_cursorRecognizer(nullptr)
1018 , m_selectionRecognizer(nullptr)
1019 , m_openMenuOnTapRecognizer(nullptr)
1020{
1021 if (qt_apple_isApplicationExtension()) {
1022 qWarning() << "text input overlays disabled in application extensions";
1023 return;
1024 }
1025
1026 connect(qApp, &QGuiApplication::focusObjectChanged, this, &QIOSTextInputOverlay::updateFocusObject);
1027}
1028
1029QIOSTextInputOverlay::~QIOSTextInputOverlay()
1030{
1031 if (qApp)
1032 disconnect(qApp, 0, this, 0);
1033}
1034
1035void QIOSTextInputOverlay::updateFocusObject()
1036{
1037 // Destroy old recognizers since they were created with
1038 // dependencies to the old focus object (focus view).
1039 if (m_cursorRecognizer) {
1040 m_cursorRecognizer.enabled = NO;
1041 [m_cursorRecognizer release];
1042 m_cursorRecognizer = nullptr;
1043 }
1044 if (m_selectionRecognizer) {
1045 m_selectionRecognizer.enabled = NO;
1046 [m_selectionRecognizer release];
1047 m_selectionRecognizer = nullptr;
1048 }
1049 if (m_openMenuOnTapRecognizer) {
1050 m_openMenuOnTapRecognizer.enabled = NO;
1051 [m_openMenuOnTapRecognizer release];
1052 m_openMenuOnTapRecognizer = nullptr;
1053 }
1054
1055 if (s_editMenu) {
1056 [s_editMenu release];
1057 s_editMenu = nullptr;
1058 }
1059
1060 const QVariant hintsVariant = QGuiApplication::inputMethod()->queryFocusObject(Qt::ImHints, QVariant());
1061 const Qt::InputMethodHints hints = Qt::InputMethodHints(hintsVariant.toUInt());
1062 if (hints & Qt::ImhNoTextHandles)
1063 return;
1064
1065 // The focus object can emit selection updates (e.g from mouse drag), and
1066 // accept modifying it through IM when dragging on the handles, even if it
1067 // doesn't accept text input and IM in general (and hence return false from
1068 // inputMethodAccepted()). This is the case for read-only text fields.
1069 // Therefore, listen for selection changes also when the focus object
1070 // reports that it's ImReadOnly (which we take as a hint that it's actually
1071 // a text field, and that selections therefore might happen). But since
1072 // we have no guarantee that the focus object can actually accept new selections
1073 // through IM (and since we also need to respect if the input accepts selections
1074 // in the first place), we only support selections started by the text field (e.g from
1075 // mouse drag), even if we in theory could also start selections from a loupe.
1076
1077 const bool inputAccepted = platformInputContext()->inputMethodAccepted();
1078 const bool readOnly = QGuiApplication::inputMethod()->queryFocusObject(Qt::ImReadOnly, QVariant()).toBool();
1079
1080 if (inputAccepted || readOnly) {
1081 if (!(hints & Qt::ImhNoEditMenu))
1082 s_editMenu = [QIOSEditMenu new];
1083 m_selectionRecognizer = [QIOSSelectionRecognizer new];
1084 m_openMenuOnTapRecognizer = [QIOSTapRecognizer new];
1085 m_selectionRecognizer.enabled = YES;
1086 m_openMenuOnTapRecognizer.enabled = YES;
1087 }
1088
1089 if (inputAccepted) {
1090 m_cursorRecognizer = [QIOSCursorRecognizer new];
1091 m_cursorRecognizer.enabled = YES;
1092 }
1093}
1094
1095QT_END_NAMESPACE
static SelectionPair querySelection()
static const CGFloat kKnobWidth
static bool hasSelection()
std::pair< int, int > SelectionPair
static void executeBlockWithoutAnimation(Block block)
void(^ Block)(void)
static QPlatformInputContext * platformInputContext()