chrome_render_widget_host_view_mac_history_swiper.mm revision e5d81f57cb97b3b6b7fccc9c5610d21eb81db09d
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)gotUnhandledWheelEvent { 47 gotUnhandledWheelEvent_ = YES; 48} 49 50- (void)scrollOffsetPinnedToLeft:(BOOL)left toRight:(BOOL)right { 51 isPinnedLeft_ = left; 52 isPinnedRight_ = right; 53} 54 55- (void)setHasHorizontalScrollbar:(BOOL)hasHorizontalScrollbar { 56 hasHorizontalScrollbar_ = hasHorizontalScrollbar; 57} 58 59- (BOOL)canRubberbandLeft:(NSView*)view { 60 Browser* browser = chrome::FindBrowserWithWindow([view window]); 61 // If history swiping isn't possible, allow rubberbanding. 62 if (!browser) 63 return true; 64 if (!chrome::CanGoBack(browser)) 65 return true; 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_; 70} 71 72- (BOOL)canRubberbandRight:(NSView*)view { 73 Browser* browser = chrome::FindBrowserWithWindow([view window]); 74 // If history swiping isn't possible, allow rubberbanding. 75 if (!browser) 76 return true; 77 if (!chrome::CanGoForward(browser)) 78 return true; 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_; 83} 84 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 { 91 inGesture_ = YES; 92 ++currentGestureId_; 93 // Reset state pertaining to previous gestures. 94 historySwipeCancelled_ = NO; 95 gestureStartPointValid_ = NO; 96 gotUnhandledWheelEvent_ = NO; 97 receivedTouch_ = NO; 98 mouseScrollDelta_ = NSZeroSize; 99} 100 101- (void)endGestureWithEvent:(NSEvent*)event { 102 inGesture_ = NO; 103} 104 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); 110 int pointCount = 0; 111 for (NSTouch* touch in 112 [event touchesMatchingPhase:NSTouchPhaseAny inView:nil]) { 113 position.x += touch.normalizedPosition.x; 114 position.y += touch.normalizedPosition.y; 115 ++pointCount; 116 } 117 118 if (pointCount > 1) { 119 position.x /= pointCount; 120 position.y /= pointCount; 121 } 122 123 return position; 124} 125 126- (void)updateGestureCurrentPointFromEvent:(NSEvent*)event { 127 // The points in an event are not valid unless the event is part of 128 // a gesture. 129 if (inGesture_) { 130 // Update the current point of the gesture. 131 gestureCurrentPoint_ = [self averagePositionInEvent:event]; 132 133 // If the gesture doesn't have a start point, set one. 134 if (!gestureStartPointValid_) { 135 gestureStartPointValid_ = YES; 136 gestureStartPoint_ = gestureCurrentPoint_; 137 } 138 } 139} 140 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; 146 // Do nothing. 147} 148 149- (void)touchesMovedWithEvent:(NSEvent*)event { 150 receivedTouch_ = YES; 151 if (![self shouldProcessEventForHistorySwiping:event]) 152 return; 153 154 [self updateGestureCurrentPointFromEvent:event]; 155 156 if (historyOverlay_) { 157 // Consider cancelling the history swipe gesture. 158 if ([self shouldCancelHorizontalSwipeWithCurrentPoint:gestureCurrentPoint_ 159 startPoint:gestureStartPoint_]) { 160 [self cancelHistorySwipe]; 161 return; 162 } 163 164 [self updateProgressBar]; 165 } 166} 167- (void)touchesCancelledWithEvent:(NSEvent*)event { 168 receivedTouch_ = YES; 169 if (![self shouldProcessEventForHistorySwiping:event]) 170 return; 171 172 if (historyOverlay_) 173 [self cancelHistorySwipe]; 174} 175- (void)touchesEndedWithEvent:(NSEvent*)event { 176 receivedTouch_ = YES; 177 if (![self shouldProcessEventForHistorySwiping:event]) 178 return; 179 180 [self updateGestureCurrentPointFromEvent:event]; 181 if (historyOverlay_) { 182 BOOL finished = [self updateProgressBar]; 183 184 // If the gesture was completed, perform a navigation. 185 if (finished) { 186 [self navigateBrowserInDirection:historySwipeDirection_]; 187 } 188 189 // Remove the history overlay. 190 [self endHistorySwipe]; 191 } 192} 193 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; 199} 200 201// Consider cancelling the horizontal swipe if the user was intending a 202// vertical swipe. 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; 209 210 // There's been a lot of vertical distance. 211 BOOL muchVert = yDelta > 0.32; 212 213 return moreVertThanHoriz || muchVert; 214} 215 216- (void)cancelHistorySwipe { 217 [self endHistorySwipe]; 218 historySwipeCancelled_ = YES; 219} 220 221- (void)endHistorySwipe { 222 [historyOverlay_ dismiss]; 223 [historyOverlay_ release]; 224 historyOverlay_ = nil; 225} 226 227// Returns whether the progress bar has been 100% filled. 228- (BOOL)updateProgressBar { 229 NSPoint currentPoint = gestureCurrentPoint_; 230 NSPoint startPoint = gestureStartPoint_; 231 232 float progress = 0; 233 BOOL finished = NO; 234 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) 240 progress *= -1; 241 // If the user has directions reversed, we need to invert progress. 242 if (historySwipeDirectionInverted_) 243 progress *= -1; 244 245 if (progress >= 1.0) 246 finished = YES; 247 248 // Progress can't be less than 0 or greater than 1. 249 progress = MAX(0.0, progress); 250 progress = MIN(1.0, progress); 251 252 [historyOverlay_ setProgress:progress finished:finished]; 253 254 return finished; 255} 256 257- (BOOL)isEventDirectionInverted:(NSEvent*)event { 258 if ([event respondsToSelector:@selector(isDirectionInvertedFromDevice)]) 259 return [event isDirectionInvertedFromDevice]; 260 return NO; 261} 262 263// goForward indicates whether the user is starting a forward or backward 264// history swipe. 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]; 276 277 HistoryOverlayController* historyOverlay = [[HistoryOverlayController alloc] 278 initForMode:(direction == history_swiper::kForwards) 279 ? kHistoryOverlayModeForward 280 : kHistoryOverlayModeBack]; 281 [historyOverlay showPanelForView:[delegate_ viewThatWantsHistoryOverlay]]; 282 historyOverlay_ = historyOverlay; 283 284 // Record whether the user was swiping forwards or backwards. 285 historySwipeDirection_ = direction; 286 // Record the user's settings. 287 historySwipeDirectionInverted_ = [self isEventDirectionInverted:event]; 288} 289 290- (BOOL)systemSettingsAllowHistorySwiping:(NSEvent*)event { 291 if ([NSEvent 292 respondsToSelector:@selector(isSwipeTrackingFromScrollEventsEnabled)]) 293 return [NSEvent isSwipeTrackingFromScrollEventsEnabled]; 294 return NO; 295} 296 297- (void)navigateBrowserInDirection: 298 (history_swiper::NavigationDirection)direction { 299 Browser* browser = chrome::FindBrowserWithWindow( 300 historyOverlay_.view.window); 301 if (browser) { 302 if (direction == history_swiper::kForwards) 303 chrome::GoForward(browser, CURRENT_TAB); 304 else 305 chrome::GoBack(browser, CURRENT_TAB); 306 } 307} 308 309- (BOOL)browserCanNavigateInDirection: 310 (history_swiper::NavigationDirection)direction 311 event:(NSEvent*)event { 312 Browser* browser = chrome::FindBrowserWithWindow([event window]); 313 if (!browser) 314 return NO; 315 316 if (direction == history_swiper::kForwards) { 317 return chrome::CanGoForward(browser); 318 } else { 319 return chrome::CanGoBack(browser); 320 } 321} 322 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) 328 return NO; 329 330 mouseScrollDelta_.width += [theEvent scrollingDeltaX]; 331 mouseScrollDelta_.height += [theEvent scrollingDeltaY]; 332 333 BOOL isHorizontalGesture = 334 std::abs(mouseScrollDelta_.width) > std::abs(mouseScrollDelta_.height); 335 if (!isHorizontalGesture) 336 return NO; 337 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]; 343 if (!browserCanMove) 344 return NO; 345 346 if (isRightScroll) { 347 if (hasHorizontalScrollbar_ && !isPinnedRight_) 348 return NO; 349 } else { 350 if (hasHorizontalScrollbar_ && !isPinnedLeft_) 351 return NO; 352 } 353 354 [self initiateMagicMouseHistorySwipe:isRightScroll event:theEvent]; 355 return YES; 356} 357 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]; 365 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 374 // to |YES|. 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 392 max:1 393 usingHandler:^(CGFloat gestureAmount, 394 NSEventPhase phase, 395 BOOL isComplete, 396 BOOL* stop) { 397 if (phase == NSEventPhaseBegan) { 398 [historyOverlay 399 showPanelForView:[delegate_ viewThatWantsHistoryOverlay]]; 400 return; 401 } 402 403 BOOL ended = phase == NSEventPhaseEnded; 404 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]; 411 412 // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice] 413 // automatically. 414 Browser* browser = 415 chrome::FindBrowserWithWindow(historyOverlay.view.window); 416 if (ended && browser) { 417 if (isRightScroll) 418 chrome::GoForward(browser, CURRENT_TAB); 419 else 420 chrome::GoBack(browser, CURRENT_TAB); 421 } 422 423 if (ended || isComplete) { 424 [historyOverlay dismiss]; 425 [historyOverlay release]; 426 historyOverlay = nil; 427 } 428 }]; 429} 430 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. 434// 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 454// 2-finger swiping. 455- (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent { 456 if (![theEvent respondsToSelector:@selector(phase)]) 457 return NO; 458 459 // Check for regular mouse wheel scroll events. 460 if ([theEvent phase] == NSEventPhaseNone && 461 [theEvent momentumPhase] == NSEventPhaseNone) { 462 return NO; 463 } 464 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) { 472 return NO; 473 } 474 475 // The user cancelled the history swiper. Ignore all events. 476 if (historySwipeCancelled_) 477 return NO; 478 479 // The user completed the history swiper. Swallow all events. 480 return YES; 481 } 482 483 BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent]; 484 if (!systemSettingsValid) 485 return NO; 486 487 if (![delegate_ shouldAllowHistorySwiping]) 488 return NO; 489 490 // Don't even consider enabling history swiping until blink has decided it is 491 // not going to handle the event. 492 if (!gotUnhandledWheelEvent_) 493 return NO; 494 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) { 500 return NO; 501 } 502 503 if (!inGesture_) 504 return NO; 505 506 if (!receivedTouch_ || forceMagicMouse) { 507 return [self maybeHandleMagicMouseHistorySwiping:theEvent]; 508 } 509 510 CGFloat yDelta = gestureCurrentPoint_.y - gestureStartPoint_.y; 511 CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x; 512 513 // Require the user's gesture to have moved more than a minimal amount. 514 if (fabs(xDelta) < 0.01) 515 return NO; 516 517 // Require the user's gesture to be slightly more horizontal than vertical. 518 BOOL isHorizontalGesture = fabs(xDelta) > 1.3 * fabs(yDelta); 519 520 if (!isHorizontalGesture) 521 return NO; 522 523 BOOL isRightScroll = xDelta > 0; 524 BOOL inverted = [self isEventDirectionInverted:theEvent]; 525 if (inverted) 526 isRightScroll = !isRightScroll; 527 528 if (isRightScroll) { 529 if (hasHorizontalScrollbar_ && !isPinnedRight_) 530 return NO; 531 } else { 532 if (hasHorizontalScrollbar_ && !isPinnedLeft_) 533 return NO; 534 } 535 536 history_swiper::NavigationDirection direction = 537 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards; 538 BOOL browserCanMove = 539 [self browserCanNavigateInDirection:direction event:theEvent]; 540 if (!browserCanMove) 541 return NO; 542 543 lastProcessedGestureId_ = currentGestureId_; 544 [self beginHistorySwipeInDirection:direction event:theEvent]; 545 return YES; 546} 547@end 548 549@implementation HistorySwiper (PrivateExposedForTesting) 550+ (void)resetMagicMouseState { 551 forceMagicMouse = NO; 552} 553@end 554