chrome_render_widget_host_view_mac_history_swiper.mm revision 1320f92c476a1ad9d19dba2a48c72b75566198e9
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.
4
5#import "chrome/browser/renderer_host/chrome_render_widget_host_view_mac_history_swiper.h"
6
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"
13
14namespace {
15// The horizontal distance required to cause the browser to perform a history
16// navigation.
17const CGFloat kHistorySwipeThreshold = 0.08;
18
19// The horizontal distance required for this class to start consuming events,
20// which stops the events from reaching the renderer.
21const CGFloat kConsumeEventThreshold = 0.01;
22
23// If there has been sufficient vertical motion, the gesture can't be intended
24// for history swiping.
25const CGFloat kCancelEventVerticalThreshold = 0.24;
26
27// If there has been sufficient vertical motion, and more vertical than
28// horizontal motion, the gesture can't be intended for history swiping.
29const CGFloat kCancelEventVerticalLowerThreshold = 0.01;
30
31// Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
32// expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
33// callbacks.
34BOOL forceMagicMouse = NO;
35}  // namespace
36
37@interface HistorySwiper ()
38// Given a touch event, returns the average touch position.
39- (NSPoint)averagePositionInEvent:(NSEvent*)event;
40
41// Updates internal state with the location information from the touch event.
42- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event;
43
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;
47
48// Returns whether the wheel event should be consumed, and not passed to the
49// renderer.
50- (BOOL)shouldConsumeWheelEvent:(NSEvent*)event;
51@end
52
53@implementation HistorySwiper
54@synthesize delegate = delegate_;
55
56- (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
57  self = [super init];
58  if (self) {
59    // Gesture ids start at 0.
60    currentGestureId_ = 0;
61    // No gestures have been processed
62    lastProcessedGestureId_ = -1;
63    delegate_ = delegate;
64  }
65  return self;
66}
67
68- (void)dealloc {
69  [self endHistorySwipe];
70  [super dealloc];
71}
72
73- (BOOL)handleEvent:(NSEvent*)event {
74  if ([event type] == NSScrollWheel)
75    return [self maybeHandleHistorySwiping:event];
76
77  return NO;
78}
79
80- (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event
81                         consumed:(BOOL)consumed {
82  if (event.phase != NSEventPhaseBegan)
83    return;
84  beganEventUnconsumed_ = !consumed;
85}
86
87- (BOOL)canRubberbandLeft:(NSView*)view {
88  Browser* browser = chrome::FindBrowserWithWindow([view window]);
89  // If history swiping isn't possible, allow rubberbanding.
90  if (!browser)
91    return true;
92  if (!chrome::CanGoBack(browser))
93    return true;
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;
98}
99
100- (BOOL)canRubberbandRight:(NSView*)view {
101  Browser* browser = chrome::FindBrowserWithWindow([view window]);
102  // If history swiping isn't possible, allow rubberbanding.
103  if (!browser)
104    return true;
105  if (!chrome::CanGoForward(browser))
106    return true;
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;
111}
112
113- (void)beginGestureWithEvent:(NSEvent*)event {
114  inGesture_ = YES;
115}
116
117- (void)endGestureWithEvent:(NSEvent*)event {
118  inGesture_ = NO;
119}
120
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);
126  int pointCount = 0;
127  for (NSTouch* touch in
128       [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
129    position.x += touch.normalizedPosition.x;
130    position.y += touch.normalizedPosition.y;
131    ++pointCount;
132  }
133
134  if (pointCount > 1) {
135    position.x /= pointCount;
136    position.y /= pointCount;
137  }
138
139  return position;
140}
141
142- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
143  // Update the current point of the gesture.
144  gestureCurrentPoint_ = [self averagePositionInEvent:event];
145
146  // If the gesture doesn't have a start point, set one.
147  if (!gestureStartPointValid_) {
148    gestureStartPointValid_ = YES;
149    gestureStartPoint_ = gestureCurrentPoint_;
150  }
151}
152
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;
158  ++currentGestureId_;
159
160  // Reset state pertaining to previous gestures.
161  gestureStartPointValid_ = NO;
162  mouseScrollDelta_ = NSZeroSize;
163  beganEventUnconsumed_ = NO;
164  recognitionState_ = history_swiper::kPending;
165}
166
167- (void)touchesMovedWithEvent:(NSEvent*)event {
168  [self processTouchEventForHistorySwiping:event];
169}
170
171- (void)touchesCancelledWithEvent:(NSEvent*)event {
172  receivingTouches_ = NO;
173
174  if (![self processTouchEventForHistorySwiping:event])
175    return;
176
177  [self cancelHistorySwipe];
178}
179
180- (void)touchesEndedWithEvent:(NSEvent*)event {
181  receivingTouches_ = NO;
182
183  if (![self processTouchEventForHistorySwiping:event])
184    return;
185
186  if (historyOverlay_) {
187    BOOL finished = [self updateProgressBar];
188
189    // If the gesture was completed, perform a navigation.
190    if (finished)
191      [self navigateBrowserInDirection:historySwipeDirection_];
192
193    // Remove the history overlay.
194    [self endHistorySwipe];
195    // The gesture was completed.
196    recognitionState_ = history_swiper::kCompleted;
197  }
198}
199
200- (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event {
201  NSEventType type = [event type];
202  if (type != NSEventTypeBeginGesture && type != NSEventTypeEndGesture &&
203      type != NSEventTypeGesture) {
204    return NO;
205  }
206
207  switch (recognitionState_) {
208    case history_swiper::kCancelled:
209    case history_swiper::kCompleted:
210      return NO;
211    case history_swiper::kPending:
212      [self updateGestureCurrentPointFromEvent:event];
213      return NO;
214    case history_swiper::kPotential:
215    case history_swiper::kTracking:
216      break;
217  }
218
219  [self updateGestureCurrentPointFromEvent:event];
220
221  // Consider cancelling the history swipe gesture.
222  if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
223                                             startPoint:gestureStartPoint_]) {
224    [self cancelHistorySwipe];
225    return NO;
226  }
227
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
231    // renderer.
232    BOOL sufficientlyFar = fabs(gestureCurrentPoint_.x - gestureStartPoint_.x) >
233                           kConsumeEventThreshold;
234    if (sufficientlyFar)
235      recognitionState_ = history_swiper::kTracking;
236  }
237
238  if (historyOverlay_)
239    [self updateProgressBar];
240  return YES;
241}
242
243// Consider cancelling the horizontal swipe if the user was intending a
244// vertical swipe.
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);
249
250  // The gesture is pretty clearly more vertical than horizontal.
251  if (yDelta > 2 * xDelta)
252    return YES;
253
254  // There's been more vertical distance than horizontal distance.
255  if (yDelta * 1.3 > xDelta && yDelta > kCancelEventVerticalLowerThreshold)
256    return YES;
257
258  // There's been a lot of vertical distance.
259  if (yDelta > kCancelEventVerticalThreshold)
260    return YES;
261
262  return NO;
263}
264
265- (void)cancelHistorySwipe {
266  [self endHistorySwipe];
267  recognitionState_ = history_swiper::kCancelled;
268}
269
270- (void)endHistorySwipe {
271  [historyOverlay_ dismiss];
272  [historyOverlay_ release];
273  historyOverlay_ = nil;
274}
275
276// Returns whether the progress bar has been 100% filled.
277- (BOOL)updateProgressBar {
278  NSPoint currentPoint = gestureCurrentPoint_;
279  NSPoint startPoint = gestureStartPoint_;
280
281  float progress = 0;
282  BOOL finished = NO;
283
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)
287    progress *= -1;
288
289  // If the user has directions reversed, we need to invert progress.
290  if (historySwipeDirectionInverted_)
291    progress *= -1;
292
293  if (progress >= 1.0)
294    finished = YES;
295
296  // Progress can't be less than 0 or greater than 1.
297  progress = MAX(0.0, progress);
298  progress = MIN(1.0, progress);
299
300  [historyOverlay_ setProgress:progress finished:finished];
301
302  return finished;
303}
304
305- (BOOL)isEventDirectionInverted:(NSEvent*)event {
306  if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
307    return [event isDirectionInvertedFromDevice];
308  return NO;
309}
310
311// goForward indicates whether the user is starting a forward or backward
312// history swipe.
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];
324
325  HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
326      initForMode:(direction == history_swiper::kForwards)
327                     ? kHistoryOverlayModeForward
328                     : kHistoryOverlayModeBack];
329  [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
330  historyOverlay_ = historyOverlay;
331
332  // Record whether the user was swiping forwards or backwards.
333  historySwipeDirection_ = direction;
334  // Record the user's settings.
335  historySwipeDirectionInverted_ = [self isEventDirectionInverted:event];
336}
337
338- (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
339  if ([NSEvent
340          respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
341    return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
342  return NO;
343}
344
345- (void)navigateBrowserInDirection:
346            (history_swiper::NavigationDirection)direction {
347  Browser* browser = chrome::FindBrowserWithWindow(
348      historyOverlay_.view.window);
349  if (browser) {
350    if (direction == history_swiper::kForwards)
351      chrome::GoForward(browser, CURRENT_TAB);
352    else
353      chrome::GoBack(browser, CURRENT_TAB);
354  }
355}
356
357- (BOOL)browserCanNavigateInDirection:
358        (history_swiper::NavigationDirection)direction
359                                event:(NSEvent*)event {
360  Browser* browser = chrome::FindBrowserWithWindow([event window]);
361  if (!browser)
362    return NO;
363
364  if (direction == history_swiper::kForwards) {
365    return chrome::CanGoForward(browser);
366  } else {
367    return chrome::CanGoBack(browser);
368  }
369}
370
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)
376    return NO;
377
378  mouseScrollDelta_.width += [theEvent scrollingDeltaX];
379  mouseScrollDelta_.height += [theEvent scrollingDeltaY];
380
381  BOOL isHorizontalGesture =
382    std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
383  if (!isHorizontalGesture)
384    return NO;
385
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];
391  if (!browserCanMove)
392    return NO;
393
394  [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
395  return YES;
396}
397
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];
405
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
414  // to |YES|.
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
432      max:1
433      usingHandler:^(CGFloat gestureAmount,
434                     NSEventPhase phase,
435                     BOOL isComplete,
436                     BOOL* stop) {
437          if (phase == NSEventPhaseBegan) {
438            [historyOverlay
439                showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
440            return;
441          }
442
443          BOOL ended = phase == NSEventPhaseEnded;
444
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];
451
452          // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
453          // automatically.
454          Browser* browser =
455              chrome::FindBrowserWithWindow(historyOverlay.view.window);
456          if (ended && browser) {
457            if (isRightScroll)
458              chrome::GoForward(browser, CURRENT_TAB);
459            else
460              chrome::GoBack(browser, CURRENT_TAB);
461          }
462
463          if (ended || isComplete) {
464            [historyOverlay dismiss];
465            [historyOverlay release];
466            historyOverlay = nil;
467          }
468      }];
469}
470
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.
474//
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
494//      2-finger swiping.
495- (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
496  if (![theEvent respondsToSelector:@selector(phase)])
497    return NO;
498
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) {
504    return NO;
505  }
506
507  // We've already processed this gesture.
508  if (lastProcessedGestureId_ == currentGestureId_ &&
509      recognitionState_ != history_swiper::kPending) {
510    return [self shouldConsumeWheelEvent:theEvent];
511  }
512
513  // Don't allow momentum events to start history swiping.
514  if ([theEvent momentumPhase] != NSEventPhaseNone)
515    return NO;
516
517  BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
518  if (!systemSettingsValid)
519    return NO;
520
521  if (![delegate_ shouldAllowHistorySwiping])
522    return NO;
523
524  // Don't enable history swiping until the renderer has decided to not consume
525  // the event with phase NSEventPhaseBegan.
526  if (!beganEventUnconsumed_)
527    return NO;
528
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];
535
536  // The scrollWheel: callback is only relevant if it happens while the user is
537  // still actively using the touchpad.
538  if (!receivingTouches_)
539    return NO;
540
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)
547    return NO;
548
549  BOOL isRightScroll = xDelta > 0;
550  BOOL inverted = [self isEventDirectionInverted:theEvent];
551  if (inverted)
552    isRightScroll = !isRightScroll;
553
554  history_swiper::NavigationDirection direction =
555      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
556  BOOL browserCanMove =
557      [self browserCanNavigateInDirection:direction event:theEvent];
558  if (!browserCanMove)
559    return NO;
560
561  lastProcessedGestureId_ = currentGestureId_;
562  [self beginHistorySwipeInDirection:direction event:theEvent];
563  recognitionState_ = history_swiper::kPotential;
564  return [self shouldConsumeWheelEvent:theEvent];
565}
566
567- (BOOL)shouldConsumeWheelEvent:(NSEvent*)event {
568  switch (recognitionState_) {
569    case history_swiper::kPending:
570    case history_swiper::kCancelled:
571      return NO;
572    case history_swiper::kTracking:
573    case history_swiper::kCompleted:
574      return YES;
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
578      // renderer.
579      return event.scrollingDeltaY == 0;
580  }
581}
582@end
583
584@implementation HistorySwiper (PrivateExposedForTesting)
585+ (void)resetMagicMouseState {
586  forceMagicMouse = NO;
587}
588@end
589