chrome_render_widget_host_view_mac_history_swiper.mm revision 46d4c2bc3267f3f028f39e7e311b0f89aba2e4fd
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 inGesture_ && 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 inGesture_ && recognitionState_ == history_swiper::kCancelled;
111}
112
113// Is is theoretically possible for multiple simultaneous gestures to occur, if
114// the user has multiple input devices. There will be 2 beginGesture events, but
115// only 1 endGesture event. The unfinished gesture will continue to send
116// touchesMoved events, but when the gesture finishes there is not endGesture
117// callback. We ignore this case, because it is sufficiently unlikely to occur.
118- (void)beginGestureWithEvent:(NSEvent*)event {
119  inGesture_ = YES;
120  ++currentGestureId_;
121  // Reset state pertaining to previous gestures.
122  gestureStartPointValid_ = NO;
123  receivedTouch_ = NO;
124  mouseScrollDelta_ = NSZeroSize;
125  beganEventUnconsumed_ = NO;
126  recognitionState_ = history_swiper::kPending;
127}
128
129- (void)endGestureWithEvent:(NSEvent*)event {
130  inGesture_ = NO;
131}
132
133// This method assumes that there is at least 1 touch in the event.
134// The event must correpond to a valid gesture, or else
135// [NSEvent touchesMatchingPhase:inView:] will fail.
136- (NSPoint)averagePositionInEvent:(NSEvent*)event {
137  NSPoint position = NSMakePoint(0,0);
138  int pointCount = 0;
139  for (NSTouch* touch in
140       [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
141    position.x += touch.normalizedPosition.x;
142    position.y += touch.normalizedPosition.y;
143    ++pointCount;
144  }
145
146  if (pointCount > 1) {
147    position.x /= pointCount;
148    position.y /= pointCount;
149  }
150
151  return position;
152}
153
154- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
155  // The points in an event are not valid unless the event is part of
156  // a gesture.
157  if (inGesture_) {
158    // Update the current point of the gesture.
159    gestureCurrentPoint_ = [self averagePositionInEvent:event];
160
161    // If the gesture doesn't have a start point, set one.
162    if (!gestureStartPointValid_) {
163      gestureStartPointValid_ = YES;
164      gestureStartPoint_ = gestureCurrentPoint_;
165    }
166  }
167}
168
169// Ideally, we'd set the gestureStartPoint_ here, but this method only gets
170// called before the gesture begins, and the touches in an event are only
171// available after the gesture begins.
172- (void)touchesBeganWithEvent:(NSEvent*)event {
173  receivedTouch_ = YES;
174  // Do nothing.
175}
176
177- (void)touchesMovedWithEvent:(NSEvent*)event {
178  [self processTouchEventForHistorySwiping:event];
179}
180
181- (void)touchesCancelledWithEvent:(NSEvent*)event {
182  if (![self processTouchEventForHistorySwiping:event])
183    return;
184
185  [self cancelHistorySwipe];
186}
187
188- (void)touchesEndedWithEvent:(NSEvent*)event {
189  if (![self processTouchEventForHistorySwiping:event])
190    return;
191
192  if (historyOverlay_) {
193    BOOL finished = [self updateProgressBar];
194
195    // If the gesture was completed, perform a navigation.
196    if (finished)
197      [self navigateBrowserInDirection:historySwipeDirection_];
198
199    // Remove the history overlay.
200    [self endHistorySwipe];
201    // The gesture was completed.
202    recognitionState_ = history_swiper::kCompleted;
203  }
204}
205
206- (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event {
207  receivedTouch_ = YES;
208
209  NSEventType type = [event type];
210  if (type != NSEventTypeBeginGesture && type != NSEventTypeEndGesture &&
211      type != NSEventTypeGesture) {
212    return NO;
213  }
214
215  switch (recognitionState_) {
216    case history_swiper::kCancelled:
217    case history_swiper::kCompleted:
218      return NO;
219    case history_swiper::kPending:
220      [self updateGestureCurrentPointFromEvent:event];
221      return NO;
222    case history_swiper::kPotential:
223    case history_swiper::kTracking:
224      break;
225  }
226
227  [self updateGestureCurrentPointFromEvent:event];
228
229  // Consider cancelling the history swipe gesture.
230  if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
231                                             startPoint:gestureStartPoint_]) {
232    [self cancelHistorySwipe];
233    return NO;
234  }
235
236  if (recognitionState_ == history_swiper::kPotential) {
237    // The user is in the process of doing history swiping.  If the history
238    // swipe has progressed sufficiently far, stop sending events to the
239    // renderer.
240    BOOL sufficientlyFar = fabs(gestureCurrentPoint_.x - gestureStartPoint_.x) >
241                           kConsumeEventThreshold;
242    if (sufficientlyFar)
243      recognitionState_ = history_swiper::kTracking;
244  }
245
246  if (historyOverlay_)
247    [self updateProgressBar];
248  return YES;
249}
250
251// Consider cancelling the horizontal swipe if the user was intending a
252// vertical swipe.
253- (BOOL)shouldCancelHorizontalSwipeWithCurrentPoint:(NSPoint)currentPoint
254    startPoint:(NSPoint)startPoint {
255  CGFloat yDelta = fabs(currentPoint.y - startPoint.y);
256  CGFloat xDelta = fabs(currentPoint.x - startPoint.x);
257
258  // The gesture is pretty clearly more vertical than horizontal.
259  if (yDelta > 2 * xDelta)
260    return YES;
261
262  // There's been more vertical distance than horizontal distance.
263  if (yDelta * 1.3 > xDelta && yDelta > kCancelEventVerticalLowerThreshold)
264    return YES;
265
266  // There's been a lot of vertical distance.
267  if (yDelta > kCancelEventVerticalThreshold)
268    return YES;
269
270  return NO;
271}
272
273- (void)cancelHistorySwipe {
274  [self endHistorySwipe];
275  recognitionState_ = history_swiper::kCancelled;
276}
277
278- (void)endHistorySwipe {
279  [historyOverlay_ dismiss];
280  [historyOverlay_ release];
281  historyOverlay_ = nil;
282}
283
284// Returns whether the progress bar has been 100% filled.
285- (BOOL)updateProgressBar {
286  NSPoint currentPoint = gestureCurrentPoint_;
287  NSPoint startPoint = gestureStartPoint_;
288
289  float progress = 0;
290  BOOL finished = NO;
291
292  progress = (currentPoint.x - startPoint.x) / kHistorySwipeThreshold;
293  // If the swipe is a backwards gesture, we need to invert progress.
294  if (historySwipeDirection_ == history_swiper::kBackwards)
295    progress *= -1;
296  // If the user has directions reversed, we need to invert progress.
297  if (historySwipeDirectionInverted_)
298    progress *= -1;
299
300  if (progress >= 1.0)
301    finished = YES;
302
303  // Progress can't be less than 0 or greater than 1.
304  progress = MAX(0.0, progress);
305  progress = MIN(1.0, progress);
306
307  [historyOverlay_ setProgress:progress finished:finished];
308
309  return finished;
310}
311
312- (BOOL)isEventDirectionInverted:(NSEvent*)event {
313  if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
314    return [event isDirectionInvertedFromDevice];
315  return NO;
316}
317
318// goForward indicates whether the user is starting a forward or backward
319// history swipe.
320// Creates and displays a history overlay controller.
321// Responsible for cleaning up after itself when the gesture is finished.
322// Responsible for starting a browser navigation if necessary.
323// Does not prevent swipe events from propagating to other handlers.
324- (void)beginHistorySwipeInDirection:
325        (history_swiper::NavigationDirection)direction
326                               event:(NSEvent*)event {
327  // We cannot make any assumptions about the current state of the
328  // historyOverlay_, since users may attempt to use multiple gesture input
329  // devices simultaneously, which confuses Cocoa.
330  [self endHistorySwipe];
331
332  HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
333      initForMode:(direction == history_swiper::kForwards)
334                     ? kHistoryOverlayModeForward
335                     : kHistoryOverlayModeBack];
336  [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
337  historyOverlay_ = historyOverlay;
338
339  // Record whether the user was swiping forwards or backwards.
340  historySwipeDirection_ = direction;
341  // Record the user's settings.
342  historySwipeDirectionInverted_ = [self isEventDirectionInverted:event];
343}
344
345- (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
346  if ([NSEvent
347          respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
348    return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
349  return NO;
350}
351
352- (void)navigateBrowserInDirection:
353            (history_swiper::NavigationDirection)direction {
354  Browser* browser = chrome::FindBrowserWithWindow(
355      historyOverlay_.view.window);
356  if (browser) {
357    if (direction == history_swiper::kForwards)
358      chrome::GoForward(browser, CURRENT_TAB);
359    else
360      chrome::GoBack(browser, CURRENT_TAB);
361  }
362}
363
364- (BOOL)browserCanNavigateInDirection:
365        (history_swiper::NavigationDirection)direction
366                                event:(NSEvent*)event {
367  Browser* browser = chrome::FindBrowserWithWindow([event window]);
368  if (!browser)
369    return NO;
370
371  if (direction == history_swiper::kForwards) {
372    return chrome::CanGoForward(browser);
373  } else {
374    return chrome::CanGoBack(browser);
375  }
376}
377
378// We use an entirely different set of logic for magic mouse swipe events,
379// since we do not get NSTouch callbacks.
380- (BOOL)maybeHandleMagicMouseHistorySwiping:(NSEvent*)theEvent {
381  // The 'trackSwipeEventWithOptions:' api doesn't handle momentum events.
382  if ([theEvent phase] == NSEventPhaseNone)
383    return NO;
384
385  mouseScrollDelta_.width += [theEvent scrollingDeltaX];
386  mouseScrollDelta_.height += [theEvent scrollingDeltaY];
387
388  BOOL isHorizontalGesture =
389    std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
390  if (!isHorizontalGesture)
391    return NO;
392
393  BOOL isRightScroll = [theEvent scrollingDeltaX] < 0;
394  history_swiper::NavigationDirection direction =
395      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
396  BOOL browserCanMove =
397      [self browserCanNavigateInDirection:direction event:theEvent];
398  if (!browserCanMove)
399    return NO;
400
401  [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
402  return YES;
403}
404
405- (void)initiateMagicMouseHistorySwipe:(BOOL)isRightScroll
406                                 event:(NSEvent*)event {
407  // Released by the tracking handler once the gesture is complete.
408  __block HistoryOverlayController* historyOverlay =
409      [[HistoryOverlayController alloc]
410          initForMode:isRightScroll ? kHistoryOverlayModeForward
411                                    : kHistoryOverlayModeBack];
412
413  // The way this API works: gestureAmount is between -1 and 1 (float).  If
414  // the user does the gesture for more than about 30% (i.e. < -0.3 or >
415  // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
416  // and after that the block is called with amounts animating towards 1
417  // (or -1, depending on the direction).  If the user lets go below that
418  // threshold, we get NSEventPhaseCancelled, and the amount animates
419  // toward 0.  When gestureAmount has reaches its final value, i.e. the
420  // track animation is done, the handler is called with |isComplete| set
421  // to |YES|.
422  // When starting a backwards navigation gesture (swipe from left to right,
423  // gestureAmount will go from 0 to 1), if the user swipes from left to
424  // right and then quickly back to the left, this call can send
425  // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
426  // picture viewer, that makes sense, but for back/forward navigation users
427  // find it confusing. There are two ways to prevent this:
428  // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
429  //    gestureAmount will always stay > 0.
430  // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
431  //    will become less than 0, but on the quick swipe back to the left,
432  //    NSEventPhaseCancelled is sent instead.
433  // The current UI looks nicer with (1) so that swiping the opposite
434  // direction after the initial swipe doesn't cause the shield to move
435  // in the wrong direction.
436  forceMagicMouse = YES;
437  [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
438      dampenAmountThresholdMin:-1
439      max:1
440      usingHandler:^(CGFloat gestureAmount,
441                     NSEventPhase phase,
442                     BOOL isComplete,
443                     BOOL* stop) {
444          if (phase == NSEventPhaseBegan) {
445            [historyOverlay
446                showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
447            return;
448          }
449
450          BOOL ended = phase == NSEventPhaseEnded;
451
452          // Dismiss the panel before navigation for immediate visual feedback.
453          CGFloat progress = std::abs(gestureAmount) / 0.3;
454          BOOL finished = progress >= 1.0;
455          progress = MAX(0.0, progress);
456          progress = MIN(1.0, progress);
457          [historyOverlay setProgress:progress finished:finished];
458
459          // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
460          // automatically.
461          Browser* browser =
462              chrome::FindBrowserWithWindow(historyOverlay.view.window);
463          if (ended && browser) {
464            if (isRightScroll)
465              chrome::GoForward(browser, CURRENT_TAB);
466            else
467              chrome::GoBack(browser, CURRENT_TAB);
468          }
469
470          if (ended || isComplete) {
471            [historyOverlay dismiss];
472            [historyOverlay release];
473            historyOverlay = nil;
474          }
475      }];
476}
477
478// Checks if |theEvent| should trigger history swiping, and if so, does
479// history swiping. Returns YES if the event was consumed or NO if it should
480// be passed on to the renderer.
481//
482// There are 4 types of scroll wheel events:
483// 1. Magic mouse swipe events.
484//      These are identical to magic trackpad events, except that there are no
485//      NSTouch callbacks.  The only way to accurately track these events is
486//      with the  `trackSwipeEventWithOptions:` API. scrollingDelta{X,Y} is not
487//      accurate over long distances (it is computed using the speed of the
488//      swipe, rather than just the distance moved by the fingers).
489// 2. Magic trackpad swipe events.
490//      These are the most common history swipe events. Our logic is
491//      predominantly designed to handle this use case.
492// 3. Traditional mouse scrollwheel events.
493//      These should not initiate scrolling. They can be distinguished by the
494//      fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
495// 4. Momentum swipe events.
496//      After a user finishes a swipe, the system continues to generate
497//      artificial callbacks. `phase` returns NSEventPhaseNone, but
498//      `momentumPhase` does not. Unfortunately, the callbacks don't work
499//      properly (OSX 10.9). Sometimes, the system start sending momentum swipe
500//      events instead of trackpad swipe events while the user is still
501//      2-finger swiping.
502- (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
503  if (![theEvent respondsToSelector:@selector(phase)])
504    return NO;
505
506  // The only events that this class consumes have type NSEventPhaseChanged.
507  // This simultaneously weeds our regular mouse wheel scroll events, and
508  // gesture events with incorrect phase.
509  if ([theEvent phase] != NSEventPhaseChanged &&
510      [theEvent momentumPhase] != NSEventPhaseChanged) {
511    return NO;
512  }
513
514  // We've already processed this gesture.
515  if (lastProcessedGestureId_ == currentGestureId_ &&
516      recognitionState_ != history_swiper::kPending) {
517    return [self shouldConsumeWheelEvent:theEvent];
518  }
519
520  // Don't allow momentum events to start history swiping.
521  if ([theEvent momentumPhase] != NSEventPhaseNone)
522    return NO;
523
524  BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
525  if (!systemSettingsValid)
526    return NO;
527
528  if (![delegate_ shouldAllowHistorySwiping])
529    return NO;
530
531  // Don't enable history swiping until the renderer has decided to not consume
532  // the event with phase NSEventPhaseBegan.
533  if (!beganEventUnconsumed_)
534    return NO;
535
536  if (!inGesture_)
537    return NO;
538
539  if (!receivedTouch_ || forceMagicMouse)
540    return [self maybeHandleMagicMouseHistorySwiping:theEvent];
541
542  CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
543
544  BOOL isRightScroll = xDelta > 0;
545  BOOL inverted = [self isEventDirectionInverted:theEvent];
546  if (inverted)
547    isRightScroll = !isRightScroll;
548
549  history_swiper::NavigationDirection direction =
550      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
551  BOOL browserCanMove =
552      [self browserCanNavigateInDirection:direction event:theEvent];
553  if (!browserCanMove)
554    return NO;
555
556  lastProcessedGestureId_ = currentGestureId_;
557  [self beginHistorySwipeInDirection:direction event:theEvent];
558  recognitionState_ = history_swiper::kPotential;
559  return [self shouldConsumeWheelEvent:theEvent];
560}
561
562- (BOOL)shouldConsumeWheelEvent:(NSEvent*)event {
563  switch (recognitionState_) {
564    case history_swiper::kPending:
565    case history_swiper::kCancelled:
566      return NO;
567    case history_swiper::kTracking:
568    case history_swiper::kCompleted:
569      return YES;
570    case history_swiper::kPotential:
571      // It is unclear whether the user is attempting to perform history
572      // swiping.  If the event has a vertical component, send it on to the
573      // renderer.
574      return event.scrollingDeltaY == 0;
575  }
576}
577@end
578
579@implementation HistorySwiper (PrivateExposedForTesting)
580+ (void)resetMagicMouseState {
581  forceMagicMouse = NO;
582}
583@end
584