| // 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/tabs/tab_strip_controller.h" |
| #import "ios/chrome/browser/ui/tabs/tab_strip_controller_private.h" |
| |
| #include <cmath> |
| #include <vector> |
| |
| #include "base/i18n/rtl.h" |
| #include "base/mac/bundle_locations.h" |
| #include "base/mac/foundation_util.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/browser_state/chrome_browser_state.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/commands/UIKit+ChromeExecuteCommand.h" |
| #include "ios/chrome/browser/ui/commands/ios_command_ids.h" |
| #import "ios/chrome/browser/ui/fullscreen_controller.h" |
| #include "ios/chrome/browser/ui/rtl_geometry.h" |
| #include "ios/chrome/browser/ui/tab_switcher/tab_switcher_tab_strip_placeholder_view.h" |
| #import "ios/chrome/browser/ui/tabs/tab_strip_controller+tab_switcher_animation.h" |
| #import "ios/chrome/browser/ui/tabs/tab_strip_view.h" |
| #import "ios/chrome/browser/ui/tabs/tab_view.h" |
| #include "ios/chrome/browser/ui/tabs/target_frame_cache.h" |
| #include "ios/chrome/browser/ui/ui_util.h" |
| #import "ios/chrome/browser/ui/uikit_ui_util.h" |
| #import "ios/chrome/browser/ui/util/snapshot_util.h" |
| #include "ios/chrome/grit/ios_strings.h" |
| #import "ios/web/public/web_state/web_state.h" |
| #include "third_party/google_toolbox_for_mac/src/iPhone/GTMFadeTruncatingLabel.h" |
| #include "ui/gfx/image/image.h" |
| |
| #if !defined(__has_feature) || !__has_feature(objc_arc) |
| #error "This file requires ARC support." |
| #endif |
| |
| using base::UserMetricsAction; |
| |
| NSString* const kWillStartTabStripTabAnimation = |
| @"kWillStartTabStripTabAnimation"; |
| NSString* const kTabStripDragStarted = @"kTabStripDragStarted"; |
| NSString* const kTabStripDragEnded = @"kTabStripDragEnded"; |
| |
| namespace TabStrip { |
| UIColor* BackgroundColor() { |
| DCHECK(IsIPadIdiom()); |
| return [UIColor colorWithRed:0.149 green:0.149 blue:0.164 alpha:1]; |
| } |
| } |
| |
| namespace { |
| |
| // Animation duration for tab animations. |
| const NSTimeInterval kTabAnimationDuration = 0.25; |
| |
| // Animation duration for tab strip fade. |
| const NSTimeInterval kTabStripFadeAnimationDuration = 0.15; |
| |
| // Amount of time needed to trigger drag and drop mode when long pressing. |
| const NSTimeInterval kDragAndDropLongPressDuration = 0.4; |
| |
| // Tab dimensions. |
| const CGFloat kTabOverlap = 26.0; |
| const CGFloat kTabOverlapForCompactLayout = 30.0; |
| |
| const CGFloat kNewTabOverlap = 8.0; |
| const CGFloat kMaxTabWidth = 265.0; |
| const CGFloat kMaxTabWidthForCompactLayout = 225.0; |
| |
| // Tab Switcher button dimensions. |
| const CGFloat kTabSwitcherButtonWidth = 46.0; |
| const CGFloat kTabSwitcherButtonBackgroundWidth = 62.0; |
| |
| const CGFloat kNewTabRightPadding = 4.0; |
| const CGFloat kMinTabWidth = 200.0; |
| const CGFloat kMinTabWidthForCompactLayout = 160.0; |
| |
| const CGFloat kCollapsedTabOverlap = 5.0; |
| const NSUInteger kMaxNumCollapsedTabs = 3; |
| const NSUInteger kMaxNumCollapsedTabsForCompactLayout = 0; |
| |
| // Tabs with a visible width smaller than this draw as collapsed tabs.. |
| const CGFloat kCollapsedTabWidthThreshold = 40.0; |
| |
| // Autoscroll constants. The autoscroll distance is set to |
| // |kMaxAutoscrollDistance| at the edges of the scroll view. As the tab moves |
| // away from the edges of the scroll view, the autoscroll distance decreases by |
| // one for each |kAutoscrollDecrementWidth| points. |
| const CGFloat kMaxAutoscrollDistance = 10.0; |
| const CGFloat kAutoscrollDecrementWidth = 10.0; |
| |
| // Dimming view constants, in points. |
| const CGFloat kDimmingViewBottomInsetHighRes = 0.0; |
| const CGFloat kDimmingViewBottomInset = 0.0; |
| |
| // The size of the tab strip view. |
| const CGFloat kTabStripHeight = 39.0; |
| |
| // The size of the new tab button. |
| const CGFloat kNewTabButtonWidth = 59.9; |
| |
| // Default image insets for the new tab button. |
| const CGFloat kNewTabButtonHorizontalImageInset = 2.0; |
| const CGFloat kNewTabButtonTopImageInset = 6.0; |
| const CGFloat kNewTabButtonBottomImageInset = 7.0; |
| |
| // Offsets needed to keep the UI properly centered on high-res screens, in |
| // points. |
| const CGFloat kNewTabButtonBottomOffsetHighRes = 2.0; |
| } |
| |
| @interface TabStripController ()<TabModelObserver, |
| TabStripViewLayoutDelegate, |
| UIGestureRecognizerDelegate, |
| UIScrollViewDelegate> { |
| TabModel* _tabModel; |
| UIView* _view; |
| TabStripView* _tabStripView; |
| UIButton* _buttonNewTab; |
| UIButton* _tabSwitcherButton; |
| |
| // Background view of the tab switcher button. Only visible while in compact |
| // layout. |
| UIImageView* _tabSwitcherButtonBackgroundView; |
| |
| TabStrip::Style _style; |
| __weak id<FullScreenControllerDelegate> _fullscreenDelegate; |
| |
| // Array of TabViews. There is a one-to-one correspondence between this array |
| // and the set of Tabs in the TabModel. |
| NSMutableArray* _tabArray; |
| |
| // Set of TabViews that are currently closing. These TabViews are also in |
| // |_tabArray|. Used to translate between |_tabArray| indexes and TabModel |
| // indexes. |
| NSMutableSet* _closingTabs; |
| |
| // Tracks target frames for TabViews. |
| // TODO(rohitrao): This is unnecessary, as UIKit updates view frames |
| // immediately, so [view frame] will always return the end state of the |
| // current animation. We can remove this cache entirely. b/5516053 |
| TargetFrameCache _targetFrames; |
| |
| // Animate when doing layout. This flag is set by setNeedsLayoutWithAnimation |
| // and cleared in layoutSubviews. |
| BOOL _animateLayout; |
| |
| // The current tab width. Recomputed whenever a tab is added or removed. |
| CGFloat _currentTabWidth; |
| |
| // View used to dim unselected tabs when in reordering mode. Nil when not |
| // reordering tabs. |
| UIView* _dimmingView; |
| |
| // Is the selected tab highlighted, used when dragging or swiping tabs. |
| BOOL _highlightsSelectedTab; |
| |
| // YES when in reordering mode. |
| // TODO(rohitrao): This is redundant with |_draggedTab|. Remove it. |
| BOOL _isReordering; |
| |
| // The tab that is currently being dragged. nil when not in reordering mode. |
| TabView* _draggedTab; |
| |
| // The last known location of the touch that is dragging the tab. This |
| // location is in the coordinate system of |[_tabStripView superview]| because |
| // that coordinate system does not change as the scroll view scrolls. |
| CGPoint _lastDragLocation; |
| |
| // Timer used to autoscroll when in reordering mode. Is nil when not active. |
| // Owned by its runloop. |
| NSTimer* _autoscrollTimer; // weak |
| |
| // The distance to scroll for each autoscroll timer tick. If negative, the |
| // tabstrip will scroll to the left; if positive, to the right. |
| CGFloat _autoscrollDistance; |
| |
| // The model index of the placeholder gap, if one exists. This value is used |
| // as the new model index of the dragged tab when it is dropped. |
| NSUInteger _placeholderGapModelIndex; |
| } |
| |
| @property(nonatomic, readonly, retain) TabStripView* tabStripView; |
| @property(nonatomic, readonly, retain) UIButton* buttonNewTab; |
| |
| // Initializes the tab array based on the the entries in the TabModel. Creates |
| // one TabView per Tab and adds it to the tabstrip. A later call to |
| // |-layoutTabs| is needed to properly place the tabs in the correct positions. |
| - (void)initializeTabArrayFromTabModel; |
| |
| // Initializes the tab array to have only one empty tab, for the case (used |
| // during startup) when there is not a tab model available. |
| - (void)initializeTabArrayWithNoModel; |
| |
| // Returns an autoreleased TabView object with no content. |
| - (TabView*)emptyTabView; |
| |
| // Returns an autoreleased TabView object based on the given Tab. |
| // |isSelected| is passed in here as an optimization, so that the TabView is |
| // drawn correctly the first time, without requiring the model to send a |
| // -setSelected message to the TabView. |
| - (TabView*)tabViewForTab:(Tab*)tab isSelected:(BOOL)isSelected; |
| |
| // Creates and installs the view used to dim unselected tabs. Does nothing if |
| // the view already exists. |
| - (void)installDimmingViewWithAnimation:(BOOL)animate; |
| |
| // Remove the dimming view, |
| - (void)removeDimmingViewWithAnimation:(BOOL)animate; |
| |
| // Converts between model indexes and |_tabArray| indexes. The conversion is |
| // necessary because |_tabArray| contains closing tabs whereas the TabModel does |
| // not. |
| - (NSUInteger)indexForModelIndex:(NSUInteger)modelIndex; |
| - (NSUInteger)modelIndexForIndex:(NSUInteger)index; |
| - (NSUInteger)modelIndexForTabView:(TabView*)view; |
| |
| // Helper methods to handle each stage of a drag. |
| - (void)beginDrag:(UILongPressGestureRecognizer*)gesture; |
| - (void)continueDrag:(UILongPressGestureRecognizer*)gesture; |
| - (void)endDrag:(UILongPressGestureRecognizer*)gesture; |
| - (void)cancelDrag:(UILongPressGestureRecognizer*)gesture; |
| |
| // Resets any internal variables used to track drag state. |
| - (void)resetDragState; |
| |
| // Returns whether or not the tabstrip is currently in reordering mode. |
| - (BOOL)isReorderingTabs; |
| |
| // Installs or removes the autoscroll timer. |
| - (void)installAutoscrollTimerIfNeeded; |
| - (void)removeAutoscrollTimer; |
| |
| // Called once per autoscroll timer tick. Adjusts the scroll view's content |
| // offset as needed. |
| - (void)autoscrollTimerFired:(NSTimer*)timer; |
| |
| // Calculates and stores the autoscroll distance for the given tab view. The |
| // autoscroll distance is a function of the distance between the edge of the |
| // scroll view and the tab's frame. |
| - (void)computeAutoscrollDistanceForTabView:(TabView*)view; |
| |
| // Constrains the stored autoscroll distance to prevent the scroll view from |
| // overscrolling. |
| - (void)constrainAutoscrollDistance; |
| |
| #if 0 |
| // Returns the appropriate model index for the currently dragged tab, given its |
| // current position. (If dropped, the tab would be at this index in the model.) |
| // TODO(rohitrao): Implement this method. |
| - (NSUInteger)modelIndexForDraggedTab; |
| #endif |
| |
| // Returns the horizontal visible tab strip width used to compute the tab width |
| // and the tabs and new tab button in regular layout mode. |
| - (CGFloat)tabStripVisibleSpace; |
| |
| // Shift all of the tab strip subviews by an amount equal to the content offset |
| // change, which effectively places the subviews back where they were before the |
| // change, in terms of screen coordinates. |
| - (void)shiftTabStripSubviews:(CGPoint)oldContentOffset; |
| |
| // Updates the scroll view's content size based on the current set of tabs and |
| // closing tabs. After updating the content size, repositions views so they |
| // they will appear stationary on screen. |
| - (void)updateContentSizeAndRepositionViews; |
| |
| // Returns the frame, in the scroll view content's coordinate system, of the |
| // given tab view. |
| - (CGRect)scrollViewFrameForTab:(TabView*)view; |
| |
| // Returns the portion of |frame| which is not covered by |frameOnTop|. |
| - (CGRect)calculateVisibleFrameForFrame:(CGRect)frame |
| whenUnderFrame:(CGRect)frameOnTop; |
| |
| // Schedules a layout of the scroll view and sets the internal |_animateLayout| |
| // flag so that the layout will be animated. |
| - (void)setNeedsLayoutWithAnimation; |
| |
| // Returns the maximum number of collapsed tabs depending on the current layout |
| // mode. |
| - (NSUInteger)maxNumCollapsedTabs; |
| |
| // Returns the tab overlap width depending on the current layout mode. |
| - (CGFloat)tabOverlap; |
| |
| // Returns the maximum tab view width depending on the current layout mode. |
| - (CGFloat)maxTabWidth; |
| |
| // Returns the minimum tab view width depending on the current layout mode. |
| - (CGFloat)minTabWidth; |
| |
| // Automatically scroll the tab strip view to keep the newly inserted tab view |
| // visible. |
| // This method must be called with a valid |tabIndex|. |
| - (void)autoScrollForNewTab:(NSUInteger)tabIndex; |
| |
| // Updates the content offset of the tab strip view in order to keep the |
| // selected tab view visible. |
| // Content offset adjustement is only needed/performed in compact mode or |
| // regular mode for newly opened tabs. |
| // This method must be called with a valid |tabIndex|. |
| - (void)updateContentOffsetForTabIndex:(NSUInteger)tabIndex |
| isNewTab:(BOOL)isNewTab; |
| |
| // Update the frame of the tab strip view (scrollview) frame, content inset and |
| // toggle buttons states depending on the current layout mode. |
| - (void)updateScrollViewFrameForTabSwitcherButton; |
| |
| @end |
| |
| @implementation TabStripController |
| |
| @synthesize buttonNewTab = _buttonNewTab; |
| @synthesize highlightsSelectedTab = _highlightsSelectedTab; |
| @synthesize tabStripView = _tabStripView; |
| @synthesize view = _view; |
| |
| - (instancetype)initWithTabModel:(TabModel*)tabModel |
| style:(TabStrip::Style)style { |
| if ((self = [super init])) { |
| _tabArray = [[NSMutableArray alloc] initWithCapacity:10]; |
| _closingTabs = [[NSMutableSet alloc] initWithCapacity:5]; |
| |
| _tabModel = tabModel; |
| [_tabModel addObserver:self]; |
| _style = style; |
| |
| // |self.view| setup. |
| CGRect tabStripFrame = [UIApplication sharedApplication].keyWindow.bounds; |
| tabStripFrame.size.height = kTabStripHeight; |
| _view = [[UIView alloc] initWithFrame:tabStripFrame]; |
| _view.autoresizingMask = (UIViewAutoresizingFlexibleWidth | |
| UIViewAutoresizingFlexibleBottomMargin); |
| _view.backgroundColor = TabStrip::BackgroundColor(); |
| if (UseRTLLayout()) |
| _view.transform = CGAffineTransformMakeScale(-1, 1); |
| |
| // |self.tabStripView| setup. |
| _tabStripView = [[TabStripView alloc] initWithFrame:_view.bounds]; |
| _tabStripView.autoresizingMask = |
| (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight); |
| _tabStripView.backgroundColor = _view.backgroundColor; |
| _tabStripView.layoutDelegate = self; |
| _tabStripView.accessibilityIdentifier = style == TabStrip::kStyleIncognito |
| ? @"Incognito Tab Strip" |
| : @"Tab Strip"; |
| [_view addSubview:_tabStripView]; |
| |
| // |self.buttonNewTab| setup. |
| CGRect buttonNewTabFrame = tabStripFrame; |
| buttonNewTabFrame.size.width = kNewTabButtonWidth; |
| _buttonNewTab = [[UIButton alloc] initWithFrame:buttonNewTabFrame]; |
| BOOL isBrowserStateIncognito = |
| tabModel && tabModel.browserState->IsOffTheRecord(); |
| _buttonNewTab.tag = |
| isBrowserStateIncognito ? IDC_NEW_INCOGNITO_TAB : IDC_NEW_TAB; |
| // TODO(crbug.com/600829): Rewrite layout code and convert these masks to |
| // to trailing and leading margins rather than right and bottom. |
| _buttonNewTab.autoresizingMask = (UIViewAutoresizingFlexibleRightMargin | |
| UIViewAutoresizingFlexibleBottomMargin); |
| _buttonNewTab.imageView.contentMode = UIViewContentModeCenter; |
| UIImage* buttonNewTabImage = nil; |
| UIImage* buttonNewTabPressedImage = nil; |
| if (_style == TabStrip::kStyleIncognito) { |
| buttonNewTabImage = [UIImage imageNamed:@"tabstrip_new_tab_incognito"]; |
| buttonNewTabPressedImage = |
| [UIImage imageNamed:@"tabstrip_new_tab_incognito_pressed"]; |
| } else { |
| buttonNewTabImage = [UIImage imageNamed:@"tabstrip_new_tab"]; |
| buttonNewTabPressedImage = |
| [UIImage imageNamed:@"tabstrip_new_tab_pressed"]; |
| } |
| [_buttonNewTab setImage:buttonNewTabImage forState:UIControlStateNormal]; |
| [_buttonNewTab setImage:buttonNewTabPressedImage |
| forState:UIControlStateHighlighted]; |
| UIEdgeInsets imageInsets = UIEdgeInsetsMake( |
| kNewTabButtonTopImageInset, kNewTabButtonHorizontalImageInset, |
| kNewTabButtonBottomImageInset, kNewTabButtonHorizontalImageInset); |
| if (IsHighResScreen()) { |
| imageInsets.top += kNewTabButtonBottomOffsetHighRes; |
| imageInsets.bottom -= kNewTabButtonBottomOffsetHighRes; |
| } |
| _buttonNewTab.imageEdgeInsets = imageInsets; |
| SetA11yLabelAndUiAutomationName( |
| _buttonNewTab, |
| isBrowserStateIncognito ? IDS_IOS_TOOLS_MENU_NEW_INCOGNITO_TAB |
| : IDS_IOS_TOOLS_MENU_NEW_TAB, |
| isBrowserStateIncognito ? @"New Incognito Tab" : @"New Tab"); |
| // Use a nil target to send |-chromeExecuteCommand:| down the responder |
| // chain. |
| [_buttonNewTab addTarget:nil |
| action:@selector(chromeExecuteCommand:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| [_buttonNewTab addTarget:self |
| action:@selector(recordUserMetrics:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| [_tabStripView addSubview:_buttonNewTab]; |
| |
| [self installTabSwitcherButton]; |
| |
| // Add tab buttons to tab strip. |
| if (_tabModel) |
| [self initializeTabArrayFromTabModel]; |
| else |
| [self initializeTabArrayWithNoModel]; |
| |
| // Update the layout of the tab buttons. |
| [self updateContentSizeAndRepositionViews]; |
| [self layoutTabStripSubviews]; |
| |
| // Don't highlight the selected tab by default. |
| self.highlightsSelectedTab = NO; |
| } |
| return self; |
| } |
| |
| - (instancetype)init { |
| NOTREACHED(); |
| return nil; |
| } |
| |
| - (void)dealloc { |
| [_tabStripView setDelegate:nil]; |
| [_tabStripView setLayoutDelegate:nil]; |
| [_tabModel removeObserver:self]; |
| } |
| |
| - (id<FullScreenControllerDelegate>)fullscreenDelegate { |
| return _fullscreenDelegate; |
| } |
| |
| - (void)setFullscreenDelegate: |
| (id<FullScreenControllerDelegate>)fullscreenDelegate { |
| _fullscreenDelegate = fullscreenDelegate; |
| } |
| |
| - (void)initializeTabArrayFromTabModel { |
| DCHECK(_tabModel); |
| for (Tab* tab in _tabModel) { |
| BOOL isSelectedTab = [_tabModel currentTab] == tab; |
| TabView* view = [self tabViewForTab:tab isSelected:isSelectedTab]; |
| [_tabArray addObject:view]; |
| [_tabStripView addSubview:view]; |
| } |
| } |
| |
| - (void)initializeTabArrayWithNoModel { |
| DCHECK(!_tabModel); |
| TabView* view = [self emptyTabView]; |
| [_tabArray addObject:view]; |
| [_tabStripView addSubview:view]; |
| [view setSelected:YES]; |
| return; |
| } |
| |
| - (TabView*)emptyTabView { |
| TabView* view = [[TabView alloc] initWithEmptyView:YES selected:YES]; |
| [view setIncognitoStyle:(_style == TabStrip::kStyleIncognito)]; |
| [view setContentMode:UIViewContentModeRedraw]; |
| |
| // Setting the tab to be hidden marks it as a new tab. The layout code will |
| // make the tab visible and set up the appropriate animations. |
| [view setHidden:YES]; |
| |
| return view; |
| } |
| |
| - (TabView*)tabViewForTab:(Tab*)tab isSelected:(BOOL)isSelected { |
| TabView* view = [[TabView alloc] initWithEmptyView:NO selected:isSelected]; |
| if (UseRTLLayout()) |
| [view setTransform:CGAffineTransformMakeScale(-1, 1)]; |
| [view setIncognitoStyle:(_style == TabStrip::kStyleIncognito)]; |
| [view setContentMode:UIViewContentModeRedraw]; |
| [[view titleLabel] setText:[tab title]]; |
| [view setFavicon:[tab favicon]]; |
| |
| // Set the tab buttons' action messages. |
| [view addTarget:self |
| action:@selector(tabTapped:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| [[view closeButton] addTarget:self |
| action:@selector(closeTab:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| |
| // Install a long press gesture recognizer to handle drag and drop. |
| UILongPressGestureRecognizer* longPress = |
| [[UILongPressGestureRecognizer alloc] |
| initWithTarget:self |
| action:@selector(handleLongPress:)]; |
| [longPress setMinimumPressDuration:kDragAndDropLongPressDuration]; |
| [longPress setDelegate:self]; |
| [view addGestureRecognizer:longPress]; |
| |
| // Giving the tab view exclusive touch prevents other views from receiving |
| // touches while a TabView is handling a touch. |
| [view setExclusiveTouch:YES]; |
| |
| // Setting the tab to be hidden marks it as a new tab. The layout code will |
| // make the tab visible and set up the appropriate animations. |
| [view setHidden:YES]; |
| |
| return view; |
| } |
| |
| - (void)setHighlightsSelectedTab:(BOOL)highlightsSelectedTab { |
| if (highlightsSelectedTab) |
| [self installDimmingViewWithAnimation:YES]; |
| else |
| [self removeDimmingViewWithAnimation:YES]; |
| |
| _highlightsSelectedTab = highlightsSelectedTab; |
| } |
| |
| - (void)installDimmingViewWithAnimation:(BOOL)animate { |
| // The dimming view should not cover the bottom 2px of the tab strip, as those |
| // pixels are visually part of the top border of the toolbar. The bottom |
| // inset constants take into account the conversion from pixels to points. |
| CGRect frame = [_tabStripView bounds]; |
| frame.size.height -= (IsHighResScreen() ? kDimmingViewBottomInsetHighRes |
| : kDimmingViewBottomInset); |
| |
| // Create the dimming view if it doesn't exist. In all cases, make sure it's |
| // set up correctly. |
| if (_dimmingView) |
| [_dimmingView setFrame:frame]; |
| else |
| _dimmingView = [[UIView alloc] initWithFrame:frame]; |
| |
| // Enable user interaction in order to eat touches from views behind it. |
| [_dimmingView setUserInteractionEnabled:YES]; |
| [_dimmingView setBackgroundColor:[TabStrip::BackgroundColor() |
| colorWithAlphaComponent:0]]; |
| [_dimmingView setAutoresizingMask:(UIViewAutoresizingFlexibleWidth | |
| UIViewAutoresizingFlexibleHeight)]; |
| [_tabStripView addSubview:_dimmingView]; |
| |
| CGFloat duration = animate ? kTabStripFadeAnimationDuration : 0; |
| [UIView animateWithDuration:duration |
| animations:^{ |
| [_dimmingView |
| setBackgroundColor:[TabStrip::BackgroundColor() |
| colorWithAlphaComponent:0.6]]; |
| }]; |
| } |
| |
| - (void)removeDimmingViewWithAnimation:(BOOL)animate { |
| if (_dimmingView) { |
| CGFloat duration = animate ? kTabStripFadeAnimationDuration : 0; |
| [UIView animateWithDuration:duration |
| animations:^{ |
| [_dimmingView setBackgroundColor:[TabStrip::BackgroundColor() |
| colorWithAlphaComponent:0]]; |
| } |
| completion:^(BOOL finished) { |
| // Do not remove the dimming view if the animation was aborted. |
| if (finished) { |
| [_dimmingView removeFromSuperview]; |
| _dimmingView = nil; |
| } |
| }]; |
| } |
| } |
| |
| - (void)recordUserMetrics:(id)sender { |
| if (sender == _buttonNewTab) |
| base::RecordAction(UserMetricsAction("MobileTabStripNewTab")); |
| else if (sender == _tabSwitcherButton) |
| base::RecordAction(UserMetricsAction("MobileTabSwitcherOpen")); |
| else |
| LOG(WARNING) << "Trying to record metrics for unknown sender " |
| << base::SysNSStringToUTF8([sender description]); |
| } |
| |
| - (void)tabTapped:(id)sender { |
| DCHECK([sender isKindOfClass:[TabView class]]); |
| |
| // Ignore taps while in reordering mode. |
| if ([self isReorderingTabs]) |
| return; |
| |
| NSUInteger index = [self modelIndexForTabView:(TabView*)sender]; |
| DCHECK_NE(NSNotFound, static_cast<NSInteger>(index)); |
| if (index == NSNotFound) |
| return; |
| Tab* tappedTab = [_tabModel tabAtIndex:index]; |
| Tab* currentTab = [_tabModel currentTab]; |
| if (IsIPadIdiom() && (currentTab != tappedTab)) { |
| [currentTab updateSnapshotWithOverlay:YES visibleFrameOnly:YES]; |
| } |
| [_tabModel setCurrentTab:tappedTab]; |
| [self updateContentOffsetForTabIndex:index isNewTab:NO]; |
| } |
| |
| - (void)closeTab:(id)sender { |
| // Ignore taps while in reordering mode. |
| // TODO(rohitrao): We should just hide the close buttons instead. |
| if ([self isReorderingTabs]) |
| return; |
| |
| base::RecordAction(UserMetricsAction("MobileTabStripCloseTab")); |
| DCHECK([sender isKindOfClass:[UIButton class]]); |
| UIView* superview = [sender superview]; |
| DCHECK([superview isKindOfClass:[TabView class]]); |
| TabView* tab = (TabView*)superview; |
| NSUInteger modelIndex = [self modelIndexForTabView:tab]; |
| if (modelIndex != NSNotFound) |
| [_tabModel closeTabAtIndex:modelIndex]; |
| } |
| |
| - (void)handleLongPress:(UILongPressGestureRecognizer*)gesture { |
| switch ([gesture state]) { |
| case UIGestureRecognizerStateBegan: |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:kTabStripDragStarted |
| object:nil]; |
| [self beginDrag:gesture]; |
| break; |
| case UIGestureRecognizerStateChanged: |
| [self continueDrag:gesture]; |
| break; |
| case UIGestureRecognizerStateEnded: |
| [self endDrag:gesture]; |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:kTabStripDragEnded |
| object:nil]; |
| break; |
| case UIGestureRecognizerStateCancelled: |
| [self cancelDrag:gesture]; |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:kTabStripDragEnded |
| object:nil]; |
| break; |
| default: |
| NOTREACHED(); |
| } |
| } |
| |
| - (NSUInteger)indexForModelIndex:(NSUInteger)modelIndex { |
| NSUInteger index = modelIndex; |
| NSUInteger i = 0; |
| for (TabView* tab in _tabArray) { |
| if ([_closingTabs containsObject:tab]) |
| ++index; |
| |
| if (i == index) |
| break; |
| |
| ++i; |
| } |
| |
| DCHECK_GE(index, modelIndex); |
| return index; |
| } |
| |
| - (NSUInteger)modelIndexForIndex:(NSUInteger)index { |
| NSUInteger modelIndex = 0; |
| NSUInteger arrayIndex = 0; |
| for (TabView* tab in _tabArray) { |
| if (arrayIndex == index) { |
| if ([_closingTabs containsObject:tab]) |
| return NSNotFound; |
| return modelIndex; |
| } |
| |
| if (![_closingTabs containsObject:tab]) |
| ++modelIndex; |
| |
| ++arrayIndex; |
| } |
| |
| return NSNotFound; |
| } |
| |
| - (NSUInteger)modelIndexForTabView:(TabView*)view { |
| return [self modelIndexForIndex:[_tabArray indexOfObject:view]]; |
| } |
| |
| #pragma mark - |
| #pragma mark Tab Drag and Drop methods |
| |
| - (void)beginDrag:(UILongPressGestureRecognizer*)gesture { |
| DCHECK([[gesture view] isKindOfClass:[TabView class]]); |
| TabView* view = (TabView*)[gesture view]; |
| |
| // Sanity checks. |
| NSUInteger index = [self modelIndexForTabView:view]; |
| DCHECK_NE(NSNotFound, static_cast<NSInteger>(index)); |
| if (index == NSNotFound) |
| return; |
| |
| // Install the dimming view, hide the new tab button, and select the tab so it |
| // appears highlighted. |
| Tab* tab = [_tabModel tabAtIndex:index]; |
| self.highlightsSelectedTab = YES; |
| _buttonNewTab.hidden = YES; |
| [_tabModel setCurrentTab:tab]; |
| |
| // Set up initial drag state. |
| _lastDragLocation = [gesture locationInView:[_tabStripView superview]]; |
| _isReordering = YES; |
| _draggedTab = view; |
| _placeholderGapModelIndex = [self modelIndexForTabView:_draggedTab]; |
| |
| // Update the autoscroll distance and timer. |
| [self computeAutoscrollDistanceForTabView:_draggedTab]; |
| if (_autoscrollDistance != 0) |
| [self installAutoscrollTimerIfNeeded]; |
| else |
| [self removeAutoscrollTimer]; |
| } |
| |
| - (void)continueDrag:(UILongPressGestureRecognizer*)gesture { |
| DCHECK([[gesture view] isKindOfClass:[TabView class]]); |
| TabView* view = (TabView*)[gesture view]; |
| |
| // Update the position of the dragged tab. |
| CGPoint location = [gesture locationInView:[_tabStripView superview]]; |
| CGFloat dx = location.x - _lastDragLocation.x; |
| CGRect frame = [view frame]; |
| frame.origin.x += dx; |
| [view setFrame:frame]; |
| _lastDragLocation = location; |
| |
| // Update the autoscroll distance and timer. |
| [self computeAutoscrollDistanceForTabView:_draggedTab]; |
| if (_autoscrollDistance != 0) |
| [self installAutoscrollTimerIfNeeded]; |
| else |
| [self removeAutoscrollTimer]; |
| |
| [self setNeedsLayoutWithAnimation]; |
| } |
| |
| - (void)endDrag:(UILongPressGestureRecognizer*)gesture { |
| DCHECK([[gesture view] isKindOfClass:[TabView class]]); |
| |
| NSUInteger fromIndex = [self modelIndexForTabView:_draggedTab]; |
| // TODO(rohitrao): We're seeing crashes where fromIndex is NSNotFound, |
| // indicating that the dragged tab is no longer in the TabModel. This could |
| // happen if a tab closed itself during a drag. Investigate this further, but |
| // for now, simply test |fromIndex| before proceeding. |
| if (fromIndex == NSNotFound) { |
| [self resetDragState]; |
| [self setNeedsLayoutWithAnimation]; |
| return; |
| } |
| |
| Tab* tab = [_tabModel tabAtIndex:fromIndex]; |
| NSUInteger toIndex = _placeholderGapModelIndex; |
| DCHECK_NE(NSNotFound, static_cast<NSInteger>(toIndex)); |
| DCHECK_LT(toIndex, [_tabModel count]); |
| |
| // Reset drag state variables before notifying the model that the tab moved. |
| [self resetDragState]; |
| |
| [_tabModel moveTab:tab toIndex:toIndex]; |
| [self setNeedsLayoutWithAnimation]; |
| } |
| |
| - (void)cancelDrag:(UILongPressGestureRecognizer*)gesture { |
| DCHECK([[gesture view] isKindOfClass:[TabView class]]); |
| |
| // Reset drag state and trigger a relayout to moved tabs back into their |
| // correct positions. |
| [self resetDragState]; |
| [self setNeedsLayoutWithAnimation]; |
| } |
| |
| - (void)resetDragState { |
| self.highlightsSelectedTab = NO; |
| _buttonNewTab.hidden = NO; |
| [self removeAutoscrollTimer]; |
| |
| _isReordering = NO; |
| _placeholderGapModelIndex = NSNotFound; |
| _draggedTab = nil; |
| } |
| |
| - (BOOL)isReorderingTabs { |
| return _isReordering; |
| } |
| |
| - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer*)recognizer { |
| DCHECK([recognizer isKindOfClass:[UILongPressGestureRecognizer class]]); |
| |
| // If a drag is already in progress, do not allow another to start. |
| return ![self isReorderingTabs]; |
| } |
| |
| #pragma mark - |
| #pragma mark Autoscroll methods |
| |
| - (void)installAutoscrollTimerIfNeeded { |
| if (_autoscrollTimer) |
| return; |
| |
| _autoscrollTimer = |
| [NSTimer scheduledTimerWithTimeInterval:(1.0 / 60.0) |
| target:self |
| selector:@selector(autoscrollTimerFired:) |
| userInfo:nil |
| repeats:YES]; |
| } |
| |
| - (void)removeAutoscrollTimer { |
| [_autoscrollTimer invalidate]; |
| _autoscrollTimer = nil; |
| } |
| |
| - (void)autoscrollTimerFired:(NSTimer*)timer { |
| [self constrainAutoscrollDistance]; |
| |
| CGPoint offset = [_tabStripView contentOffset]; |
| offset.x += _autoscrollDistance; |
| [_tabStripView setContentOffset:offset]; |
| |
| // Fixed-position views need to have their frames adusted to compensate for |
| // the content offset shift. These include the dragged tab, the dimming |
| // view, and the new tab button. |
| CGRect tabFrame = [_draggedTab frame]; |
| tabFrame.origin.x += _autoscrollDistance; |
| [_draggedTab setFrame:tabFrame]; |
| |
| CGRect dimFrame = [_dimmingView frame]; |
| dimFrame.origin.x += _autoscrollDistance; |
| [_dimmingView setFrame:dimFrame]; |
| |
| // Even though the new tab button is hidden during drag and drop, keep its |
| // frame updated to prevent it from animating back into place when the drag |
| // finishes. |
| CGRect newTabFrame = [_buttonNewTab frame]; |
| newTabFrame.origin.x += _autoscrollDistance; |
| [_buttonNewTab setFrame:newTabFrame]; |
| |
| // TODO(rohitrao): Find a good way to re-enable the sliding over animation |
| // when autoscrolling. Right now any running animations are immediately |
| // stopped by the next call to autoscrollTimerFired. |
| [_tabStripView setNeedsLayout]; |
| } |
| |
| - (void)computeAutoscrollDistanceForTabView:(TabView*)view { |
| CGRect scrollBounds = [_tabStripView bounds]; |
| CGRect viewFrame = [view frame]; |
| |
| // The distance between this tab and the edge of the scroll view. |
| CGFloat distanceFromEdge = |
| MIN(CGRectGetMinX(viewFrame) - CGRectGetMinX(scrollBounds), |
| CGRectGetMaxX(scrollBounds) - CGRectGetMaxX(viewFrame)); |
| if (distanceFromEdge < 0) |
| distanceFromEdge = 0; |
| |
| // Negative if the tab is closer to the left edge of the scroll view, positive |
| // if it is closer to the right edge. |
| CGFloat leftRightMultiplier = |
| (CGRectGetMidX(viewFrame) < CGRectGetMidX(scrollBounds)) ? -1.0 : 1.0; |
| |
| // The autoscroll distance decreases linearly as the tab view gets further |
| // from the edge of the scroll view. |
| _autoscrollDistance = |
| leftRightMultiplier * |
| MAX(0.0, ceilf(kMaxAutoscrollDistance - |
| distanceFromEdge / kAutoscrollDecrementWidth)); |
| } |
| |
| - (void)constrainAutoscrollDistance { |
| // Make sure autoscroll distance is not so large as to cause overscroll. |
| CGPoint offset = [_tabStripView contentOffset]; |
| |
| // Check to make sure there is no overscroll off the right edge. |
| CGFloat maxOffset = [_tabStripView contentSize].width - |
| CGRectGetWidth([_tabStripView bounds]); |
| if (offset.x + _autoscrollDistance > maxOffset) |
| _autoscrollDistance = (maxOffset - offset.x); |
| |
| // Perform the left edge check after the right edge check, to prevent |
| // right-justifying the tabs when there is no overflow. |
| if (offset.x + _autoscrollDistance < 0) |
| _autoscrollDistance = -offset.x; |
| } |
| |
| #pragma mark - |
| #pragma mark TabStripModelObserver methods |
| |
| // Observer method. |
| - (void)tabModel:(TabModel*)model |
| didInsertTab:(Tab*)tab |
| atIndex:(NSUInteger)modelIndex |
| inForeground:(BOOL)fg { |
| TabView* view = [self tabViewForTab:tab isSelected:fg]; |
| [_tabArray insertObject:view atIndex:[self indexForModelIndex:modelIndex]]; |
| [[self tabStripView] addSubview:view]; |
| |
| [self updateContentSizeAndRepositionViews]; |
| [self setNeedsLayoutWithAnimation]; |
| [self updateContentOffsetForTabIndex:modelIndex isNewTab:YES]; |
| } |
| |
| // Observer method. |
| - (void)tabModel:(TabModel*)model |
| didRemoveTab:(Tab*)tab |
| atIndex:(NSUInteger)modelIndex { |
| // Keep the actual view around while it is animating out. Once the animation |
| // is done, remove the view. |
| NSUInteger index = [self indexForModelIndex:modelIndex]; |
| TabView* view = [_tabArray objectAtIndex:index]; |
| [_closingTabs addObject:view]; |
| _targetFrames.RemoveFrame(view); |
| |
| // Adjust the content size now that the tab has been removed from the model. |
| [self updateContentSizeAndRepositionViews]; |
| |
| // Signal the FullscreenController that the toolbar needs to stay on |
| // screen for a bit, so the animation is visible. |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:kWillStartTabStripTabAnimation |
| object:nil]; |
| |
| // Leave the view where it is horizontally and animate it downwards out of |
| // sight. |
| CGRect frame = [view frame]; |
| frame = CGRectOffset(frame, 0, CGRectGetHeight(frame)); |
| [UIView animateWithDuration:kTabAnimationDuration |
| animations:^{ |
| [view setFrame:frame]; |
| } |
| completion:^(BOOL finished) { |
| [view removeFromSuperview]; |
| [_tabArray removeObject:view]; |
| [_closingTabs removeObject:view]; |
| }]; |
| |
| [self setNeedsLayoutWithAnimation]; |
| } |
| |
| // Observer method. |
| - (void)tabModel:(TabModel*)model |
| didMoveTab:(Tab*)tab |
| fromIndex:(NSUInteger)fromIndex |
| toIndex:(NSUInteger)toIndex { |
| DCHECK(!_isReordering); |
| |
| // Reorder the objects in _tabArray to keep in sync with the model ordering. |
| NSUInteger arrayIndex = [self indexForModelIndex:fromIndex]; |
| TabView* view = [_tabArray objectAtIndex:arrayIndex]; |
| [_tabArray removeObject:view]; |
| [_tabArray insertObject:view atIndex:toIndex]; |
| [self setNeedsLayoutWithAnimation]; |
| } |
| |
| // Observer method. |
| - (void)tabModel:(TabModel*)model |
| didChangeActiveTab:(Tab*)newTab |
| previousTab:(Tab*)previousTab |
| atIndex:(NSUInteger)modelIndex { |
| for (TabView* view in _tabArray) { |
| [view setSelected:NO]; |
| } |
| |
| NSUInteger index = [self indexForModelIndex:modelIndex]; |
| TabView* activeView = [_tabArray objectAtIndex:index]; |
| [activeView setSelected:YES]; |
| |
| // No need to animate this change, as selecting a new tab simply changes the |
| // z-ordering of the TabViews. If a new tab was selected as a result of a tab |
| // closure, then the animated layout has already been scheduled. |
| [_tabStripView setNeedsLayout]; |
| } |
| |
| // Observer method. |
| - (void)tabModel:(TabModel*)model didChangeTab:(Tab*)tab { |
| NSUInteger modelIndex = [_tabModel indexOfTab:tab]; |
| if (modelIndex == NSNotFound) { |
| DCHECK(false) << "Received notification for a Tab that is not contained in " |
| << "the TabModel"; |
| return; |
| } |
| NSUInteger index = [self indexForModelIndex:modelIndex]; |
| TabView* view = [_tabArray objectAtIndex:index]; |
| [view setTitle:tab.title]; |
| [view setFavicon:[tab favicon]]; |
| if (tab.webState->IsLoading()) |
| [view startProgressSpinner]; |
| else |
| [view stopProgressSpinner]; |
| [view setNeedsDisplay]; |
| } |
| |
| // Observer method. |
| - (void)tabModel:(TabModel*)model |
| didReplaceTab:(Tab*)oldTab |
| withTab:(Tab*)newTab |
| atIndex:(NSUInteger)index { |
| // TabViews do not hold references to their parent Tabs, so it's safe to treat |
| // this as a tab change rather than a tab replace. |
| [self tabModel:model didChangeTab:newTab]; |
| } |
| |
| #pragma mark - |
| #pragma mark Views and Layout |
| |
| - (CGFloat)tabStripVisibleSpace { |
| CGFloat availableSpace = CGRectGetWidth([_tabStripView bounds]) - |
| CGRectGetWidth([_buttonNewTab frame]) + |
| kNewTabOverlap - kNewTabRightPadding - |
| kTabSwitcherButtonWidth; |
| return availableSpace; |
| } |
| |
| - (void)installTabSwitcherButton { |
| DCHECK(!_tabSwitcherButton); |
| UIImage* tabSwitcherButtonIcon = |
| [UIImage imageNamed:@"tabswitcher_tab_switcher_button"]; |
| tabSwitcherButtonIcon = [tabSwitcherButtonIcon |
| imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; |
| int tabSwitcherButtonIdsAccessibilityLabel = |
| IDS_IOS_TAB_STRIP_ENTER_TAB_SWITCHER; |
| NSString* tabSwitcherButtonEnglishUiAutomationName = @"Enter Tab Switcher"; |
| const CGFloat tabStripHeight = _view.frame.size.height; |
| CGRect buttonFrame = |
| CGRectMake(CGRectGetMaxX(_view.frame) - kTabSwitcherButtonWidth, 0.0, |
| kTabSwitcherButtonWidth, tabStripHeight); |
| _tabSwitcherButton = [UIButton buttonWithType:UIButtonTypeCustom]; |
| [_tabSwitcherButton setTintColor:[UIColor whiteColor]]; |
| [_tabSwitcherButton setFrame:buttonFrame]; |
| [_tabSwitcherButton setContentMode:UIViewContentModeCenter]; |
| [_tabSwitcherButton setAutoresizingMask:UIViewAutoresizingFlexibleLeftMargin]; |
| [_tabSwitcherButton setBackgroundColor:[UIColor clearColor]]; |
| [_tabSwitcherButton setExclusiveTouch:YES]; |
| [_tabSwitcherButton setImage:tabSwitcherButtonIcon |
| forState:UIControlStateNormal]; |
| // Set target/action to bubble up with command id as tag. |
| [_tabSwitcherButton addTarget:nil |
| action:@selector(chromeExecuteCommand:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| [_tabSwitcherButton setTag:IDC_TOGGLE_TAB_SWITCHER]; |
| [_tabSwitcherButton addTarget:self |
| action:@selector(recordUserMetrics:) |
| forControlEvents:UIControlEventTouchUpInside]; |
| |
| SetA11yLabelAndUiAutomationName(_tabSwitcherButton, |
| tabSwitcherButtonIdsAccessibilityLabel, |
| tabSwitcherButtonEnglishUiAutomationName); |
| [_view addSubview:_tabSwitcherButton]; |
| // Shrink the scroll view. |
| [self updateScrollViewFrameForTabSwitcherButton]; |
| } |
| |
| - (void)shiftTabStripSubviews:(CGPoint)oldContentOffset { |
| CGFloat dx = [_tabStripView contentOffset].x - oldContentOffset.x; |
| for (UIView* view in [_tabStripView subviews]) { |
| CGRect frame = [view frame]; |
| frame.origin.x += dx; |
| [view setFrame:frame]; |
| _targetFrames.AddFrame(view, frame); |
| } |
| } |
| |
| - (void)updateContentSizeAndRepositionViews { |
| // TODO(rohitrao): The following lines are duplicated in |
| // layoutTabStripSubviews. Find a way to consolidate this logic. |
| const NSUInteger tabCount = [_tabArray count] - [_closingTabs count]; |
| if (!tabCount) |
| return; |
| const CGFloat tabHeight = CGRectGetHeight([_tabStripView bounds]); |
| CGFloat visibleSpace = [self tabStripVisibleSpace]; |
| _currentTabWidth = |
| (visibleSpace + ([self tabOverlap] * (tabCount - 1))) / tabCount; |
| _currentTabWidth = MIN(_currentTabWidth, [self maxTabWidth]); |
| _currentTabWidth = MAX(_currentTabWidth, [self minTabWidth]); |
| |
| // Set the content size to be large enough to contain all the tabs at the |
| // desired width, with the standard overlap, plus the new tab button. |
| CGSize contentSize = CGSizeMake( |
| _currentTabWidth * tabCount - ([self tabOverlap] * (tabCount - 1)) + |
| CGRectGetWidth([_buttonNewTab frame]) - kNewTabOverlap, |
| tabHeight); |
| if (CGSizeEqualToSize([_tabStripView contentSize], contentSize)) |
| return; |
| |
| // Background: The scroll view might change the content offset when updating |
| // the content size. This can happen when the old content offset would result |
| // in an overscroll at the new content size. (Note that the content offset |
| // will never change if the content size is growing.) |
| // |
| // To handle this without making views appear to jump, shift all of the |
| // subviews by an amount equal to the size change. |
| CGPoint oldOffset = [_tabStripView contentOffset]; |
| [_tabStripView setContentSize:contentSize]; |
| [self shiftTabStripSubviews:oldOffset]; |
| } |
| |
| - (CGRect)scrollViewFrameForTab:(TabView*)view { |
| NSUInteger index = [self modelIndexForTabView:view]; |
| |
| CGRect frame = [view frame]; |
| frame.origin.x = |
| (_currentTabWidth * index) - ([self tabOverlap] * (index - 1)); |
| return frame; |
| } |
| |
| - (CGRect)calculateVisibleFrameForFrame:(CGRect)frame |
| whenUnderFrame:(CGRect)frameOnTop { |
| CGFloat minX = CGRectGetMinX(frame); |
| CGFloat maxX = CGRectGetMaxX(frame); |
| |
| if (CGRectGetMinX(frame) < CGRectGetMinX(frameOnTop)) |
| maxX = CGRectGetMinX(frameOnTop); |
| else |
| minX = CGRectGetMaxX(frameOnTop); |
| |
| frame.origin.x = minX; |
| frame.size.width = maxX - minX; |
| return frame; |
| } |
| |
| #pragma mark - |
| #pragma mark - compact layout |
| |
| - (NSUInteger)maxNumCollapsedTabs { |
| return IsCompactTablet() ? kMaxNumCollapsedTabsForCompactLayout |
| : kMaxNumCollapsedTabs; |
| } |
| |
| - (CGFloat)tabOverlap { |
| return IsCompactTablet() ? kTabOverlapForCompactLayout : kTabOverlap; |
| } |
| |
| - (CGFloat)maxTabWidth { |
| return IsCompactTablet() ? kMaxTabWidthForCompactLayout : kMaxTabWidth; |
| } |
| |
| - (CGFloat)minTabWidth { |
| return IsCompactTablet() ? kMinTabWidthForCompactLayout : kMinTabWidth; |
| } |
| |
| - (void)autoScrollForNewTab:(NSUInteger)tabIndex { |
| DCHECK_NE(NSNotFound, static_cast<NSInteger>(tabIndex)); |
| |
| // The following code calculates the amount of scroll needed to make |
| // |tabIndex| visible in the "virtual" coordinate system, where root is x=0 |
| // and it contains all the tabs laid out as if the tabstrip was infinitely |
| // long. The amount of scroll is calculated as a desired length that it is |
| // just large enough to contain all the tabs to the left of |tabIndex|, with |
| // the standard overlap. |
| if (tabIndex == [_tabArray count] - 1) { |
| const CGFloat tabStripAvailableSpace = |
| _tabStripView.frame.size.width - _tabStripView.contentInset.right; |
| CGPoint oldOffset = [_tabStripView contentOffset]; |
| if (_tabStripView.contentSize.width > tabStripAvailableSpace) { |
| CGFloat scrollToPoint = |
| _tabStripView.contentSize.width - tabStripAvailableSpace; |
| [_tabStripView setContentOffset:CGPointMake(scrollToPoint, 0)]; |
| } |
| |
| // To handle content offset change without making views appear to jump, |
| // shift all of the subviews by an amount equal to the size change. |
| [self shiftTabStripSubviews:oldOffset]; |
| return; |
| } |
| |
| NSUInteger numNonClosingTabsToLeft = 0; |
| NSUInteger i = 0; |
| for (TabView* tab in _tabArray) { |
| if ([_closingTabs containsObject:tab]) |
| ++i; |
| |
| if (i == tabIndex) |
| break; |
| |
| ++numNonClosingTabsToLeft; |
| ++i; |
| } |
| |
| const CGFloat tabHeight = CGRectGetHeight([_tabStripView bounds]); |
| CGRect scrollRect = |
| CGRectMake(_currentTabWidth * numNonClosingTabsToLeft - |
| ([self tabOverlap] * (numNonClosingTabsToLeft - 1)), |
| 0, _currentTabWidth, tabHeight); |
| [_tabStripView scrollRectToVisible:scrollRect animated:YES]; |
| } |
| |
| - (void)updateContentOffsetForTabIndex:(NSUInteger)tabIndex |
| isNewTab:(BOOL)isNewTab { |
| DCHECK_NE(NSNotFound, static_cast<NSInteger>(tabIndex)); |
| |
| if (experimental_flags::IsTabStripAutoScrollNewTabsEnabled() && isNewTab) { |
| [self autoScrollForNewTab:tabIndex]; |
| return; |
| } |
| |
| if (IsCompactTablet()) { |
| if (tabIndex == [_tabArray count] - 1) { |
| const CGFloat tabStripAvailableSpace = |
| _tabStripView.frame.size.width - _tabStripView.contentInset.right; |
| if (_tabStripView.contentSize.width > tabStripAvailableSpace) { |
| CGFloat scrollToPoint = |
| _tabStripView.contentSize.width - tabStripAvailableSpace; |
| [_tabStripView setContentOffset:CGPointMake(scrollToPoint, 0) |
| animated:YES]; |
| } |
| } else { |
| TabView* tabView = [_tabArray objectAtIndex:tabIndex]; |
| CGRect scrollRect = |
| CGRectInset(tabView.frame, -_tabStripView.contentInset.right, 0); |
| if (tabView) |
| [_tabStripView scrollRectToVisible:scrollRect animated:YES]; |
| } |
| } |
| } |
| |
| - (void)updateScrollViewFrameForTabSwitcherButton { |
| CGRect tabFrame = _tabStripView.frame; |
| tabFrame.size.width = _view.bounds.size.width; |
| if (!IsCompactTablet()) { |
| tabFrame.size.width -= kTabSwitcherButtonWidth; |
| _tabStripView.contentInset = UIEdgeInsetsZero; |
| [_tabSwitcherButtonBackgroundView setHidden:YES]; |
| } else { |
| if (!_tabSwitcherButtonBackgroundView) { |
| _tabSwitcherButtonBackgroundView = [[UIImageView alloc] init]; |
| const CGFloat tabStripHeight = _view.frame.size.height; |
| const CGRect backgroundViewFrame = CGRectMake( |
| CGRectGetMaxX(_view.frame) - kTabSwitcherButtonBackgroundWidth, 0.0, |
| kTabSwitcherButtonBackgroundWidth, tabStripHeight); |
| [_tabSwitcherButtonBackgroundView setFrame:backgroundViewFrame]; |
| [_tabSwitcherButtonBackgroundView |
| setAutoresizingMask:UIViewAutoresizingFlexibleLeftMargin]; |
| UIImage* backgroundTabSwitcherImage = |
| [UIImage imageNamed:@"tabstrip_toggle_button_gradient"]; |
| [_tabSwitcherButtonBackgroundView setImage:backgroundTabSwitcherImage]; |
| [_view addSubview:_tabSwitcherButtonBackgroundView]; |
| } |
| [_tabSwitcherButtonBackgroundView setHidden:NO]; |
| _tabStripView.contentInset = |
| UIEdgeInsetsMake(0, 0, 0, kTabSwitcherButtonWidth); |
| [_view bringSubviewToFront:_tabSwitcherButton]; |
| } |
| [_tabStripView setFrame:tabFrame]; |
| } |
| |
| #pragma mark - TabStripViewLayoutDelegate |
| |
| // Creates TabViews for each Tab in the TabModel and positions them in the |
| // correct location onscreen. |
| - (void)layoutTabStripSubviews { |
| const NSUInteger tabCount = [_tabArray count] - [_closingTabs count]; |
| if (!tabCount) |
| return; |
| BOOL animate = _animateLayout; |
| _animateLayout = NO; |
| // Disable the animation if the tab count is changing from 0 to 1. |
| if (tabCount == 1 && [_closingTabs count] == 0) |
| animate = NO; |
| |
| const CGFloat tabHeight = CGRectGetHeight([_tabStripView bounds]); |
| |
| // In compact layout mode the space used to layout the tabs is not |
| // constrained and uses the whole scroll view content size width. In regular |
| // layout mode the available space is constrained to the visible space. |
| CGFloat availableSpace = IsCompactTablet() ? _tabStripView.contentSize.width |
| : [self tabStripVisibleSpace]; |
| |
| // The array and model indexes of the selected tab. |
| NSUInteger selectedModelIndex = [_tabModel indexOfTab:[_tabModel currentTab]]; |
| NSUInteger selectedArrayIndex = [self indexForModelIndex:selectedModelIndex]; |
| |
| // This method lays out tabs in two coordinate systems. The first, the |
| // "virtual" coordinate system, is a system rooted at x=0 that contains all |
| // the tabs laid out as if the tabstrip was infinitely long. In this system, |
| // |virtualMinX| contains the starting X coordinate of the next tab to be |
| // placed and |virtualMaxX| contains the maximum X coordinate of the last tab |
| // to be placed. |
| // |
| // The scroll view's content area is sized to be large enough to hold all the |
| // tabs with proper overlap, but the viewport is set to only show a part of |
| // the content area. The specific part that is shown is given by the scroll |
| // view's contentOffset. |
| // |
| // To layout tabs, first calculate where the tab should be in the "virtual" |
| // coordinate system. This gives the frame of the tab assuming the tabstrip |
| // was large enough to hold all tabs without needing to overflow. Then, |
| // adjust the tab's virtual frame to move it onscreen. This gives the tab's |
| // real frame. |
| CGFloat virtualMinX = 0; |
| CGFloat virtualMaxX = 0; |
| CGFloat offset = IsCompactTablet() ? 0 : [_tabStripView contentOffset].x; |
| |
| // Keeps track of which tabs need to be animated. Using an autoreleased array |
| // instead of scoped_nsobject because scoped_nsobject doesn't seem to work |
| // well with blocks. |
| NSMutableArray* tabsNeedingAnimation = |
| [NSMutableArray arrayWithCapacity:tabCount]; |
| |
| CGRect dragFrame = [_draggedTab frame]; |
| |
| TabView* previousTabView = nil; |
| CGRect previousTabFrame = CGRectZero; |
| BOOL hasPlaceholderGap = NO; |
| for (NSUInteger arrayIndex = 0; arrayIndex < [_tabArray count]; |
| ++arrayIndex) { |
| TabView* view = (TabView*)[_tabArray objectAtIndex:arrayIndex]; |
| |
| // Arrange the tabs in a V going backwards from the selected tab. This |
| // differs from desktop in order to make the tab overflow behavior work (on |
| // desktop, the tabs are arranged going backwards from left to right, with |
| // the selected tab above all others). |
| // |
| // When reordering, use slightly different logic. Instead of a V based on |
| // the model indexes of the tabs, the V fans out from the placeholder gap, |
| // which is visually where the dragged tab is. In reordering mode, the tabs |
| // are not necessarily z-ordered according to their model indexes, because |
| // they are not necessarily drawn in the spot dictated by their current |
| // model index. |
| BOOL isSelectedTab = (arrayIndex == selectedArrayIndex); |
| BOOL zOrderedAbove = |
| _isReordering ? !hasPlaceholderGap : (arrayIndex <= selectedArrayIndex); |
| |
| if (isSelectedTab) { |
| // Order matters. The dimming view needs to end up behind the selected |
| // tab, so it's brought to the front first, followed by the tab. |
| [_tabStripView bringSubviewToFront:_dimmingView]; |
| [_tabStripView bringSubviewToFront:view]; |
| } else if (zOrderedAbove) { |
| // If the current tab comes after the selected tab in the model but still |
| // needs to be z-ordered above, place it relative to the dimming view, |
| // rather than blindly bringing it to the front. This can only happen in |
| // reordering mode. |
| if (arrayIndex > selectedArrayIndex) { |
| DCHECK(_isReordering); |
| [_tabStripView insertSubview:view belowSubview:_dimmingView]; |
| } else { |
| [_tabStripView bringSubviewToFront:view]; |
| } |
| } else { |
| [_tabStripView sendSubviewToBack:view]; |
| } |
| |
| // Ignore closing tabs when repositioning. |
| NSUInteger currentModelIndex = [self modelIndexForIndex:arrayIndex]; |
| if (currentModelIndex == NSNotFound) |
| continue; |
| |
| // Ignore the tab that is currently being dragged. |
| if (_isReordering && view == _draggedTab) |
| continue; |
| |
| // |realMinX| is the furthest left the tab can be, in real coordinates. |
| // This is computed by counting the number of possible collapsed tabs that |
| // can be to the left of this tab, then multiplying that count by the size |
| // of a collapsed tab. |
| // |
| // There can be up to |[self maxNumCollapsedTabs]| to the left of the |
| // selected |
| // tab, and the same number to the right of the selected tab. |
| NSUInteger numPossibleCollapsedTabsToLeft = |
| std::min(currentModelIndex, [self maxNumCollapsedTabs]); |
| if (currentModelIndex > selectedModelIndex) { |
| // If this tab is to the right of the selected tab, also include the |
| // number of collapsed tabs on the right of the selected tab. |
| numPossibleCollapsedTabsToLeft = |
| std::min(selectedModelIndex, [self maxNumCollapsedTabs]) + |
| std::min(currentModelIndex - selectedModelIndex, |
| [self maxNumCollapsedTabs]); |
| } |
| CGFloat realMinX = |
| offset + (numPossibleCollapsedTabsToLeft * kCollapsedTabOverlap); |
| |
| // |realMaxX| is the furthest right the tab can be, in real coordinates. |
| NSUInteger numPossibleCollapsedTabsToRight = |
| std::min(tabCount - currentModelIndex - 1, [self maxNumCollapsedTabs]); |
| if (currentModelIndex < selectedModelIndex) { |
| // If this tab is to the left of the selected tab, also include the |
| // number of collapsed tabs on the left of the selected tab. |
| numPossibleCollapsedTabsToRight = |
| std::min(tabCount - selectedModelIndex - 1, |
| [self maxNumCollapsedTabs]) + |
| std::min(selectedModelIndex - currentModelIndex, |
| [self maxNumCollapsedTabs]); |
| } |
| CGFloat realMaxX = offset + availableSpace - |
| (numPossibleCollapsedTabsToRight * kCollapsedTabOverlap); |
| |
| // If this tab is to the right of the currently dragged tab, add a |
| // placeholder gap. |
| if (_isReordering && !hasPlaceholderGap && |
| CGRectGetMinX(dragFrame) < virtualMinX + (_currentTabWidth / 2.0)) { |
| virtualMinX += _currentTabWidth - [self tabOverlap]; |
| hasPlaceholderGap = YES; |
| |
| // Fix up the z-ordering of the current view. It was placed assuming that |
| // the placeholder gap hasn't been hit yet. |
| [_tabStripView sendSubviewToBack:view]; |
| |
| // The model index of the placeholder gap is equal to the model index of |
| // the shifted tab, adjusted for the presence of the dragged tab. This |
| // value will be used as the new model index for the dragged tab when it |
| // is dropped. |
| _placeholderGapModelIndex = currentModelIndex; |
| if ([self modelIndexForTabView:_draggedTab] < currentModelIndex) |
| _placeholderGapModelIndex--; |
| } |
| |
| // |tabX| stores where we are placing the tab, in real coordinates. Start |
| // by trying to place the tab at the computed |virtualMinX|, then constrain |
| // that by |realMinX| and |realMaxX|. |
| CGFloat tabX = MAX(virtualMinX, realMinX); |
| if (tabX + _currentTabWidth > realMaxX) |
| tabX = realMaxX - _currentTabWidth; |
| |
| CGRect frame = CGRectMake(AlignValueToPixel(tabX), 0, |
| AlignValueToPixel(_currentTabWidth), tabHeight); |
| virtualMinX += (_currentTabWidth - [self tabOverlap]); |
| virtualMaxX = CGRectGetMaxX(frame); |
| |
| // TODO(rohitrao): Temporarily disabled this logic as it does not play well with |
| // tab scrolling. |
| #if 0 |
| // If this tab is completely hidden by the previous tab, remove it from the |
| // scroll view. Otherwise, add it back in if needed. |
| if (selectedArrayIndex != arrayIndex && |
| CGRectEqualToRect(frame, previousTabFrame)) { |
| [view removeFromSuperview]; |
| } else if (![view superview]) { |
| // TODO(rohitrao): Find a way to move the z-ordering code from the top of |
| // the function to down here, so we can consolidate the logic. |
| [_tabStripView insertSubview:view atIndex: |
| (zOrderedAbove ? [[_tabStripView subviews] count] : 0)]; |
| } |
| #endif |
| |
| // Update the tab's collapsed state based on overlap with the previous tab. |
| if (zOrderedAbove) { |
| CGRect visibleRect = [self calculateVisibleFrameForFrame:previousTabFrame |
| whenUnderFrame:frame]; |
| BOOL collapsed = |
| CGRectGetWidth(visibleRect) < kCollapsedTabWidthThreshold; |
| [previousTabView setCollapsed:collapsed]; |
| |
| // The selected tab can never be collapsed, since no tab will ever be |
| // z-ordered above it to obscure it. |
| if (isSelectedTab) |
| [view setCollapsed:NO]; |
| } else { |
| CGRect visibleRect = |
| [self calculateVisibleFrameForFrame:frame |
| whenUnderFrame:previousTabFrame]; |
| BOOL collapsed = |
| CGRectGetWidth(visibleRect) < kCollapsedTabWidthThreshold; |
| [view setCollapsed:collapsed]; |
| } |
| |
| if (animate) { |
| if (!CGRectEqualToRect(frame, [view frame])) |
| [tabsNeedingAnimation addObject:view]; |
| } else { |
| if (!CGRectEqualToRect(frame, [view frame])) |
| [view setFrame:frame]; |
| } |
| |
| // Throw the target frame into the dictionary so we can animate it later. |
| _targetFrames.AddFrame(view, frame); |
| |
| // Ensure the tab is visible. |
| if ([view isHidden]) { |
| if (animate) { |
| // If it is a new tab, and animation is enabled, make it a submarine tab |
| // by immediately positioning it under the tabstrip. |
| CGRect submarineFrame = CGRectOffset(frame, 0, CGRectGetHeight(frame)); |
| [view setFrame:submarineFrame]; |
| } |
| [view setHidden:NO]; |
| } |
| |
| previousTabView = view; |
| previousTabFrame = frame; |
| } |
| |
| // If in reordering mode and there was no placeholder gap, then the dragged |
| // tab must be all the way to the right of the other tabs. Set the |
| // _placeholderGapModelIndex accordingly. |
| if (!hasPlaceholderGap && _isReordering) |
| _placeholderGapModelIndex = [_tabModel count] - 1; |
| |
| // Do not move the new tab button if it is hidden. This will lead to better |
| // animations when exiting drag and drop mode, as the new tab button will not |
| // have moved during the drag. |
| CGRect newTabFrame = [_buttonNewTab frame]; |
| BOOL moveNewTab = |
| (newTabFrame.origin.x != virtualMaxX) && !_buttonNewTab.hidden; |
| newTabFrame.origin = CGPointMake(virtualMaxX - kNewTabOverlap, 0); |
| if (!animate && moveNewTab) |
| [_buttonNewTab setFrame:newTabFrame]; |
| |
| if (animate) { |
| float delay = 0.0; |
| if ([self.fullscreenDelegate currentHeaderOffset] != 0) { |
| // Move the toolbar to visible and wait for the end of that animation to |
| // animate the appearance of the new tab. |
| delay = ios_internal::kToolbarAnimationDuration; |
| // Signal the FullscreenController that the toolbar needs to stay on |
| // screen for a bit, so the animation is visible. |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:kWillStartTabStripTabAnimation |
| object:nil]; |
| } |
| |
| [UIView animateWithDuration:kTabAnimationDuration |
| delay:delay |
| options:UIViewAnimationOptionAllowUserInteraction |
| animations:^{ |
| for (TabView* view in tabsNeedingAnimation) { |
| DCHECK(_targetFrames.HasFrame(view)); |
| [view setFrame:_targetFrames.GetFrame(view)]; |
| } |
| if (moveNewTab) |
| [_buttonNewTab setFrame:newTabFrame]; |
| } |
| completion:nil]; |
| } |
| } |
| |
| - (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { |
| [self updateScrollViewFrameForTabSwitcherButton]; |
| [self updateContentSizeAndRepositionViews]; |
| NSUInteger selectedModelIndex = [_tabModel indexOfTab:[_tabModel currentTab]]; |
| if (selectedModelIndex != NSNotFound) { |
| [self updateContentOffsetForTabIndex:selectedModelIndex isNewTab:NO]; |
| } |
| } |
| |
| - (void)setNeedsLayoutWithAnimation { |
| _animateLayout = YES; |
| [_tabStripView setNeedsLayout]; |
| } |
| |
| @end |
| |
| #pragma mark - TabSwitcherAnimation |
| |
| @implementation TabStripController (TabSwitcherAnimation) |
| |
| - (TabSwitcherTabStripPlaceholderView*)placeholderView { |
| TabSwitcherTabStripPlaceholderView* placeholderView = |
| [[TabSwitcherTabStripPlaceholderView alloc] |
| initWithFrame:self.view.bounds]; |
| CGFloat xOffset = [_tabStripView contentOffset].x; |
| UIView* previousView = nil; |
| const NSUInteger selectedModelIndex = |
| [_tabModel indexOfTab:[_tabModel currentTab]]; |
| const NSUInteger selectedArrayIndex = |
| [self indexForModelIndex:selectedModelIndex]; |
| [self updateContentSizeAndRepositionViews]; |
| [self layoutTabStripSubviews]; |
| for (NSUInteger tabArrayIndex = 0; tabArrayIndex < [_tabArray count]; |
| ++tabArrayIndex) { |
| UIView* tabView = _tabArray[tabArrayIndex]; |
| UIView* tabSnapshotView = snapshot_util::GenerateSnapshot(tabView); |
| tabSnapshotView.frame = CGRectOffset(tabView.frame, -xOffset, 0); |
| tabSnapshotView.transform = tabView.transform; |
| // Order views of the tabs in a pyramid fashion, culminating with |
| // the selected tab. |
| // For example, if _tabArray has views [0..6], and 3 is the selected index, |
| // they will be arranged in the order [0, 1, 2, 6, 5, 4, 3]. |
| if (previousView && tabArrayIndex > selectedArrayIndex) { |
| [placeholderView insertSubview:tabSnapshotView belowSubview:previousView]; |
| } else { |
| [placeholderView addSubview:tabSnapshotView]; |
| } |
| previousView = tabSnapshotView; |
| } |
| UIView* buttonSnapshot = snapshot_util::GenerateSnapshot(_buttonNewTab); |
| buttonSnapshot.frame = CGRectOffset(_buttonNewTab.frame, -xOffset, 0); |
| [placeholderView addSubview:buttonSnapshot]; |
| return placeholderView; |
| } |
| |
| @end |
| |
| @implementation TabStripController (Testing) |
| |
| - (TabView*)existingTabViewForTab:(Tab*)tab { |
| NSUInteger tabIndex = [_tabModel indexOfTab:tab]; |
| NSUInteger tabViewIndex = [self indexForModelIndex:tabIndex]; |
| return [_tabArray objectAtIndex:tabViewIndex]; |
| } |
| |
| @end |