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 |