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.
5 #import "chrome/browser/renderer_host/chrome_render_widget_host_view_mac_history_swiper.h"
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 "chrome/browser/ui/tabs/tab_strip_model.h"
13 #include "third_party/WebKit/public/web/WebInputEvent.h"
16 // The horizontal distance required to cause the browser to perform a history
18 const CGFloat kHistorySwipeThreshold = 0.08;
20 // The horizontal distance required for this class to start consuming events,
21 // which stops the events from reaching the renderer.
22 const CGFloat kConsumeEventThreshold = 0.01;
24 // If there has been sufficient vertical motion, the gesture can't be intended
25 // for history swiping.
26 const CGFloat kCancelEventVerticalThreshold = 0.24;
28 // If there has been sufficient vertical motion, and more vertical than
29 // horizontal motion, the gesture can't be intended for history swiping.
30 const CGFloat kCancelEventVerticalLowerThreshold = 0.01;
32 // Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
33 // expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
35 BOOL forceMagicMouse = NO;
38 @interface HistorySwiper ()
39 // Given a touch event, returns the average touch position.
40 - (NSPoint)averagePositionInEvent:(NSEvent*)event;
42 // Updates internal state with the location information from the touch event.
43 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event;
45 // Updates the state machine with the given touch event.
46 // Returns NO if no further processing of the event should happen.
47 - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event;
49 // Returns whether the wheel event should be consumed, and not passed to the
51 - (BOOL)shouldConsumeWheelEvent:(NSEvent*)event;
53 // Shows the history swiper overlay.
54 - (void)showHistoryOverlay:(history_swiper::NavigationDirection)direction;
56 // Removes the history swiper overlay.
57 - (void)removeHistoryOverlay;
59 // Returns YES if the event was consumed or NO if it should be passed on to the
60 // renderer. If |event| was generated by a Magic Mouse, this method forwards to
61 // handleMagicMouseWheelEvent. Otherwise, this method attempts to transition
62 // the state machine from kPending -> kPotential. If it performs the
63 // transition, it also shows the history overlay. In order for a history swipe
64 // gesture to be recognized, the transition must occur.
66 // There are 4 types of scroll wheel events:
67 // 1. Magic mouse swipe events.
68 // These are identical to magic trackpad events, except that there are no
69 // -[NSView touches*WithEvent:] callbacks. The only way to accurately
70 // track these events is with the `trackSwipeEventWithOptions:` API.
71 // scrollingDelta{X,Y} is not accurate over long distances (it is computed
72 // using the speed of the swipe, rather than just the distance moved by
74 // 2. Magic trackpad swipe events.
75 // These are the most common history swipe events. The logic of this
76 // method is predominantly designed to handle this use case.
77 // 3. Traditional mouse scrollwheel events.
78 // These should not initiate scrolling. They can be distinguished by the
79 // fact that `phase` and `momentumPhase` both return NSEventPhaseNone.
80 // 4. Momentum swipe events.
81 // After a user finishes a swipe, the system continues to generate
82 // artificial callbacks. `phase` returns NSEventPhaseNone, but
83 // `momentumPhase` does not. Unfortunately, the callbacks don't work
84 // properly (OSX 10.9). Sometimes, the system start sending momentum swipe
85 // events instead of trackpad swipe events while the user is still
87 - (BOOL)handleScrollWheelEvent:(NSEvent*)event;
89 // Returns YES if the event was consumed or NO if it should be passed on to the
90 // renderer. Attempts to initiate history swiping for Magic Mouse events.
91 - (BOOL)handleMagicMouseWheelEvent:(NSEvent*)theEvent;
94 @implementation HistorySwiper
95 @synthesize delegate = delegate_;
97 - (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
100 delegate_ = delegate;
106 [self removeHistoryOverlay];
110 - (BOOL)handleEvent:(NSEvent*)event {
111 if ([event type] != NSScrollWheel)
114 return [self handleScrollWheelEvent:event];
117 - (void)rendererHandledWheelEvent:(const blink::WebMouseWheelEvent&)event
118 consumed:(BOOL)consumed {
119 if (event.phase != NSEventPhaseBegan)
121 beganEventUnconsumed_ = !consumed;
124 - (BOOL)canRubberbandLeft:(NSView*)view {
125 Browser* browser = chrome::FindBrowserWithWindow([view window]);
126 // If history swiping isn't possible, allow rubberbanding.
130 // TODO(erikchen): Update this comment after determining whether this
131 // NULL-check fixes the crash.
132 // This NULL check likely prevents a crash. http://crbug.com/418761
133 if (!browser->tab_strip_model()->GetActiveWebContents())
136 if (!chrome::CanGoBack(browser))
138 // History swiping is possible. By default, disallow rubberbanding. If the
139 // user has both started, and then cancelled history swiping for this
140 // gesture, allow rubberbanding.
141 return receivingTouches_ && recognitionState_ == history_swiper::kCancelled;
144 - (BOOL)canRubberbandRight:(NSView*)view {
145 Browser* browser = chrome::FindBrowserWithWindow([view window]);
146 // If history swiping isn't possible, allow rubberbanding.
150 // TODO(erikchen): Update this comment after determining whether this
151 // NULL-check fixes the crash.
152 // This NULL check likely prevents a crash. http://crbug.com/418761
153 if (!browser->tab_strip_model()->GetActiveWebContents())
156 if (!chrome::CanGoForward(browser))
158 // History swiping is possible. By default, disallow rubberbanding. If the
159 // user has both started, and then cancelled history swiping for this
160 // gesture, allow rubberbanding.
161 return receivingTouches_ && recognitionState_ == history_swiper::kCancelled;
164 - (void)beginGestureWithEvent:(NSEvent*)event {
168 - (void)endGestureWithEvent:(NSEvent*)event {
172 // This method assumes that there is at least 1 touch in the event.
173 // The event must correpond to a valid gesture, or else
174 // [NSEvent touchesMatchingPhase:inView:] will fail.
175 - (NSPoint)averagePositionInEvent:(NSEvent*)event {
176 NSPoint position = NSMakePoint(0,0);
178 for (NSTouch* touch in
179 [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
180 position.x += touch.normalizedPosition.x;
181 position.y += touch.normalizedPosition.y;
185 if (pointCount > 1) {
186 position.x /= pointCount;
187 position.y /= pointCount;
193 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
194 NSPoint averagePosition = [self averagePositionInEvent:event];
196 // If the start point is valid, then so is the current point.
197 if (gestureStartPointValid_)
198 gestureTotalY_ += fabs(averagePosition.y - gestureCurrentPoint_.y);
200 // Update the current point of the gesture.
201 gestureCurrentPoint_ = averagePosition;
203 // If the gesture doesn't have a start point, set one.
204 if (!gestureStartPointValid_) {
205 gestureStartPointValid_ = YES;
206 gestureStartPoint_ = gestureCurrentPoint_;
210 // Ideally, we'd set the gestureStartPoint_ here, but this method only gets
211 // called before the gesture begins, and the touches in an event are only
212 // available after the gesture begins.
213 - (void)touchesBeganWithEvent:(NSEvent*)event {
214 receivingTouches_ = YES;
216 // Reset state pertaining to previous gestures.
217 gestureStartPointValid_ = NO;
219 mouseScrollDelta_ = NSZeroSize;
220 beganEventUnconsumed_ = NO;
221 recognitionState_ = history_swiper::kPending;
224 - (void)touchesMovedWithEvent:(NSEvent*)event {
225 [self processTouchEventForHistorySwiping:event];
228 - (void)touchesCancelledWithEvent:(NSEvent*)event {
229 receivingTouches_ = NO;
231 if (![self processTouchEventForHistorySwiping:event])
234 [self cancelHistorySwipe];
237 - (void)touchesEndedWithEvent:(NSEvent*)event {
238 receivingTouches_ = NO;
239 if (![self processTouchEventForHistorySwiping:event])
242 if (historyOverlay_) {
243 BOOL finished = [self updateProgressBar];
245 // If the gesture was completed, perform a navigation.
247 [self navigateBrowserInDirection:historySwipeDirection_];
249 [self removeHistoryOverlay];
251 // The gesture was completed.
252 recognitionState_ = history_swiper::kCompleted;
256 - (BOOL)processTouchEventForHistorySwiping:(NSEvent*)event {
257 NSEventType type = [event type];
258 if (type != NSEventTypeBeginGesture && type != NSEventTypeEndGesture &&
259 type != NSEventTypeGesture) {
263 switch (recognitionState_) {
264 case history_swiper::kCancelled:
265 case history_swiper::kCompleted:
267 case history_swiper::kPending:
268 case history_swiper::kPotential:
269 case history_swiper::kTracking:
273 [self updateGestureCurrentPointFromEvent:event];
275 // Consider cancelling the history swipe gesture.
276 if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
277 startPoint:gestureStartPoint_]) {
278 [self cancelHistorySwipe];
282 // Don't do any more processing if the state machine is in the pending state.
283 if (recognitionState_ == history_swiper::kPending)
286 if (recognitionState_ == history_swiper::kPotential) {
287 // The user is in the process of doing history swiping. If the history
288 // swipe has progressed sufficiently far, stop sending events to the
290 BOOL sufficientlyFar = fabs(gestureCurrentPoint_.x - gestureStartPoint_.x) >
291 kConsumeEventThreshold;
293 recognitionState_ = history_swiper::kTracking;
297 [self updateProgressBar];
301 // Consider cancelling the horizontal swipe if the user was intending a
303 - (BOOL)shouldCancelHorizontalSwipeWithCurrentPoint:(NSPoint)currentPoint
304 startPoint:(NSPoint)startPoint {
305 CGFloat yDelta = gestureTotalY_;
306 CGFloat xDelta = fabs(currentPoint.x - startPoint.x);
308 // The gesture is pretty clearly more vertical than horizontal.
309 if (yDelta > 2 * xDelta)
312 // There's been more vertical distance than horizontal distance.
313 if (yDelta * 1.3 > xDelta && yDelta > kCancelEventVerticalLowerThreshold)
316 // There's been a lot of vertical distance.
317 if (yDelta > kCancelEventVerticalThreshold)
323 - (void)cancelHistorySwipe {
324 [self removeHistoryOverlay];
325 recognitionState_ = history_swiper::kCancelled;
328 - (void)removeHistoryOverlay {
329 [historyOverlay_ dismiss];
330 [historyOverlay_ release];
331 historyOverlay_ = nil;
334 // Returns whether the progress bar has been 100% filled.
335 - (BOOL)updateProgressBar {
336 NSPoint currentPoint = gestureCurrentPoint_;
337 NSPoint startPoint = gestureStartPoint_;
342 progress = (currentPoint.x - startPoint.x) / kHistorySwipeThreshold;
343 // If the swipe is a backwards gesture, we need to invert progress.
344 if (historySwipeDirection_ == history_swiper::kBackwards)
347 // If the user has directions reversed, we need to invert progress.
348 if (historySwipeDirectionInverted_)
354 // Progress can't be less than 0 or greater than 1.
355 progress = MAX(0.0, progress);
356 progress = MIN(1.0, progress);
358 [historyOverlay_ setProgress:progress finished:finished];
363 - (BOOL)isEventDirectionInverted:(NSEvent*)event {
364 if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
365 return [event isDirectionInvertedFromDevice];
369 - (void)showHistoryOverlay:(history_swiper::NavigationDirection)direction {
370 // We cannot make any assumptions about the current state of the
371 // historyOverlay_, since users may attempt to use multiple gesture input
372 // devices simultaneously, which confuses Cocoa.
373 [self removeHistoryOverlay];
375 HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
376 initForMode:(direction == history_swiper::kForwards)
377 ? kHistoryOverlayModeForward
378 : kHistoryOverlayModeBack];
379 [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
380 historyOverlay_ = historyOverlay;
383 - (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
385 respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
386 return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
390 - (void)navigateBrowserInDirection:
391 (history_swiper::NavigationDirection)direction {
392 Browser* browser = chrome::FindBrowserWithWindow(
393 historyOverlay_.view.window);
395 if (direction == history_swiper::kForwards)
396 chrome::GoForward(browser, CURRENT_TAB);
398 chrome::GoBack(browser, CURRENT_TAB);
402 - (BOOL)browserCanNavigateInDirection:
403 (history_swiper::NavigationDirection)direction
404 event:(NSEvent*)event {
405 Browser* browser = chrome::FindBrowserWithWindow([event window]);
409 if (direction == history_swiper::kForwards) {
410 return chrome::CanGoForward(browser);
412 return chrome::CanGoBack(browser);
416 - (BOOL)handleMagicMouseWheelEvent:(NSEvent*)theEvent {
417 // The 'trackSwipeEventWithOptions:' api doesn't handle momentum events.
418 if ([theEvent phase] == NSEventPhaseNone)
421 mouseScrollDelta_.width += [theEvent scrollingDeltaX];
422 mouseScrollDelta_.height += [theEvent scrollingDeltaY];
424 BOOL isHorizontalGesture =
425 std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
426 if (!isHorizontalGesture)
429 BOOL isRightScroll = [theEvent scrollingDeltaX] < 0;
430 history_swiper::NavigationDirection direction =
431 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
432 BOOL browserCanMove =
433 [self browserCanNavigateInDirection:direction event:theEvent];
437 [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
441 - (void)initiateMagicMouseHistorySwipe:(BOOL)isRightScroll
442 event:(NSEvent*)event {
443 // Released by the tracking handler once the gesture is complete.
444 __block HistoryOverlayController* historyOverlay =
445 [[HistoryOverlayController alloc]
446 initForMode:isRightScroll ? kHistoryOverlayModeForward
447 : kHistoryOverlayModeBack];
449 // The way this API works: gestureAmount is between -1 and 1 (float). If
450 // the user does the gesture for more than about 30% (i.e. < -0.3 or >
451 // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded,
452 // and after that the block is called with amounts animating towards 1
453 // (or -1, depending on the direction). If the user lets go below that
454 // threshold, we get NSEventPhaseCancelled, and the amount animates
455 // toward 0. When gestureAmount has reaches its final value, i.e. the
456 // track animation is done, the handler is called with |isComplete| set
458 // When starting a backwards navigation gesture (swipe from left to right,
459 // gestureAmount will go from 0 to 1), if the user swipes from left to
460 // right and then quickly back to the left, this call can send
461 // NSEventPhaseEnded and then animate to gestureAmount of -1. For a
462 // picture viewer, that makes sense, but for back/forward navigation users
463 // find it confusing. There are two ways to prevent this:
464 // 1. Set Options to NSEventSwipeTrackingLockDirection. This way,
465 // gestureAmount will always stay > 0.
466 // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount
467 // will become less than 0, but on the quick swipe back to the left,
468 // NSEventPhaseCancelled is sent instead.
469 // The current UI looks nicer with (1) so that swiping the opposite
470 // direction after the initial swipe doesn't cause the shield to move
471 // in the wrong direction.
472 forceMagicMouse = YES;
473 [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection
474 dampenAmountThresholdMin:-1
476 usingHandler:^(CGFloat gestureAmount,
480 if (phase == NSEventPhaseBegan) {
482 showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
486 BOOL ended = phase == NSEventPhaseEnded;
488 // Dismiss the panel before navigation for immediate visual feedback.
489 CGFloat progress = std::abs(gestureAmount) / 0.3;
490 BOOL finished = progress >= 1.0;
491 progress = MAX(0.0, progress);
492 progress = MIN(1.0, progress);
493 [historyOverlay setProgress:progress finished:finished];
495 // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
498 chrome::FindBrowserWithWindow(historyOverlay.view.window);
499 if (ended && browser) {
501 chrome::GoForward(browser, CURRENT_TAB);
503 chrome::GoBack(browser, CURRENT_TAB);
506 if (ended || isComplete) {
507 [historyOverlay dismiss];
508 [historyOverlay release];
509 historyOverlay = nil;
514 - (BOOL)handleScrollWheelEvent:(NSEvent*)theEvent {
515 if (![theEvent respondsToSelector:@selector(phase)])
518 // The only events that this class consumes have type NSEventPhaseChanged.
519 // This simultaneously weeds our regular mouse wheel scroll events, and
520 // gesture events with incorrect phase.
521 if ([theEvent phase] != NSEventPhaseChanged &&
522 [theEvent momentumPhase] != NSEventPhaseChanged) {
526 // We've already processed this gesture.
527 if (recognitionState_ != history_swiper::kPending) {
528 return [self shouldConsumeWheelEvent:theEvent];
531 // Don't allow momentum events to start history swiping.
532 if ([theEvent momentumPhase] != NSEventPhaseNone)
535 BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
536 if (!systemSettingsValid)
539 if (![delegate_ shouldAllowHistorySwiping])
542 // Don't enable history swiping until the renderer has decided to not consume
543 // the event with phase NSEventPhaseBegan.
544 if (!beganEventUnconsumed_)
547 // Magic mouse and touchpad swipe events are identical except magic mouse
548 // events do not generate NSTouch callbacks. Since we rely on NSTouch
549 // callbacks to perform history swiping, magic mouse swipe events use an
550 // entirely different set of logic.
551 if ((inGesture_ && !receivingTouches_) || forceMagicMouse)
552 return [self handleMagicMouseWheelEvent:theEvent];
554 // The scrollWheel: callback is only relevant if it happens while the user is
555 // still actively using the touchpad.
556 if (!receivingTouches_)
559 // TODO(erikchen): Ideally, the direction of history swiping should not be
560 // determined this early in a gesture, when it's unclear what the user is
561 // intending to do. Since it is determined this early, make sure that there
562 // is at least a minimal amount of horizontal motion.
563 CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
564 if (fabs(xDelta) < 0.001)
567 BOOL isRightScroll = xDelta > 0;
568 BOOL inverted = [self isEventDirectionInverted:theEvent];
570 isRightScroll = !isRightScroll;
572 history_swiper::NavigationDirection direction =
573 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
574 BOOL browserCanMove =
575 [self browserCanNavigateInDirection:direction event:theEvent];
579 historySwipeDirection_ = direction;
580 historySwipeDirectionInverted_ = [self isEventDirectionInverted:theEvent];
581 recognitionState_ = history_swiper::kPotential;
582 [self showHistoryOverlay:direction];
583 return [self shouldConsumeWheelEvent:theEvent];
586 - (BOOL)shouldConsumeWheelEvent:(NSEvent*)event {
587 switch (recognitionState_) {
588 case history_swiper::kPending:
589 case history_swiper::kCancelled:
591 case history_swiper::kTracking:
592 case history_swiper::kCompleted:
594 case history_swiper::kPotential:
595 // It is unclear whether the user is attempting to perform history
596 // swiping. If the event has a vertical component, send it on to the
598 return event.scrollingDeltaY == 0;
604 @implementation HistorySwiper (PrivateExposedForTesting)
605 + (void)resetMagicMouseState {
606 forceMagicMouse = NO;