blob: cdb4ba2b667ddd566f64f01b38844dd897bdc29d [file] [log] [blame]
// 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_macros.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_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"
#import "ios/shared/chrome/browser/ui/tools_menu/tools_menu_configuration.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 insertTabWithURL:URL
referrer:web::Referrer()
transition:transition
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<ToolsMenuConfiguration> configuration(
[[ToolsMenuConfiguration alloc] initWithDisplayView:[self view]]);
[configuration 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))
[configuration setNoOpenedTabs:YES];
if (_activeCardSet == _otrCardSet)
[configuration setInIncognito:YES];
[_toolbarController showToolsMenuPopupWithConfiguration:configuration];
}
#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