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