Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(208)

Unified Diff: ios/chrome/browser/ui/stack_view/stack_view_controller.mm

Issue 2587023002: Upstream Chrome on iOS source code [8/11]. (Closed)
Patch Set: Created 4 years ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: ios/chrome/browser/ui/stack_view/stack_view_controller.mm
diff --git a/ios/chrome/browser/ui/stack_view/stack_view_controller.mm b/ios/chrome/browser/ui/stack_view/stack_view_controller.mm
new file mode 100644
index 0000000000000000000000000000000000000000..190b7ca8712633cfd5dc8a377afc152ba5fe7c24
--- /dev/null
+++ b/ios/chrome/browser/ui/stack_view/stack_view_controller.mm
@@ -0,0 +1,3513 @@
+// Copyright 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/ui/stack_view/stack_view_controller.h"
+
+#import <QuartzCore/QuartzCore.h>
+
+#include <algorithm>
+#include <cmath>
+#include <limits>
+
+#include "base/format_macros.h"
+#import "base/ios/block_types.h"
+#import "base/ios/weak_nsobject.h"
+#include "base/logging.h"
+#import "base/mac/bundle_locations.h"
+#import "base/mac/foundation_util.h"
+#import "base/mac/objc_property_releaser.h"
+#include "base/mac/scoped_block.h"
+#import "base/mac/scoped_nsobject.h"
+#include "base/metrics/histogram.h"
+#include "base/metrics/user_metrics.h"
+#include "base/metrics/user_metrics_action.h"
+#include "base/strings/sys_string_conversions.h"
+#include "ios/chrome/browser/chrome_url_constants.h"
+#include "ios/chrome/browser/experimental_flags.h"
+#import "ios/chrome/browser/tabs/tab.h"
+#import "ios/chrome/browser/tabs/tab_model.h"
+#import "ios/chrome/browser/tabs/tab_model_observer.h"
+#import "ios/chrome/browser/ui/animation_util.h"
+#import "ios/chrome/browser/ui/background_generator.h"
+#import "ios/chrome/browser/ui/commands/UIKit+ChromeExecuteCommand.h"
+#import "ios/chrome/browser/ui/commands/generic_chrome_command.h"
+#include "ios/chrome/browser/ui/commands/ios_command_ids.h"
+#import "ios/chrome/browser/ui/keyboard/UIKeyCommand+Chrome.h"
+#import "ios/chrome/browser/ui/ntp/new_tab_page_toolbar_controller.h"
+#import "ios/chrome/browser/ui/reversed_animation.h"
+#import "ios/chrome/browser/ui/rtl_geometry.h"
+#import "ios/chrome/browser/ui/stack_view/card_stack_layout_manager.h"
+#import "ios/chrome/browser/ui/stack_view/card_stack_pinch_gesture_recognizer.h"
+#import "ios/chrome/browser/ui/stack_view/card_view.h"
+#import "ios/chrome/browser/ui/stack_view/close_button.h"
+#import "ios/chrome/browser/ui/stack_view/page_animation_util.h"
+#import "ios/chrome/browser/ui/stack_view/stack_card.h"
+#import "ios/chrome/browser/ui/stack_view/stack_view_controller_private.h"
+#import "ios/chrome/browser/ui/stack_view/stack_view_toolbar_controller.h"
+#import "ios/chrome/browser/ui/stack_view/title_label.h"
+#import "ios/chrome/browser/ui/toolbar/new_tab_button.h"
+#import "ios/chrome/browser/ui/toolbar/toolbar_owner.h"
+#import "ios/chrome/browser/ui/tools_menu/tools_menu_context.h"
+#import "ios/chrome/browser/ui/tools_menu/tools_menu_view_item.h"
+#import "ios/chrome/browser/ui/ui_util.h"
+#import "ios/chrome/browser/ui/uikit_ui_util.h"
+#import "ios/chrome/common/material_timing.h"
+#include "ios/chrome/grit/ios_strings.h"
+#include "ios/web/public/referrer.h"
+#import "net/base/mac/url_conversions.h"
+#include "ui/base/l10n/l10n_util.h"
+
+using base::UserMetricsAction;
+
+// To obtain scroll behavior, places the card stacks' display views within a
+// UIScrollView container. The container is used only as a driver of scroll
+// events. To avoid the finite size of the container from impacting scrolling,
+// (1) the container is made large enough that it cannot be scrolled to a
+// boundary point without the user having first fully scrolled the card stack
+// in that direction, and (2) after scroll events, the container's scroll
+// offset is recentered if necessary.
+
+namespace {
+// The fraction of the display to use for the active deck if both decks are
+// being displayed.
+const CGFloat kActiveDeckDisplayFraction = 0.85;
+// Animation durations.
+const NSTimeInterval kCascadingCardCloseDelay = 0.1;
+const NSTimeInterval kDefaultAnimationDuration = 0.25;
+// The length of the animation that eliminates overextension after a
+// scroll/pinch.
+const NSTimeInterval kOverextensionEliminationAnimationDuration = .4;
+// Fraction of the screen that must be swiped for a stack to switch or a card
+// to dismiss.
+const CGFloat kSwipeCardScreenFraction = 0.35;
+// The velocity (in points / millisecond) below which a scroll's deceleration
+// will be killed once the stack is overscrolled. Determined by experimentation
+// to see what resulted in a good feel.
+const CGFloat kMinFlingVelocityInOverscroll = 0.3;
+// The velocity (in points / millisecond) at/above which a scroll will be
+// treated as a fling even if it is not for the purposes of determining
+// overscroll behavior. Used to handle the corner case where the user flings
+// the cards toward the start stack, but at the moment that the scrolled card
+// would become overscrolled, the finger is still registered as tracking. Has
+// the tradeoff that if user is legimately scrolling above this velocity near
+// the start stack, the card will not track the user's finger. Value determined
+// by experimentation to see what resulted in a good handling of this tradeoff.
+const CGFloat kThresholdVelocityForTreatingScrollAsFling = 1.0;
+// The factor by which scroll velocity is decayed once a fling becomes
+// overscrolled.
+const CGFloat kDecayFactorInBounce = .75;
+// The duration (in seconds) that the user must press on a card before
+// beginning an ambiguous swipe (i.e., a swipe that could result in either
+// dismissing the card or changing decks) for that swipe to trigger card
+// dismissal instead of changing decks.
+const CGFloat kPressDurationForAmbiguousSwipeToTriggerDismissal = .2;
+// The vertical overlap between the scroll view and the toolbar (chosen to match
+// aspect ratio of snapshotted webview).
+const CGFloat kVerticalToolbarOverlap = 0.0;
+// The delay into the dismissal transition animation at which to update the
+// status bar. The value was provided by UX, and corresponds to approximately
+// when the selected card's frame image crosses the status bar in the animation.
+const NSTimeInterval kDismissalStatusBarUpdateDelay = 0.15;
+// When choosing the size of the cards, ensure that the bottom of the card
+// is at most |kCardBottomPadding| from the bottom of the scroll view.
+const CGFloat kCardBottomPadding = 29.0;
+// Animation key used for the dummy toolbar background view animation.
+NSString* const kDummyToolbarBackgroundViewAnimationKey =
+ @"DummyToolbarBackgroundViewAnimationKey";
+} // anonymous namespace
+
+// Container for the state associated with gesture-related events.
+@interface GestureStateTracker : NSObject
+
+@property(nonatomic, assign) NSUInteger scrollCardIndex;
+@property(nonatomic, assign) CGFloat previousScrollOffset;
+@property(nonatomic, assign) base::TimeTicks previousScrollTime;
+// The current scroll velocity, in points / millisecond.
+@property(nonatomic, readonly) CGFloat scrollVelocity;
+@property(nonatomic, assign) BOOL resetScrollCardOnNextDrag;
+@property(nonatomic, assign) NSUInteger firstPinchCardIndex;
+@property(nonatomic, assign) NSUInteger secondPinchCardIndex;
+@property(nonatomic, assign) CGFloat previousFirstPinchOffset;
+@property(nonatomic, assign) CGFloat previousSecondPinchOffset;
+// YES when a pinch gesture is currently being recognized.
+@property(nonatomic, assign) BOOL pinching;
+// YES when a 1-fingered pinch gesture is being interpreted by
+// StackViewController's |handlePinchFrom:| as a scroll.
+@property(nonatomic, assign) BOOL scrollingInPinch;
+// Swipe gesture starting position. In portrait, this is the x position of the
+// beginning touch. In landscape this is the y position.
+@property(nonatomic, assign) CGFloat swipeStartingPosition;
+// If YES, a swipe gesture is interpreted as being a swipe to change decks.
+// Otherwise, a swipe gesture is interpreted as being a swipe to close a card.
+@property(nonatomic, assign) BOOL swipeChangesDecks;
+// The index of the card being swiped. Undefined if |swipeChangesDecks| is YES.
+@property(nonatomic, assign) NSUInteger swipedCardIndex;
+@property(nonatomic, assign) BOOL resetSwipedCardOnNextSwipe;
+@property(nonatomic, assign) BOOL swipeIsBeginning;
+// Whether a swipe whose intent is ambiguous should change decks (as opposed to
+// dismiss a card). Relevant only when multiple stacks are present.
+@property(nonatomic, assign) BOOL ambiguousSwipeChangesDecks;
+
+// Given |distance|, which should be the distance scrolled since
+// |previousScrollTime|, updates |scrollVelocity|.
+- (void)updateScrollVelocityWithScrollDistance:(CGFloat)distance;
+
+@end
+
+@implementation GestureStateTracker
+
+@synthesize ambiguousSwipeChangesDecks = _ambiguousSwipeChangesDecks;
+@synthesize firstPinchCardIndex = _firstPinchCardIndex;
+@synthesize pinching = _pinching;
+@synthesize previousFirstPinchOffset = _previousFirstPinchOffset;
+@synthesize previousScrollOffset = _previousScrollOffset;
+@synthesize previousScrollTime = _previousScrollTime;
+@synthesize previousSecondPinchOffset = _previousSecondPinchOffset;
+@synthesize resetScrollCardOnNextDrag = _resetScrollCardOnNextDrag;
+@synthesize resetSwipedCardOnNextSwipe = _resetSwipedCardOnNextSwipe;
+@synthesize scrollCardIndex = _scrollCardIndex;
+@synthesize scrollingInPinch = _scrollingInPinch;
+@synthesize scrollVelocity = _scrollVelocity;
+@synthesize secondPinchCardIndex = _secondPinchCardIndex;
+@synthesize swipeChangesDecks = _swipeChangesDecks;
+@synthesize swipedCardIndex = _swipedCardIndex;
+@synthesize swipeIsBeginning = _swipeIsBeginning;
+@synthesize swipeStartingPosition = _swipeStartingPosition;
+
+- (instancetype)init {
+ if ((self = [super init])) {
+ _resetScrollCardOnNextDrag = YES;
+ }
+ return self;
+}
+
+- (void)updateScrollVelocityWithScrollDistance:(CGFloat)distance {
+ base::TimeDelta elapsedTime = base::TimeTicks::Now() - _previousScrollTime;
+ if (elapsedTime == base::TimeDelta::FromMicroseconds(0))
+ return;
+ _scrollVelocity =
+ fabs(distance / CGFloat(elapsedTime.InMillisecondsRoundedUp()));
+}
+
+@end
+
+@interface StackViewController (Private)
+
+// Clears the internal state of the object. Should only be called when the
+// object is not being shown. After this method is called, a call to
+// |restoreInternalState| must be made before the object is reshown.
+- (void)clearInternalState;
+// Updates the layout parameters of the scroll view and the display views based
+// on the viewport size. Should be called any time that the viewport size is
+// changed.
+- (void)viewportSizeWasChanged;
+// Configures the scroll view to be large enough so that the user could not
+// scroll to one of its boundaries without also having reached the
+// corresponding boundary of the stack being scrolled.
+- (void)updateScrollViewContentSize;
+// Deregisters for the notifications |registerForNotifications| specifies.
+- (void)deregisterForNotifications;
+// Eliminates the ability for the user to perform any further interactions
+// with the stack view. Should be called when the stack view starts being
+// dismissed.
+- (void)prepareForDismissal;
+
+// Asynchronously adds the remaining cards to the display view to pre-load them.
+// This is safe to call multiple times.
+- (void)preloadCardViewsAsynchronously;
+// Adds the next not-yet-loaded card to the display view.
+- (void)preloadNextCardView;
+// Animates the removal of |cardView| from the superview, with the start of the
+// animation being delayed by |delay|. Performs |completion| on animation
+// finish (may be NULL). Card will dismiss clockwise when |clockwise| is YES and
+// counter-clockwise when |clockwise| is NO.
+- (void)animateOutCardView:(CardView*)cardView
+ delay:(NSTimeInterval)delay
+ clockwise:(BOOL)clockwise
+ completion:(ProceduralBlock)completion;
+// Removes all cards in |cardSet| from the underlying model and their superview.
+- (void)removeAllCardsFromSet:(CardSet*)cardSet;
+// Disable all the gesture handlers. Must be called before removing cards from
+// the active set.
+- (void)disableGestureHandlers;
+// Enable all the gesture handlers.
+- (void)enableGestureHandlers;
+// Should be called whenever the number of cards in the active set changes
+// (including the active set itself changing).
+- (void)activeCardCountChanged;
+// Computes and stores the initial card size information that will decide how
+// layout is done for the remainder of the stack view's lifetime, and configures
+// the card sets accordingly.
+- (void)setInitialCardSizing;
+// Updates the card sizing and layout for the current device orientation.
+// If |animates| is true, the size change will be animated, otherwise it will be
+// done synchronously.
+- (void)updateDeckOrientationWithAnimation:(BOOL)animates;
+// Updates the card sizing for the current deck states and device orientation.
+// If |animates| is true, the size change will be animated, otherwise it will be
+// done synchronously.
+- (void)updateCardSizesWithAnimation:(BOOL)animates;
+// Animates setting the opacity of the card tabs of the current deck to
+// |opacity|.
+- (void)animateActiveSetCardTabsToOpacity:(CGFloat)opacity
+ withDuration:(CGFloat)duration
+ completion:(ProceduralBlock)completion;
+// Updates the positions of the decks in the non-layout direction. Should be
+// called if the layout direction, card size, or active set changes.
+- (void)updateDeckAxisPositions;
+// Updates the positions of the decks in the non-layout direction and then
+// shifts them from the standard positions by |amount|.
+- (void)updateDeckAxisPositionsWithShiftAmount:(CGFloat)amount;
+// Updates the position of |cardSet| in the non-layout direction and then
+// shifts it from its standard position by |amount|.
+- (void)updateDeckAxisPositionForCardSet:(CardSet*)cardSet
+ withShiftAmount:(CGFloat)shiftAmount;
+// The amount by which |cardSet| should be shifted from its default layout axis
+// position to be positioned offscreen.
+- (CGFloat)shiftOffscreenAmountForCardSet:(CardSet*)cardSet;
+// Refreshes the card display, using the current orientation. Should be called
+// any time the orientation has changed or the display views have been rebuilt.
+- (void)refreshCardDisplayWithAnimation:(BOOL)animates;
+// Updates the UI to reflect the current card set. Called automatically by
+// setActiveCardSet:, but also called during setup.
+- (void)displayActiveCardSet;
+// Switches to showing only the main card set, with no room for showing the
+// incognito set. Should be called when the last incognito card is closed.
+- (void)displayMainCardSetOnly;
+// Updates the appearance of the toolbar for the current state of the card
+// stack.
+- (void)updateToolbarAppearanceWithAnimation:(BOOL)animate;
+
+// Returns the size of a single card (at normal zoom).
+- (CGSize)cardSize;
+// Returns the size that should be used for cards being displayed in a viewport
+// with breadth |breadth|, taking margins into account and preserving the
+// content area aspect ratio.
+- (CGSize)cardSizeForBreadth:(CGFloat)breadth;
+// Returns the amount that |point| is offset on the current scroll axis.
+- (CGFloat)scrollOffsetAmountForPoint:(CGPoint)point;
+// Returns the amount that |position| is offset on the current scroll axis.
+- (CGFloat)scrollOffsetAmountForPosition:(LayoutRectPosition)position;
+// Returns the CGPoint offset |offset| in the current scroll direction, with
+// 0 as the other component of the point.
+- (CGPoint)scrollOffsetPointWithAmount:(CGFloat)offset;
+// Returns the length of |size| in the current scroll direction.
+- (CGFloat)scrollLength:(CGSize)size;
+// Returns the length of |size| in the non-scroll direction.
+- (CGFloat)scrollBreadth:(CGSize)size;
+// Returns a size for the current scroll direction with the given scroll length
+// and breadth.
+- (CGSize)sizeForScrollLength:(CGFloat)length breadth:(CGFloat)breadth;
+// Returns the index of the card that |point| is contained within, or
+// |NSNotFound| if |point| is not contained in any card. This is not an
+// efficient lookup, so this should *not* be called frequently.
+- (NSUInteger)indexOfCardAtPoint:(CGPoint)point;
+// Will reverse the current transition animations if the tab switcher button is
+// pressed before the animation can finish.
+- (void)cancelTransitionAnimation;
+// Called within the completion block for the transition animations.
+- (void)finishTransitionAnimation;
+// Called within the completion block for the transition animations. Notifies
+// the delegates that the current transition has finished.
+- (void)notifyDelegatesTransitionFinished;
+// Sets up the view hierarchy for transitioning and calls the stack view and
+// toolbar animation selectors below, wrapping the animations in a single
+// CATransaction. Use |transitionStyle| = StackTransitionStylePresenting for
+// presentation and |transitionStyle| = StackTransitionStyleDismissing for
+// dismissal.
+- (void)animateTransitionWithStyle:(StackTransitionStyle)transitionStyle;
+// Updates the view heirarchy for the transition based on the current transition
+// style. If the style is STACK_TRANSITION_STYLE_PRESENTING or
+// STACK_TRANSITION_STYLE_DISMISSING, the display views are added to the root
+// view and the toolbar is inserted between them. If the style is
+// STACK_TRANSITION_STYLE_NONE, the display views are reparented into the scroll
+// view and aligned to the view port.
+- (void)reorderSubviewsForTransition;
+// Animates the cards in |cardSet| from |beginLayouts| to |endLayouts| with the
+// transition style specified by |self.transitionStyle|.
+- (void)animateCardSet:(CardSet*)cardSet
+ fromBeginLayouts:(std::vector<LayoutRect>)beginLayouts
+ toEndLayouts:(std::vector<LayoutRect>)endLayouts;
+// Reverses the cancelled transition animations added to the card set.
+- (void)reverseTransitionAnimationsForCardSet:(CardSet*)cardSet;
+// Adds transition animations to the active card set. If |self.transitionStyle|
+// = StackTransitionStylePresenting, this will fan out the cards in the active
+// set from the frames returned by |-cardTransitionFrames| to the fanned out
+// frames. If |transitionStyle| = StackTransitionStyleDismissing, the cards will
+// animate from their current frames to |-cardTransitionFrames|.
+- (void)animateActiveSetTransition;
+// Adds transition animations to the inactive set. The inactive card set will
+// be laid out in the fanned frames and will animate in from offscreen if
+// |self.transitionStyle| = StackTransitionStylePresenting, and will animate off
+// the screen if |transitionStyle| = StackTransitionStyleDismissing.
+- (void)animateInactiveSetTransition;
+// Adds the dummy toolbar background to the active card set's current card and
+// animates alongside the toolbar, creating a cross-fade effect from the
+// toolbar's frame at the top of the screen to the tab portion of |cardFrame|
+// and vise versa.
+- (void)animateDummyToolbarForCardFrame:(CGRect)cardFrame
+ transitionStyle:(StackTransitionStyle)transitionStyle;
+// Reverses the dummy toolbar background animations for cancelled transitions.
+- (void)reverseDummyToolbarBackgroundViewAnimation;
+// Adds the toolbar view owned by |transitionToolbarController| to the stack's
+// view hierarchy and animates the frame alongside the active card set's current
+// card (i.e. from its position at the top of the screen to the tab portion of
+// the provided |cardFrame| or vise versa). The transition completion delegate
+// callbacks will reparent the toolbar view back into the BVC's view hiearchy.
+- (void)animateTransitionToolbarWithCardFrame:(CGRect)cardFrame
+ transitionStyle:
+ (StackTransitionStyle)transitionStyle;
+// Returns an vector of LayoutRects corresponding to the active card set's
+// frames at the moment of switching to or from the stack view. The cards below
+// the current card in the z axis will have top-aligned unscaled frames, the
+// current card will have a top-aligned frame scaled such that the web view
+// snapshot takes the entire screen below the toolbar, and cards above the the
+// current card will have the scaled frame translated offscreen.
+- (std::vector<LayoutRect>)cardTransitionLayouts;
+// Returns the index of what should be the first visible card in the initial
+// fanout of |cardSet|.
+- (NSUInteger)startIndexOfInitialFanoutForCardSet:(CardSet*)cardSet;
+// Perform the animation for switching out of the stack view while
+// simultaneously opening a tab with |url|, at |position| with the given
+// |transition|. The tab where |url| is opened is returned.
+- (Tab*)dismissWithNewTabAnimation:(const GURL&)url
+ atIndex:(NSUInteger)position
+ transition:(ui::PageTransition)transition;
+- (void)closeTab:(id)sender;
+- (void)handleLongPressFrom:(UIPinchGestureRecognizer*)recognizer;
+- (void)handlePinchFrom:(UIPinchGestureRecognizer*)recognizer;
+- (void)handleTapFrom:(UITapGestureRecognizer*)recognizer;
+// Returns the card corresponding to |view|. This is not an efficient lookup,
+// so this should *not* be called frequently.
+- (StackCard*)cardForView:(CardView*)view;
+// Shows the tools menu popup.
+- (void)showToolsMenuPopup;
+// All local tab state is cleared, and |currentlyClosingAllTabs_| is set to
+// |NO|.
+- (void)allModelTabsHaveClosed:(NSNotification*)notify;
+
+// Updates the display views so that they are aligned to the scroll view's
+// viewport. Should be called any time the scroll view's content offset
+// changes.
+- (void)alignDisplayViewsToViewport;
+
+// Handles swipe gestures between card sets and swipes to remove cards.
+- (void)handlePanFrom:(UIPanGestureRecognizer*)gesture;
+// Determines whether the current swipe should be treated as a swipe to dismiss
+// a card or a swipe to change decks.
+- (void)determineSwipeType:(UIPanGestureRecognizer*)gesture;
+// Returns the distance that a swipe needs to travel in order to trigger an
+// action (close card/change deck).
+- (CGFloat)distanceForSwipeToTriggerAction;
+// Returns |YES| if the current swipe should trigger an action (close
+// card/change deck) based on the swipe's ending position and its starting
+// position.
+- (BOOL)swipeShouldTriggerAction:(CGFloat)endingPosition;
+// Moves between card sets, potentially changing the active card set at the end
+// of the gesture.
+- (void)swipeDeck:(UIPanGestureRecognizer*)gesture;
+// Moves the card being swiped, potentially dismissing the card at the end of
+// the gesture.
+- (void)swipeCard:(UIPanGestureRecognizer*)gesture;
+
+// Returns whether the current scroll should be ended.
+- (BOOL)shouldEndScroll;
+// Performs any necessary cleanup actions after a scroll is completed.
+- (void)scrollEnded;
+// Performs any necessary cleanup actions after a pinch is completed.
+- (void)pinchEnded;
+// Animates overextension elimination from the active card set. Performs
+// |completion| on animation finish (may be |NULL|).
+- (void)animateOverextensionEliminationWithCompletion:
+ (ProceduralBlock)completion;
+// Cancels a scroll that is in its deceleration phase.
+// NOTE: Will not have the desired behavior if invoked on a scroll that is not
+// in the deceleration phase, i.e., the user is still dragging on the screen.
+- (void)killScrollDeceleration;
+// Adjusts the amount that the stack is allowed to overextend depending on
+// whether the current scroll is a fling.
+- (void)adjustMaximumOverextensionAmount:(BOOL)isFling;
+
+// Responds to voice over focusing on TitleLabel or CloseButton. If the element
+// label is covered, scroll it toward the start stack, or the next card toward
+// the end stack as appropriate. If the element is in the middle of a stack, fan
+// outcards from its card's index.
+- (void)accessibilityFocusedOnElement:(id)element;
+
+// Determine the center of |sender| if it's a view or a toolbar item and store.
+- (void)setLastTapPoint:(id)sender;
+
+@end
+
+@implementation StackViewController {
+ base::scoped_nsobject<UIScrollView> _scrollView;
+ // The view containing the stack view's background.
+ base::scoped_nsobject<UIView> _backgroundView;
+ // The main card set.
+ base::scoped_nsobject<CardSet> _mainCardSet;
+ // The off-the-record card set.
+ base::scoped_nsobject<CardSet> _otrCardSet;
+ // The currently active card set; one of _mainCardSet or _otrCardSet.
+ CardSet* _activeCardSet; // weak
+ id<TabSwitcherDelegate> _delegate; // weak
+ id<StackViewControllerTestDelegate> _testDelegate; // weak
+ // Controller for the stack view toolbar.
+ base::scoped_nsobject<StackViewToolbarController> _toolbarController;
+ // The size of a card at the time the stack was first shown.
+ CGSize _initialCardSize;
+ // The previous orientation of the interface.
+ UIInterfaceOrientation _lastInterfaceOrientation;
+ // Gesture recognizer to catch taps on the inactive stack.
+ base::scoped_nsobject<UITapGestureRecognizer> _modeSwitchRecognizer;
+ // Gesture recognizer to catch pinches in the active scroll view.
+ base::scoped_nsobject<UIGestureRecognizer> _pinchRecognizer;
+ // Gesture recognizer to catch swipes to switch decks/dismiss cards.
+ base::scoped_nsobject<UIGestureRecognizer> _swipeGestureRecognizer;
+ // Gesture recognizer that determines whether an ambiguous swipe action
+ // (i.e., a swipe on an active card in the direction that would cause a deck
+ // change) should trigger a change of decks or a card dismissal.
+ base::scoped_nsobject<UILongPressGestureRecognizer>
+ _swipeDismissesCardRecognizer;
+ // Tracks the parameters of gesture-related events.
+ base::scoped_nsobject<GestureStateTracker> _gestureStateTracker;
+ // If |YES|, callbacks to |scrollViewDidScroll:| do not trigger scrolling.
+ // Default is |NO|.
+ BOOL _ignoreScrollCallbacks;
+ // The scroll view's pan gesture recognizer.
+ UIPanGestureRecognizer* _scrollGestureRecognizer; // weak
+ // Because the removal of the StackCard during a swipe happens in a callback,
+ // track which direction the animation should dismiss with.
+ // |_reverseDismissCard| is only set when the dismissal happens in reverse.
+ base::scoped_nsobject<StackCard> _reverseDismissCard;
+ // |YES| if the stack view is in the process of being dismissed.
+ BOOL _isBeingDismissed;
+ // |YES| if the stack view is currently active.
+ BOOL _isActive;
+ // Records whether a memory warning occurred in the current session.
+ BOOL _receivedMemoryWarningInSession;
+ // |YES| if there is card set animation being processed. For testing only.
+ // Save last touch point used by new tab animation.
+ CGPoint _lastTapPoint;
+
+ base::mac::ObjCPropertyReleaser _propertyReleaserStackViewController;
+}
+
+@synthesize activeCardSet = _activeCardSet;
+@synthesize delegate = _delegate;
+@synthesize dummyToolbarBackgroundView = _dummyToolbarBackgroundView;
+@synthesize inActiveDeckChangeAnimation = _inActiveDeckChangeAnimation;
+@synthesize testDelegate = _testDelegate;
+@synthesize transitionStyle = _transitionStyle;
+@synthesize transitionTappedCard = _transitionTappedCard;
+@synthesize transitionToolbarController = _transitionToolbarController;
+@synthesize transitionToolbarFrame = _transitionToolbarFrame;
+@synthesize transitionToolbarOwner = _transitionToolbarOwner;
+@synthesize transitionWasCancelled = _transitionWasCancelled;
+
+- (instancetype)initWithMainCardSet:(CardSet*)mainCardSet
+ otrCardSet:(CardSet*)otrCardSet
+ activeCardSet:(CardSet*)activeCardSet {
+ DCHECK(mainCardSet);
+ DCHECK(otrCardSet);
+ DCHECK(activeCardSet == otrCardSet || activeCardSet == mainCardSet);
+ self = [super initWithNibName:nil bundle:nil];
+ if (self) {
+ _propertyReleaserStackViewController.Init(self,
+ [StackViewController class]);
+ [self setUpWithMainCardSet:mainCardSet
+ otrCardSet:otrCardSet
+ activeCardSet:activeCardSet];
+ _swipeDismissesCardRecognizer.reset([[UILongPressGestureRecognizer alloc]
+ initWithTarget:self
+ action:@selector(handleLongPressFrom:)]);
+ [_swipeDismissesCardRecognizer
+ setMinimumPressDuration:
+ kPressDurationForAmbiguousSwipeToTriggerDismissal];
+ [_swipeDismissesCardRecognizer setDelegate:self];
+ _pinchRecognizer.reset([[CardStackPinchGestureRecognizer alloc]
+ initWithTarget:self
+ action:@selector(handlePinchFrom:)]);
+ [_pinchRecognizer setDelegate:self];
+ _modeSwitchRecognizer.reset([[UITapGestureRecognizer alloc]
+ initWithTarget:self
+ action:@selector(handleTapFrom:)]);
+ [_modeSwitchRecognizer setDelegate:self];
+ }
+ return self;
+}
+
+- (instancetype)initWithMainTabModel:(TabModel*)mainModel
+ otrTabModel:(TabModel*)otrModel
+ activeTabModel:(TabModel*)activeModel {
+ DCHECK(mainModel);
+ DCHECK(otrModel);
+ DCHECK(activeModel == otrModel || activeModel == mainModel);
+ base::scoped_nsobject<CardSet> mainCardSet(
+ [[CardSet alloc] initWithModel:mainModel]);
+ base::scoped_nsobject<CardSet> otrCardSet(
+ [[CardSet alloc] initWithModel:otrModel]);
+ CardSet* activeCardSet =
+ (activeModel == mainModel) ? mainCardSet.get() : otrCardSet.get();
+ return [self initWithMainCardSet:mainCardSet
+ otrCardSet:otrCardSet
+ activeCardSet:activeCardSet];
+}
+
+- (instancetype)initWithNibName:(NSString*)nibNameOrNil
+ bundle:(NSBundle*)nibBundleOrNil {
+ NOTREACHED();
+ return nil;
+}
+
+- (instancetype)initWithCoder:(NSCoder*)aDecoder {
+ NOTREACHED();
+ return nil;
+}
+
+- (void)setUpWithMainCardSet:(CardSet*)mainCardSet
+ otrCardSet:(CardSet*)otrCardSet
+ activeCardSet:(CardSet*)activeCardSet {
+ _mainCardSet.reset([mainCardSet retain]);
+ _otrCardSet.reset([otrCardSet retain]);
+ if (experimental_flags::IsLRUSnapshotCacheEnabled()) {
+ [_mainCardSet setKeepOnlyVisibleCardViewsAlive:YES];
+ [_otrCardSet setKeepOnlyVisibleCardViewsAlive:YES];
+ }
+ _activeCardSet = (activeCardSet == mainCardSet) ? mainCardSet : otrCardSet;
+ _gestureStateTracker.reset([[GestureStateTracker alloc] init]);
+ _pinchRecognizer.reset([[CardStackPinchGestureRecognizer alloc]
+ initWithTarget:self
+ action:@selector(handlePinchFrom:)]);
+ [_pinchRecognizer setDelegate:self];
+ _modeSwitchRecognizer.reset([[UITapGestureRecognizer alloc]
+ initWithTarget:self
+ action:@selector(handleTapFrom:)]);
+ [_modeSwitchRecognizer setDelegate:self];
+}
+
+- (void)restoreInternalStateWithMainTabModel:(TabModel*)mainModel
+ otrTabModel:(TabModel*)otrModel
+ activeTabModel:(TabModel*)activeModel {
+ DCHECK(mainModel);
+ DCHECK(otrModel);
+ DCHECK(activeModel == otrModel || activeModel == mainModel);
+ DCHECK(!_isActive);
+ base::scoped_nsobject<CardSet> mainCardSet(
+ [[CardSet alloc] initWithModel:mainModel]);
+ base::scoped_nsobject<CardSet> otrCardSet(
+ [[CardSet alloc] initWithModel:otrModel]);
+ CardSet* activeCardSet =
+ (activeModel == mainModel) ? mainCardSet.get() : otrCardSet.get();
+ [self setUpWithMainCardSet:mainCardSet
+ otrCardSet:otrCardSet
+ activeCardSet:activeCardSet];
+
+ // If the view is not currently loaded, do not adjust its size or add
+ // gesture recognizers. That work will be done in |viewDidLoad|.
+ if ([self isViewLoaded]) {
+ [self prepareForDisplay];
+ // The delegate is set to nil when the stack view is dismissed.
+ [_scrollView setDelegate:self];
+ }
+}
+
+- (void)setOtrTabModel:(TabModel*)otrModel {
+ DCHECK(_isActive);
+ DCHECK(_mainCardSet == _activeCardSet);
+ DCHECK([otrModel count] == 0);
+ DCHECK([[_otrCardSet tabModel] count] == 0);
+ [_otrCardSet setTabModel:otrModel];
+}
+
+- (void)clearInternalState {
+ DCHECK(!_isActive);
+ [[_mainCardSet displayView] removeFromSuperview];
+ [[_otrCardSet displayView] removeFromSuperview];
+
+ // Only deregister from the specific notifications for which this class
+ // registered. Do not use the blanket |removeObserver|, otherwise the low
+ // memory notification is not received and the view is never unloaded.
+ [self deregisterForNotifications];
+
+ _mainCardSet.reset();
+ _otrCardSet.reset();
+ _activeCardSet = nil;
+
+ // Remove gesture recognizers and notifications.
+ [self prepareForDismissal];
+ _gestureStateTracker.reset();
+ _pinchRecognizer.reset();
+ _modeSwitchRecognizer.reset();
+ _swipeGestureRecognizer.reset();
+
+ // The cards need to recompute their sizes the next time they are shown.
+ _initialCardSize.height = _initialCardSize.width = 0.0f;
+ // The scroll view will need to recenter itself relative to its viewport.
+ [_scrollView setContentOffset:CGPointZero];
+ _isBeingDismissed = NO;
+}
+
+- (void)viewportSizeWasChanged {
+ [self updateScrollViewContentSize];
+ [_mainCardSet displayViewSizeWasChanged];
+ [_otrCardSet displayViewSizeWasChanged];
+}
+
+- (void)updateScrollViewContentSize {
+ // Configure the scroll view to be large enough so that the user could not
+ // scroll to one of its boundaries from the center without also having
+ // reached the corresponding boundary of the stack being scrolled: the
+ // maximum size of the larger of the two stacks plus padding.
+ CGFloat scrollLength = std::max([_mainCardSet maximumStackLength],
+ [_otrCardSet maximumStackLength]);
+ scrollLength += [self scrollLength:[self cardSize]];
+ scrollLength *= 2.0;
+ CGFloat scrollBreadth = [self scrollBreadth:[_scrollView bounds].size];
+ // Changing the scroll view's content size will result in a callback to
+ // |scrollViewDidScroll|.
+ _ignoreScrollCallbacks = YES;
+ [_scrollView setContentSize:[self sizeForScrollLength:scrollLength
+ breadth:scrollBreadth]];
+ _ignoreScrollCallbacks = NO;
+ [self recenterScrollViewIfNecessary];
+}
+
+- (void)setUpDisplayViews {
+ CGRect displayViewFrame = CGRectMake(0, 0, [_scrollView frame].size.width,
+ [_scrollView frame].size.height);
+ base::scoped_nsobject<UIView> mainDisplayView(
+ [[UIView alloc] initWithFrame:displayViewFrame]);
+ [mainDisplayView setAutoresizingMask:UIViewAutoresizingFlexibleWidth |
+ UIViewAutoresizingFlexibleHeight];
+ base::scoped_nsobject<UIView> otrDisplayView(
+ [[UIView alloc] initWithFrame:displayViewFrame]);
+ [otrDisplayView setAutoresizingMask:UIViewAutoresizingFlexibleWidth |
+ UIViewAutoresizingFlexibleHeight];
+
+ [_scrollView addSubview:mainDisplayView];
+ [_scrollView addSubview:otrDisplayView];
+ [_mainCardSet setDisplayView:mainDisplayView];
+ [_otrCardSet setDisplayView:otrDisplayView];
+}
+
+- (void)prepareForDisplay {
+ [self setUpDisplayViews];
+
+ // Now that the toolbar and the display views are set up, configure the
+ // initial display state.
+ [self displayActiveCardSet];
+
+ _lastInterfaceOrientation = GetInterfaceOrientation();
+ if (_lastInterfaceOrientation == UIInterfaceOrientationUnknown) {
+ CGRect screenBounds = [[UIScreen mainScreen] bounds];
+ _lastInterfaceOrientation =
+ CGRectGetHeight(screenBounds) > CGRectGetWidth(screenBounds)
+ ? UIInterfaceOrientationPortrait
+ : UIInterfaceOrientationLandscapeRight;
+ }
+ [self registerForNotifications];
+
+ // TODO(blundell): Why isn't this recognizer initialized with the
+ // pinch and mode switch recognizers?
+ UIPanGestureRecognizer* panGestureRecognizer =
+ [[UIPanGestureRecognizer alloc] initWithTarget:self
+ action:@selector(handlePanFrom:)];
+ [panGestureRecognizer setMaximumNumberOfTouches:1];
+ _swipeGestureRecognizer.reset(panGestureRecognizer);
+ [[self view] addGestureRecognizer:_swipeGestureRecognizer];
+ [_swipeGestureRecognizer setDelegate:self];
+}
+
+- (void)loadView {
+ [super loadView];
+
+ _backgroundView.reset([[UIView alloc] initWithFrame:self.view.bounds]);
+ [_backgroundView setAutoresizingMask:(UIViewAutoresizingFlexibleHeight |
+ UIViewAutoresizingFlexibleWidth)];
+ [self.view addSubview:_backgroundView];
+
+ _toolbarController.reset(
+ [[StackViewToolbarController alloc] initWithStackViewToolbar]);
+ CGRect toolbarFrame = [self.view bounds];
+ toolbarFrame.origin.y = CGRectGetMinY([[_toolbarController view] frame]);
+ toolbarFrame.size.height = CGRectGetHeight([[_toolbarController view] frame]);
+ [[_toolbarController view] setFrame:toolbarFrame];
+ [self.view addSubview:[_toolbarController view]];
+ [self updateToolbarAppearanceWithAnimation:NO];
+
+ InstallBackgroundInView(_backgroundView);
+
+ UIEdgeInsets contentInsets = UIEdgeInsetsMake(
+ toolbarFrame.size.height - kVerticalToolbarOverlap, 0.0, 0.0, 0.0);
+ CGRect scrollViewFrame =
+ UIEdgeInsetsInsetRect(self.view.bounds, contentInsets);
+ _scrollView.reset([[UIScrollView alloc] initWithFrame:scrollViewFrame]);
+ [self.view addSubview:_scrollView];
+ [_scrollView setAutoresizingMask:(UIViewAutoresizingFlexibleHeight |
+ UIViewAutoresizingFlexibleWidth)];
+ [_scrollView setBounces:NO];
+ [_scrollView setScrollsToTop:NO];
+ [_scrollView setClipsToBounds:NO];
+ [_scrollView setShowsVerticalScrollIndicator:NO];
+ [_scrollView setShowsHorizontalScrollIndicator:NO];
+ [_scrollView setDelegate:self];
+
+ _scrollGestureRecognizer = [_scrollView panGestureRecognizer];
+
+ [self prepareForDisplay];
+}
+
+- (void)viewWillAppear:(BOOL)animated {
+ _isActive = YES;
+ // Sizing steps need to be done here rather than viewDidLoad since they
+ // depend on the view bounds being correct. Setting initial card size should
+ // be done only once, however, and viewWillAppear: can be called more than
+ // once. For initial display, the transition animation will handle initial
+ // layout. Avoid doing it here since that will potentially cause more views
+ // to be added to the hierarchy synchronously, slowing down inital load. The
+ // rest of the time refreshing is necessary because the card views may have
+ // been purged and recreated or the orientation might have changed while in
+ // a modal view.
+ BOOL isInitialDisplay = _initialCardSize.height == 0.0;
+ if (isInitialDisplay) {
+ [_mainCardSet setObserver:self];
+ [_otrCardSet setObserver:self];
+ [self setInitialCardSizing];
+ [self viewportSizeWasChanged];
+ } else {
+ [self refreshCardDisplayWithAnimation:NO];
+ [self updateToolbarAppearanceWithAnimation:NO];
+ }
+ [self preloadCardViewsAsynchronously];
+
+ // Reset the gesture state tracker to clear gesture-related information from
+ // the last time the stack view was shown.
+ _gestureStateTracker.reset([[GestureStateTracker alloc] init]);
+
+ [super viewWillAppear:animated];
+}
+
+- (void)refreshCardDisplayWithAnimation:(BOOL)animates {
+ _lastInterfaceOrientation = GetInterfaceOrientation();
+ [self updateDeckOrientationWithAnimation:animates];
+ [self viewportSizeWasChanged];
+ [_mainCardSet updateCardVisibilities];
+ [_otrCardSet updateCardVisibilities];
+}
+
+- (void)viewDidDisappear:(BOOL)animated {
+ if (![self presentedViewController]) {
+ // Stop pre-loading card views if the stack view has been dismissed.
+ [NSObject cancelPreviousPerformRequestsWithTarget:self];
+ _isActive = NO;
+ [self clearInternalState];
+ }
+ [_toolbarController dismissToolsMenuPopup];
+
+ [super viewDidDisappear:animated];
+}
+
+- (void)dealloc {
+ [_mainCardSet clearGestureRecognizerTargetAndDelegateFromCards:self];
+ [_otrCardSet clearGestureRecognizerTargetAndDelegateFromCards:self];
+ // Card sets shouldn't have any other references, but nil the observer just
+ // in case one somehow does end up with another ref.
+ [_mainCardSet setObserver:nil];
+ [_otrCardSet setObserver:nil];
+ [self cleanUpViewsAndNotifications];
+ [super dealloc];
+}
+
+// Overridden to always return NO, ensuring that the status bar shows in
+// landscape on iOS8.
+- (BOOL)prefersStatusBarHidden {
+ return NO;
+}
+
+// Called when in the foreground and the OS needs more memory. Release as much
+// as possible.
+- (void)didReceiveMemoryWarning {
+ // Releases the view if it doesn't have a superview.
+ [super didReceiveMemoryWarning];
+ _receivedMemoryWarningInSession = YES;
+ [_mainCardSet setKeepOnlyVisibleCardViewsAlive:YES];
+ [_otrCardSet setKeepOnlyVisibleCardViewsAlive:YES];
+
+ if (![self isViewLoaded]) {
+ [self cleanUpViewsAndNotifications];
+ }
+}
+
+- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)orient
+ duration:(NSTimeInterval)duration {
+ [super willRotateToInterfaceOrientation:orient duration:duration];
+ // No animation is performed on rotation if the view is not on screen.
+ if (!_isActive)
+ return;
+
+ // Hide the inactive set. NOTE: Ideally this hiding would be done as a
+ // sliding-off-the-screen animation during the first half of the rotation
+ // animation. However, integrating that custom animation with the default
+ // animation that is being done to the cards on rotation has proved
+ // challenging. For now, the inactive set is invisible during the rotation
+ // itself.
+ [[[self inactiveCardSet] displayView] setHidden:YES];
+}
+
+- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)orient
+ duration:(NSTimeInterval)duration {
+ [super willAnimateRotationToInterfaceOrientation:orient duration:duration];
+
+ if (orient == _lastInterfaceOrientation)
+ return;
+
+ // If the stack view controller is not actually active, internal state will
+ // not necessarily be consistent, and the animations could crash as a result.
+ // Short-circuit out in this case (which should happen only in rare race
+ // conditions involving the device being rotated as stack view is
+ // entered/exited).
+ if (!_isActive) {
+ _lastInterfaceOrientation = orient;
+ return;
+ }
+
+ [self updateToolbarAppearanceWithAnimation:YES];
+
+ [_toolbarController dismissToolsMenuPopup];
+
+ [self refreshCardDisplayWithAnimation:YES];
+
+ // Animate the update of the card tabs.
+ CGFloat halfOfTotalDuration = duration / 2.0;
+ void (^cardTabFadeIn)(void) = ^{
+ // Update the card tabs to their new positions instantaneously and then
+ // fade them back in.
+ [self animateActiveSetCardTabsToOpacity:1.0
+ withDuration:halfOfTotalDuration
+ completion:nil];
+ };
+ [self animateActiveSetCardTabsToOpacity:0.0
+ withDuration:halfOfTotalDuration
+ completion:cardTabFadeIn];
+
+ [_gestureStateTracker setResetScrollCardOnNextDrag:YES];
+ [_gestureStateTracker setFirstPinchCardIndex:NSNotFound];
+ [_gestureStateTracker setSecondPinchCardIndex:NSNotFound];
+}
+
+- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)orient {
+ [super didRotateFromInterfaceOrientation:orient];
+ // No animation is performed on rotation if the view is not on screen.
+ if (!_isActive)
+ return;
+
+ // Animate the inactive card set sliding in. NOTE: Ideally this animation
+ // would be done during the second half of the rotation animation. However,
+ // integrating this animation and the default animation that is being done to
+ // the cards on rotation has proved challenging. For now, the inactive set is
+ // invisible during the rotation itself.
+ CardSet* inactiveSet = [self inactiveCardSet];
+ [self updateDeckAxisPositionForCardSet:inactiveSet
+ withShiftAmount:
+ [self shiftOffscreenAmountForCardSet:inactiveSet]];
+ [[inactiveSet displayView] setHidden:NO];
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ delay:0
+ options:0
+ animations:^{
+ [self updateDeckAxisPositionForCardSet:inactiveSet
+ withShiftAmount:0];
+ }
+ completion:nil];
+}
+
+- (void)registerForNotifications {
+ NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
+ [defaultCenter addObserver:self
+ selector:@selector(allModelTabsHaveClosed:)
+ name:kTabModelAllTabsDidCloseNotification
+ object:nil];
+}
+
+- (void)deregisterForNotifications {
+ NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
+ [defaultCenter removeObserver:self
+ name:kTabModelAllTabsDidCloseNotification
+ object:nil];
+}
+
+- (void)prepareForDismissal {
+ UIView* activeView = [_activeCardSet displayView];
+ [activeView removeGestureRecognizer:_pinchRecognizer];
+ [activeView removeGestureRecognizer:_modeSwitchRecognizer];
+ [activeView removeGestureRecognizer:_swipeDismissesCardRecognizer];
+ [[self view] removeGestureRecognizer:_swipeGestureRecognizer];
+ [_mainCardSet clearGestureRecognizerTargetAndDelegateFromCards:self];
+ [_otrCardSet clearGestureRecognizerTargetAndDelegateFromCards:self];
+ [_scrollView setDelegate:nil];
+ [_scrollView setScrollEnabled:YES];
+ _ignoreScrollCallbacks = NO;
+
+ // Record per-session metrics.
+ UMA_HISTOGRAM_BOOLEAN("MemoryWarning.OccurredDuringCardStackSession",
+ _receivedMemoryWarningInSession);
+}
+
+- (void)cleanUpViewsAndNotifications {
+ [_mainCardSet setDisplayView:nil];
+ [_otrCardSet setDisplayView:nil];
+ // Stop pre-loading cards.
+ [NSObject cancelPreviousPerformRequestsWithTarget:self];
+ [_scrollView setDelegate:nil];
+ _scrollView.reset();
+ _backgroundView.reset();
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+- (UIStatusBarStyle)preferredStatusBarStyle {
+ // When dismissing the stack view, the status bar's style is updated when this
+ // view controller is still responsible. If the stack view is dismissing into
+ // a non-incognito BVC, the status bar needs to use the default style.
+ BOOL useDefaultStyle = _isBeingDismissed && ![self isCurrentSetIncognito];
+ return useDefaultStyle ? UIStatusBarStyleDefault
+ : UIStatusBarStyleLightContent;
+}
+
+#pragma mark -
+#pragma mark Card and Stack Construction
+
+- (void)preloadCardViewsAsynchronously {
+ // Start the deferred loading of card views. Defers the pre-loading slightly
+ // in order to give the initially visible cards a head start.
+ [NSObject cancelPreviousPerformRequestsWithTarget:self];
+ [self performSelector:@selector(preloadNextCardView)
+ withObject:nil
+ afterDelay:0.01];
+}
+
+- (void)preloadNextCardView {
+ if (_isBeingDismissed)
+ return;
+ // Preload one card from the active set, or if that's already loaded, from
+ // the other set.
+ BOOL preloadedCard = [_activeCardSet preloadNextCard];
+ if (!preloadedCard)
+ preloadedCard = [[self inactiveCardSet] preloadNextCard];
+ // If there was a card to preload, queue the next round.
+ if (preloadedCard) {
+ [self performSelector:@selector(preloadNextCardView)
+ withObject:nil
+ afterDelay:0];
+ } else {
+ [_testDelegate stackViewControllerPreloadCardViewsDidEnd];
+ }
+}
+
+- (void)animateOutCardView:(CardView*)cardView
+ delay:(NSTimeInterval)delay
+ clockwise:(BOOL)clockwise
+ completion:(ProceduralBlock)completion {
+ DCHECK(cardView);
+ void (^toDoWhenDone)(void) = ^{
+ [cardView removeFromSuperview];
+ if (completion)
+ completion();
+ };
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ ios_internal::page_animation_util::AnimateOutWithCompletion(
+ cardView, delay, clockwise, isPortrait, toDoWhenDone);
+}
+
+- (void)removeAllCardsFromSet:(CardSet*)cardSet {
+ // Ignore model updates while the cards are closing, to batch all the
+ // re-laying-out work.
+ [cardSet setIgnoresTabModelChanges:YES];
+
+ NSTimeInterval delay = 0;
+ NSArray* cards = [cardSet cards];
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+
+ // Find the last visible card.
+ StackCard* lastVisibleCard = nil;
+ for (StackCard* card in [cards reverseObjectEnumerator]) {
+ if ([card viewIsLive]) {
+ lastVisibleCard = card;
+ break;
+ }
+ }
+ if (lastVisibleCard == nil) {
+ [cardSet.tabModel closeAllTabs];
+ return;
+ }
+
+ for (StackCard* card in cards) {
+ NSInteger cardIndex = [cards indexOfObject:card];
+ DCHECK(cardIndex != NSNotFound);
+ BOOL cardWasCollapsed = [cardSet cardIsCollapsed:card];
+
+ if ([card viewIsLive]) {
+ void (^toDoWhenDone)(void) = NULL;
+ if (card == lastVisibleCard) {
+ toDoWhenDone = ^{
+ [cardSet.tabModel closeAllTabs];
+ };
+ }
+ [self animateOutCardView:card.view
+ delay:delay
+ clockwise:isPortrait
+ completion:toDoWhenDone];
+ } else {
+ // It's too late to create a view for this card now. This case should only
+ // occur if the card was covered, meaning that its animation out would
+ // have been invisible anyway.
+ DCHECK(cardWasCollapsed);
+ }
+
+ // Add a delay before the next card's animation if this card was not
+ // collapsed into the next card.
+ if (!cardWasCollapsed)
+ delay += kCascadingCardCloseDelay;
+ }
+}
+
+- (void)disableGestureHandlers {
+ // Disable gesture handlers before modifying the stack. Don't call this too
+ // late or a gesture callback could occur while still in the old state of the
+ // world.
+ // (see the comment in -cardSet:willRemoveCard:atIndex for details).
+ [_scrollView setScrollEnabled:NO];
+ _pinchRecognizer.get().enabled = NO;
+ _swipeGestureRecognizer.get().enabled = NO;
+}
+
+- (void)enableGestureHandlers {
+ // Reenable gesture handlers after modifying the stack. Don't call this too
+ // early or a gesture callback could occur while still in the old state of the
+ // world.
+ // (see the comment in -cardSet:willRemoveCard:atIndex for details).
+ [_scrollView setScrollEnabled:YES];
+ _pinchRecognizer.get().enabled = YES;
+ _swipeGestureRecognizer.get().enabled = YES;
+}
+
+- (void)activeCardCountChanged {
+ // Cancel any outstanding gestures (see the comment in
+ // -cardSet:willRemoveCard:atIndex).
+ [self disableGestureHandlers];
+ [self enableGestureHandlers];
+}
+
+- (void)setInitialCardSizing {
+ DCHECK(_initialCardSize.height == 0.0);
+ CGFloat viewportBreadth = [self scrollBreadth:[_scrollView bounds].size];
+ _initialCardSize = [self cardSizeForBreadth:viewportBreadth];
+
+ // Configure the stack layout behaviors. This is done only once because the
+ // fan-out, margins, etc. should stay the same even if the cards change size
+ // due to rotation.
+ [self updateDeckOrientationWithAnimation:NO];
+ [_mainCardSet configureLayoutParametersWithMargin:
+ ios_internal::page_animation_util::kCardMargin];
+ [_otrCardSet configureLayoutParametersWithMargin:
+ ios_internal::page_animation_util::kCardMargin];
+}
+
+- (void)updateDeckOrientationWithAnimation:(BOOL)animates {
+ [self updateDeckAxisPositions];
+ [self updateCardSizesWithAnimation:animates];
+}
+
+- (void)updateCardSizesWithAnimation:(BOOL)animates {
+ CGSize cardSize = [self cardSize];
+ NSTimeInterval animationDuration = animates ? kDefaultAnimationDuration : 0;
+ [UIView animateWithDuration:animationDuration
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^{
+ [_mainCardSet setCardSize:cardSize];
+ [_otrCardSet setCardSize:cardSize];
+ }
+ completion:nil];
+}
+
+- (void)animateActiveSetCardTabsToOpacity:(CGFloat)opacity
+ withDuration:(CGFloat)duration
+ completion:(ProceduralBlock)completion {
+ [UIView animateWithDuration:duration
+ delay:0
+ options:(UIViewAnimationOptionBeginFromCurrentState |
+ UIViewAnimationOptionOverrideInheritedDuration)
+ animations:^{
+ for (StackCard* card in [_activeCardSet cards]) {
+ if (card.viewIsLive)
+ [card.view setTabOpacity:opacity];
+ }
+ }
+ completion:^(BOOL) {
+ if (completion)
+ completion();
+ }];
+}
+
+- (void)updateDeckAxisPositions {
+ [self updateDeckAxisPositionsWithShiftAmount:0];
+}
+
+- (void)updateDeckAxisPositionsWithShiftAmount:(CGFloat)shiftAmount {
+ [self updateDeckAxisPositionForCardSet:_activeCardSet
+ withShiftAmount:shiftAmount];
+ [self updateDeckAxisPositionForCardSet:[self inactiveCardSet]
+ withShiftAmount:shiftAmount];
+}
+
+- (void)updateDeckAxisPositionForCardSet:(CardSet*)cardSet
+ withShiftAmount:(CGFloat)shiftAmount {
+ // Skip axis layout if card size hasn't been set up yet; it will be handled
+ // when card size is.
+ if (_initialCardSize.height == 0.0)
+ return;
+
+ if (!cardSet)
+ return;
+
+ CGFloat viewportBreadth =
+ [self scrollBreadth:[_mainCardSet displayView].bounds.size];
+ CGFloat fullDisplayBreadth =
+ [self bothDecksShouldBeDisplayed]
+ ? (viewportBreadth * kActiveDeckDisplayFraction)
+ : viewportBreadth;
+
+ CGFloat center = (fullDisplayBreadth / 2.0);
+ if ([self isCurrentSetIncognito])
+ center += viewportBreadth - fullDisplayBreadth;
+ // Adjust the set's center if it's not the active card set.
+ if (cardSet != _activeCardSet) {
+ CGFloat inactiveSetDelta = fullDisplayBreadth -
+ ios_internal::page_animation_util::kCardMargin +
+ kCardFrameInset;
+ center = [self isCurrentSetIncognito] ? center - inactiveSetDelta
+ : center + inactiveSetDelta;
+ }
+ center += shiftAmount;
+
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ [cardSet setLayoutAxisPosition:center isVertical:isPortrait];
+}
+
+- (CGFloat)shiftOffscreenAmountForCardSet:(CardSet*)cardSet {
+ if (!cardSet)
+ return 0;
+
+ CGFloat viewportBreadth =
+ [self scrollBreadth:[_activeCardSet displayView].bounds.size];
+ // The incognito card set moves offscreen to the right; the main card set
+ // moves offscreen to the left.
+ CGFloat offset = (1 - kActiveDeckDisplayFraction) * viewportBreadth;
+ if (cardSet == _mainCardSet)
+ offset = -offset;
+ return offset;
+}
+
+- (BOOL)bothDecksShouldBeDisplayed {
+ return [[_otrCardSet cards] count] > 0;
+}
+
+#pragma mark -
+#pragma mark Current Set Handling
+
+- (BOOL)isCurrentSetIncognito {
+ return _activeCardSet == _otrCardSet.get();
+}
+
+- (CardSet*)inactiveCardSet {
+ return [self isCurrentSetIncognito] ? _mainCardSet.get() : _otrCardSet.get();
+}
+
+- (void)setActiveCardSet:(CardSet*)cardSet {
+ DCHECK(cardSet);
+ if (cardSet == _activeCardSet)
+ return;
+ [self activeCardCountChanged];
+ _activeCardSet = cardSet;
+
+ [self displayActiveCardSet];
+}
+
+- (void)displayActiveCardSet {
+ UIView* activeView = [_activeCardSet displayView];
+ DCHECK(activeView);
+ UIView* inactiveView = [[self inactiveCardSet] displayView];
+
+ [_scrollView bringSubviewToFront:activeView];
+
+ // |_swipeGestureRecognizer| is added to the main SVC view, so don't add or
+ // remove that here.
+ // TODO(blundell): Figure out which recognizers need to be associated with the
+ // active display view and which can just be on the superview.
+ [inactiveView removeGestureRecognizer:_pinchRecognizer];
+ [inactiveView removeGestureRecognizer:_modeSwitchRecognizer];
+ [inactiveView removeGestureRecognizer:_swipeDismissesCardRecognizer];
+ [activeView addGestureRecognizer:_pinchRecognizer];
+ [activeView addGestureRecognizer:_modeSwitchRecognizer];
+ [activeView addGestureRecognizer:_swipeDismissesCardRecognizer];
+
+ activeView.accessibilityElementsHidden = NO;
+ inactiveView.accessibilityElementsHidden = YES;
+
+ _inActiveDeckChangeAnimation = YES; // This flag is used for testing.
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^{
+ [self updateDeckAxisPositions];
+ }
+ completion:^(BOOL finished) {
+ _inActiveDeckChangeAnimation = NO;
+ }];
+
+ [self updateToolbarAppearanceWithAnimation:YES];
+}
+
+- (void)displayMainCardSetOnly {
+ [self updateCardSizesWithAnimation:YES];
+ if ([self isCurrentSetIncognito]) {
+ [self setActiveCardSet:[self inactiveCardSet]];
+ } else {
+ // Ensure that layout axis position is up to date.
+ [self displayActiveCardSet];
+ }
+}
+
+- (void)updateToolbarAppearanceWithAnimation:(BOOL)animate {
+ [_toolbarController setTabCount:[_activeCardSet.cards count]];
+ [[_toolbarController openNewTabButton]
+ setIncognito:[self isCurrentSetIncognito]
+ animated:animate];
+
+ // Position the toolbar above/below the cards depending on the current state
+ // of the cards and the device. In landscape with multiple stacks, the cards
+ // must be behind the toolbar to avoid covering it. In all other cases, the
+ // cards are positioned to go in front of the toolbar.
+ BOOL toolbarShouldHaveBackground = NO;
+ if (([[_otrCardSet cards] count] > 0) && IsLandscape())
+ toolbarShouldHaveBackground = YES;
+
+ NSUInteger scrollViewIndex =
+ [[[self view] subviews] indexOfObject:_scrollView];
+ NSUInteger toolbarViewIndex =
+ [[[self view] subviews] indexOfObject:[_toolbarController view]];
+ BOOL toolbarInFrontOfScrollView = (toolbarViewIndex > scrollViewIndex);
+
+ // If moving the toolbar to the front, have it cover the cards before any
+ // animation of the background starts to occur, as this looks cleanest.
+ if (toolbarShouldHaveBackground && !toolbarInFrontOfScrollView) {
+ [[self view] exchangeSubviewAtIndex:scrollViewIndex
+ withSubviewAtIndex:toolbarViewIndex];
+ }
+
+ void (^updateToolbar)(void) = ^{
+ CGFloat alpha = toolbarShouldHaveBackground ? 1.0 : 0.0;
+ [_toolbarController backgroundView].alpha = alpha;
+ [_toolbarController shadowView].alpha = alpha;
+ };
+
+ // If moving the toolbar to the back, have the cards move forward only after
+ // the toolbar background finishes disappearing, as this looks cleanest.
+ void (^toDoWhenDone)(void) = ^{
+ if (!toolbarShouldHaveBackground && toolbarInFrontOfScrollView) {
+ [[self view] exchangeSubviewAtIndex:scrollViewIndex
+ withSubviewAtIndex:toolbarViewIndex];
+ [_scrollView setClipsToBounds:NO];
+ }
+ };
+
+ if (animate) {
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^{
+ updateToolbar();
+ }
+ completion:^(BOOL finished) {
+ toDoWhenDone();
+ }];
+ } else {
+ updateToolbar();
+ toDoWhenDone();
+ }
+}
+
+#pragma mark -
+#pragma mark Sizing/Measuring Helpers
+
+- (CGSize)cardSize {
+ DCHECK(_initialCardSize.height != 0.0);
+ CGFloat availableBreadth = [self scrollBreadth:[_scrollView bounds].size];
+ if ([self bothDecksShouldBeDisplayed])
+ availableBreadth *= kActiveDeckDisplayFraction;
+ CGSize idealCardSize = [self cardSizeForBreadth:availableBreadth];
+
+ // Crop the ideal size so that it's no bigger than the initial size.
+ return CGSizeMake(std::min(idealCardSize.width, _initialCardSize.width),
+ std::min(idealCardSize.height, _initialCardSize.height));
+}
+
+- (CGSize)cardSizeForBreadth:(CGFloat)breadth {
+ BOOL isPortrait = IsPortrait();
+ CGFloat cardBreadth =
+ breadth - 2 * ios_internal::page_animation_util::kCardMargin;
+ CGFloat contentBreadthInset =
+ isPortrait ? kCardImageInsets.left + kCardImageInsets.right
+ : kCardImageInsets.top + kCardImageInsets.bottom;
+ CGFloat contentBreadth = cardBreadth - contentBreadthInset;
+ CGSize viewSize = [_scrollView bounds].size;
+ CGFloat aspectRatio =
+ [self scrollLength:viewSize] / [self scrollBreadth:viewSize];
+ CGFloat contentLength = std::floor(aspectRatio * contentBreadth);
+ CGFloat contentLengthInset =
+ isPortrait ? kCardImageInsets.top + kCardImageInsets.bottom
+ : kCardImageInsets.left + kCardImageInsets.right;
+ CGFloat cardLength = contentLength + contentLengthInset;
+ // Truncate the card length so that the entire card can be visible at once.
+ CGFloat viewLength = isPortrait ? viewSize.height : viewSize.width;
+ CGFloat truncatedCardLength = viewLength -
+ ios_internal::page_animation_util::kCardMargin -
+ kCardBottomPadding;
+ cardLength = std::min(cardLength, truncatedCardLength);
+ return [self sizeForScrollLength:cardLength breadth:cardBreadth];
+}
+
+- (CGFloat)scrollOffsetAmountForPoint:(CGPoint)point {
+ return IsPortrait() ? point.y : point.x;
+}
+
+- (CGFloat)scrollOffsetAmountForPosition:(LayoutRectPosition)position {
+ return IsPortrait() ? position.originY : position.leading;
+}
+
+- (CGPoint)scrollOffsetPointWithAmount:(CGFloat)offset {
+ return IsPortrait() ? CGPointMake(0, offset) : CGPointMake(offset, 0);
+}
+
+- (CGFloat)scrollLength:(CGSize)size {
+ return IsPortrait() ? size.height : size.width;
+}
+
+- (CGFloat)scrollBreadth:(CGSize)size {
+ return IsPortrait() ? size.width : size.height;
+}
+
+- (CGSize)sizeForScrollLength:(CGFloat)length breadth:(CGFloat)breadth {
+ return IsPortrait() ? CGSizeMake(breadth, length)
+ : CGSizeMake(length, breadth);
+}
+
+- (CGRect)inactiveDeckRegion {
+ // If only one deck is showing, there's no inactive deck region.
+ if (![self bothDecksShouldBeDisplayed])
+ return CGRectZero;
+
+ CGSize viewportSize = [_activeCardSet displayView].frame.size;
+ CGFloat viewportBreadth = [self scrollBreadth:viewportSize];
+ CGFloat inactiveBreadth = (1 - kActiveDeckDisplayFraction) * viewportBreadth;
+ CGSize regionSize = [self sizeForScrollLength:[self scrollLength:viewportSize]
+ breadth:inactiveBreadth];
+ CGPoint regionOrigin = [_scrollView contentOffset];
+ if (IsPortrait()) {
+ BOOL inactiveOnRight = UseRTLLayout() == [self isCurrentSetIncognito];
+ if (inactiveOnRight)
+ regionOrigin.x = viewportBreadth - regionSize.width;
+ } else {
+ BOOL inactiveOnBottom = ![self isCurrentSetIncognito];
+ if (inactiveOnBottom)
+ regionOrigin.y = viewportBreadth - regionSize.height;
+ }
+ return {regionOrigin, regionSize};
+}
+
+- (NSUInteger)indexOfCardAtPoint:(CGPoint)point {
+ UIView* view = [_activeCardSet.displayView hitTest:point withEvent:nil];
+ while (view && ![view isKindOfClass:[CardView class]]) {
+ view = [view superview];
+ }
+ if (!view)
+ return NSNotFound;
+ StackCard* card = [self cardForView:(CardView*)view];
+ return [_activeCardSet.cards indexOfObject:card];
+}
+
+#pragma mark -
+#pragma mark Stack View Transition Helpers
+
+// Determine what should be the first visible card. Preference is to start one
+// card before the current card so that the current card ends up in the middle
+// of the visible cards. However, if the current card is the last in a stack of
+// > 2 cards, start two cards before so that the screen is fully populated (and
+// if the current card is the first card, the only option is to start with the
+// first card).
+- (NSUInteger)startIndexOfInitialFanoutForCardSet:(CardSet*)cardSet {
+ if ([[cardSet cards] count] == 0)
+ return 0;
+ NSUInteger currentCardIndex =
+ [cardSet.tabModel indexOfTab:cardSet.tabModel.currentTab];
+ NSUInteger startingCardIndex =
+ (currentCardIndex == 0) ? 0 : currentCardIndex - 1;
+ if ((currentCardIndex > 1) &&
+ (currentCardIndex == ([cardSet.tabModel count] - 1)))
+ startingCardIndex -= 1;
+ return startingCardIndex;
+}
+
+- (void)showWithSelectedTabAnimation {
+ [self animateTransitionWithStyle:STACK_TRANSITION_STYLE_PRESENTING];
+
+ [_testDelegate stackViewControllerShowWithSelectedTabAnimationDidStart];
+
+ [_activeCardSet.currentCard setIsActiveTab:YES];
+
+ // When in accessbility mode, fan out cards from the start, announce open tabs
+ // and move the VoiceOver cursor to the New Tab button. Fanning out the cards
+ // from the start eliminates the screen change that would otherwise occur when
+ // moving the VoiceOver cursor from the Show Tabs button to the card stack.
+ if (UIAccessibilityIsVoiceOverRunning()) {
+ [_activeCardSet fanOutCardsWithStartIndex:0];
+ [self postOpenTabsAccessibilityNotification];
+ UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,
+ _toolbarController.get().view);
+ }
+}
+
+- (void)cancelTransitionAnimation {
+ // Set up transaction.
+ [CATransaction begin];
+ [CATransaction setCompletionBlock:^{
+ [self finishTransitionAnimation];
+ }];
+ self.transitionWasCancelled = YES;
+ // Reverse all the animations.
+ [self reverseDummyToolbarBackgroundViewAnimation];
+ [self reverseTransitionAnimationsForCardSet:_activeCardSet];
+ [self reverseTransitionAnimationsForCardSet:[self inactiveCardSet]];
+ [self.transitionToolbarController reverseTransitionAnimations];
+ // Commit the transaction. Since the animations added for the previous
+ // transition are all removed, this commit will call the previous
+ // animation's completion block.
+ [CATransaction commit];
+}
+
+- (void)finishTransitionAnimation {
+ // Early return if cancelled.
+ if (self.transitionWasCancelled) {
+ // Notify the delegates.
+ [self notifyDelegatesTransitionFinished];
+ // When transitions are cancelled, reverse the transition style so that the
+ // new completion block sends the correct delegate methods.
+ self.transitionStyle =
+ self.transitionStyle == STACK_TRANSITION_STYLE_PRESENTING
+ ? STACK_TRANSITION_STYLE_DISMISSING
+ : STACK_TRANSITION_STYLE_PRESENTING;
+ self.transitionWasCancelled = NO;
+ return;
+ }
+ // Clean up card view animations.
+ for (StackCard* card in _activeCardSet.cards) {
+ if ([card viewIsLive])
+ [card.view cleanUpAnimations];
+ }
+ for (StackCard* card in [self inactiveCardSet].cards) {
+ if ([card viewIsLive])
+ [card.view cleanUpAnimations];
+ }
+ // Clean up toolbar animations.
+ [self.transitionToolbarController.view removeFromSuperview];
+ [self.transitionToolbarOwner reparentToolbarController];
+ [self.transitionToolbarController cleanUpTransitionAnimations];
+ self.transitionToolbarController.view.animatingTransition = NO;
+ [self.transitionToolbarController view].frame = self.transitionToolbarFrame;
+ self.transitionToolbarController = nil;
+ self.transitionToolbarFrame = CGRectZero;
+ self.transitionToolbarOwner = nil;
+ // Clean up dummy toolbar background.
+ [self.dummyToolbarBackgroundView removeFromSuperview];
+ self.dummyToolbarBackgroundView = nil;
+ // Notify the delegates.
+ [self notifyDelegatesTransitionFinished];
+ // Reset the current transition style.
+ StackTransitionStyle transitionStyleAtFinish = self.transitionStyle;
+ self.transitionStyle = STACK_TRANSITION_STYLE_NONE;
+ // Restore the original subview ordering.
+ [self reorderSubviewsForTransition];
+ // Dismiss immediately if a card was selected mid-presentation.
+ if (self.transitionTappedCard) {
+ _activeCardSet.currentCard = self.transitionTappedCard;
+ self.transitionTappedCard = nil;
+ [self dismissWithSelectedTabAnimation];
+ }
+
+ if (transitionStyleAtFinish == STACK_TRANSITION_STYLE_DISMISSING) {
+ // Dismissal is complete and delegate was told that stack view has been
+ // dismissed. Make sure that internal state reflects that.
+ _isActive = NO;
+ [self clearInternalState];
+ }
+}
+
+- (void)notifyDelegatesTransitionFinished {
+ // Notify delegates.
+ DCHECK_NE(self.transitionStyle, STACK_TRANSITION_STYLE_NONE);
+ if (self.transitionStyle == STACK_TRANSITION_STYLE_PRESENTING) {
+ [_testDelegate stackViewControllerShowWithSelectedTabAnimationDidEnd];
+ [_delegate tabSwitcherPresentationTransitionDidEnd:self];
+ } else {
+ [_delegate tabSwitcherDismissTransitionDidEnd:self];
+ }
+}
+
+- (void)animateTransitionWithStyle:(StackTransitionStyle)transitionStyle {
+ // If the dummy toolbar background view is instantiated, reverse the current
+ // transition animations.
+ if (self.dummyToolbarBackgroundView) {
+ [self cancelTransitionAnimation];
+ return;
+ }
+
+ // The transition style must be specified.
+ DCHECK_NE(transitionStyle, STACK_TRANSITION_STYLE_NONE);
+ self.transitionStyle = transitionStyle;
+ BOOL isPresenting = self.transitionStyle == STACK_TRANSITION_STYLE_PRESENTING;
+
+ // Get reference to toolbar for transition.
+ self.transitionToolbarOwner = [_delegate tabSwitcherTransitionToolbarOwner];
+ self.transitionToolbarController =
+ [self.transitionToolbarOwner relinquishedToolbarController];
+ self.transitionToolbarController.view.animatingTransition = YES;
+ self.transitionToolbarFrame = self.transitionToolbarController.view.frame;
+
+ // Create dummy toolbar background view.
+ self.dummyToolbarBackgroundView =
+ [[[UIView alloc] initWithFrame:CGRectZero] autorelease];
+ [self.dummyToolbarBackgroundView setClipsToBounds:YES];
+
+ // Set the transition completion block.
+ [CATransaction begin];
+ [CATransaction setCompletionBlock:^{
+ [self finishTransitionAnimation];
+ }];
+
+ // Slide in/out the inactive card set.
+ [self animateInactiveSetTransition];
+
+ // The current card's frame is necessary for the toolbar animation below. For
+ // dismissals, the toolbar animates from the card's current frame (i.e. the
+ // frame before the animation is added). For presentation, the toolbar
+ // animates to the final frame (i.e. the frame after the animation is added).
+ LayoutRect currentCardLayout = _activeCardSet.currentCard.layout;
+ [self animateActiveSetTransition];
+ if (isPresenting)
+ currentCardLayout = _activeCardSet.currentCard.layout;
+ CGRect currentCardFrame =
+ AlignRectOriginAndSizeToPixels(LayoutRectGetRect(currentCardLayout));
+
+ // Animate the dummy toolbar background view.
+ [self animateDummyToolbarForCardFrame:currentCardFrame
+ transitionStyle:transitionStyle];
+
+ // Animate the transition toolbar.
+ [self animateTransitionToolbarWithCardFrame:currentCardFrame
+ transitionStyle:transitionStyle];
+
+ // Update the order of the view hierarchy.
+ [self reorderSubviewsForTransition];
+
+ [CATransaction commit];
+}
+
+- (void)reorderSubviewsForTransition {
+ if (self.transitionStyle != STACK_TRANSITION_STYLE_NONE) {
+ // Add the card set display views to the main view and insert the toolbar
+ // between them.
+ [self.view addSubview:[self inactiveCardSet].displayView];
+ [self inactiveCardSet].displayView.frame = [_scrollView frame];
+ [self.view addSubview:_activeCardSet.displayView];
+ _activeCardSet.displayView.frame = [_scrollView frame];
+ [self.view insertSubview:[_toolbarController view]
+ belowSubview:_activeCardSet.displayView];
+ } else {
+ // Add the display views back into the scroll view.
+ [_scrollView addSubview:[self inactiveCardSet].displayView];
+ [_scrollView addSubview:_activeCardSet.displayView];
+ [self updateToolbarAppearanceWithAnimation:NO];
+ [self alignDisplayViewsToViewport];
+ }
+}
+
+- (void)animateCardSet:(CardSet*)cardSet
+ fromBeginLayouts:(std::vector<LayoutRect>)beginLayouts
+ toEndLayouts:(std::vector<LayoutRect>)endLayouts {
+ NSUInteger cardCount = [cardSet.cards count];
+ DCHECK_EQ(cardCount, beginLayouts.size());
+ DCHECK_EQ(cardCount, endLayouts.size());
+
+ [CATransaction begin];
+ [CATransaction setDisableActions:YES];
+ // Place cards into final position.
+ for (NSUInteger i = 0; i < cardCount; ++i)
+ [cardSet.cards[i] setLayout:endLayouts[i]];
+ // For presentation, update visibilty so only cards that will ultimately be
+ // shown are live.
+ BOOL isPresenting = self.transitionStyle == STACK_TRANSITION_STYLE_PRESENTING;
+ if (isPresenting)
+ [cardSet updateCardVisibilities];
+ [CATransaction commit];
+
+ // Animate each card to its final frame.
+ StackCard* currentCard = cardSet.currentCard;
+ BOOL isActiveCardSet = (cardSet == _activeCardSet);
+ for (NSUInteger i = 0; i < cardCount; ++i) {
+ StackCard* card = cardSet.cards[i];
+ if ([card viewIsLive]) {
+ CardTabAnimationStyle tabAnimationStyle = CARD_TAB_ANIMATION_STYLE_NONE;
+ if (isActiveCardSet && card == currentCard) {
+ tabAnimationStyle = isPresenting ? CARD_TAB_ANIMATION_STYLE_FADE_IN
+ : CARD_TAB_ANIMATION_STYLE_FADE_OUT;
+ }
+ [card.view animateFromBeginFrame:LayoutRectGetRect(beginLayouts[i])
+ toEndFrame:LayoutRectGetRect(endLayouts[i])
+ tabAnimationStyle:tabAnimationStyle];
+ }
+ }
+}
+
+- (void)reverseTransitionAnimationsForCardSet:(CardSet*)cardSet {
+ for (StackCard* card in cardSet.cards) {
+ if ([card viewIsLive])
+ [card.view reverseAnimations];
+ }
+}
+
+- (void)animateActiveSetTransition {
+ // Early return for an empty active card set.
+ if (![_activeCardSet.cards count])
+ return;
+
+ std::vector<LayoutRect> beginLayouts;
+ std::vector<LayoutRect> endLayouts;
+ BOOL isPresenting = self.transitionStyle == STACK_TRANSITION_STYLE_PRESENTING;
+ if (isPresenting) {
+ // For presentation, animate from transition frames to fan frames.
+ NSUInteger activeSetStartIndex =
+ [self startIndexOfInitialFanoutForCardSet:_activeCardSet];
+ beginLayouts = [self cardTransitionLayouts];
+ [_activeCardSet fanOutCardsWithStartIndex:activeSetStartIndex];
+ endLayouts = [_activeCardSet cardLayouts];
+ } else {
+ // For dismissal, animate from the cards' current frames to the transition
+ // frames.
+ beginLayouts = [_activeCardSet cardLayouts];
+ endLayouts = [self cardTransitionLayouts];
+ // For dismissals, the status bar needs to be updated early.
+ [self performSelector:@selector(setNeedsStatusBarAppearanceUpdate)
+ withObject:nil
+ afterDelay:kDismissalStatusBarUpdateDelay];
+ // Ensure that the current card view is visible.
+ _activeCardSet.currentCard.view.hidden = NO;
+ }
+
+ // Add animations.
+ [self animateCardSet:_activeCardSet
+ fromBeginLayouts:beginLayouts
+ toEndLayouts:endLayouts];
+}
+
+- (void)animateInactiveSetTransition {
+ // Early return for an emtpy inactive card set.
+ CardSet* inactiveCardSet = [self inactiveCardSet];
+ if (![[inactiveCardSet cards] count])
+ return;
+
+ BOOL isPresenting = self.transitionStyle == STACK_TRANSITION_STYLE_PRESENTING;
+
+ // Calculate transition animation card frames
+ if (isPresenting) {
+ // For presentation, fan out the cards for the transition. Otherwise, use
+ // the current frames of the cards.
+ NSUInteger inactiveSetStartIndex =
+ [self startIndexOfInitialFanoutForCardSet:inactiveCardSet];
+ [inactiveCardSet fanOutCardsWithStartIndex:inactiveSetStartIndex];
+ }
+ std::vector<LayoutRect> cardStackLayouts = [inactiveCardSet cardLayouts];
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ CGFloat shiftAmount = [self shiftOffscreenAmountForCardSet:inactiveCardSet];
+ std::vector<LayoutRect> shiftedStackLayouts;
+ for (const auto& cardLayout : cardStackLayouts) {
+ LayoutRect shiftedLayout = cardLayout;
+ if (isPortrait)
+ shiftedLayout.position.leading += shiftAmount;
+ else
+ shiftedLayout.position.originY += shiftAmount;
+ shiftedStackLayouts.push_back(shiftedLayout);
+ }
+
+ std::vector<LayoutRect> beginLayouts =
+ isPresenting ? shiftedStackLayouts : cardStackLayouts;
+ std::vector<LayoutRect> endLayouts =
+ isPresenting ? cardStackLayouts : shiftedStackLayouts;
+
+ // Add animations.
+ [self animateCardSet:inactiveCardSet
+ fromBeginLayouts:beginLayouts
+ toEndLayouts:endLayouts];
+}
+
+- (void)animateDummyToolbarForCardFrame:(CGRect)cardFrame
+ transitionStyle:(StackTransitionStyle)transitionStyle {
+ // Install the dummy toolbar background view into the card tab.
+ CardView* cardView = _activeCardSet.currentCard.view;
+ [cardView installDummyToolbarBackgroundView:self.dummyToolbarBackgroundView];
+
+ // When calculating the frames below, convert them into the card's tab's
+ // coordinate system, whose origin is at |cardTabOriginOffset| from the card's
+ // frame origin.
+ CGVector cardTabOriginOffset =
+ CGVectorMake(kCardFrameInset, kCardTabTopInset);
+
+ // The card's frame image extends beyond the edges of the screen when the
+ // current card is scaled to the full content area, so extend the toolbar
+ // background to match the card's width. For the NTP toolbar, extend the
+ // frame downward so that it covers the portion of the card frame that
+ // overlaps with the content snapshot.
+ UIView* toolbarView = [_toolbarController view];
+ UIView* displayView = _activeCardSet.displayView;
+ CGRect screenToolbarFrame = [displayView convertRect:toolbarView.frame
+ fromView:toolbarView.superview];
+ CGFloat bottomOutset = [self.transitionToolbarController
+ isKindOfClass:[NewTabPageToolbarController class]]
+ ? -kCardFrameImageSnapshotOverlap
+ : 0.0;
+ UIEdgeInsets screenToolbarFrameOutsets =
+ UIEdgeInsetsMake(0.0, kCardFrameInset - kCardImageInsets.left,
+ bottomOutset, kCardFrameInset - kCardImageInsets.right);
+ screenToolbarFrame =
+ UIEdgeInsetsInsetRect(screenToolbarFrame, screenToolbarFrameOutsets);
+ CGPoint screenCardOrigin =
+ CGPointMake(displayView.bounds.origin.x - kCardImageInsets.left,
+ displayView.bounds.origin.y - kCardImageInsets.top);
+ screenToolbarFrame = CGRectOffset(
+ screenToolbarFrame, -(screenCardOrigin.x + cardTabOriginOffset.dx),
+ -(screenCardOrigin.y + cardTabOriginOffset.dy));
+
+ // The frame should interpolate to the frame of the card's tab view.
+ CGRect cardToolbarFrame =
+ CGRectInset(cardFrame, kCardFrameInset, kCardFrameInset);
+ cardToolbarFrame.size.height = kCardImageInsets.top - kCardFrameInset;
+ cardToolbarFrame = CGRectOffset(
+ cardToolbarFrame, -(cardFrame.origin.x + cardTabOriginOffset.dx),
+ -(cardFrame.origin.y + cardTabOriginOffset.dy));
+
+ // Calculate colors for the crossfade.
+ UIColor* cardBackgroundColor =
+ [self isCurrentSetIncognito]
+ ? [UIColor colorWithWhite:kCardFrameBackgroundBrightnessIncognito
+ alpha:1.0]
+ : [UIColor colorWithWhite:kCardFrameBackgroundBrightness alpha:1.0];
+ UIColor* toolbarBackgroundColor = cardBackgroundColor;
+ if ([self.transitionToolbarController
+ isKindOfClass:[NewTabPageToolbarController class]]) {
+ // Use white for the non-incognito NTP toolbar.
+ toolbarBackgroundColor = [UIColor whiteColor];
+ } else if (self.transitionToolbarController.backgroundView.hidden ||
+ self.transitionToolbarController.backgroundView.alpha == 0) {
+ // If the background view isn't visible, use the base toolbar view's
+ // background color.
+ toolbarBackgroundColor =
+ self.transitionToolbarController.view.backgroundColor;
+ }
+
+ // Create frame animation.
+ CFTimeInterval duration = ios::material::kDuration1;
+ CAMediaTimingFunction* timingFunction =
+ ios::material::TimingFunction(ios::material::CurveEaseInOut);
+ BOOL isPresentingStackView =
+ (transitionStyle == STACK_TRANSITION_STYLE_PRESENTING);
+ CGRect beginFrame =
+ isPresentingStackView ? screenToolbarFrame : cardToolbarFrame;
+ CGRect endFrame =
+ isPresentingStackView ? cardToolbarFrame : screenToolbarFrame;
+ CAAnimation* frameAnimation = FrameAnimationMake(
+ self.dummyToolbarBackgroundView.layer, beginFrame, endFrame);
+ frameAnimation.duration = duration;
+ frameAnimation.timingFunction = timingFunction;
+
+ // Create color animation.
+ UIColor* beginColor =
+ isPresentingStackView ? toolbarBackgroundColor : cardBackgroundColor;
+ UIColor* endColor =
+ isPresentingStackView ? cardBackgroundColor : toolbarBackgroundColor;
+ CABasicAnimation* colorAnimation =
+ [CABasicAnimation animationWithKeyPath:@"backgroundColor"];
+ colorAnimation.fromValue = reinterpret_cast<id>(beginColor.CGColor);
+ colorAnimation.toValue = reinterpret_cast<id>(endColor.CGColor);
+ colorAnimation.fillMode = kCAFillModeBoth;
+ colorAnimation.removedOnCompletion = NO;
+ colorAnimation.duration = duration;
+ colorAnimation.timingFunction = timingFunction;
+
+ // Create corner radius animation.
+ CGFloat toolbarCornerRadius = toolbarView.layer.cornerRadius;
+ CGFloat beginCornerRadius =
+ isPresentingStackView ? toolbarCornerRadius : kCardFrameCornerRadius;
+ CGFloat endCornerRadius =
+ isPresentingStackView ? kCardFrameCornerRadius : toolbarCornerRadius;
+ CABasicAnimation* cornerRadiusAnimation =
+ [CABasicAnimation animationWithKeyPath:@"cornerRadius"];
+ cornerRadiusAnimation.fromValue = @(beginCornerRadius);
+ cornerRadiusAnimation.toValue = @(endCornerRadius);
+ cornerRadiusAnimation.fillMode = kCAFillModeBoth;
+ cornerRadiusAnimation.removedOnCompletion = NO;
+ cornerRadiusAnimation.duration = duration;
+ cornerRadiusAnimation.timingFunction = timingFunction;
+
+ // Add animations.
+ CAAnimation* animation = AnimationGroupMake(
+ @[ frameAnimation, colorAnimation, cornerRadiusAnimation ]);
+ [self.dummyToolbarBackgroundView.layer
+ addAnimation:animation
+ forKey:kDummyToolbarBackgroundViewAnimationKey];
+}
+
+- (void)reverseDummyToolbarBackgroundViewAnimation {
+ ReverseAnimationsForKeyForLayers(kDummyToolbarBackgroundViewAnimationKey,
+ @[ self.dummyToolbarBackgroundView.layer ]);
+}
+
+- (void)animateTransitionToolbarWithCardFrame:(CGRect)cardFrame
+ transitionStyle:
+ (StackTransitionStyle)transitionStyle {
+ // Add the transition toolbar and update its frame.
+ CGFloat toolbarHeight =
+ self.transitionToolbarController.view.frame.size.height;
+ [_activeCardSet.displayView
+ insertSubview:self.transitionToolbarController.view
+ aboveSubview:_activeCardSet.currentCard.view];
+ CGRect toolbarFrame =
+ [_activeCardSet.displayView convertRect:[_toolbarController view].frame
+ fromView:self.view];
+ CGFloat heightDifference = toolbarFrame.size.height - toolbarHeight;
+ toolbarFrame.origin.y += heightDifference;
+ toolbarFrame.size.height -= heightDifference;
+ self.transitionToolbarController.view.frame = toolbarFrame;
+
+ // The toolbar should animate such that its frame interpolates between the
+ // normal toolbar frame at the top of the screen and the frame of the current
+ // card's tab view.
+ CGRect screenToolbarFrame = self.transitionToolbarController.view.frame;
+ CGFloat cardTabHeight = kCardImageInsets.top - kCardFrameInset;
+ CGRect cardToolbarFrame =
+ CGRectInset(cardFrame, kCardFrameInset, kCardFrameInset);
+ cardToolbarFrame.size.height = cardTabHeight;
+
+ // Add animations.
+ BOOL isPresentingStackView =
+ (transitionStyle == STACK_TRANSITION_STYLE_PRESENTING);
+ ToolbarTransitionStyle style = isPresentingStackView
+ ? TOOLBAR_TRANSITION_STYLE_TO_STACK_VIEW
+ : TOOLBAR_TRANSITION_STYLE_TO_BVC;
+ CGRect beginFrame =
+ isPresentingStackView ? screenToolbarFrame : cardToolbarFrame;
+ CGRect endFrame =
+ isPresentingStackView ? cardToolbarFrame : screenToolbarFrame;
+ [self.transitionToolbarController animateTransitionWithBeginFrame:beginFrame
+ endFrame:endFrame
+ transitionStyle:style];
+}
+
+- (void)dismissWithSelectedTabAnimation {
+ if (_isBeingDismissed || _activeCardSet.closingCard ||
+ !_activeCardSet.cards.count) {
+ return;
+ }
+ DCHECK(_isActive);
+ [self prepareForDismissal];
+ _isBeingDismissed = YES;
+ // Once the stack view is starting to be dismissed, stop loading cards in the
+ // background.
+ [NSObject cancelPreviousPerformRequestsWithTarget:self];
+
+ [_delegate tabSwitcher:self
+ dismissTransitionWillStartWithActiveModel:_activeCardSet.tabModel];
+
+ [self animateTransitionWithStyle:STACK_TRANSITION_STYLE_DISMISSING];
+}
+
+- (std::vector<LayoutRect>)cardTransitionLayouts {
+ std::vector<LayoutRect> cardLayouts;
+ UIView* activeSetView = _activeCardSet.displayView;
+
+ // Setting a card's layout to |fullscreenLayout| will scale the content
+ // snapshot such that it will fill the entire portion of the screen below the
+ // toolbar. Used for the current card.
+ LayoutRect fullscreenLayout = LayoutRectZero;
+ fullscreenLayout.boundingWidth = CGRectGetWidth(activeSetView.bounds);
+ fullscreenLayout.position.leading = -UIEdgeInsetsGetLeading(kCardImageInsets);
+ fullscreenLayout.position.originY = -kCardImageInsets.top;
+ fullscreenLayout.size.width = fullscreenLayout.boundingWidth +
+ kCardImageInsets.left + kCardImageInsets.right;
+ fullscreenLayout.size.height = CGRectGetHeight(activeSetView.bounds) +
+ kCardImageInsets.top + kCardImageInsets.bottom;
+
+ // Cards above the current card (in z-index terms) should start/end offscreen.
+ // Also account for the shadow so that the shadows cast by offscreen cards are
+ // not visible at the beginning/end of the animation.
+ CGFloat viewportLength =
+ [self scrollLength:activeSetView.bounds.size] + kCardShadowThickness;
+ LayoutRect offscreenLayout = fullscreenLayout;
+ if (IsPortrait()) {
+ offscreenLayout.position.originY += viewportLength + kCardImageInsets.top;
+ } else {
+ offscreenLayout.position.leading +=
+ viewportLength + UIEdgeInsetsGetLeading(kCardImageInsets);
+ }
+
+ // Cards below the current card (in z-index terms) should be top-aligned with
+ // the toolbar and at the final card size.
+ LayoutRect cardLayout = LayoutRectZero;
+ cardLayout.boundingWidth = CGRectGetWidth(activeSetView.bounds);
+ cardLayout.size = [self cardSize];
+ cardLayout.position.leading = ios_internal::page_animation_util::kCardMargin;
+ cardLayout.position.originY = -kCardImageInsets.top;
+
+ for (StackCard* card in _activeCardSet.cards) {
+ if (card == _activeCardSet.currentCard) {
+ // Current card takes the full screen.
+ cardLayout = fullscreenLayout;
+ } else if (LayoutRectEqualToRect(cardLayout, fullscreenLayout)) {
+ // The card after the current card animates from off screen.
+ cardLayout = offscreenLayout;
+ }
+ cardLayouts.push_back(cardLayout);
+ }
+
+ return cardLayouts;
+}
+
+- (Tab*)dismissWithNewTabAnimationToModel:(TabModel*)targetModel
+ withURL:(const GURL&)url
+ atIndex:(NSUInteger)position
+ transition:(ui::PageTransition)transition {
+ if (_isBeingDismissed)
+ return NULL;
+ if ([_activeCardSet tabModel] != targetModel)
+ [self setActiveCardSet:[self inactiveCardSet]];
+ return [self dismissWithNewTabAnimation:url
+ atIndex:position
+ transition:transition];
+}
+
+- (void)setLastTapPoint:(id)sender {
+ UIView* parentView = nil;
+ CGPoint center;
+ if ([sender isKindOfClass:[UIView class]]) {
+ center = [sender center];
+ parentView = [sender superview];
+ }
+ if ([sender isKindOfClass:[ToolsMenuViewItem class]]) {
+ parentView = [[sender tableViewCell] superview];
+ center = [[sender tableViewCell] center];
+ }
+
+ if (parentView) {
+ CGPoint viewCoordinate = [parentView convertPoint:center toView:self.view];
+ _lastTapPoint = viewCoordinate;
+ }
+}
+
+- (Tab*)dismissWithNewTabAnimation:(const GURL&)URL
+ atIndex:(NSUInteger)position
+ transition:(ui::PageTransition)transition {
+ // This helps smooth out the animation.
+ [[_scrollView layer] setShouldRasterize:YES];
+ if (_isBeingDismissed)
+ return NULL;
+ DCHECK(_isActive);
+ [self prepareForDismissal];
+ _isBeingDismissed = YES;
+ [self setNeedsStatusBarAppearanceUpdate];
+ DCHECK(URL.is_valid());
+ // Stop pre-loading cards.
+ [NSObject cancelPreviousPerformRequestsWithTarget:self];
+
+ // This uses a custom animation, so ignore the change that would be triggered
+ // by adding a new tab to the model. This is left on since the stack view is
+ // going away at this point, so staying in sync doesn't matter any more.
+ [_activeCardSet setIgnoresTabModelChanges:YES];
+ if (position == NSNotFound)
+ position = [_activeCardSet.tabModel count];
+ DCHECK(position <= [_activeCardSet.tabModel count]);
+
+ Tab* tab = [_activeCardSet.tabModel insertOrUpdateTabWithURL:URL
+ referrer:web::Referrer()
+ transition:transition
+ windowName:nil
+ opener:nil
+ openedByDOM:NO
+ atIndex:position
+ inBackground:NO];
+ [_activeCardSet.tabModel setCurrentTab:tab];
+
+ [_delegate tabSwitcher:self
+ dismissTransitionWillStartWithActiveModel:_activeCardSet.tabModel];
+
+ CGFloat statusBarHeight = StatusBarHeight();
+ CGRect viewBounds, remainder;
+ CGRectDivide([self.view bounds], &remainder, &viewBounds, statusBarHeight,
+ CGRectMinYEdge);
+ UIImageView* newCard =
+ [[[UIImageView alloc] initWithFrame:viewBounds] autorelease];
+ // Temporarily resize the tab's view to ensure it matches the card while
+ // generating a snapshot, but then restore the original frame.
+ CGRect originalTabFrame = [tab view].frame;
+ [tab view].frame = viewBounds;
+ newCard.image = [tab updateSnapshotWithOverlay:YES visibleFrameOnly:YES];
+ [tab view].frame = originalTabFrame;
+ newCard.center =
+ CGPointMake(CGRectGetMidX(viewBounds), CGRectGetMidY(viewBounds));
+ [self.view addSubview:newCard];
+
+ void (^completionBlock)(void) = ^{
+ [newCard removeFromSuperview];
+ [[_scrollView layer] setShouldRasterize:NO];
+ [_delegate tabSwitcherDismissTransitionDidEnd:self];
+ };
+
+ CGPoint origin = _lastTapPoint;
+ _lastTapPoint = CGPointZero;
+ ios_internal::page_animation_util::AnimateInPaperWithAnimationAndCompletion(
+ newCard, -statusBarHeight,
+ newCard.frame.size.height - newCard.image.size.height, origin,
+ [self isCurrentSetIncognito], nil, completionBlock);
+ // TODO(stuartmorgan): Animate the other set off to the side.
+
+ return tab;
+}
+
+#pragma mark UIGestureRecognizerDelegate methods
+
+- (BOOL)gestureRecognizer:(UIGestureRecognizer*)recognizer
+ shouldReceiveTouch:(UITouch*)touch {
+ // Don't swallow any touches while the tools popup menu is open.
+ if ([_toolbarController toolsPopupController])
+ return NO;
+
+ if ((recognizer == _pinchRecognizer) ||
+ (recognizer == _swipeGestureRecognizer.get()))
+ return YES;
+
+ // Only the mode switch recognizer should be triggered in the inactive deck
+ // region (and it should only be triggered there).
+ CGPoint touchLocation = [touch locationInView:_scrollView];
+ BOOL inInactiveDeckRegion =
+ CGRectContainsPoint([self inactiveDeckRegion], touchLocation);
+ if (recognizer == _modeSwitchRecognizer.get())
+ return inInactiveDeckRegion;
+ else if (inInactiveDeckRegion)
+ return NO;
+
+ // Extract the card on which the touch is occurring.
+ CardView* cardView = nil;
+ StackCard* card = nil;
+ if (recognizer == _swipeDismissesCardRecognizer.get()) {
+ UIView* activeView = _activeCardSet.displayView;
+ CGPoint locationInActiveView = [touch locationInView:activeView];
+ NSUInteger cardIndex = [self indexOfCardAtPoint:locationInActiveView];
+ // |_swipeDismissesCardRecognizer| is interested only in touches that are
+ // on cards in the active set.
+ if (cardIndex == NSNotFound)
+ return NO;
+ DCHECK(cardIndex < [[_activeCardSet cards] count]);
+ card = [[_activeCardSet cards] objectAtIndex:cardIndex];
+ // This case seems like it should never happen, but it can be easily
+ // handled anyway.
+ if (![card viewIsLive])
+ return YES;
+ cardView = card.view;
+ } else {
+ // The recognizer is one of those attached to the card.
+ DCHECK([recognizer.view isKindOfClass:[CardView class]]);
+ cardView = (CardView*)recognizer.view;
+ card = [self cardForView:cardView];
+ }
+
+ // Prevent taps/presses in an uncollapsed card's close button from being
+ // swallowed by the swipe-triggers-dismissal long press recognizer or
+ // the card's tap/long press recognizer.
+ if (CGRectContainsPoint([cardView closeButtonFrame],
+ [touch locationInView:cardView]) &&
+ card && ![_activeCardSet cardIsCollapsed:card])
+ return NO;
+
+ return YES;
+}
+
+- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
+ shouldRecognizeSimultaneouslyWithGestureRecognizer:
+ (UIGestureRecognizer*)otherGestureRecognizer {
+ // Pinch and scroll must be allowed to recognize simultaneously to enable
+ // smooth transitioning between scrolling and pinching.
+ BOOL pinchRecognizerInvolved = (gestureRecognizer == _pinchRecognizer ||
+ otherGestureRecognizer == _pinchRecognizer);
+ BOOL scrollRecognizerInvolved =
+ (gestureRecognizer == _scrollGestureRecognizer ||
+ otherGestureRecognizer == _scrollGestureRecognizer);
+ if (pinchRecognizerInvolved && scrollRecognizerInvolved)
+ return YES;
+
+ // Swiping must be allowed to recognize simultaneously with the recognizer of
+ // long presses that turn ambiguous swipes into card dismissals.
+ BOOL swipeRecognizerInvolved =
+ (gestureRecognizer == _swipeGestureRecognizer ||
+ otherGestureRecognizer == _swipeGestureRecognizer);
+ BOOL swipeDismissesCardRecognizerInvolved =
+ (gestureRecognizer == _swipeDismissesCardRecognizer.get() ||
+ otherGestureRecognizer == _swipeDismissesCardRecognizer.get());
+ if (swipeRecognizerInvolved && swipeDismissesCardRecognizerInvolved)
+ return YES;
+
+ // The swipe-triggers-card-dismissal long press recognizer must be allowed to
+ // recognize simultaneously with the cards' long press recognizers that
+ // trigger show-more-of-card.
+ BOOL longPressRecognizerInvolved =
+ ([gestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]] ||
+ [otherGestureRecognizer
+ isKindOfClass:[UILongPressGestureRecognizer class]]);
+ if (swipeDismissesCardRecognizerInvolved && longPressRecognizerInvolved)
+ return YES;
+
+ return NO;
+}
+
+#pragma mark Action Handlers
+
+- (void)closeTab:(id)sender {
+ // Don't close any tabs mid-dismissal.
+ if (_isBeingDismissed)
+ return;
+
+ // Remove the frame animation before adding the fade out animation.
+ DCHECK([sender isKindOfClass:[CardView class]]);
+ CardView* cardView = static_cast<CardView*>(sender);
+ [cardView removeFrameAnimation];
+
+ base::RecordAction(UserMetricsAction("MobileStackViewCloseTab"));
+ StackCard* card = [self cardForView:cardView];
+ DCHECK(card);
+ NSUInteger tabIndex = [_activeCardSet.cards indexOfObject:card];
+ if (tabIndex == NSNotFound)
+ return;
+
+ // TODO(blundell): Crashes have been seen wherein |tabIndex| is out of bounds
+ // of the TabModel's array. It is not currently understood how this case
+ // occurs. To work around these crashes, close the tab only if it is indeed
+ // the tab that corresponds to this card; otherwise, remove the card directly
+ // without modifying the tab model. b/8321162
+ BOOL cardCorrespondsToTab = NO;
+ if (tabIndex < [_activeCardSet.tabModel count]) {
+ Tab* tab = [_activeCardSet.tabModel tabAtIndex:tabIndex];
+ cardCorrespondsToTab = (card.tabID == reinterpret_cast<NSUInteger>(tab));
+ }
+
+ _activeCardSet.closingCard = card;
+ if (cardCorrespondsToTab) {
+ [_activeCardSet.tabModel closeTabAtIndex:tabIndex];
+ } else {
+ if (tabIndex < [_activeCardSet.tabModel count])
+ DLOG(ERROR) << "Closed a card that didn't match the tab at its index";
+ else
+ DLOG(ERROR) << "Closed card at an index out of range of the tab model";
+ [_activeCardSet removeCardAtIndex:tabIndex];
+ }
+}
+
+- (void)handleLongPressFrom:(UIGestureRecognizer*)recognizer {
+ DCHECK(!_isBeingDismissed);
+ DCHECK(_isActive);
+
+ if (recognizer == _swipeDismissesCardRecognizer.get())
+ return;
+
+ UIGestureRecognizerState state = [recognizer state];
+ if (state != UIGestureRecognizerStateBegan)
+ return;
+ if ([recognizer numberOfTouches] == 0)
+ return;
+
+ // Don't take action on a card that is in the inactive stack, collapsed, or
+ // the last card.
+ CardView* cardView = (CardView*)recognizer.view;
+ StackCard* card = [self cardForView:cardView];
+ DCHECK(card);
+ NSUInteger cardIndex = [[_activeCardSet cards] indexOfObject:card];
+ DCHECK(cardIndex != NSNotFound);
+ NSUInteger numCards = [[_activeCardSet cards] count];
+ UIView* activeView = _activeCardSet.displayView;
+
+ if ([cardView superview] != activeView ||
+ [_activeCardSet cardIsCollapsed:card] || cardIndex == (numCards - 1))
+ return;
+
+ // Defer hiding the views of any cards that will be covered after the scroll
+ // until the animation completes, as otherwise these cards immediately
+ // disappear at the start of the animation.
+ _activeCardSet.defersCardHiding = YES;
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ animations:^{
+ [_activeCardSet scrollCardAtIndex:cardIndex + 1 awayFromNeighbor:YES];
+ }
+ completion:^(BOOL finished) {
+ _activeCardSet.defersCardHiding = NO;
+ }];
+}
+
+- (void)handlePinchFrom:(UIPinchGestureRecognizer*)recognizer {
+ DCHECK(!_isBeingDismissed);
+ DCHECK(_isActive);
+ UIView* currentView = _activeCardSet.displayView;
+ DCHECK(recognizer.view == currentView);
+
+ [_gestureStateTracker setPinching:YES];
+ // Disable scrollView scrolling while a pinch is occurring. If the user lifts
+ // a finger while pinching, callbacks to |handlePinchFrom:| will continue to
+ // be made, and the code below will ensure that the cards get scrolled
+ // properly.
+ // TODO(blundell): Try to figure out how to re-enable deceleration for
+ // such scrolls. b/5976932
+ if ([_scrollView isScrollEnabled]) {
+ [_scrollView setScrollEnabled:NO];
+ _ignoreScrollCallbacks = YES;
+ [self recenterScrollViewIfNecessary];
+ }
+
+ UIGestureRecognizerState state = [recognizer state];
+ if ((state == UIGestureRecognizerStateCancelled) ||
+ (state == UIGestureRecognizerStateEnded)) {
+ [_gestureStateTracker setScrollingInPinch:NO];
+ [self pinchEnded];
+ _ignoreScrollCallbacks = NO;
+ [_scrollView setScrollEnabled:YES];
+ [_gestureStateTracker setPinching:NO];
+ return;
+ }
+
+ DCHECK((state == UIGestureRecognizerStateBegan) ||
+ (state == UIGestureRecognizerStateChanged));
+
+ CardStackPinchGestureRecognizer* pinchGestureRecognizer =
+ base::mac::ObjCCastStrict<CardStackPinchGestureRecognizer>(recognizer);
+ if ([pinchGestureRecognizer numberOfActiveTouches] < 2) {
+ // Clear the pinch card indices so that they are refetched if the user puts
+ // a second finger back down.
+ [_gestureStateTracker setFirstPinchCardIndex:NSNotFound];
+ [_gestureStateTracker setSecondPinchCardIndex:NSNotFound];
+
+ // The recognizer may continue to register two touches for a short period
+ // after one of the touches is no longer active. Wait until there is only
+ // one touch to be sure of accessing the information for the right touch.
+ if ([recognizer numberOfTouches] != 1) {
+ return;
+ }
+
+ CGPoint fingerLocation =
+ [_pinchRecognizer locationOfTouch:0 inView:currentView];
+ CGFloat fingerOffset = [self scrollOffsetAmountForPoint:fingerLocation];
+ if (![_gestureStateTracker scrollingInPinch]) {
+ NSUInteger scrolledIndex = [self indexOfCardAtPoint:fingerLocation];
+ if (scrolledIndex != NSNotFound) {
+ // Begin the scroll.
+ [_gestureStateTracker setScrollCardIndex:scrolledIndex];
+ [_gestureStateTracker setPreviousFirstPinchOffset:fingerOffset];
+ [_gestureStateTracker setPreviousScrollTime:(base::TimeTicks::Now())];
+ [_gestureStateTracker setScrollingInPinch:YES];
+ // Animate back overpinch as necessary.
+ [self pinchEnded];
+ }
+ return;
+ }
+
+ // Perform the scroll.
+ CGFloat delta =
+ fingerOffset - [_gestureStateTracker previousFirstPinchOffset];
+ NSInteger scrolledIndex = [_gestureStateTracker scrollCardIndex];
+ DCHECK(scrolledIndex != NSNotFound);
+ [_activeCardSet scrollCardAtIndex:scrolledIndex
+ byDelta:delta
+ allowEarlyOverscroll:YES
+ decayOnOverscroll:YES
+ scrollLeadingCards:YES];
+ [_gestureStateTracker setPreviousFirstPinchOffset:fingerOffset];
+ [_gestureStateTracker updateScrollVelocityWithScrollDistance:delta];
+ [_gestureStateTracker setPreviousScrollTime:(base::TimeTicks::Now())];
+ return;
+ }
+
+ [_gestureStateTracker setScrollingInPinch:NO];
+
+ DCHECK([recognizer numberOfTouches] >= 2);
+ // Extract first and second offsets of the pinch.
+ CGPoint firstPinchPoint = [recognizer locationOfTouch:0 inView:currentView];
+ CGPoint secondPinchPoint = [recognizer locationOfTouch:1 inView:currentView];
+ if ([self scrollOffsetAmountForPoint:firstPinchPoint] >
+ [self scrollOffsetAmountForPoint:secondPinchPoint]) {
+ CGPoint temp = firstPinchPoint;
+ firstPinchPoint = secondPinchPoint;
+ secondPinchPoint = temp;
+ }
+ CGFloat firstOffset = [self scrollOffsetAmountForPoint:firstPinchPoint];
+ CGFloat secondOffset = [self scrollOffsetAmountForPoint:secondPinchPoint];
+ NSInteger firstPinchCardIndex = [_gestureStateTracker firstPinchCardIndex];
+ NSInteger secondPinchCardIndex = [_gestureStateTracker secondPinchCardIndex];
+
+ // Pinch does not actually cause cards to move until user has started moving
+ // fingers with each finger on a distinct card.
+ if ((state == UIGestureRecognizerStateBegan) ||
+ (firstPinchCardIndex == NSNotFound) ||
+ (secondPinchCardIndex == NSNotFound) ||
+ (firstPinchCardIndex == secondPinchCardIndex)) {
+ [_gestureStateTracker
+ setFirstPinchCardIndex:[self indexOfCardAtPoint:firstPinchPoint]];
+ [_gestureStateTracker
+ setSecondPinchCardIndex:[self indexOfCardAtPoint:secondPinchPoint]];
+ [_gestureStateTracker setPreviousFirstPinchOffset:firstOffset];
+ [_gestureStateTracker setPreviousSecondPinchOffset:secondOffset];
+ return;
+ }
+
+ DCHECK(firstPinchCardIndex != NSNotFound);
+ DCHECK(secondPinchCardIndex != NSNotFound);
+ DCHECK(firstPinchCardIndex < secondPinchCardIndex);
+
+ CGFloat firstDelta =
+ firstOffset - [_gestureStateTracker previousFirstPinchOffset];
+ CGFloat secondDelta =
+ secondOffset - [_gestureStateTracker previousSecondPinchOffset];
+ [_activeCardSet.stackModel handleMultitouchWithFirstDelta:firstDelta
+ secondDelta:secondDelta
+ firstCardIndex:firstPinchCardIndex
+ secondCardIndex:secondPinchCardIndex
+ decayOnOverpinch:YES];
+
+ [_activeCardSet updateCardVisibilities];
+
+ [_gestureStateTracker setPreviousFirstPinchOffset:firstOffset];
+ [_gestureStateTracker setPreviousSecondPinchOffset:secondOffset];
+}
+
+- (void)handleTapFrom:(UITapGestureRecognizer*)recognizer {
+ DCHECK(!_isBeingDismissed);
+ DCHECK(_isActive);
+ if (recognizer.state != UIGestureRecognizerStateEnded)
+ return;
+
+ if (recognizer == _modeSwitchRecognizer.get()) {
+ DCHECK(CGRectContainsPoint([self inactiveDeckRegion],
+ [recognizer locationInView:_scrollView]));
+ [self setActiveCardSet:[self inactiveCardSet]];
+ return;
+ }
+
+ CardView* cardView = (CardView*)recognizer.view;
+ UIView* activeView = _activeCardSet.displayView;
+ if ([cardView superview] != activeView)
+ return;
+
+ // Don't open the card if it's collapsed behind its succeeding neighbor, as
+ // this was likely just a misplaced tap in that case.
+ StackCard* card = [self cardForView:cardView];
+ // TODO(blundell) : The card should not be nil here, see b/6759862.
+ if (!card || [_activeCardSet cardIsCollapsed:card])
+ return;
+
+ DCHECK(!CGRectContainsPoint([cardView closeButtonFrame],
+ [recognizer locationInView:cardView]));
+
+ if (self.transitionStyle == STACK_TRANSITION_STYLE_NONE) {
+ [_activeCardSet setCurrentCard:card];
+ [self dismissWithSelectedTabAnimation];
+ } else if ([card isEqual:_activeCardSet.currentCard]) {
+ // If the currently selected card is tapped mid-presentation animation,
+ // simply reverse the animation immediately if it hasn't already been
+ // reversed.
+ if (self.transitionStyle != STACK_TRANSITION_STYLE_DISMISSING)
+ [self cancelTransitionAnimation];
+ } else {
+ // If a new card is tapped mid-presentation, store a reference to the card
+ // so that it can be selected upon dismissal in the presentation's
+ // completion block.
+ self.transitionTappedCard = card;
+ }
+}
+
+- (void)handlePanFrom:(UIPanGestureRecognizer*)gesture {
+ DCHECK(!_isBeingDismissed);
+ DCHECK(_isActive);
+ // Check if the gesture's initial state needs to be set.
+ if (gesture.state == UIGestureRecognizerStateBegan ||
+ [_gestureStateTracker resetSwipedCardOnNextSwipe]) {
+ // Save first position to be able to calculate swipe distances later.
+ BOOL isPortrait =
+ UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ // TODO(crbug.com/228456): All of the swipe code should be operating on the
+ // swipe's first touch in order to avoid the "dancing swipe" bug. Note that
+ // this might involve adding some checks to handle the case where the number
+ // of touches in the swipe is 0.
+ CGPoint point = [gesture locationInView:_scrollView];
+ [_gestureStateTracker
+ setSwipeStartingPosition:(isPortrait ? point.x : point.y)];
+
+ // Save the index of the card on which the swipe started (if any).
+ CGPoint activePoint = [gesture locationInView:_activeCardSet.displayView];
+ NSUInteger cardIndex = [self indexOfCardAtPoint:activePoint];
+ [_gestureStateTracker setSwipedCardIndex:cardIndex];
+
+ [_gestureStateTracker setResetSwipedCardOnNextSwipe:NO];
+ // Signal that the swipe is beginning so that the type of the swipe will be
+ // calculated on the next callback (note that it is too early to calculate
+ // it here as the direction of the swipe, which is a component used in the
+ // calculation, is currently unknown).
+ [_gestureStateTracker setSwipeIsBeginning:YES];
+ // Determine whether a swipe of ambiguous intent should change decks or
+ // dismiss a card.
+ UIGestureRecognizerState state = [_swipeDismissesCardRecognizer state];
+ BOOL ambiguousSwipeChangesDecks =
+ (state != UIGestureRecognizerStateBegan &&
+ state != UIGestureRecognizerStateChanged);
+ [_gestureStateTracker
+ setAmbiguousSwipeChangesDecks:ambiguousSwipeChangesDecks];
+ return;
+ }
+
+ if ([_gestureStateTracker swipeIsBeginning]) {
+ [self determineSwipeType:gesture];
+ [_gestureStateTracker setSwipeIsBeginning:NO];
+ }
+
+ if ([_gestureStateTracker swipeChangesDecks]) {
+ [self swipeDeck:gesture];
+ return;
+ }
+
+ // Check whether the swipe is actually on a card.
+ NSUInteger cardIndex = [_gestureStateTracker swipedCardIndex];
+ if (cardIndex == NSNotFound) {
+ [_gestureStateTracker setResetSwipedCardOnNextSwipe:YES];
+ return;
+ }
+ // Take action only if the card being swiped is not collapsed.
+ DCHECK(cardIndex < [[_activeCardSet cards] count]);
+ StackCard* card = [[_activeCardSet cards] objectAtIndex:cardIndex];
+ if ([_activeCardSet cardIsCollapsed:card]) {
+ [_gestureStateTracker setResetSwipedCardOnNextSwipe:YES];
+ return;
+ }
+ // Remove the transition toolbar controller from the view hierarchy if the
+ // card swipe occurs while a transition animation is occurring.
+ if ([self.transitionToolbarController.view isDescendantOfView:self.view])
+ [self.transitionToolbarController.view removeFromSuperview];
+ [self swipeCard:gesture];
+}
+
+- (void)determineSwipeType:(UIPanGestureRecognizer*)gesture {
+ if (![self bothDecksShouldBeDisplayed]) {
+ [_gestureStateTracker setSwipeChangesDecks:NO];
+ return;
+ }
+
+ if ([_gestureStateTracker swipedCardIndex] == NSNotFound) {
+ [_gestureStateTracker setSwipeChangesDecks:YES];
+ return;
+ }
+
+ // The swipe is on a card. Check whether the intent of the swipe is
+ // ambiguous.
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ CGPoint swipePoint = [gesture locationInView:_scrollView];
+ CGFloat swipePosition = isPortrait ? swipePoint.x : swipePoint.y;
+ CGFloat swipeStartingPosition = [_gestureStateTracker swipeStartingPosition];
+ CGFloat swipeDistance = swipePosition - swipeStartingPosition;
+ if (UseRTLLayout() && isPortrait)
+ swipeDistance *= -1;
+ BOOL mainSetActive = (_activeCardSet == _mainCardSet);
+ BOOL swipeIntentIsAmbiguous = (mainSetActive && swipeDistance < 0.0) ||
+ (!mainSetActive && swipeDistance > 0.0);
+
+ BOOL swipeChangesDecks =
+ swipeIntentIsAmbiguous ? [_gestureStateTracker ambiguousSwipeChangesDecks]
+ : NO;
+ [_gestureStateTracker setSwipeChangesDecks:swipeChangesDecks];
+}
+
+- (CGFloat)distanceForSwipeToTriggerAction {
+ return ([self scrollBreadth:[self view].bounds.size] *
+ kSwipeCardScreenFraction);
+}
+
+- (BOOL)swipeShouldTriggerAction:(CGFloat)endingPosition {
+ CGFloat swipeStartingPosition = [_gestureStateTracker swipeStartingPosition];
+ CGFloat threshold = [self distanceForSwipeToTriggerAction];
+ return std::abs(endingPosition - swipeStartingPosition) > threshold;
+}
+
+- (void)swipeDeck:(UIPanGestureRecognizer*)gesture {
+ // StateBegan is handled by the caller handlePanFrom.
+ DCHECK(gesture.state != UIGestureRecognizerStateBegan);
+ // Swiping between decks should only be invoked when more than one deck is
+ // being displayed.
+ DCHECK([self bothDecksShouldBeDisplayed]);
+
+ CGPoint point = [gesture locationInView:_scrollView];
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ CGFloat position = isPortrait ? point.x : point.y;
+ CGFloat swipeStartingPosition = [_gestureStateTracker swipeStartingPosition];
+
+ // In portrait on RTL, the incognito card stack is laid out to the left of
+ // the maint stack, so invert the pan direction.
+ BOOL shouldInvert = UseRTLLayout() && isPortrait;
+
+ if (gesture.state == UIGestureRecognizerStateChanged) {
+ CGFloat offset = position - swipeStartingPosition;
+ // Decay drag if going off screen to mimic UIScrollView's bounce.
+ BOOL isIncognito = [self isCurrentSetIncognito];
+ if ((isIncognito && swipeStartingPosition > position) ||
+ (!isIncognito && swipeStartingPosition < position))
+ offset /= 2;
+ if (shouldInvert)
+ offset *= -1;
+ [self updateDeckAxisPositionsWithShiftAmount:offset];
+
+ } else if (gesture.state == UIGestureRecognizerStateEnded) {
+ if ([self swipeShouldTriggerAction:position]) {
+ // |topLeftCardSet| is the card set on the left in portrait and on top in
+ // landscape, and |bottomRightCardSet| is the card set on the right in
+ // portrait and the bottom in landscape. If |position| is greater than
+ // |swipeStartingPosition|, the gesture is dragging |topLeftCardSet| into
+ // view. Otherwise, |bottomRightCardSet| is being dragged into view. Can't
+ // just flip the active card set because this swipe might be a bounce that
+ // is leaving the active card set unchanged.
+ CardSet* topLeftCardSet = shouldInvert ? _otrCardSet : _mainCardSet;
+ CardSet* bottomRightCardSet = shouldInvert ? _mainCardSet : _otrCardSet;
+ _activeCardSet = position > swipeStartingPosition ? topLeftCardSet
+ : bottomRightCardSet;
+ }
+ [self displayActiveCardSet];
+ }
+}
+
+- (void)swipeCard:(UIPanGestureRecognizer*)gesture {
+ // StateBegan is handled by the caller handlePanFrom.
+ DCHECK(gesture.state != UIGestureRecognizerStateBegan);
+
+ CGPoint point = [gesture locationInView:_scrollView];
+ BOOL isPortrait = UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ CGFloat position = isPortrait ? point.x : point.y;
+ CGFloat swipeStartingPosition = [_gestureStateTracker swipeStartingPosition];
+ CGFloat distanceMoved = fabs(position - swipeStartingPosition);
+
+ // Calculate fractions of animation breadth to trigger dismissal that have
+ // been covered so far.
+ CGFloat fractionOfAnimationBreadth =
+ distanceMoved /
+ ios_internal::page_animation_util::AnimateOutTransformBreadth();
+ // User can potentially move their finger further than animation breath/
+ // dismissal threshold distance. Ensure that these corner cases don't cause
+ // any unexpected behavior.
+ fractionOfAnimationBreadth =
+ std::min(fractionOfAnimationBreadth, CGFloat(1.0));
+
+ // Calculate direction of the swipe.
+ BOOL clockwise = position - swipeStartingPosition > 0;
+ if (!isPortrait)
+ clockwise = !clockwise;
+
+ NSUInteger swipedCardIndex = [_gestureStateTracker swipedCardIndex];
+ StackCard* card = [_activeCardSet.cards objectAtIndex:swipedCardIndex];
+ _activeCardSet.closingCard = card;
+
+ if (gesture.state == UIGestureRecognizerStateChanged) {
+ // Transform card along |AnimateOutTransform| by the fraction of the
+ // animation breadth that has been covered so far.
+ [card view].transform =
+ ios_internal::page_animation_util::AnimateOutTransform(
+ fractionOfAnimationBreadth, clockwise, isPortrait);
+ // Fade the card to become transparent at the conclusion of the animation,
+ // and the card's tab to become transparent at the time that the card
+ // reaches the threshold for being dismissed.
+ [card view].alpha = 1 - fractionOfAnimationBreadth;
+ } else {
+ if (gesture.state == UIGestureRecognizerStateEnded &&
+ [self swipeShouldTriggerAction:position]) {
+ // Track card if animation should dismiss in reverse from the norm of
+ // clockwise in portrait, counter-clockwise in landscape.
+ if ((isPortrait && !clockwise) || (!isPortrait && clockwise))
+ _reverseDismissCard.reset([card retain]);
+ // This will trigger the completion of the close card animation.
+ [self closeTab:card.view];
+ } else {
+ // Animate back to starting position.
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ delay:0
+ options:UIViewAnimationCurveEaseOut
+ animations:^{
+ [card view].alpha = 1;
+ [[card view] setTabOpacity:1];
+ [card view].transform = CGAffineTransformIdentity;
+ }
+ completion:^(BOOL finished) {
+ _activeCardSet.closingCard = nil;
+ }];
+ }
+ }
+}
+
+- (StackCard*)cardForView:(CardView*)view {
+ // This isn't terribly efficient, but since it is only intended for use in
+ // response to a user action it's not worth the bookkeeping of a reverse
+ // mapping to make it constant time.
+ for (StackCard* card in _activeCardSet.cards) {
+ if (card.viewIsLive && card.view == view) {
+ return card;
+ }
+ }
+ return nil;
+}
+
+- (IBAction)chromeExecuteCommand:(id)sender {
+ int command = [sender tag];
+
+ switch (command) {
+ case IDC_SHOW_TOOLS_MENU:
+ [self showToolsMenuPopup];
+ break;
+ // Closing all while the main set is active closes everything, but closing
+ // all while incognito is active only closes incognito tabs.
+ case IDC_CLOSE_ALL_TABS:
+ DCHECK(![self isCurrentSetIncognito]);
+ [self removeAllCardsFromSet:_mainCardSet];
+ [self removeAllCardsFromSet:_otrCardSet];
+ break;
+ case IDC_CLOSE_ALL_INCOGNITO_TABS:
+ DCHECK([self isCurrentSetIncognito]);
+ [self removeAllCardsFromSet:_activeCardSet];
+ break;
+ case IDC_NEW_INCOGNITO_TAB:
+ case IDC_NEW_TAB:
+ // Ensure that the right mode is showing.
+ if ([self isCurrentSetIncognito] != (command == IDC_NEW_INCOGNITO_TAB))
+ [self setActiveCardSet:[self inactiveCardSet]];
+ [self setLastTapPoint:sender];
+ [self dismissWithNewTabAnimation:GURL(kChromeUINewTabURL)
+ atIndex:NSNotFound
+ transition:ui::PAGE_TRANSITION_TYPED];
+ break;
+ case IDC_TOGGLE_TAB_SWITCHER:
+ [self dismissWithSelectedTabAnimation];
+ break;
+ default:
+ [super chromeExecuteCommand:sender];
+ break;
+ }
+}
+
+- (void)showToolsMenuPopup {
+ base::scoped_nsobject<ToolsMenuContext> context(
+ [[ToolsMenuContext alloc] initWithDisplayView:[self view]]);
+ [context setInTabSwitcher:YES];
+ // When checking for the existence of tabs, catch the case where the main set
+ // is both active and empty, but the incognito set has some cards.
+ if (([[_activeCardSet cards] count] == 0) &&
+ (_activeCardSet == _otrCardSet || [[_otrCardSet cards] count] == 0))
+ [context setNoOpenedTabs:YES];
+ if (_activeCardSet == _otrCardSet)
+ [context setInIncognito:YES];
+ [_toolbarController showToolsMenuPopupWithContext:context];
+}
+
+#pragma mark Notification Handlers
+
+- (void)allModelTabsHaveClosed:(NSNotification*)notify {
+ // Early return if the stack view is not active. This can sometimes occur if
+ // |clearInternalState| triggers the deletion of a tab model.
+ if (!_isActive)
+ return;
+
+ CardSet* closedSet =
+ (notify.object == [_mainCardSet tabModel]) ? _mainCardSet : _otrCardSet;
+
+ // If the tabModel that send the notification is not one handled by one of
+ // the two card sets, just return. This will happen when the otr tab model is
+ // deleted because the incognito profile is deleted.
+ if (notify.object != [closedSet tabModel])
+ return;
+
+ if (closedSet == _activeCardSet)
+ [self activeCardCountChanged];
+ for (UIView* card in [closedSet.displayView subviews]) {
+ [card removeFromSuperview];
+ }
+ [closedSet setIgnoresTabModelChanges:NO];
+ // No need to re-sync with the card set here, since the tab model (and thus
+ // the card set) is known to be empty.
+
+ // The animation of closing all the main set's cards interacts badly with the
+ // animation of switching to main-card-set-only mode, so if the incognito set
+ // finishes closing while the main set is still animating (in the case of
+ // closing all cards at once) wait until the main set finishes before updating
+ // the display (neccessary so the state is right if a new tab is opened).
+ if ((closedSet == _otrCardSet && ![_mainCardSet ignoresTabModelChanges]) ||
+ (closedSet == _mainCardSet && [[_otrCardSet cards] count] == 0)) {
+ [self displayMainCardSetOnly];
+ }
+
+ [_toolbarController setTabCount:[_activeCardSet.cards count]];
+}
+
+#pragma mark CardSetObserver Methods
+
+- (void)cardSet:(CardSet*)cardSet didAddCard:(StackCard*)newCard {
+ [self updateScrollViewContentSize];
+
+ if (cardSet == _activeCardSet) {
+ [self activeCardCountChanged];
+ [_toolbarController setTabCount:[_activeCardSet.cards count]];
+ }
+
+ // Place the card at the right destination point to animate in: staggered
+ // from its previous neighbor if it is the last card, or in the location of
+ // its successive neighbor (which will slide down to make room) otherwise.
+ NSArray* cards = [cardSet cards];
+ NSUInteger cardIndex = [cards indexOfObject:newCard];
+ CGFloat maxStagger = [[cardSet stackModel] maxStagger];
+
+ if (newCard == [cards lastObject]) {
+ if ([cards count] == 1) {
+ // Simply lay out the card.
+ [cardSet fanOutCards];
+ } else {
+ StackCard* previousCard = [cards objectAtIndex:cardIndex - 1];
+ BOOL isPortrait =
+ UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ LayoutRect layout = previousCard.layout;
+ if (isPortrait)
+ layout.position.originY += maxStagger;
+ else
+ layout.position.leading += maxStagger;
+ newCard.layout = layout;
+ }
+
+ } else {
+ DCHECK(cardIndex != NSNotFound);
+ DCHECK(cardIndex + 1 < [cards count]);
+ newCard.layout = [[cards objectAtIndex:cardIndex + 1] layout];
+ }
+
+ // Animate the new card in at its destination location.
+ ios_internal::page_animation_util::AnimateInCardWithAnimationAndCompletion(
+ newCard.view, NULL, NULL);
+
+ // Set up the animation of the existing cards.
+ NSUInteger indexToScroll = cardIndex + 1;
+ CGFloat scrollAmount = maxStagger;
+ if (newCard == [cards lastObject] ||
+ [cardSet isCardInEndStaggerRegion:newCard]) {
+ // No scrolling actually needs to be done, although |scrollCardAtIndex:|
+ // still has to be called if the new card is not in the start stack in
+ // order to ensure that the end stack is re-laid out if necessary.
+ indexToScroll = cardIndex;
+ scrollAmount = 0;
+ }
+
+ // If the new card is in the start stack, just re-lay out the start stack.
+ // Otherwise, slide down the successive cards to make room and/or re-lay out
+ // the end stack. TODO(blundell): The animation is behaving incorrectly when
+ // the card being inserted is near the end stack: sometimes the slide down
+ // doesn't occur, and sometimes it overscrolls, causing a visible bounce.
+ void (^stackAnimation)(void) = ^{
+ if ([cardSet isCardInStartStaggerRegion:newCard]) {
+ [cardSet layOutStartStack];
+ } else {
+ [cardSet scrollCardAtIndex:indexToScroll
+ byDelta:scrollAmount
+ allowEarlyOverscroll:NO
+ decayOnOverscroll:NO
+ scrollLeadingCards:YES];
+ }
+ };
+
+ cardSet.defersCardHiding = YES;
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:stackAnimation
+ completion:^(BOOL) {
+ cardSet.defersCardHiding = NO;
+ if (cardSet == _activeCardSet) {
+ // Ensure that state is properly reset if there was a
+ // scroll/pinch occurring.
+ [self scrollEnded];
+ }
+ }];
+}
+
+- (void)cardSet:(CardSet*)cardSet
+ willRemoveCard:(StackCard*)cardBeingRemoved
+ atIndex:(NSUInteger)index {
+ // All handlers working on that card must be stopped to prevent concurrency
+ // and/or UI inconcistencies.
+ if (cardSet == _activeCardSet) {
+ // Cancel any outstanding gestures that were tracking a card index, as they
+ // might have been operating on cards that no longer exist. Ideally, these
+ // events would allow the gestures to continue and just reset the cards on
+ // which they are operating. However, doing that correctly in all cases
+ // proves near-impossible: if the call to -disableGestureHandlers happens
+ // slightly too late, then a gesture callback could occur in the new state
+ // of the world with the gesture still operating on the old state of the
+ // world. Meanwhile if the call to -enableGestureHandlers happens
+ // slightly too early, then a gesture callback could occur while still in
+ // the old state of the world, meaning that the card being tracked would
+ // revert to the old (problematic) card.
+ [self disableGestureHandlers];
+ }
+}
+
+- (void)cardSet:(CardSet*)cardSet
+ didRemoveCard:(StackCard*)removedCard
+ atIndex:(NSUInteger)index {
+ if (cardSet == _activeCardSet) {
+ // Reenable the gesture handlers (disabled in
+ // -cardSet:willRemoveCard:atIndex). It is now safe to do so as the card
+ // that was being removed has been removed at this point.
+ [self enableGestureHandlers];
+ [_toolbarController setTabCount:[_activeCardSet.cards count]];
+ }
+
+ // If no view was ever created for this card, it's too late to make one. This
+ // can only happen if a tab is closed by something other than user action,
+ // and even then only if the card hasn't been pre-loaded yet, so not doing th
+ // animation isn't a problem.
+ if (removedCard.viewIsLive) {
+ // Determine what direction animation should rotate in. The norm is
+ // clockwise in portrait, counter-clockwise in landscape; however, it needs
+ // to be reversed if this animation is occurring as the conclusion of a
+ // swipe that went in the opposite direction from the norm.
+ BOOL isPortrait =
+ UIInterfaceOrientationIsPortrait(_lastInterfaceOrientation);
+ BOOL clockwise = isPortrait ? _reverseDismissCard != removedCard
+ : _reverseDismissCard == removedCard;
+ [self animateOutCardView:removedCard.view
+ delay:0
+ clockwise:clockwise
+ completion:nil];
+ // Reset |reverseDismissCard| if that card was the one dismissed.
+ if ((isPortrait && !clockwise) || (!isPortrait && clockwise))
+ _reverseDismissCard.reset();
+ }
+ // Nil out the the closing card after all closing animations have finished.
+ [CATransaction begin];
+ [CATransaction setCompletionBlock:^{
+ cardSet.closingCard = nil;
+ }];
+ // If the last incognito card closes, switch back to just the main set.
+ if ([cardSet.cards count] == 0 && cardSet == _otrCardSet.get()) {
+ [self displayMainCardSetOnly];
+ } else {
+ NSUInteger numCards = [[cardSet cards] count];
+ if (numCards == 0) {
+ // Commit the transaction before early return.
+ [CATransaction commit];
+ return;
+ }
+ if (index == numCards) {
+ // If the card that was closed was the last card and was in the start
+ // stack, the start stack might need to be re-laid out to show a
+ // previously hidden card.
+ if ([cardSet overextensionTowardStartOnCardAtIndex:numCards - 1]) {
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ animations:^{
+ [cardSet layOutStartStack];
+ }];
+ }
+ } else {
+ // Scroll up the card following the removed card to be placed where the
+ // removed card was.
+ LayoutRectPosition removedCardPosition = removedCard.layout.position;
+ LayoutRectPosition followingCardPosition =
+ [[cardSet cards][index] layout].position;
+ CGFloat scrollAmount =
+ [self scrollOffsetAmountForPosition:removedCardPosition] -
+ [self scrollOffsetAmountForPosition:followingCardPosition];
+ [cardSet updateShadowLayout];
+ [UIView animateWithDuration:kDefaultAnimationDuration
+ animations:^{
+ [cardSet scrollCardAtIndex:index
+ byDelta:scrollAmount
+ allowEarlyOverscroll:YES
+ decayOnOverscroll:NO
+ scrollLeadingCards:NO];
+ [cardSet updateShadowLayout];
+ }];
+ }
+ }
+ [CATransaction commit];
+}
+
+- (void)cardSet:(CardSet*)cardSet displayedCard:(StackCard*)card {
+ // Add gesture recognizers to the card.
+ [card.view addCardCloseTarget:self action:@selector(closeTab:)];
+ [card.view addAccessibilityTarget:self
+ action:@selector(accessibilityFocusedOnElement:)];
+
+ base::scoped_nsobject<UIGestureRecognizer> tapRecognizer([
+ [UITapGestureRecognizer alloc] initWithTarget:self
+ action:@selector(handleTapFrom:)]);
+ tapRecognizer.get().delegate = self;
+ [card.view addGestureRecognizer:tapRecognizer.get()];
+
+ base::scoped_nsobject<UIGestureRecognizer> longPressRecognizer(
+ [[UILongPressGestureRecognizer alloc]
+ initWithTarget:self
+ action:@selector(handleLongPressFrom:)]);
+ longPressRecognizer.get().delegate = self;
+ [card.view addGestureRecognizer:longPressRecognizer.get()];
+}
+
+- (void)cardSetRecreatedCards:(CardSet*)cardSet {
+ // Remove the old card views, if any, then start loading the new ones.
+ for (UIView* card in [cardSet.displayView subviews]) {
+ [card removeFromSuperview];
+ }
+ [self preloadCardViewsAsynchronously];
+}
+
+#pragma mark -
+
+// The following method is based on Apple sample code available at
+// http://developer.apple.com/library/ios/samplecode/StreetScroller/
+// Introduction/Intro.html.
+- (void)recenterScrollViewIfNecessary {
+ CGFloat contentOffset =
+ [self scrollOffsetAmountForPoint:[_scrollView contentOffset]];
+ CGFloat contentLength = [self scrollLength:[_scrollView contentSize]];
+ CGFloat viewportLength = [self scrollLength:[_scrollView bounds].size];
+ DCHECK(contentLength > viewportLength || [_activeCardSet.cards count] == 0);
+
+ CGFloat centerOffset = (contentLength - viewportLength) / 2.0;
+ CGFloat distanceFromCenter = fabs(contentOffset - centerOffset);
+
+ if (distanceFromCenter > centerOffset / 2.0) {
+ _ignoreScrollCallbacks = YES;
+ [_scrollView
+ setContentOffset:[self scrollOffsetPointWithAmount:centerOffset]];
+ [self alignDisplayViewsToViewport];
+ _ignoreScrollCallbacks = NO;
+ }
+}
+
+- (void)alignDisplayViewsToViewport {
+ DCHECK(CGSizeEqualToSize([_mainCardSet displayView].frame.size,
+ [_scrollView frame].size));
+ DCHECK(CGSizeEqualToSize([_otrCardSet displayView].frame.size,
+ [_scrollView frame].size));
+ CGRect newDisplayViewFrame = CGRectMake(
+ [_scrollView contentOffset].x, [_scrollView contentOffset].y,
+ [_scrollView frame].size.width, [_scrollView frame].size.height);
+ [_mainCardSet displayView].frame = newDisplayViewFrame;
+ [_otrCardSet displayView].frame = newDisplayViewFrame;
+}
+
+// Caps overscroll once the stack becomes fully overextended or deceleration
+// slows below a given velocity to achieve a nice-looking bounce effect.
+- (BOOL)shouldEndScroll {
+ if ([[_activeCardSet cards] count] == 0 ||
+ ![_activeCardSet stackIsOverextended] || ![_scrollView isDecelerating])
+ return NO;
+ if ([_activeCardSet stackIsFullyOverextended])
+ return YES;
+ NSUInteger lastCardIndex = [[_activeCardSet cards] count] - 1;
+ // Kill the scroll in the case where a fling wasn't detected early enough,
+ // resulting in part of the stack being overscrolled toward the start without
+ // the whole stack being overscrolled toward the start.
+ if ([_activeCardSet overextensionTowardStartOnCardAtIndex:0] &&
+ ![_activeCardSet overextensionTowardStartOnCardAtIndex:lastCardIndex])
+ return YES;
+ return [_gestureStateTracker scrollVelocity] < kMinFlingVelocityInOverscroll;
+}
+
+- (void)scrollEnded {
+ if ([_activeCardSet stackIsOverextended]) {
+ void (^toDoWhenDone)(void) = ^{
+ [self recenterScrollViewIfNecessary];
+ };
+ [self animateOverextensionEliminationWithCompletion:toDoWhenDone];
+ } else {
+ [self recenterScrollViewIfNecessary];
+ }
+}
+
+- (void)pinchEnded {
+ BOOL scrollingInPinch = [_gestureStateTracker scrollingInPinch];
+
+ if (![_activeCardSet stackIsOverextended]) {
+ if (!scrollingInPinch)
+ [self recenterScrollViewIfNecessary];
+ return;
+ }
+
+ if (scrollingInPinch) {
+ NSUInteger scrollCardIndex = [_gestureStateTracker scrollCardIndex];
+ DCHECK(scrollCardIndex != NSNotFound);
+ if ([_activeCardSet overextensionOnCardAtIndex:scrollCardIndex])
+ return;
+ }
+
+ void (^toDoWhenDone)(void) = NULL;
+ if (!scrollingInPinch) {
+ toDoWhenDone = ^{
+ [self recenterScrollViewIfNecessary];
+ };
+ }
+ [self animateOverextensionEliminationWithCompletion:toDoWhenDone];
+}
+
+- (void)animateOverextensionEliminationWithCompletion:
+ (ProceduralBlock)completion {
+ _activeCardSet.defersCardHiding = YES;
+ [UIView animateWithDuration:kOverextensionEliminationAnimationDuration
+ delay:0
+ options:(UIViewAnimationOptionAllowUserInteraction |
+ UIViewAnimationOptionOverrideInheritedCurve |
+ UIViewAnimationOptionCurveEaseOut)
+ animations:^{
+ [_activeCardSet eliminateOverextension];
+ }
+ completion:^(BOOL finished) {
+ _activeCardSet.defersCardHiding = NO;
+ if (completion)
+ completion();
+ }];
+}
+
+- (void)killScrollDeceleration {
+ _ignoreScrollCallbacks = YES;
+ [_scrollView setContentOffset:[_scrollView contentOffset] animated:NO];
+ // The above call does not always generate a callback to
+ // |scrollViewDidScroll:|, so it is necessary to update the gesture state
+ // tracker's previous scroll offset explicitly here.
+ [_gestureStateTracker
+ setPreviousScrollOffset:
+ [self scrollOffsetAmountForPoint:[_scrollView contentOffset]]];
+ _ignoreScrollCallbacks = NO;
+}
+
+// To mimic standard iOS behavior on overscroll, the stack is allowed to
+// overscroll approximately half of the screen length on drag and a lesser
+// amount on fling.
+- (void)adjustMaximumOverextensionAmount:(BOOL)scrollIsFling {
+ CGFloat screenLength = [self scrollLength:self.view.bounds.size];
+ CGFloat maximumOverextensionAmount =
+ scrollIsFling ? [_activeCardSet overextensionAmount] + screenLength / 4.0
+ : screenLength / 2.0;
+
+ // The overextension amount is not allowed to grow after a fling begins, as
+ // otherwise the fling would just keep overextending further and further.
+ if (scrollIsFling &&
+ maximumOverextensionAmount > [_activeCardSet maximumOverextensionAmount])
+ return;
+ [_activeCardSet setMaximumOverextensionAmount:maximumOverextensionAmount];
+}
+
+#pragma mark UIScrollViewDelegate methods
+
+- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
+ DCHECK(!_isBeingDismissed);
+ DCHECK(_isActive);
+ DCHECK(scrollView == _scrollView);
+ // Whether this callback will trigger a scroll or not, have to ensure that
+ // the display views' positions are updated after any change in the scroll
+ // view's content offset.
+ [self alignDisplayViewsToViewport];
+
+ if (_ignoreScrollCallbacks) {
+ [_gestureStateTracker
+ setPreviousScrollOffset:
+ [self scrollOffsetAmountForPoint:[_scrollView contentOffset]]];
+ return;
+ }
+
+ if ([[_activeCardSet cards] count] == 0)
+ return;
+
+ // First check if the scrolled card needs to be reset. Have to be careful to
+ // do this only when the user is actually starting a new scroll.
+ if ([_scrollView isTracking] &&
+ [_gestureStateTracker resetScrollCardOnNextDrag]) {
+ CGPoint fingerLocation =
+ [_scrollGestureRecognizer locationOfTouch:0
+ inView:_activeCardSet.displayView];
+ [_gestureStateTracker
+ setScrollCardIndex:[self indexOfCardAtPoint:fingerLocation]];
+ // In certain corner cases the previous scroll offset is not up-to-date
+ // when the scrolled card needs to be reset (most notably, when rotating
+ // the device while scrolling). Below provides a fix for these cases
+ // without harming other cases.
+ [_gestureStateTracker
+ setPreviousScrollOffset:
+ [self scrollOffsetAmountForPoint:[_scrollView contentOffset]]];
+ [_gestureStateTracker setPreviousScrollTime:(base::TimeTicks::Now())];
+ [_gestureStateTracker setResetScrollCardOnNextDrag:NO];
+ }
+
+ CGFloat contentOffset =
+ [self scrollOffsetAmountForPoint:[_scrollView contentOffset]];
+ CGFloat delta = contentOffset - [_gestureStateTracker previousScrollOffset];
+
+ // If overscrolled and in a fling, compute the delta to apply manually to
+ // achieve a nice-looking deceleration effect.
+ if ([_activeCardSet stackIsOverextended] && ![_scrollView isTracking]) {
+ CGFloat currentVelocity = [_gestureStateTracker scrollVelocity];
+ CGFloat elapsedTime = CGFloat(
+ (base::TimeTicks::Now() - [_gestureStateTracker previousScrollTime])
+ .InMilliseconds());
+ if (elapsedTime > 0.0) {
+ CGFloat sign = (delta >= 0) ? 1.0 : -1.0;
+ CGFloat distanceAtCurrentVelocity = sign * currentVelocity * elapsedTime;
+ delta = distanceAtCurrentVelocity * kDecayFactorInBounce;
+ }
+ }
+ [_gestureStateTracker updateScrollVelocityWithScrollDistance:delta];
+
+ if ([self shouldEndScroll]) {
+ [self killScrollDeceleration];
+ return;
+ }
+
+ // Perform the scroll.
+ NSInteger scrolledIndex = [_gestureStateTracker scrollCardIndex];
+ if (scrolledIndex == NSNotFound) {
+ // User is scrolling outside the active card stack. Ideally, this scroll
+ // would be ignored; however, that is challenging to implement properly
+ // (in particular, continuing to ensure that scroll view is recentered
+ // when it needs to be). For now, pick a reasonable index to do the
+ // scrolling on. TODO(blundell): Figure out how to ignore these scrolls
+ // while ensuring that scroll view is recentered as necessary. b/5858386
+ scrolledIndex = 0;
+ if (delta > 0)
+ scrolledIndex = [[_activeCardSet cards] count] - 1;
+ // On the next scroll, check again so that if the user starts scrolling on
+ // a card, the scroll moves to be on that card.
+ [_gestureStateTracker setResetScrollCardOnNextDrag:YES];
+ }
+
+ DCHECK(scrolledIndex != NSNotFound);
+
+ // Scrolls that are greater than a given velocity are assumed to be flings
+ // even if the user's finger is still registered as being down, as it is
+ // extremely likely that the user is actually in the middle of doing a fling
+ // motion (and if the scrolled card is allowed to visibly overscroll before
+ // the stack is fully collapsed, the ability to handle the fling as a fling
+ // from a UI perspective is lost). The latter heuristic has the cost of
+ // sometimes ending up in the scrolled card not tracking the user's finger if
+ // the user is scrolling very fast near the start stack.
+ BOOL isFling =
+ ![_scrollView isTracking] || ([_gestureStateTracker scrollVelocity] >
+ kThresholdVelocityForTreatingScrollAsFling);
+ [self adjustMaximumOverextensionAmount:isFling];
+
+ // The scroll view's content offset increases with scrolling toward the start
+ // stack. These semantics are inverted from those of
+ // |scrollCardAtIndex:byDelta:|. If the scroll is a drag, then overscroll
+ // occurs with the scrolled card and the scroll decays once overscrolling
+ // begins to mimic the native iOS behavior on overscroll. In the case of
+ // fling, overscroll does not occur until the scroll is fully
+ // collapsed/expanded and no decay occurs on overscroll as the delta has
+ // already been manually adjusted in this case (see above).
+ BOOL inverseFanDirection = UseRTLLayout() && !IsPortrait();
+ if (inverseFanDirection) {
+ // In landscape RTL layouts, StackCard's application of its layout values to
+ // its underlying CardView is flipped across the midpoint Y axis, but the
+ // scroll view maintains its non-RTL scrolling behavior. In this
+ // situation, reverse the scrolling direction before applying it to the
+ // CardSet.
+ delta *= -1;
+ }
+ [_activeCardSet scrollCardAtIndex:scrolledIndex
+ byDelta:-delta
+ allowEarlyOverscroll:!isFling
+ decayOnOverscroll:!isFling
+ scrollLeadingCards:YES];
+
+ // Verify that if scroll view's content offset has hit a boundary point, the
+ // active card stack is fully scrolled in the corresponding direction. Note
+ // that this check intentionally doesn't take into account overextension: due
+ // to the fact that overextension is a transient state, the stack is not
+ // guaranteed to be fully overextended when these checks are performed (and
+ // that is OK).
+ DCHECK(contentOffset >= 0);
+ CGFloat epsilon = std::numeric_limits<CGFloat>::epsilon();
+ if (contentOffset < epsilon) {
+ if (inverseFanDirection)
+ DCHECK([_activeCardSet stackIsFullyCollapsed]);
+ else
+ DCHECK([_activeCardSet stackIsFullyFannedOut]);
+ }
+ CGFloat viewportLength = [self scrollLength:[_scrollView bounds].size];
+ CGFloat contentSizeUpperLimit =
+ [self scrollLength:[_scrollView contentSize]] - viewportLength;
+ DCHECK(contentOffset <= contentSizeUpperLimit);
+ if (std::abs(contentSizeUpperLimit - contentOffset) < epsilon) {
+ if (inverseFanDirection)
+ DCHECK([_activeCardSet stackIsFullyFannedOut]);
+ else
+ DCHECK([_activeCardSet stackIsFullyCollapsed]);
+ }
+
+ [_gestureStateTracker setPreviousScrollTime:(base::TimeTicks::Now())];
+ [_gestureStateTracker
+ setPreviousScrollOffset:
+ [self scrollOffsetAmountForPoint:[_scrollView contentOffset]]];
+}
+
+- (void)scrollViewDidEndDragging:(UIScrollView*)scrollView
+ willDecelerate:(BOOL)willDecelerate {
+ DCHECK(scrollView == _scrollView);
+ [_gestureStateTracker setResetScrollCardOnNextDrag:YES];
+ // Recenter the scroll view's content offset after making sure that there is
+ // no scrolling currently occurring.
+ if (willDecelerate || [_scrollView isDragging] ||
+ [_scrollView isDecelerating] || [_gestureStateTracker pinching])
+ return;
+ [self scrollEnded];
+}
+
+- (void)scrollViewDidEndDecelerating:(UIScrollView*)scrollView {
+ DCHECK(scrollView == _scrollView);
+ // Recenter the scroll view's content offset after making sure that there is
+ // no scrolling currently occurring (this deceleration might have been ended
+ // by the user starting a new scroll).
+ if ([_scrollView isDragging] || [_scrollView isDecelerating] ||
+ [_gestureStateTracker pinching])
+ return;
+ [self scrollEnded];
+}
+
+#pragma mark - Accessibility methods
+
+// Handles scrolling through the card stack and scrolling between main and
+// incognito card stacks while in voiceover mode. Three finger scroll toward
+// an edge stack displays the next few collapsed tabs from the appropriate edge
+// stack. Three finger scroll toward the inactive stack switches between the
+// main and incognito card stacks, as appropriate.
+- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
+ BOOL isPortrait = IsPortrait();
+ BOOL shouldScrollTowardEnd = NO;
+ BOOL shouldScrollTowardStart = NO;
+ BOOL shouldSwitchToMain = NO;
+ BOOL shouldSwitchToIncognito = NO;
+
+ switch (direction) {
+ case UIAccessibilityScrollDirectionDown:
+ if (isPortrait) {
+ shouldScrollTowardEnd = YES;
+ } else {
+ shouldSwitchToIncognito = YES;
+ }
+ break;
+ case UIAccessibilityScrollDirectionUp:
+ if (isPortrait) {
+ shouldScrollTowardStart = YES;
+ } else {
+ shouldSwitchToMain = YES;
+ }
+ break;
+ case UIAccessibilityScrollDirectionLeft:
+ if (isPortrait) {
+ shouldSwitchToIncognito = YES;
+ } else {
+ shouldScrollTowardEnd = YES;
+ }
+ break;
+ case UIAccessibilityScrollDirectionRight:
+ if (isPortrait) {
+ shouldSwitchToMain = YES;
+ } else {
+ shouldScrollTowardStart = YES;
+ }
+ break;
+ default:
+ break;
+ }
+
+ if (shouldScrollTowardEnd) {
+ DCHECK([_activeCardSet.stackModel firstEndStackCardIndex] > -1);
+ [_activeCardSet
+ fanOutCardsWithStartIndex:
+ std::min(
+ (NSInteger)[_activeCardSet.cards count] - 1,
+ (NSInteger)[_activeCardSet.stackModel firstEndStackCardIndex])];
+ } else if (shouldScrollTowardStart) {
+ DCHECK([_activeCardSet.stackModel lastStartStackCardIndex] > -1);
+ [_activeCardSet
+ fanOutCardsWithStartIndex:
+ std::max((NSInteger)0,
+ (NSInteger)(
+ [_activeCardSet.stackModel lastStartStackCardIndex] -
+ [_activeCardSet.stackModel fannedStackCount]))];
+ } else if ([self bothDecksShouldBeDisplayed]) {
+ if (shouldSwitchToMain) {
+ [self setActiveCardSet:_mainCardSet];
+ } else if (shouldSwitchToIncognito) {
+ [self setActiveCardSet:_otrCardSet];
+ }
+ } else {
+ return NO;
+ }
+
+ NSUInteger firstCardIndex = std::max(
+ [_activeCardSet.stackModel lastStartStackCardIndex], (NSInteger)0);
+ StackCard* card = [_activeCardSet.cards objectAtIndex:firstCardIndex];
+ [[card view] postAccessibilityNotification];
+ [self postOpenTabsAccessibilityNotification];
+
+ return YES;
+}
+
+// Posts accessibility notification that announces to the user which tabs are
+// currently visible on the screen.
+- (void)postOpenTabsAccessibilityNotification {
+ if ([_activeCardSet.cards count] == 0) {
+ return;
+ }
+
+ NSInteger count = [_activeCardSet.cards count];
+ NSInteger lastStartIndex =
+ [_activeCardSet.stackModel lastStartStackCardIndex];
+ DCHECK(lastStartIndex < (int)[_activeCardSet.cards count]);
+ if (lastStartIndex < 0) {
+ lastStartIndex = 0;
+ }
+ NSInteger firstEndIndex = [_activeCardSet.stackModel firstEndStackCardIndex];
+ NSInteger first = lastStartIndex < 0 ? 1 : lastStartIndex + 1;
+ NSInteger last = firstEndIndex < 0 ? count : firstEndIndex;
+
+ // Post notification to voiceover to read which tabs are currently visible.
+ NSString* incognito = [self isCurrentSetIncognito] ? @"Incognito" : @"";
+ NSString* firstVisible = [NSString stringWithFormat:@"%" PRIuNS, first];
+ NSString* lastVisible = [NSString stringWithFormat:@"%" PRIuNS, last];
+ NSString* numCards = [NSString stringWithFormat:@"%" PRIuNS, count];
+ UIAccessibilityPostNotification(
+ UIAccessibilityPageScrolledNotification,
+ l10n_util::GetNSStringF(IDS_IOS_CARD_STACK_SCROLLED_NOTIFICATION,
+ base::SysNSStringToUTF16(incognito),
+ base::SysNSStringToUTF16(firstVisible),
+ base::SysNSStringToUTF16(lastVisible),
+ base::SysNSStringToUTF16(numCards)));
+}
+
+// Returns the StackCard with |element| in its view hierarchy.
+// Handles CardView, TitleLabel, and CloseButton elements.
+- (StackCard*)cardForAccessibilityElement:(UIView*)element {
+ DCHECK([element isKindOfClass:[CardView class]] ||
+ [element isKindOfClass:[TitleLabel class]] ||
+ [element isKindOfClass:[CloseButton class]]);
+
+ if ([element isKindOfClass:[CardView class]]) {
+ for (StackCard* card in _activeCardSet.cards) {
+ if (card.view == element) {
+ return card;
+ }
+ }
+ } else {
+ for (StackCard* card in _activeCardSet.cards) {
+ if (card.view == element.superview.superview) {
+ return card;
+ }
+ }
+ }
+ return nil;
+}
+
+- (void)accessibilityFocusedOnElement:(id)element {
+ StackCard* card = [self cardForAccessibilityElement:element];
+ DCHECK(card);
+ NSInteger index = [_activeCardSet.cards indexOfObject:card];
+
+ if (![_activeCardSet.stackModel cardLabelCovered:card]) {
+ return;
+ }
+
+ if (index >= [_activeCardSet.stackModel firstEndStackCardIndex] - 1) {
+ // If card is in the end stack, scroll it toward the start.
+ [_activeCardSet scrollCardAtIndex:index awayFromNeighbor:NO];
+ } else if (index == [_activeCardSet.stackModel lastStartStackCardIndex] - 1) {
+ // If card is the last covered card in the start stack, scroll the last
+ // start stack card away from the start stack to reveal it.
+ [_activeCardSet scrollCardAtIndex:index + 1 awayFromNeighbor:YES];
+ } else {
+ // If the card is in the middle of a stack that is not the end stack, fan
+ // the cards out starting with that card.
+ [_activeCardSet fanOutCardsWithStartIndex:index];
+ [card.view postAccessibilityNotification];
+ [self postOpenTabsAccessibilityNotification];
+ }
+}
+
+#pragma mark - UIResponder
+
+- (NSArray*)keyCommands {
+ base::WeakNSObject<StackViewController> weakSelf(self);
+
+ // Block to execute a command from the |tag|.
+ base::mac::ScopedBlock<void (^)(NSInteger)> execute(
+ ^(NSInteger tag) {
+ [weakSelf
+ chromeExecuteCommand:[GenericChromeCommand commandWithTag:tag]];
+ },
+ base::scoped_policy::RETAIN);
+
+ return @[
+ [UIKeyCommand cr_keyCommandWithInput:@"t"
+ modifierFlags:UIKeyModifierCommand
+ title:l10n_util::GetNSStringWithFixup(
+ IDS_IOS_TOOLS_MENU_NEW_TAB)
+ action:^{
+ if ([weakSelf isCurrentSetIncognito])
+ execute.get()(IDC_NEW_INCOGNITO_TAB);
+ else
+ execute.get()(IDC_NEW_TAB);
+ }],
+ [UIKeyCommand
+ cr_keyCommandWithInput:@"n"
+ modifierFlags:UIKeyModifierCommand | UIKeyModifierShift
+ title:l10n_util::GetNSStringWithFixup(
+ IDS_IOS_TOOLS_MENU_NEW_INCOGNITO_TAB)
+ action:^{
+ execute.get()(IDC_NEW_INCOGNITO_TAB);
+ }],
+ [UIKeyCommand cr_keyCommandWithInput:@"n"
+ modifierFlags:UIKeyModifierCommand
+ title:nil
+ action:^{
+ if ([weakSelf isCurrentSetIncognito])
+ execute.get()(IDC_NEW_INCOGNITO_TAB);
+ else
+ execute.get()(IDC_NEW_TAB);
+ }],
+ ];
+}
+
+@end
+
+@implementation StackViewController (Testing)
+
+- (UIScrollView*)scrollView {
+ return _scrollView.get();
+}
+
+@end

Powered by Google App Engine
This is Rietveld 408576698