// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/tabs/ui_bundled/tab_view.h"
#import <MaterialComponents/MaterialActivityIndicator.h>
#import "base/ios/ios_util.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/browser/shared/ui/elements/fade_truncating_label.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/shared/ui/util/image/image_util.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/elements/highlight_button.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"
#import "ui/base/l10n/l10n_util_mac.h"
#import "ui/base/resource/resource_bundle.h"
#import "ui/gfx/image/image.h"
#import "ui/gfx/ios/uikit_util.h"
#import "url/gurl.h"
namespace {
// The size of the xmark symbol image.
NSInteger kXmarkSymbolPointSize = 17;
// Tab close button insets.
const CGFloat kTabCloseTopInset = 1.0;
const CGFloat kTabCloseLeftInset = 0.0;
const CGFloat kTabCloseBottomInset = 0.0;
const CGFloat kTabCloseRightInset = 0.0;
const CGFloat kFaviconLeftInset = 28;
const CGFloat kFaviconVerticalOffset = 1.0;
const CGFloat kTabStripLineMargin = 2.5;
const CGFloat kTabStripLineHeight = 0.5;
const CGFloat kCloseButtonHorizontalShift = 22;
const CGFloat kTitleLeftMargin = 8.0;
const CGFloat kTitleRightMargin = 0.0;
const CGFloat kCloseButtonSize = 24.0;
const CGFloat kFaviconSize = 16.0;
const CGFloat kFontSize = 14.0;
// Returns a default favicon with `UIImageRenderingModeAlwaysTemplate`.
UIImage* DefaultFaviconImage() {
return [[UIImage imageNamed:@"default_world_favicon"]
imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
}
} // namespace
@interface TabView () <UIPointerInteractionDelegate> {
__weak id<TabViewDelegate> _delegate;
// Close button for this tab.
UIButton* _closeButton;
// View that draws the tab title.
FadeTruncatingLabel* _titleLabel;
// Background image for this tab.
UIImageView* _backgroundImageView;
BOOL _incognitoStyle;
// Set to YES when the layout constraints have been initialized.
BOOL _layoutConstraintsInitialized;
// Image view used to draw the favicon and spinner.
UIImageView* _faviconView;
// If `YES`, this view will adjust its appearance and draw as a collapsed tab.
BOOL _collapsed;
MDCActivityIndicator* _activityIndicator;
// Adds hover interaction to background tabs.
UIPointerInteraction* _pointerInteraction;
}
@end
@interface TabView (Private)
// Creates the close button, favicon button, and title.
- (void)createButtonsAndLabel;
// Returns the rect in which to draw the favicon.
- (CGRect)faviconRectForBounds:(CGRect)bounds;
// Returns the rect in which to draw the tab title.
- (CGRect)titleRectForBounds:(CGRect)bounds;
// Returns the frame rect for the close button.
- (CGRect)closeRectForBounds:(CGRect)bounds;
@end
@implementation TabView
- (id)initWithEmptyView:(BOOL)emptyView selected:(BOOL)selected {
if ((self = [super initWithFrame:CGRectZero])) {
[self setOpaque:NO];
[self createCommonViews];
if (!emptyView)
[self createButtonsAndLabel];
// -setSelected only calls -updateStyleForSelected if the selected state
// changes. `isSelected` defaults to NO, so if `selected` is also NO,
// -updateStyleForSelected needs to be called explicitly.
[self setSelected:selected];
if (!selected) {
[self updateStyleForSelected:selected];
}
[self addTarget:self
action:@selector(tabWasTapped)
forControlEvents:UIControlEventTouchUpInside];
}
return self;
}
- (void)setSelected:(BOOL)selected {
if ([super isSelected] == selected) {
return;
}
[super setSelected:selected];
[self updateStyleForSelected:selected];
}
- (void)setCollapsed:(BOOL)collapsed {
if (_collapsed != collapsed) {
[_closeButton setHidden:collapsed];
}
_collapsed = collapsed;
}
- (void)setTitle:(NSString*)title {
if ([_titleLabel.text isEqualToString:title])
return;
_titleLabel.text = title;
[_closeButton setAccessibilityValue:title];
}
- (UIImage*)favicon {
return [_faviconView image];
}
- (void)setFavicon:(UIImage*)favicon {
if (!favicon)
favicon = DefaultFaviconImage();
[_faviconView setImage:favicon];
}
- (void)setIncognitoStyle:(BOOL)incognitoStyle {
if (_incognitoStyle == incognitoStyle) {
return;
}
_incognitoStyle = incognitoStyle;
self.overrideUserInterfaceStyle = _incognitoStyle
? UIUserInterfaceStyleDark
: UIUserInterfaceStyleUnspecified;
return;
}
- (void)startProgressSpinner {
[_activityIndicator startAnimating];
[_activityIndicator setHidden:NO];
[_faviconView setHidden:YES];
[_faviconView setImage:DefaultFaviconImage()];
}
- (void)stopProgressSpinner {
[_activityIndicator stopAnimating];
[_activityIndicator setHidden:YES];
[_faviconView setHidden:NO];
}
#pragma mark - UIView overrides
- (void)setFrame:(CGRect)frame {
const CGRect previousFrame = [self frame];
[super setFrame:frame];
// We are checking for a zero frame before triggering constraints updates in
// order to prevent computation of constraints that will never be used for the
// final layout. We could also initialize with a dummy frame but first this is
// inefficient and second it's non trivial to compute the minimum valid frame
// in regard to tweakable constants.
if (CGRectEqualToRect(CGRectZero, previousFrame) &&
!_layoutConstraintsInitialized) {
[self setNeedsUpdateConstraints];
}
}
- (void)updateConstraints {
[super updateConstraints];
if (!_layoutConstraintsInitialized &&
!CGRectEqualToRect(CGRectZero, self.frame)) {
_layoutConstraintsInitialized = YES;
[self addCommonConstraints];
// Add buttons and labels constraints if needed.
if (_closeButton) {
[self addButtonsAndLabelConstraints];
}
}
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event {
// Account for the trapezoidal shape of the tab. Inset the tab bounds by
// (y = -2.2x + 56), determined empirically from looking at the tab background
// images.
CGFloat inset = MAX(0.0, (point.y - 56) / -2.2);
return CGRectContainsPoint(CGRectInset([self bounds], inset, 0), point);
}
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
// As of iOS 13 Beta 4, resizable images are flaky for dark mode.
// This triggers the styling again, where the image is resolved instead of
// relying in the system's magic. Radar filled:
// b/137942721.hasDifferentColorAppearanceComparedToTraitCollection
if ([self.traitCollection
hasDifferentColorAppearanceComparedToTraitCollection:
previousTraitCollection]) {
[self updateStyleForSelected:self.selected];
}
}
#pragma mark - Private
- (void)createCommonViews {
_backgroundImageView = [[UIImageView alloc] init];
[_backgroundImageView setTranslatesAutoresizingMaskIntoConstraints:NO];
[self addSubview:_backgroundImageView];
}
- (void)addCommonConstraints {
NSDictionary* commonViewsDictionary = @{
@"backgroundImageView" : _backgroundImageView,
};
NSArray* commonConstraints = @[
@"H:|-0-[backgroundImageView]-0-|",
@"V:|-0-[backgroundImageView]-0-|",
];
NSDictionary* commonMetrics = @{
@"tabStripLineMargin" : @(kTabStripLineMargin),
@"tabStripLineHeight" : @(kTabStripLineHeight)
};
ApplyVisualConstraintsWithMetrics(commonConstraints, commonViewsDictionary,
commonMetrics);
}
- (void)createButtonsAndLabel {
_closeButton = [HighlightButton buttonWithType:UIButtonTypeCustom];
[_closeButton setTranslatesAutoresizingMaskIntoConstraints:NO];
UIImage* closeButton =
DefaultSymbolTemplateWithPointSize(kXMarkSymbol, kXmarkSymbolPointSize);
UIButtonConfiguration* buttonConfiguration =
[UIButtonConfiguration plainButtonConfiguration];
buttonConfiguration.contentInsets =
NSDirectionalEdgeInsetsMake(kTabCloseTopInset, kTabCloseLeftInset,
kTabCloseBottomInset, kTabCloseRightInset);
buttonConfiguration.image = closeButton;
_closeButton.configuration = buttonConfiguration;
[_closeButton setAccessibilityLabel:l10n_util::GetNSString(
IDS_IOS_TOOLS_MENU_CLOSE_TAB)];
[_closeButton addTarget:self
action:@selector(closeButtonPressed)
forControlEvents:UIControlEventTouchUpInside];
_closeButton.pointerInteractionEnabled = YES;
[self addSubview:_closeButton];
// Add fade truncating label.
_titleLabel = [[FadeTruncatingLabel alloc] initWithFrame:CGRectZero];
[_titleLabel setTranslatesAutoresizingMaskIntoConstraints:NO];
[self addSubview:_titleLabel];
CGRect faviconFrame = CGRectMake(0, 0, kFaviconSize, kFaviconSize);
_faviconView = [[UIImageView alloc] initWithFrame:faviconFrame];
[_faviconView setTranslatesAutoresizingMaskIntoConstraints:NO];
[_faviconView setContentMode:UIViewContentModeScaleAspectFit];
[_faviconView setImage:DefaultFaviconImage()];
[_faviconView setAccessibilityIdentifier:@"Favicon"];
[self addSubview:_faviconView];
_activityIndicator =
[[MDCActivityIndicator alloc] initWithFrame:faviconFrame];
[_activityIndicator setTranslatesAutoresizingMaskIntoConstraints:NO];
[_activityIndicator setCycleColors:@[ [UIColor colorNamed:kBlueColor] ]];
[_activityIndicator setRadius:ui::AlignValueToUpperPixel(kFaviconSize / 2)];
[self addSubview:_activityIndicator];
}
- (void)addButtonsAndLabelConstraints {
// Constraints on the Top bar, snapshot view, and shadow view.
NSDictionary* viewsDictionary = @{
@"close" : _closeButton,
@"title" : _titleLabel,
@"favicon" : _faviconView,
};
NSArray* constraints = @[
@"H:|-faviconLeftInset-[favicon(faviconSize)]",
@"V:|-faviconVerticalOffset-[favicon]-0-|",
@"H:[close(==closeButtonSize)]-closeButtonHorizontalShift-|",
@"V:|-0-[close]-0-|",
@"H:[favicon]-titleLeftMargin-[title]-titleRightMargin-[close]",
@"V:[title(==titleHeight)]",
];
NSDictionary* metrics = @{
@"closeButtonSize" : @(kCloseButtonSize),
@"closeButtonHorizontalShift" : @(kCloseButtonHorizontalShift),
@"titleLeftMargin" : @(kTitleLeftMargin),
@"titleRightMargin" : @(kTitleRightMargin),
@"titleHeight" : @(kFaviconSize),
@"faviconLeftInset" : @(kFaviconLeftInset),
@"faviconVerticalOffset" : @(kFaviconVerticalOffset),
@"faviconSize" : @(kFaviconSize),
};
ApplyVisualConstraintsWithMetrics(constraints, viewsDictionary, metrics);
AddSameCenterXConstraint(self, _faviconView, _activityIndicator);
AddSameCenterYConstraint(self, _faviconView, _activityIndicator);
AddSameCenterYConstraint(self, _faviconView, _titleLabel);
}
// Updates this tab's style based on the value of `selected` and the current
// incognito style.
- (void)updateStyleForSelected:(BOOL)selected {
// Style the background image first.
NSString* state = (selected ? @"foreground" : @"background");
NSString* imageName = [NSString stringWithFormat:@"tabstrip_%@_tab", state];
_backgroundImageView.image = [UIImage imageNamed:imageName];
if (selected) {
if (_pointerInteraction)
[self removeInteraction:_pointerInteraction];
} else {
if (!_pointerInteraction)
_pointerInteraction =
[[UIPointerInteraction alloc] initWithDelegate:self];
[self addInteraction:_pointerInteraction];
}
// Style the close button tint color.
NSString* closeButtonColorName = selected ? kGrey600Color : kGrey500Color;
_closeButton.tintColor = [UIColor colorNamed:closeButtonColorName];
// Style the favicon tint color.
NSString* faviconColorName = selected ? kGrey600Color : kGrey500Color;
_faviconView.tintColor = [UIColor colorNamed:faviconColorName];
// Style the title tint color.
NSString* titleColorName = selected ? kTextPrimaryColor : kGrey600Color;
_titleLabel.textColor = [UIColor colorNamed:titleColorName];
_titleLabel.font = [UIFont systemFontOfSize:kFontSize
weight:UIFontWeightMedium];
// It would make more sense to set active/inactive on tab_view itself, but
// tab_view is not an an accessible element, and making it one would add
// several complicated layers to UIA. Instead, simply set active/inactive
// here to be used by UIA.
[_titleLabel setAccessibilityValue:(selected ? @"active" : @"inactive")];
}
// Bezier path for the border shape of the tab. While the shape of the tab is an
// illusion achieved with a background image, the actual border path is required
// for the hover pointer interaction.
- (UIBezierPath*)borderPath {
CGFloat margin = 15;
CGFloat width = self.frame.size.width - margin * 2;
CGFloat height = self.frame.size.height;
CGFloat cornerRadius = 12;
UIBezierPath* path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(margin - cornerRadius, height)];
// Lower left arc.
[path
addArcWithCenter:CGPointMake(margin - cornerRadius, height - cornerRadius)
radius:cornerRadius
startAngle:M_PI / 2
endAngle:0
clockwise:NO];
// Left vertical line.
[path addLineToPoint:CGPointMake(margin, cornerRadius)];
// Upper left arc.
[path addArcWithCenter:CGPointMake(margin + cornerRadius, cornerRadius)
radius:cornerRadius
startAngle:M_PI
endAngle:3 * M_PI / 2
clockwise:YES];
// Top horizontal line.
[path addLineToPoint:CGPointMake(width + margin - cornerRadius, 0)];
// Upper right arc.
[path
addArcWithCenter:CGPointMake(width + margin - cornerRadius, cornerRadius)
radius:cornerRadius
startAngle:3 * M_PI / 2
endAngle:0
clockwise:YES];
// Right vertical line.
[path addLineToPoint:CGPointMake(width + margin, height - cornerRadius)];
// Lower right arc.
[path addArcWithCenter:CGPointMake(width + margin + cornerRadius,
height - cornerRadius)
radius:cornerRadius
startAngle:M_PI
endAngle:M_PI / 2
clockwise:NO];
[path closePath];
return path;
}
#pragma mark UIPointerInteractionDelegate
- (UIPointerRegion*)pointerInteraction:(UIPointerInteraction*)interaction
regionForRequest:(UIPointerRegionRequest*)request
defaultRegion:(UIPointerRegion*)defaultRegion {
return defaultRegion;
}
- (UIPointerStyle*)pointerInteraction:(UIPointerInteraction*)interaction
styleForRegion:(UIPointerRegion*)region {
// Hovering over this tab view and closing the tab simultaneously could result
// in this tab view having been removed from the window at the beginning of
// this method. If this tab view has already been removed from the view
// hierarchy, a nil pointer style should be returned so that the pointer
// remains with a default style. Attempting to construct a UITargetedPreview
// with a tab view that has already been removed from the hierarchy will
// result in a crash with an exception stating that the view has no window.
if (!_backgroundImageView.window) {
return nil;
}
UIPreviewParameters* parameters = [[UIPreviewParameters alloc] init];
parameters.visiblePath = [self borderPath];
// Use the background view for the preview so that z-order of overlapping tabs
// is respected.
UIPointerHoverEffect* effect = [UIPointerHoverEffect
effectWithPreview:[[UITargetedPreview alloc]
initWithView:_backgroundImageView
parameters:parameters]];
effect.prefersScaledContent = NO;
effect.prefersShadow = NO;
return [UIPointerStyle styleWithEffect:effect shape:nil];
}
#pragma mark - Touch events
- (void)closeButtonPressed {
[_delegate tabViewCloseButtonPressed:self];
}
- (void)tabWasTapped {
[_delegate tabViewTapped:self];
}
#pragma mark - Properties
- (UILabel*)titleLabel {
return _titleLabel;
}
@end