1 // Copyright 2013 The Chromium 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.
5 #import "chrome/browser/renderer_host/chrome_render_widget_host_view_mac_history_swiper.h"
7 #import "base/mac/sdk_forward_declarations.h"
8 #include "chrome/browser/ui/browser.h"
9 #include "chrome/browser/ui/browser_commands.h"
10 #include "chrome/browser/ui/browser_finder.h"
11 #import "chrome/browser/ui/cocoa/history_overlay_controller.h"
12 #include "third_party/WebKit/public/web/WebInputEvent.h"
15 // The horizontal distance required to cause the browser to perform a history
17 const CGFloat kHistorySwipeThreshold = 0.08;
19 // The horizontal distance required for this class to start consuming events,
20 // which stops the events from reaching the renderer.
21 const CGFloat kConsumeEventThreshold = 0.01;
23 // If there has been sufficient vertical motion, the gesture can't be intended
24 // for history swiping.
25 const CGFloat kCancelEventVerticalThreshold = 0.24;
27 // If there has been sufficient vertical motion, and more vertical than
28 // horizontal motion, the gesture can't be intended for history swiping.
29 const CGFloat kCancelEventVerticalLowerThreshold = 0.01;
31 // Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
32 // expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
34 BOOL forceMagicMouse = NO;
37 @interface HistorySwiper ()
38 // Given a touch event, returns the average touch position.
39 - (NSPoint)averagePositionInEvent:(NSEvent*)event;
41 // Updates internal state with the location information from the touch event.
42 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event;
44 // Updates the state machine with the given touch event.
45 // Returns NO if no further processing of the event should happen.
46 - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event;
48 // Returns whether the wheel event should be consumed, and not passed to the
50 - (BOOL)shouldConsumeWheelEvent:(NSEvent*)event;
53 @implementation HistorySwiper
54 @synthesize delegate = delegate_;
56 - (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
59 // Gesture ids start at 0.
60 currentGestureId_ = 0;
61 // No gestures have been processed
62 lastProcessedGestureId_ = -1;
69 [self endHistorySwipe];
73 - (BOOL)handleEvent:(NSEvent*)event {
74 if ([event type] == NSScrollWheel)
75 return [self maybeHandleHistorySwiping:event];
80 - (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event
81 consumed:(BOOL)consumed {
82 if (event.phase != NSEventPhaseBegan)
84 beganEventUnconsumed_ = !consumed;
87 - (BOOL)canRubberbandLeft:(NSView*)view {
88 Browser* browser = chrome::FindBrowserWithWindow([view window]);
89 // If history swiping isn't possible, allow rubberbanding.
92 if (!chrome::CanGoBack(browser))
94 // History swiping is possible. By default, disallow rubberbanding. If the
95 // user has both started, and then cancelled history swiping for this
96 // gesture, allow rubberbanding.
97 return receivingTouches_ && recognitionState_ == history_swiper::kCancelled;
100 - (BOOL)canRubberbandRight:(NSView*)view {
101 Browser* browser = chrome::FindBrowserWithWindow([view window]);
102 // If history swiping isn't possible, allow rubberbanding.
105 if (!chrome::CanGoForward(browser))
107 // History swiping is possible. By default, disallow rubberbanding. If the
108 // user has both started, and then cancelled history swiping for this
109 // gesture, allow rubberbanding.
110 return receivingTouches_ && recognitionState_ == history_swiper::kCancelled;
113 - (void)beginGestureWithEvent:(NSEvent*)event {
117 - (void)endGestureWithEvent:(NSEvent*)event {
121 // This method assumes that there is at least 1 touch in the event.
122 // The event must correpond to a valid gesture, or else
123 // [NSEvent touchesMatchingPhase:inView:] will fail.
124 - (NSPoint)averagePositionInEvent:(NSEvent*)event {
125 NSPoint position = NSMakePoint(0,0);
127 for (NSTouch* touch in
128 [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
129 position.x += touch.normalizedPosition.x;
130 position.y += touch.normalizedPosition.y;
134 if (pointCount > 1) {
135 position.x /= pointCount;
136 position.y /= pointCount;
142 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
143 // Update the current point of the gesture.
144 gestureCurrentPoint_ = [self averagePositionInEvent:event];
146 // If the gesture doesn't have a start point, set one.
147 if (!gestureStartPointValid_) {
148 gestureStartPointValid_ = YES;
149 gestureStartPoint_ = gestureCurrentPoint_;
153 // Ideally, we'd set the gestureStartPoint_ here, but this method only gets
154 // called before the gesture begins, and the touches in an event are only
155 // available after the gesture begins.
156 - (void)touchesBeganWithEvent:(NSEvent*)event {
157 receivingTouches_ = YES;
160 // Reset state pertaining to previous gestures.
161 gestureStartPointValid_ = NO;
162 mouseScrollDelta_ = NSZeroSize;
163 beganEventUnconsumed_ = NO;
164 recognitionState_ = history_swiper::kPending;
167 - (void)touchesMovedWithEvent:(NSEvent*)event {
168 [self processTouchEventForHistorySwiping:event];
171 - (void)touchesCancelledWithEvent:(NSEvent*)event {
172 receivingTouches_ = NO;
174 if (![self processTouchEventForHistorySwiping:event])
177 [self cancelHistorySwipe];
180 - (void)touchesEndedWithEvent:(NSEvent*)event {
181 receivingTouches_ = NO;
183 if (![self processTouchEventForHistorySwiping:event])
186 if (historyOverlay_) {
187 BOOL finished = [self updateProgressBar];
189 // If the gesture was completed, perform a navigation.
191 [self navigateBrowserInDirection:historySwipeDirection_];
193 // Remove the history overlay.
194 [self endHistorySwipe];
195 // The gesture was completed.
196 recognitionState_ = history_swiper::kCompleted;
200 - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event {
201 NSEventType type = [event type];
202 if (type != NSEventTypeBeginGesture && type != NSEventTypeEndGesture &&
203 type != NSEventTypeGesture) {
207 switch (recognitionState_) {
208 case history_swiper::kCancelled:
209 case history_swiper::kCompleted:
211 case history_swiper::kPending:
212 [self updateGestureCurrentPointFromEvent:event];
214 case history_swiper::kPotential:
215 case history_swiper::kTracking:
219 [self updateGestureCurrentPointFromEvent:event];
221 // Consider cancelling the history swipe gesture.
222 if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
223 startPoint:gestureStartPoint_]) {
224 [self cancelHistorySwipe];
228 if (recognitionState_ == history_swiper::kPotential) {
229 // The user is in the process of doing history swiping. If the history
230 // swipe has progressed sufficiently far, stop sending events to the
232 BOOL sufficientlyFar = fabs(gestureCurrentPoint_.x - gestureStartPoint_.x) >
233 kConsumeEventThreshold;
235 recognitionState_ = history_swiper::kTracking;
239 [self updateProgressBar];
243 // Consider cancelling the horizontal swipe if the user was intending a
245 - (BOOL)shouldCancelHorizontalSwipeWithCurrentPoint:(NSPoint)currentPoint
246 startPoint:(NSPoint)startPoint {
247 CGFloat yDelta = fabs(currentPoint.y - startPoint.y);
248 CGFloat xDelta = fabs(currentPoint.x - startPoint.x);
250 // The gesture is pretty clearly more vertical than horizontal.
251 if (yDelta > 2 * xDelta)
254 // There's been more vertical distance than horizontal distance.
255 if (yDelta * 1.3 > xDelta && yDelta > kCancelEventVerticalLowerThreshold)
258 // There's been a lot of vertical distance.
259 if (yDelta > kCancelEventVerticalThreshold)
265 - (void)cancelHistorySwipe {
266 [self endHistorySwipe];
267 recognitionState_ = history_swiper::kCancelled;
270 - (void)endHistorySwipe {
271 [historyOverlay_ dismiss];
272 [historyOverlay_ release];
273 historyOverlay_ = nil;
276 // Returns whether the progress bar has been 100% filled.
277 - (BOOL)updateProgressBar {
278 NSPoint currentPoint = gestureCurrentPoint_;
279 NSPoint startPoint = gestureStartPoint_;
284 progress = (currentPoint.x - startPoint.x) / kHistorySwipeThreshold;
285 // If the swipe is a backwards gesture, we need to invert progress.
286 if (historySwipeDirection_ == history_swiper::kBackwards)
289 // If the user has directions reversed, we need to invert progress.
290 if (historySwipeDirectionInverted_)
296 // Progress can't be less than 0 or greater than 1.
297 progress = MAX(0.0, progress);
298 progress = MIN(1.0, progress);
300 [historyOverlay_ setProgress:progress finished:finished];
305 - (BOOL)isEventDirectionInverted:(NSEvent*)event {
306 if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
307 return [event isDirectionInvertedFromDevice];
311 // goForward indicates whether the user is starting a forward or backward
313 // Creates and displays a history overlay controller.
314 // Responsible for cleaning up after itself when the gesture is finished.
315 // Responsible for starting a browser navigation if necessary.
316 // Does not prevent swipe events from propagating to other handlers.
317 - (void)beginHistorySwipeInDirection:
318 (history_swiper::NavigationDirection)direction
319 event:(NSEvent*)event {
320 // We cannot make any assumptions about the current state of the
321 // historyOverlay_, since users may attempt to use multiple gesture input
322 // devices simultaneously, which confuses Cocoa.
323 [self endHistorySwipe];
325 HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
326 initForMode:(direction == history_swiper::kForwards)
327 ? kHistoryOverlayModeForward
328 : kHistoryOverlayModeBack];
329 [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
330 historyOverlay_ = historyOverlay;
332 // Record whether the user was swiping forwards or backwards.
333 historySwipeDirection_ = direction;
334 // Record the user's settings.
335 historySwipeDirectionInverted_ = [self isEventDirectionInverted:event];
338 - (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
340 respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
341 return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
345 - (void)navigateBrowserInDirection:
346 (history_swiper::NavigationDirection)direction {
347 Browser* browser = chrome::FindBrowserWithWindow(
348 historyOverlay_.view.window);
350 if (direction == history_swiper::kForwards)
351 chrome::GoForward(browser, CURRENT_TAB);
353 chrome::GoBack(browser, CURRENT_TAB);
357 - (BOOL)browserCanNavigateInDirection:
358 (history_swiper::NavigationDirection)direction
359 event:(NSEvent*)event {
360 Browser* browser = chrome::FindBrowserWithWindow([event window]);
364 if (direction == history_swiper::kForwards) {
365 return chrome::CanGoForward(browser);
367 return chrome::CanGoBack(browser);
371 // We use an entirely different set of logic for magic mouse swipe events,
372 // since we do not get NSTouch callbacks.
373 - (BOOL)maybeHandleMagicMouseHistorySwiping:(NSEvent*)theEvent {
374 // The 'trackSwipeEventWithOptions:' api doesn't handle momentum events.
375 if ([theEvent phase] == NSEventPhaseNone)
378 mouseScrollDelta_.width += [theEvent scrollingDeltaX];
379 mouseScrollDelta_.height += [theEvent scrollingDeltaY];
381 BOOL isHorizontalGesture =
382 std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
383 if (!isHorizontalGesture)
386 BOOL isRightScroll = [theEvent scrollingDeltaX] < 0;
387 history_swiper::NavigationDirection direction =
388 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
389 BOOL browserCanMove =
390 [self browserCanNavigateInDirection:direction event:theEvent];
394 [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
398 - (void)initiateMagicMouseHistorySwipe:(BOOL)isRightScroll
399 event:(NSEvent*)event {
400 // Released by the tracking handler once the gesture is complete.
401 __block HistoryOverlayController* historyOverlay =
402 [[HistoryOverlayController alloc]
403 initForMode:isRightScroll ? kHistoryOverlayModeForward
404 : kHistoryOverlayModeBack];
406 // The way this API works: gestureAmount is between -1 and 1 (float). If
407 // the user does the gesture for more than about 30% (i.e. < -0.3 or >
408 // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
409 // and after that the block is called with amounts animating towards 1
410 // (or -1, depending on the direction). If the user lets go below that
411 // threshold, we get NSEventPhaseCancelled, and the amount animates
412 // toward 0. When gestureAmount has reaches its final value, i.e. the
413 // track animation is done, the handler is called with |isComplete| set
415 // When starting a backwards navigation gesture (swipe from left to right,
416 // gestureAmount will go from 0 to 1), if the user swipes from left to
417 // right and then quickly back to the left, this call can send
418 // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
419 // picture viewer, that makes sense, but for back/forward navigation users
420 // find it confusing. There are two ways to prevent this:
421 // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
422 // gestureAmount will always stay > 0.
423 // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
424 // will become less than 0, but on the quick swipe back to the left,
425 // NSEventPhaseCancelled is sent instead.
426 // The current UI looks nicer with (1) so that swiping the opposite
427 // direction after the initial swipe doesn't cause the shield to move
428 // in the wrong direction.
429 forceMagicMouse = YES;
430 [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
431 dampenAmountThresholdMin:-1
433 usingHandler:^(CGFloat gestureAmount,
437 if (phase == NSEventPhaseBegan) {
439 showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
443 BOOL ended = phase == NSEventPhaseEnded;
445 // Dismiss the panel before navigation for immediate visual feedback.
446 CGFloat progress = std::abs(gestureAmount) / 0.3;
447 BOOL finished = progress >= 1.0;
448 progress = MAX(0.0, progress);
449 progress = MIN(1.0, progress);
450 [historyOverlay setProgress:progress finished:finished];
452 // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
455 chrome::FindBrowserWithWindow(historyOverlay.view.window);
456 if (ended && browser) {
458 chrome::GoForward(browser, CURRENT_TAB);
460 chrome::GoBack(browser, CURRENT_TAB);
463 if (ended || isComplete) {
464 [historyOverlay dismiss];
465 [historyOverlay release];
466 historyOverlay = nil;
471 // Checks if |theEvent| should trigger history swiping, and if so, does
472 // history swiping. Returns YES if the event was consumed or NO if it should
473 // be passed on to the renderer.
475 // There are 4 types of scroll wheel events:
476 // 1. Magic mouse swipe events.
477 // These are identical to magic trackpad events, except that there are no
478 // NSTouch callbacks. The only way to accurately track these events is
479 // with the `trackSwipeEventWithOptions:` API. scrollingDelta{X,Y} is not
480 // accurate over long distances (it is computed using the speed of the
481 // swipe, rather than just the distance moved by the fingers).
482 // 2. Magic trackpad swipe events.
483 // These are the most common history swipe events. Our logic is
484 // predominantly designed to handle this use case.
485 // 3. Traditional mouse scrollwheel events.
486 // These should not initiate scrolling. They can be distinguished by the
487 // fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
488 // 4. Momentum swipe events.
489 // After a user finishes a swipe, the system continues to generate
490 // artificial callbacks. `phase` returns NSEventPhaseNone, but
491 // `momentumPhase` does not. Unfortunately, the callbacks don't work
492 // properly (OSX 10.9). Sometimes, the system start sending momentum swipe
493 // events instead of trackpad swipe events while the user is still
495 - (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
496 if (![theEvent respondsToSelector:@selector(phase)])
499 // The only events that this class consumes have type NSEventPhaseChanged.
500 // This simultaneously weeds our regular mouse wheel scroll events, and
501 // gesture events with incorrect phase.
502 if ([theEvent phase] != NSEventPhaseChanged &&
503 [theEvent momentumPhase] != NSEventPhaseChanged) {
507 // We've already processed this gesture.
508 if (lastProcessedGestureId_ == currentGestureId_ &&
509 recognitionState_ != history_swiper::kPending) {
510 return [self shouldConsumeWheelEvent:theEvent];
513 // Don't allow momentum events to start history swiping.
514 if ([theEvent momentumPhase] != NSEventPhaseNone)
517 BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
518 if (!systemSettingsValid)
521 if (![delegate_ shouldAllowHistorySwiping])
524 // Don't enable history swiping until the renderer has decided to not consume
525 // the event with phase NSEventPhaseBegan.
526 if (!beganEventUnconsumed_)
529 // Magic mouse and touchpad swipe events are identical except magic mouse
530 // events do not generate NSTouch callbacks. Since we rely on NSTouch
531 // callbacks to perform history swiping, magic mouse swipe events use an
532 // entirely different set of logic.
533 if ((inGesture_ && !receivingTouches_) || forceMagicMouse)
534 return [self maybeHandleMagicMouseHistorySwiping:theEvent];
536 // The scrollWheel: callback is only relevant if it happens while the user is
537 // still actively using the touchpad.
538 if (!receivingTouches_)
541 // TODO(erikchen): Ideally, the direction of history swiping should not be
542 // determined this early in a gesture, when it's unclear what the user is
543 // intending to do. Since it is determined this early, make sure that there
544 // is at least a minimal amount of horizontal motion.
545 CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
546 if (fabs(xDelta) < 0.001)
549 BOOL isRightScroll = xDelta > 0;
550 BOOL inverted = [self isEventDirectionInverted:theEvent];
552 isRightScroll = !isRightScroll;
554 history_swiper::NavigationDirection direction =
555 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
556 BOOL browserCanMove =
557 [self browserCanNavigateInDirection:direction event:theEvent];
561 lastProcessedGestureId_ = currentGestureId_;
562 [self beginHistorySwipeInDirection:direction event:theEvent];
563 recognitionState_ = history_swiper::kPotential;
564 return [self shouldConsumeWheelEvent:theEvent];
567 - (BOOL)shouldConsumeWheelEvent:(NSEvent*)event {
568 switch (recognitionState_) {
569 case history_swiper::kPending:
570 case history_swiper::kCancelled:
572 case history_swiper::kTracking:
573 case history_swiper::kCompleted:
575 case history_swiper::kPotential:
576 // It is unclear whether the user is attempting to perform history
577 // swiping. If the event has a vertical component, send it on to the
579 return event.scrollingDeltaY == 0;
584 @implementation HistorySwiper (PrivateExposedForTesting)
585 + (void)resetMagicMouseState {
586 forceMagicMouse = NO;