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