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 #include "chrome/browser/ui/browser.h"
8 #include "chrome/browser/ui/browser_commands.h"
9 #include "chrome/browser/ui/browser_finder.h"
11 #import "base/mac/sdk_forward_declarations.h"
12 #import "chrome/browser/ui/cocoa/history_overlay_controller.h"
14 // Once we call `[NSEvent trackSwipeEventWithOptions:]`, we cannot reliably
15 // expect NSTouch callbacks. We set this variable to YES and ignore NSTouch
17 static BOOL forceMagicMouse = NO;
19 @implementation HistorySwiper
20 @synthesize delegate = delegate_;
22 - (id)initWithDelegate:(id<HistorySwiperDelegate>)delegate {
25 // Gesture ids start at 0.
26 currentGestureId_ = 0;
27 // No gestures have been processed
28 lastProcessedGestureId_ = -1;
35 [self endHistorySwipe];
39 - (BOOL)handleEvent:(NSEvent*)event {
40 if ([event type] == NSScrollWheel)
41 return [self maybeHandleHistorySwiping:event];
46 - (void)gotUnhandledWheelEvent {
47 gotUnhandledWheelEvent_ = YES;
50 - (void)scrollOffsetPinnedToLeft:(BOOL)left toRight:(BOOL)right {
52 isPinnedRight_ = right;
55 - (void)setHasHorizontalScrollbar:(BOOL)hasHorizontalScrollbar {
56 hasHorizontalScrollbar_ = hasHorizontalScrollbar;
59 - (BOOL)canRubberbandLeft:(NSView*)view {
60 Browser* browser = chrome::FindBrowserWithWindow([view window]);
61 // If history swiping isn't possible, allow rubberbanding.
64 if (!chrome::CanGoBack(browser))
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_;
72 - (BOOL)canRubberbandRight:(NSView*)view {
73 Browser* browser = chrome::FindBrowserWithWindow([view window]);
74 // If history swiping isn't possible, allow rubberbanding.
77 if (!chrome::CanGoForward(browser))
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_;
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 {
93 // Reset state pertaining to previous gestures.
94 historySwipeCancelled_ = NO;
95 gestureStartPointValid_ = NO;
96 gotUnhandledWheelEvent_ = NO;
98 mouseScrollDelta_ = NSZeroSize;
101 - (void)endGestureWithEvent:(NSEvent*)event {
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);
111 for (NSTouch* touch in
112 [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) {
113 position.x += touch.normalizedPosition.x;
114 position.y += touch.normalizedPosition.y;
118 if (pointCount > 1) {
119 position.x /= pointCount;
120 position.y /= pointCount;
126 - (void)updateGestureCurrentPointFromEvent:(NSEvent*)event {
127 // The points in an event are not valid unless the event is part of
130 // Update the current point of the gesture.
131 gestureCurrentPoint_ = [self averagePositionInEvent:event];
133 // If the gesture doesn't have a start point, set one.
134 if (!gestureStartPointValid_) {
135 gestureStartPointValid_ = YES;
136 gestureStartPoint_ = gestureCurrentPoint_;
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;
149 - (void)touchesMovedWithEvent:(NSEvent*)event {
150 receivedTouch_ = YES;
151 if (![self shouldProcessEventForHistorySwiping:event])
154 [self updateGestureCurrentPointFromEvent:event];
156 if (historyOverlay_) {
157 // Consider cancelling the history swipe gesture.
158 if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_
159 startPoint:gestureStartPoint_]) {
160 [self cancelHistorySwipe];
164 [self updateProgressBar];
167 - (void)touchesCancelledWithEvent:(NSEvent*)event {
168 receivedTouch_ = YES;
169 if (![self shouldProcessEventForHistorySwiping:event])
173 [self cancelHistorySwipe];
175 - (void)touchesEndedWithEvent:(NSEvent*)event {
176 receivedTouch_ = YES;
177 if (![self shouldProcessEventForHistorySwiping:event])
180 [self updateGestureCurrentPointFromEvent:event];
181 if (historyOverlay_) {
182 BOOL finished = [self updateProgressBar];
184 // If the gesture was completed, perform a navigation.
186 [self navigateBrowserInDirection:historySwipeDirection_];
189 // Remove the history overlay.
190 [self endHistorySwipe];
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;
201 // Consider cancelling the horizontal swipe if the user was intending a
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;
210 // There's been a lot of vertical distance.
211 BOOL muchVert = yDelta > 0.32;
213 return moreVertThanHoriz || muchVert;
216 - (void)cancelHistorySwipe {
217 [self endHistorySwipe];
218 historySwipeCancelled_ = YES;
221 - (void)endHistorySwipe {
222 [historyOverlay_ dismiss];
223 [historyOverlay_ release];
224 historyOverlay_ = nil;
227 // Returns whether the progress bar has been 100% filled.
228 - (BOOL)updateProgressBar {
229 NSPoint currentPoint = gestureCurrentPoint_;
230 NSPoint startPoint = gestureStartPoint_;
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)
241 // If the user has directions reversed, we need to invert progress.
242 if (historySwipeDirectionInverted_)
248 // Progress can't be less than 0 or greater than 1.
249 progress = MAX(0.0, progress);
250 progress = MIN(1.0, progress);
252 [historyOverlay_ setProgress:progress finished:finished];
257 - (BOOL)isEventDirectionInverted:(NSEvent*)event {
258 if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)])
259 return [event isDirectionInvertedFromDevice];
263 // goForward indicates whether the user is starting a forward or backward
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];
277 HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc]
278 initForMode:(direction == history_swiper::kForwards)
279 ? kHistoryOverlayModeForward
280 : kHistoryOverlayModeBack];
281 [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
282 historyOverlay_ = historyOverlay;
284 // Record whether the user was swiping forwards or backwards.
285 historySwipeDirection_ = direction;
286 // Record the user's settings.
287 historySwipeDirectionInverted_ = [self isEventDirectionInverted:event];
290 - (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event {
292 respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)])
293 return [NSEvent isSwipeTrackingFromScrollEventsEnabled];
297 - (void)navigateBrowserInDirection:
298 (history_swiper::NavigationDirection)direction {
299 Browser* browser = chrome::FindBrowserWithWindow(
300 historyOverlay_.view.window);
302 if (direction == history_swiper::kForwards)
303 chrome::GoForward(browser, CURRENT_TAB);
305 chrome::GoBack(browser, CURRENT_TAB);
309 - (BOOL)browserCanNavigateInDirection:
310 (history_swiper::NavigationDirection)direction
311 event:(NSEvent*)event {
312 Browser* browser = chrome::FindBrowserWithWindow([event window]);
316 if (direction == history_swiper::kForwards) {
317 return chrome::CanGoForward(browser);
319 return chrome::CanGoBack(browser);
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)
330 mouseScrollDelta_.width += [theEvent scrollingDeltaX];
331 mouseScrollDelta_.height += [theEvent scrollingDeltaY];
333 BOOL isHorizontalGesture =
334 std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height);
335 if (!isHorizontalGesture)
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];
347 if (hasHorizontalScrollbar_ && !isPinnedRight_)
350 if (hasHorizontalScrollbar_ && !isPinnedLeft_)
354 [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent];
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];
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
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
393 usingHandler:^(CGFloat gestureAmount,
397 if (phase == NSEventPhaseBegan) {
399 showPanelForView:[delegate_ viewThatWantsHistoryOverlay]];
403 BOOL ended = phase == NSEventPhaseEnded;
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];
412 // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice]
415 chrome::FindBrowserWithWindow(historyOverlay.view.window);
416 if (ended && browser) {
418 chrome::GoForward(browser, CURRENT_TAB);
420 chrome::GoBack(browser, CURRENT_TAB);
423 if (ended || isComplete) {
424 [historyOverlay dismiss];
425 [historyOverlay release];
426 historyOverlay = nil;
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.
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
455 - (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent {
456 if (![theEvent respondsToSelector:@selector(phase)])
459 // Check for regular mouse wheel scroll events.
460 if ([theEvent phase] == NSEventPhaseNone &&
461 [theEvent momentumPhase] == NSEventPhaseNone) {
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) {
475 // The user cancelled the history swiper. Ignore all events.
476 if (historySwipeCancelled_)
479 // The user completed the history swiper. Swallow all events.
483 BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent];
484 if (!systemSettingsValid)
487 if (![delegate_ shouldAllowHistorySwiping])
490 // Don't even consider enabling history swiping until blink has decided it is
491 // not going to handle the event.
492 if (!gotUnhandledWheelEvent_)
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) {
506 if (!receivedTouch_ || forceMagicMouse) {
507 return [self maybeHandleMagicMouseHistorySwiping:theEvent];
510 CGFloat yDelta = gestureCurrentPoint_.y - gestureStartPoint_.y;
511 CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x;
513 // Require the user's gesture to have moved more than a minimal amount.
514 if (fabs(xDelta) < 0.01)
517 // Require the user's gesture to be slightly more horizontal than vertical.
518 BOOL isHorizontalGesture = fabs(xDelta) > 1.3 * fabs(yDelta);
520 if (!isHorizontalGesture)
523 BOOL isRightScroll = xDelta > 0;
524 BOOL inverted = [self isEventDirectionInverted:theEvent];
526 isRightScroll = !isRightScroll;
529 if (hasHorizontalScrollbar_ && !isPinnedRight_)
532 if (hasHorizontalScrollbar_ && !isPinnedLeft_)
536 history_swiper::NavigationDirection direction =
537 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards;
538 BOOL browserCanMove =
539 [self browserCanNavigateInDirection:direction event:theEvent];
543 lastProcessedGestureId_ = currentGestureId_;
544 [self beginHistorySwipeInDirection:direction event:theEvent];
549 @implementation HistorySwiper (PrivateExposedForTesting)
550 + (void)resetMagicMouseState {
551 forceMagicMouse = NO;