chrome_render_widget_host_view_mac_history_swiper.mm revision a1401311d1ab56c4ed0a474bd38c108f75cb0cd9
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  __block HistoryOverlayController* historyOverlay =
334      [[HistoryOverlayController alloc]
335          initForMode:isRightScroll ? kHistoryOverlayModeForward
336                                    : kHistoryOverlayModeBack];
337
338  // The way this API works: gestureAmount is between -1 and 1 (float).  If
339  // the user does the gesture for more than about 30% (i.e. < -0.3 or >
340  // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
341  // and after that the block is called with amounts animating towards 1
342  // (or -1, depending on the direction).  If the user lets go below that
343  // threshold, we get NSEventPhaseCancelled, and the amount animates
344  // toward 0.  When gestureAmount has reaches its final value, i.e. the
345  // track animation is done, the handler is called with |isComplete| set
346  // to |YES|.
347  // When starting a backwards navigation gesture (swipe from left to right,
348  // gestureAmount will go from 0 to 1), if the user swipes from left to
349  // right and then quickly back to the left, this call can send
350  // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
351  // picture viewer, that makes sense, but for back/forward navigation users
352  // find it confusing. There are two ways to prevent this:
353  // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
354  //    gestureAmount will always stay > 0.
355  // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
356  //    will become less than 0, but on the quick swipe back to the left,
357  //    NSEventPhaseCancelled is sent instead.
358  // The current UI looks nicer with (1) so that swiping the opposite
359  // direction after the initial swipe doesn't cause the shield to move
360  // in the wrong direction.
361  forceMagicMouse = YES;
362  [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
363      dampenAmountThresholdMin:-1
364      max:1
365      usingHandler:^(CGFloat gestureAmount,
366                     NSEventPhase phase,
367                     BOOL isComplete,
368                     BOOL* stop) {
369          if (phase == NSEventPhaseBegan) {
370            [historyOverlay
371                showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
372            return;
373          }
374
375          BOOL ended = phase == NSEventPhaseEnded;
376
377          // Dismiss the panel before navigation for immediate visual feedback.
378          CGFloat progress = std::abs(gestureAmount) / 0.3;
379          BOOL finished = progress >= 1.0;
380          progress = MAX(0.0, progress);
381          progress = MIN(1.0, progress);
382          [historyOverlay setProgress:progress finished:finished];
383
384          // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
385          // automatically.
386          Browser* browser =
387              chrome::FindBrowserWithWindow(historyOverlay.view.window);
388          if (ended && browser) {
389            if (isRightScroll)
390              chrome::GoForward(browser, CURRENT_TAB);
391            else
392              chrome::GoBack(browser, CURRENT_TAB);
393          }
394
395          if (ended || isComplete) {
396            [historyOverlay dismiss];
397            [historyOverlay release];
398            historyOverlay = nil;
399          }
400      }];
401}
402
403// Checks if |theEvent| should trigger history swiping, and if so, does
404// history swiping. Returns YES if the event was consumed or NO if it should
405// be passed on to the renderer.
406//
407// There are 4 types of scroll wheel events:
408// 1. Magic mouse swipe events.
409//      These are identical to magic trackpad events, except that there are no
410//      NSTouch callbacks.  The only way to accurately track these events is
411//      with the  `trackSwipeEventWithOptions:` API. scrollingDelta{X,Y} is not
412//      accurate over long distances (it is computed using the speed of the
413//      swipe, rather than just the distance moved by the fingers).
414// 2. Magic trackpad swipe events.
415//      These are the most common history swipe events. Our logic is
416//      predominantly designed to handle this use case.
417// 3. Traditional mouse scrollwheel events.
418//      These should not initiate scrolling. They can be distinguished by the
419//      fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
420// 4. Momentum swipe events.
421//      After a user finishes a swipe, the system continues to generate
422//      artificial callbacks. `phase` returns NSEventPhaseNone, but
423//      `momentumPhase` does not. Unfortunately, the callbacks don't work
424//      properly (OSX 10.9). Sometimes, the system start sending momentum swipe
425//      events instead of trackpad swipe events while the user is still
426//      2-finger swiping.
427- (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
428  if (![theEvent respondsToSelector:@selector(phase)])
429    return NO;
430
431  // Check for regular mouse wheel scroll events.
432  if ([theEvent phase] == NSEventPhaseNone &&
433      [theEvent momentumPhase] == NSEventPhaseNone) {
434    return NO;
435  }
436
437  // We've already processed this gesture.
438  if (lastProcessedGestureId_ == currentGestureId_) {
439    // A new event may come in before it's recognized as a gesture.
440    // We have not yet reset the state from the last gesture.
441    // Let it pass through.
442    if ([theEvent phase] == NSEventPhaseBegan ||
443        [theEvent phase] == NSEventPhaseMayBegin) {
444      return NO;
445    }
446
447    // The user cancelled the history swiper. Ignore all events.
448    if (historySwipeCancelled_)
449      return NO;
450
451    // The user completed the history swiper. Swallow all events.
452    return YES;
453  }
454
455  BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
456  if (!systemSettingsValid)
457    return NO;
458
459  if (![delegate_ shouldAllowHistorySwiping])
460    return NO;
461
462  // Don't even consider enabling history swiping until blink has decided it is
463  // not going to handle the event.
464  if (!gotUnhandledWheelEvent_)
465    return NO;
466
467  // If the window has a horizontal scroll bar, sometimes Cocoa gets confused
468  // and sends us momentum scroll wheel events instead of gesture scroll events
469  // (even though the user is still actively swiping).
470  if ([theEvent phase] != NSEventPhaseChanged &&
471      [theEvent momentumPhase] != NSEventPhaseChanged) {
472    return NO;
473  }
474
475  if (!inGesture_)
476    return NO;
477
478  if (!receivedTouch_ || forceMagicMouse) {
479    return [self maybeHandleMagicMouseHistorySwiping:theEvent];
480  }
481
482  CGFloat yDelta = gestureCurrentPoint_.y - gestureStartPoint_.y;
483  CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
484
485  // Require the user's gesture to have moved more than a minimal amount.
486  if (fabs(xDelta) < 0.01)
487    return NO;
488
489  // Require the user's gesture to be slightly more horizontal than vertical.
490  BOOL isHorizontalGesture = fabs(xDelta) > 1.3 * fabs(yDelta);
491
492  if (!isHorizontalGesture)
493    return NO;
494
495  BOOL isRightScroll = xDelta > 0;
496  BOOL inverted = [self isEventDirectionInverted:theEvent];
497  if (inverted)
498    isRightScroll = !isRightScroll;
499
500  if (isRightScroll) {
501    if (hasHorizontalScrollbar_ && !isPinnedRight_)
502      return NO;
503  } else {
504    if (hasHorizontalScrollbar_ && !isPinnedLeft_)
505      return NO;
506  }
507
508  history_swiper::NavigationDirection direction =
509      isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
510  BOOL browserCanMove =
511      [self browserCanNavigateInDirection:direction event:theEvent];
512  if (!browserCanMove)
513    return NO;
514
515  lastProcessedGestureId_ = currentGestureId_;
516  [self beginHistorySwipeInDirection:direction event:theEvent];
517  return YES;
518}
519@end
520
521@implementation HistorySwiper (PrivateExposedForTesting)
522+ (void)resetMagicMouseState {
523  forceMagicMouse = NO;
524}
525@end
526