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