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