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