chrome_render_widget_host_view_mac_history_swiper.mm revision 0529e5d033099cbfc42635f6f6183833b09dff6e
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#include "chrome/browser/ui/browser.h"
8#include "chrome/browser/ui/browser_commands.h"
9#include "chrome/browser/ui/browser_finder.h"
10
11#import "base/mac/sdk_forward_declarations.h"
12#import "chrome/browser/ui/cocoa/history_overlay_controller.h"
13
14// Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
15// expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
16// callbacks.
17static BOOL forceMagicMouse = NO;
18
19@implementation HistorySwiper
20@synthesize delegate = delegate_;
21
22- (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
23  self = [super init];
24  if (self) {
25    // Gesture ids start at 0.
26    currentGestureId_ = 0;
27    // No gestures have been processed
28    lastProcessedGestureId_ = -1;
29    delegate_ = delegate;
30  }
31  return self;
32}
33
34- (void)dealloc {
35  [self endHistorySwipe];
36  [super dealloc];
37}
38
39- (BOOL)handleEvent:(NSEvent*)event {
40  if ([event type] == NSScrollWheel)
41    return [self maybeHandleHistorySwiping:event];
42
43  return NO;
44}
45
46- (void)gotUnhandledWheelEvent {
47  gotUnhandledWheelEvent_ = YES;
48}
49
50- (void)scrollOffsetPinnedToLeft:(BOOL)left toRight:(BOOL)right {
51  isPinnedLeft_ = left;
52  isPinnedRight_ = right;
53}
54
55- (void)setHasHorizontalScrollbar:(BOOL)hasHorizontalScrollbar {
56  hasHorizontalScrollbar_ = hasHorizontalScrollbar;
57}
58
59- (BOOL)canRubberbandLeft:(NSView*)view {
60  Browser* browser = chrome::FindBrowserWithWindow([view window]);
61  // If history swiping isn't possible, allow rubberbanding.
62  if (!browser)
63    return true;
64  if (!chrome::CanGoBack(browser))
65    return true;
66  // History swiping is possible. By default, disallow rubberbanding.  If the
67  // user has both started, and then cancelled history swiping for this
68  // gesture, allow rubberbanding.
69  return inGesture_ && historySwipeCancelled_;
70}
71
72- (BOOL)canRubberbandRight:(NSView*)view {
73  Browser* browser = chrome::FindBrowserWithWindow([view window]);
74  // If history swiping isn't possible, allow rubberbanding.
75  if (!browser)
76    return true;
77  if (!chrome::CanGoForward(browser))
78    return true;
79  // History swiping is possible. By default, disallow rubberbanding.  If the
80  // user has both started, and then cancelled history swiping for this
81  // gesture, allow rubberbanding.
82  return inGesture_ && historySwipeCancelled_;
83}
84
85// Is is theoretically possible for multiple simultaneous gestures to occur, if
86// the user has multiple input devices. There will be 2 beginGesture events, but
87// only 1 endGesture event. The unfinished gesture will continue to send
88// touchesMoved events, but when the gesture finishes there is not endGesture
89// callback. We ignore this case, because it is sufficiently unlikely to occur.
90- (void)beginGestureWithEvent:(NSEvent*)event {
91  inGesture_ = YES;
92  ++currentGestureId_;
93  // Reset state pertaining to previous gestures.
94  historySwipeCancelled_ = NO;
95  gestureStartPointValid_ = NO;
96  gotUnhandledWheelEvent_ = NO;
97  receivedTouch_ = NO;
98  mouseScrollDelta_ = NSZeroSize;
99}
100
101- (void)endGestureWithEvent:(NSEvent*)event {
102  inGesture_ = NO;
103}
104
105// This method assumes that there is at least 1 touch in the event.
106// The event must correpond to a valid gesture, or else
107// [NSEvent touchesMatchingPhase:inView:] will fail.
108- (NSPoint)averagePositionInEvent:(NSEvent*)event {
109  NSPoint position = NSMakePoint(0,0);
110  int pointCount = 0;
111  for (NSTouch* touch in
112       [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
113    position.x += touch.normalizedPosition.x;
114    position.y += touch.normalizedPosition.y;
115    ++pointCount;
116  }
117
118  if (pointCount > 1) {
119    position.x /= pointCount;
120    position.y /= pointCount;
121  }
122
123  return position;
124}
125
126- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
127  // The points in an event are not valid unless the event is part of
128  // a gesture.
129  if (inGesture_) {
130    // Update the current point of the gesture.
131    gestureCurrentPoint_ = [self averagePositionInEvent:event];
132
133    // If the gesture doesn't have a start point, set one.
134    if (!gestureStartPointValid_) {
135      gestureStartPointValid_ = YES;
136      gestureStartPoint_ = gestureCurrentPoint_;
137    }
138  }
139}
140
141// Ideally, we'd set the gestureStartPoint_ here, but this method only gets
142// called before the gesture begins, and the touches in an event are only
143// available after the gesture begins.
144- (void)touchesBeganWithEvent:(NSEvent*)event {
145  receivedTouch_ = YES;
146  // Do nothing.
147}
148
149- (void)touchesMovedWithEvent:(NSEvent*)event {
150  receivedTouch_ = YES;
151  if (![self shouldProcessEventForHistorySwiping:event])
152    return;
153
154  [self updateGestureCurrentPointFromEvent:event];
155
156  if (historyOverlay_) {
157    // Consider cancelling the history swipe gesture.
158    if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
159        startPoint:gestureStartPoint_]) {
160      [self cancelHistorySwipe];
161      return;
162    }
163
164    [self updateProgressBar];
165  }
166}
167- (void)touchesCancelledWithEvent:(NSEvent*)event {
168  receivedTouch_ = YES;
169  if (![self shouldProcessEventForHistorySwiping:event])
170    return;
171
172  if (historyOverlay_)
173    [self cancelHistorySwipe];
174}
175- (void)touchesEndedWithEvent:(NSEvent*)event {
176  receivedTouch_ = YES;
177  if (![self shouldProcessEventForHistorySwiping:event])
178    return;
179
180  [self updateGestureCurrentPointFromEvent:event];
181  if (historyOverlay_) {
182    BOOL finished = [self updateProgressBar];
183
184    // If the gesture was completed, perform a navigation.
185    if (finished) {
186      [self navigateBrowserInDirection:historySwipeDirection_];
187    }
188
189    // Remove the history overlay.
190    [self endHistorySwipe];
191  }
192}
193
194- (BOOL)shouldProcessEventForHistorySwiping:(NSEvent*)event {
195  // TODO(erikchen): what is the point of NSEventTypeSwipe and NSScrollWheel?
196  NSEventType type = [event type];
197  return type == NSEventTypeBeginGesture || type == NSEventTypeEndGesture ||
198      type == NSEventTypeGesture;
199}
200
201// Consider cancelling the horizontal swipe if the user was intending a
202// vertical swipe.
203- (BOOL)shouldCancelHorizontalSwipeWithCurrentPoint:(NSPoint)currentPoint
204    startPoint:(NSPoint)startPoint {
205  // There's been more vertical distance than horizontal distance.
206  CGFloat yDelta = fabs(currentPoint.y - startPoint.y);
207  CGFloat xDelta = fabs(currentPoint.x - startPoint.x);
208  BOOL moreVertThanHoriz = yDelta > xDelta && yDelta > 0.1;
209
210  // There's been a lot of vertical distance.
211  BOOL muchVert = yDelta > 0.32;
212
213  return moreVertThanHoriz || muchVert;
214}
215
216- (void)cancelHistorySwipe {
217  [self endHistorySwipe];
218  historySwipeCancelled_ = YES;
219}
220
221- (void)endHistorySwipe {
222  [historyOverlay_ dismiss];
223  [historyOverlay_ release];
224  historyOverlay_ = nil;
225}
226
227// Returns whether the progress bar has been 100% filled.
228- (BOOL)updateProgressBar {
229  NSPoint currentPoint = gestureCurrentPoint_;
230  NSPoint startPoint = gestureStartPoint_;
231
232  float progress = 0;
233  BOOL finished = NO;
234
235  // This value was determined by experimentation.
236  CGFloat requiredSwipeDistance = 0.08;
237  progress = (currentPoint.x - startPoint.x) / requiredSwipeDistance;
238  // If the swipe is a backwards gesture, we need to invert progress.
239  if (historySwipeDirection_ == history_swiper::kBackwards)
240    progress *= -1;
241  // If the user has directions reversed, we need to invert progress.
242  if (historySwipeDirectionInverted_)
243    progress *= -1;
244
245  if (progress >= 1.0)
246    finished = YES;
247
248  // Progress can't be less than 0 or greater than 1.
249  progress = MAX(0.0, progress);
250  progress = MIN(1.0, progress);
251
252  [historyOverlay_ setProgress:progress finished:finished];
253
254  return finished;
255}
256
257- (BOOL)isEventDirectionInverted:(NSEvent*)event {
258  if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
259    return [event isDirectionInvertedFromDevice];
260  return NO;
261}
262
263// goForward indicates whether the user is starting a forward or backward
264// history swipe.
265// Creates and displays a history overlay controller.
266// Responsible for cleaning up after itself when the gesture is finished.
267// Responsible for starting a browser navigation if necessary.
268// Does not prevent swipe events from propagating to other handlers.
269- (void)beginHistorySwipeInDirection:
270        (history_swiper::NavigationDirection)direction
271                               event:(NSEvent*)event {
272  // We cannot make any assumptions about the current state of the
273  // historyOverlay_, since users may attempt to use multiple gesture input
274  // devices simultaneously, which confuses Cocoa.
275  [self endHistorySwipe];
276
277  HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
278      initForMode:(direction == history_swiper::kForwards)
279                     ? kHistoryOverlayModeForward
280                     : kHistoryOverlayModeBack];
281  [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
282  historyOverlay_ = historyOverlay;
283
284  // Record whether the user was swiping forwards or backwards.
285  historySwipeDirection_ = direction;
286  // Record the user's settings.
287  historySwipeDirectionInverted_ = [self isEventDirectionInverted:event];
288}
289
290- (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
291  if ([NSEvent
292          respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
293    return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
294  return NO;
295}
296
297- (void)navigateBrowserInDirection:
298            (history_swiper::NavigationDirection)direction {
299  Browser* browser = chrome::FindBrowserWithWindow(
300      historyOverlay_.view.window);
301  if (browser) {
302    if (direction == history_swiper::kForwards)
303      chrome::GoForward(browser, CURRENT_TAB);
304    else
305      chrome::GoBack(browser, CURRENT_TAB);
306  }
307}
308
309- (BOOL)browserCanNavigateInDirection:
310        (history_swiper::NavigationDirection)direction
311                                event:(NSEvent*)event {
312  Browser* browser = chrome::FindBrowserWithWindow([event window]);
313  if (!browser)
314    return NO;
315
316  if (direction == history_swiper::kForwards) {
317    return chrome::CanGoForward(browser);
318  } else {
319    return chrome::CanGoBack(browser);
320  }
321}
322
323// We use an entirely different set of logic for magic mouse swipe events,
324// since we do not get NSTouch callbacks.
325- (BOOL)maybeHandleMagicMouseHistorySwiping:(NSEvent*)theEvent {
326  // The 'trackSwipeEventWithOptions:' api doesn't handle momentum events.
327  if ([theEvent phase] == NSEventPhaseNone)
328    return NO;
329
330  mouseScrollDelta_.width += [theEvent scrollingDeltaX];
331  mouseScrollDelta_.height += [theEvent scrollingDeltaY];
332
333  BOOL isHorizontalGesture =
334    std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
335  if (!isHorizontalGesture)
336    return NO;
337
338  BOOL isRightScroll = [theEvent scrollingDeltaX] < 0;
339  history_swiper::NavigationDirection direction =
340      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
341  BOOL browserCanMove =
342      [self browserCanNavigateInDirection:direction event:theEvent];
343  if (!browserCanMove)
344    return NO;
345
346  if (isRightScroll) {
347    if (hasHorizontalScrollbar_ && !isPinnedRight_)
348      return NO;
349  } else {
350    if (hasHorizontalScrollbar_ && !isPinnedLeft_)
351      return NO;
352  }
353
354  [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
355  return YES;
356}
357
358- (void)initiateMagicMouseHistorySwipe:(BOOL)isRightScroll
359                                 event:(NSEvent*)event {
360  // Released by the tracking handler once the gesture is complete.
361  __block HistoryOverlayController* historyOverlay =
362      [[HistoryOverlayController alloc]
363          initForMode:isRightScroll ? kHistoryOverlayModeForward
364                                    : kHistoryOverlayModeBack];
365
366  // The way this API works: gestureAmount is between -1 and 1 (float).  If
367  // the user does the gesture for more than about 30% (i.e. < -0.3 or >
368  // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
369  // and after that the block is called with amounts animating towards 1
370  // (or -1, depending on the direction).  If the user lets go below that
371  // threshold, we get NSEventPhaseCancelled, and the amount animates
372  // toward 0.  When gestureAmount has reaches its final value, i.e. the
373  // track animation is done, the handler is called with |isComplete| set
374  // to |YES|.
375  // When starting a backwards navigation gesture (swipe from left to right,
376  // gestureAmount will go from 0 to 1), if the user swipes from left to
377  // right and then quickly back to the left, this call can send
378  // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
379  // picture viewer, that makes sense, but for back/forward navigation users
380  // find it confusing. There are two ways to prevent this:
381  // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
382  //    gestureAmount will always stay > 0.
383  // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
384  //    will become less than 0, but on the quick swipe back to the left,
385  //    NSEventPhaseCancelled is sent instead.
386  // The current UI looks nicer with (1) so that swiping the opposite
387  // direction after the initial swipe doesn't cause the shield to move
388  // in the wrong direction.
389  forceMagicMouse = YES;
390  [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
391      dampenAmountThresholdMin:-1
392      max:1
393      usingHandler:^(CGFloat gestureAmount,
394                     NSEventPhase phase,
395                     BOOL isComplete,
396                     BOOL* stop) {
397          if (phase == NSEventPhaseBegan) {
398            [historyOverlay
399                showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
400            return;
401          }
402
403          BOOL ended = phase == NSEventPhaseEnded;
404
405          // Dismiss the panel before navigation for immediate visual feedback.
406          CGFloat progress = std::abs(gestureAmount) / 0.3;
407          BOOL finished = progress >= 1.0;
408          progress = MAX(0.0, progress);
409          progress = MIN(1.0, progress);
410          [historyOverlay setProgress:progress finished:finished];
411
412          // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
413          // automatically.
414          Browser* browser =
415              chrome::FindBrowserWithWindow(historyOverlay.view.window);
416          if (ended && browser) {
417            if (isRightScroll)
418              chrome::GoForward(browser, CURRENT_TAB);
419            else
420              chrome::GoBack(browser, CURRENT_TAB);
421          }
422
423          if (ended || isComplete) {
424            [historyOverlay dismiss];
425            [historyOverlay release];
426            historyOverlay = nil;
427          }
428      }];
429}
430
431// Checks if |theEvent| should trigger history swiping, and if so, does
432// history swiping. Returns YES if the event was consumed or NO if it should
433// be passed on to the renderer.
434//
435// There are 4 types of scroll wheel events:
436// 1. Magic mouse swipe events.
437//      These are identical to magic trackpad events, except that there are no
438//      NSTouch callbacks.  The only way to accurately track these events is
439//      with the  `trackSwipeEventWithOptions:` API. scrollingDelta{X,Y} is not
440//      accurate over long distances (it is computed using the speed of the
441//      swipe, rather than just the distance moved by the fingers).
442// 2. Magic trackpad swipe events.
443//      These are the most common history swipe events. Our logic is
444//      predominantly designed to handle this use case.
445// 3. Traditional mouse scrollwheel events.
446//      These should not initiate scrolling. They can be distinguished by the
447//      fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
448// 4. Momentum swipe events.
449//      After a user finishes a swipe, the system continues to generate
450//      artificial callbacks. `phase` returns NSEventPhaseNone, but
451//      `momentumPhase` does not. Unfortunately, the callbacks don't work
452//      properly (OSX 10.9). Sometimes, the system start sending momentum swipe
453//      events instead of trackpad swipe events while the user is still
454//      2-finger swiping.
455- (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
456  if (![theEvent respondsToSelector:@selector(phase)])
457    return NO;
458
459  // Check for regular mouse wheel scroll events.
460  if ([theEvent phase] == NSEventPhaseNone &&
461      [theEvent momentumPhase] == NSEventPhaseNone) {
462    return NO;
463  }
464
465  // We've already processed this gesture.
466  if (lastProcessedGestureId_ == currentGestureId_) {
467    // A new event may come in before it's recognized as a gesture.
468    // We have not yet reset the state from the last gesture.
469    // Let it pass through.
470    if ([theEvent phase] == NSEventPhaseBegan ||
471        [theEvent phase] == NSEventPhaseMayBegin) {
472      return NO;
473    }
474
475    // The user cancelled the history swiper. Ignore all events.
476    if (historySwipeCancelled_)
477      return NO;
478
479    // The user completed the history swiper. Swallow all events.
480    return YES;
481  }
482
483  BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
484  if (!systemSettingsValid)
485    return NO;
486
487  if (![delegate_ shouldAllowHistorySwiping])
488    return NO;
489
490  // Don't even consider enabling history swiping until blink has decided it is
491  // not going to handle the event.
492  if (!gotUnhandledWheelEvent_)
493    return NO;
494
495  // If the window has a horizontal scroll bar, sometimes Cocoa gets confused
496  // and sends us momentum scroll wheel events instead of gesture scroll events
497  // (even though the user is still actively swiping).
498  if ([theEvent phase] != NSEventPhaseChanged &&
499      [theEvent momentumPhase] != NSEventPhaseChanged) {
500    return NO;
501  }
502
503  if (!inGesture_)
504    return NO;
505
506  if (!receivedTouch_ || forceMagicMouse) {
507    return [self maybeHandleMagicMouseHistorySwiping:theEvent];
508  }
509
510  CGFloat yDelta = gestureCurrentPoint_.y - gestureStartPoint_.y;
511  CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
512
513  // Require the user's gesture to have moved more than a minimal amount.
514  if (fabs(xDelta) < 0.01)
515    return NO;
516
517  // Require the user's gesture to be slightly more horizontal than vertical.
518  BOOL isHorizontalGesture = fabs(xDelta) > 1.3 * fabs(yDelta);
519
520  if (!isHorizontalGesture)
521    return NO;
522
523  BOOL isRightScroll = xDelta > 0;
524  BOOL inverted = [self isEventDirectionInverted:theEvent];
525  if (inverted)
526    isRightScroll = !isRightScroll;
527
528  if (isRightScroll) {
529    if (hasHorizontalScrollbar_ && !isPinnedRight_)
530      return NO;
531  } else {
532    if (hasHorizontalScrollbar_ && !isPinnedLeft_)
533      return NO;
534  }
535
536  history_swiper::NavigationDirection direction =
537      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
538  BOOL browserCanMove =
539      [self browserCanNavigateInDirection:direction event:theEvent];
540  if (!browserCanMove)
541    return NO;
542
543  lastProcessedGestureId_ = currentGestureId_;
544  [self beginHistorySwipeInDirection:direction event:theEvent];
545  return YES;
546}
547@end
548
549@implementation HistorySwiper (PrivateExposedForTesting)
550+ (void)resetMagicMouseState {
551  forceMagicMouse = NO;
552}
553@end
554