chrome_render_widget_host_view_mac_history_swiper.mm revision a1401311d1ab56c4ed0a474bd38c108f75cb0cd9
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 __block HistoryOverlayController* historyOverlay = 334 [[HistoryOverlayController alloc] 335 initForMode:isRightScroll ? kHistoryOverlayModeForward 336 : kHistoryOverlayModeBack]; 337 338 // The way this API works: gestureAmount is between -1 and 1 (float). If 339 // the user does the gesture for more than about 30% (i.e. < -0.3 or > 340 // 0.3) and then lets go, it is accepted, we get a NSEventPhaseEnded, 341 // and after that the block is called with amounts animating towards 1 342 // (or -1, depending on the direction). If the user lets go below that 343 // threshold, we get NSEventPhaseCancelled, and the amount animates 344 // toward 0. When gestureAmount has reaches its final value, i.e. the 345 // track animation is done, the handler is called with |isComplete| set 346 // to |YES|. 347 // When starting a backwards navigation gesture (swipe from left to right, 348 // gestureAmount will go from 0 to 1), if the user swipes from left to 349 // right and then quickly back to the left, this call can send 350 // NSEventPhaseEnded and then animate to gestureAmount of -1. For a 351 // picture viewer, that makes sense, but for back/forward navigation users 352 // find it confusing. There are two ways to prevent this: 353 // 1. Set Options to NSEventSwipeTrackingLockDirection. This way, 354 // gestureAmount will always stay > 0. 355 // 2. Pass min:0 max:1 (instead of min:-1 max:1). This way, gestureAmount 356 // will become less than 0, but on the quick swipe back to the left, 357 // NSEventPhaseCancelled is sent instead. 358 // The current UI looks nicer with (1) so that swiping the opposite 359 // direction after the initial swipe doesn't cause the shield to move 360 // in the wrong direction. 361 forceMagicMouse = YES; 362 [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection 363 dampenAmountThresholdMin:-1 364 max:1 365 usingHandler:^(CGFloat gestureAmount, 366 NSEventPhase phase, 367 BOOL isComplete, 368 BOOL* stop) { 369 if (phase == NSEventPhaseBegan) { 370 [historyOverlay 371 showPanelForView:[delegate_ viewThatWantsHistoryOverlay]]; 372 return; 373 } 374 375 BOOL ended = phase == NSEventPhaseEnded; 376 377 // Dismiss the panel before navigation for immediate visual feedback. 378 CGFloat progress = std::abs(gestureAmount) / 0.3; 379 BOOL finished = progress >= 1.0; 380 progress = MAX(0.0, progress); 381 progress = MIN(1.0, progress); 382 [historyOverlay setProgress:progress finished:finished]; 383 384 // |gestureAmount| obeys -[NSEvent isDirectionInvertedFromDevice] 385 // automatically. 386 Browser* browser = 387 chrome::FindBrowserWithWindow(historyOverlay.view.window); 388 if (ended && browser) { 389 if (isRightScroll) 390 chrome::GoForward(browser, CURRENT_TAB); 391 else 392 chrome::GoBack(browser, CURRENT_TAB); 393 } 394 395 if (ended || isComplete) { 396 [historyOverlay dismiss]; 397 [historyOverlay release]; 398 historyOverlay = nil; 399 } 400 }]; 401} 402 403// Checks if |theEvent| should trigger history swiping, and if so, does 404// history swiping. Returns YES if the event was consumed or NO if it should 405// be passed on to the renderer. 406// 407// There are 4 types of scroll wheel events: 408// 1. Magic mouse swipe events. 409// These are identical to magic trackpad events, except that there are no 410// NSTouch callbacks. The only way to accurately track these events is 411// with the `trackSwipeEventWithOptions:` API. scrollingDelta{X,Y} is not 412// accurate over long distances (it is computed using the speed of the 413// swipe, rather than just the distance moved by the fingers). 414// 2. Magic trackpad swipe events. 415// These are the most common history swipe events. Our logic is 416// predominantly designed to handle this use case. 417// 3. Traditional mouse scrollwheel events. 418// These should not initiate scrolling. They can be distinguished by the 419// fact that `phase` and `momentumPhase` both return NSEventPhaseNone. 420// 4. Momentum swipe events. 421// After a user finishes a swipe, the system continues to generate 422// artificial callbacks. `phase` returns NSEventPhaseNone, but 423// `momentumPhase` does not. Unfortunately, the callbacks don't work 424// properly (OSX 10.9). Sometimes, the system start sending momentum swipe 425// events instead of trackpad swipe events while the user is still 426// 2-finger swiping. 427- (BOOL)maybeHandleHistorySwiping:(NSEvent*)theEvent { 428 if (![theEvent respondsToSelector:@selector(phase)]) 429 return NO; 430 431 // Check for regular mouse wheel scroll events. 432 if ([theEvent phase] == NSEventPhaseNone && 433 [theEvent momentumPhase] == NSEventPhaseNone) { 434 return NO; 435 } 436 437 // We've already processed this gesture. 438 if (lastProcessedGestureId_ == currentGestureId_) { 439 // A new event may come in before it's recognized as a gesture. 440 // We have not yet reset the state from the last gesture. 441 // Let it pass through. 442 if ([theEvent phase] == NSEventPhaseBegan || 443 [theEvent phase] == NSEventPhaseMayBegin) { 444 return NO; 445 } 446 447 // The user cancelled the history swiper. Ignore all events. 448 if (historySwipeCancelled_) 449 return NO; 450 451 // The user completed the history swiper. Swallow all events. 452 return YES; 453 } 454 455 BOOL systemSettingsValid = [self systemSettingsAllowHistorySwiping:theEvent]; 456 if (!systemSettingsValid) 457 return NO; 458 459 if (![delegate_ shouldAllowHistorySwiping]) 460 return NO; 461 462 // Don't even consider enabling history swiping until blink has decided it is 463 // not going to handle the event. 464 if (!gotUnhandledWheelEvent_) 465 return NO; 466 467 // If the window has a horizontal scroll bar, sometimes Cocoa gets confused 468 // and sends us momentum scroll wheel events instead of gesture scroll events 469 // (even though the user is still actively swiping). 470 if ([theEvent phase] != NSEventPhaseChanged && 471 [theEvent momentumPhase] != NSEventPhaseChanged) { 472 return NO; 473 } 474 475 if (!inGesture_) 476 return NO; 477 478 if (!receivedTouch_ || forceMagicMouse) { 479 return [self maybeHandleMagicMouseHistorySwiping:theEvent]; 480 } 481 482 CGFloat yDelta = gestureCurrentPoint_.y - gestureStartPoint_.y; 483 CGFloat xDelta = gestureCurrentPoint_.x - gestureStartPoint_.x; 484 485 // Require the user's gesture to have moved more than a minimal amount. 486 if (fabs(xDelta) < 0.01) 487 return NO; 488 489 // Require the user's gesture to be slightly more horizontal than vertical. 490 BOOL isHorizontalGesture = fabs(xDelta) > 1.3 * fabs(yDelta); 491 492 if (!isHorizontalGesture) 493 return NO; 494 495 BOOL isRightScroll = xDelta > 0; 496 BOOL inverted = [self isEventDirectionInverted:theEvent]; 497 if (inverted) 498 isRightScroll = !isRightScroll; 499 500 if (isRightScroll) { 501 if (hasHorizontalScrollbar_ && !isPinnedRight_) 502 return NO; 503 } else { 504 if (hasHorizontalScrollbar_ && !isPinnedLeft_) 505 return NO; 506 } 507 508 history_swiper::NavigationDirection direction = 509 isRightScroll ? history_swiper::kForwards : history_swiper::kBackwards; 510 BOOL browserCanMove = 511 [self browserCanNavigateInDirection:direction event:theEvent]; 512 if (!browserCanMove) 513 return NO; 514 515 lastProcessedGestureId_ = currentGestureId_; 516 [self beginHistorySwipeInDirection:direction event:theEvent]; 517 return YES; 518} 519@end 520 521@implementation HistorySwiper (PrivateExposedForTesting) 522+ (void)resetMagicMouseState { 523 forceMagicMouse = NO; 524} 525@end 526