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