blob: 84dac0b8d46c0fa54dc2fc555c7e3604fa4fc5c9 [file] [log] [blame] [edit]
// Copyright 2016-present the Material Components for iOS authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#import "MDCItemBarCell.h"
#import "MDCItemBarCell+Private.h"
#import <MDFInternationalization/MDFInternationalization.h>
#import "MDCItemBarBadge.h"
#import "MDCItemBarStyle.h"
#import "MaterialAnimationTiming.h"
#import "MaterialInk.h"
#import "MaterialMath.h"
#import "MaterialTypography.h"
/// Size of image in points.
static const CGSize kImageSize = {24, 24};
/// Font point size for badges.
static const CGFloat kBadgeFontSize = 12;
/// Padding between top of the cell and the badge.
static const CGFloat kBadgeTopPadding = 6;
/// Outer edge padding from spec: https://material.io/go/design-tabs#spec.
static const UIEdgeInsets kEdgeInsets = {.top = 0, .right = 16, .bottom = 0, .left = 16};
/// File name of the bundle (without the '.bundle' extension) containing resources.
static NSString *const kResourceBundleName = @"MaterialTabs";
/// String table name containing localized strings.
static NSString *const kStringTableName = @"MaterialTabs";
/// Scale factor applied to the title of bottom navigation items when selected.
const CGFloat kSelectedNavigationTitleScaleFactor = (16.0f / 14.0f);
/// Vertical translation applied to image components bottom navigation items when selected.
const CGFloat kSelectedNavigationImageYOffset = -2;
/// Duration of selection animations in applicable content styles.
static const NSTimeInterval kSelectionAnimationDuration = 0.3;
@interface MDCItemBarCell ()
@property(nonatomic, strong) UIImageView *imageView;
@property(nonatomic, strong) MDCItemBarBadge *badge;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
@property(nonatomic, strong) MDCInkTouchController *inkTouchController;
#pragma clang diagnostic pop
@property(nonatomic, strong) MDCItemBarStyle *style;
@property(nonatomic) NSInteger itemIndex;
@property(nonatomic) NSInteger itemCount;
@end
@implementation MDCItemBarCell
#pragma mark - Init
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_style = [[MDCItemBarStyle alloc] init];
_title = @"";
_itemIndex = NSNotFound;
self.isAccessibilityElement = YES;
// Create initial subviews
[self updateSubviews];
// Set up ink controller to splash ink on taps.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
_inkTouchController = [[MDCInkTouchController alloc] initWithView:self];
#pragma clang diagnostic pop
[_inkTouchController addInkView]; // Ink should always be on top of other views
_rippleTouchController = [[MDCRippleTouchController alloc] init];
[self updateInk];
[self updateRipple];
[self updateColors];
[self updateTransformsAnimated:NO];
}
return self;
}
#pragma mark - Public
+ (CGSize)sizeThatFits:(CGSize)size item:(UITabBarItem *)item style:(MDCItemBarStyle *)style {
NSString *title = [self displayedTitleForTitle:item.title style:style];
CGRect textBounds = CGRectZero;
// Only compute text bounding rect if necessary (all except image-only items)
if (style.shouldDisplayTitle) {
// Determine size based on the unselected state because the majority of tabs are unselected.
UIFont *font = style.unselectedTitleFont;
NSDictionary *titleAttributes = @{NSFontAttributeName : font};
textBounds = [title boundingRectWithSize:size
options:NSStringDrawingTruncatesLastVisibleLine
attributes:titleAttributes
context:nil];
}
CGRect badgeBounds = CGRectZero;
// Only compute badge bounding rect if necessary.
NSString *badge = item.badgeValue;
if (style.shouldDisplayBadge && badge.length > 0) {
UIFont *badgeFont = [[MDCTypography fontLoader] regularFontOfSize:kBadgeFontSize];
NSDictionary *badgeAttributes = @{NSFontAttributeName : badgeFont};
badgeBounds = [badge boundingRectWithSize:size
options:NSStringDrawingTruncatesLastVisibleLine
attributes:badgeAttributes
context:nil];
}
// Determine size based on content style.
CGRect bounds = CGRectZero;
if (style.shouldDisplayTitle) {
if (style.shouldDisplayImage) {
// Title and image
bounds.size.width = MAX(textBounds.size.width, kImageSize.width + badgeBounds.size.width * 2);
bounds.size.height = textBounds.size.height + style.titleImagePadding + kImageSize.height;
} else {
// Just title
bounds = textBounds;
}
} else {
if (style.shouldDisplayImage) {
// Image only.
bounds.size = kImageSize;
bounds.size.width += badgeBounds.size.width * 2;
} else {
// No image or title: NOP.
}
}
// Constrain to provided width.
bounds.size.width = MIN(bounds.size.width, size.width);
// Add insets.
UIEdgeInsets insets = kEdgeInsets;
bounds.size.width += insets.left + insets.right;
bounds.size.height += insets.top + insets.bottom;
// Snap to integral coordinates.
bounds = CGRectIntegral(bounds);
return bounds.size;
}
- (void)setTitle:(NSString *)title {
_title = [title copy];
[self updateDisplayedTitle];
}
- (void)setImage:(nullable UIImage *)image {
_image = image;
[self updateDisplayedImage];
}
- (void)setBadgeValue:(nullable NSString *)badgeValue {
_badgeValue = [badgeValue copy];
_badge.badgeValue = _badgeValue;
_badge.hidden = !_badgeValue;
[self setNeedsLayout];
}
- (CGRect)contentFrame {
if (_style.shouldDisplayTitle) {
if (_style.shouldDisplayImage) {
// Title and image.
CGRect titleFrame = [self convertRect:_titleLabel.bounds fromView:_titleLabel];
CGRect imageFrame = [self convertRect:_imageView.bounds fromView:_imageView];
return CGRectUnion(titleFrame, imageFrame);
} else {
// Only title.
return [self convertRect:_titleLabel.bounds fromView:_titleLabel];
}
} else {
// Only image.
return [self convertRect:_imageView.bounds fromView:_imageView];
}
}
- (void)applyStyle:(MDCItemBarStyle *)style {
if (style != _style && ![style isEqual:_style]) {
_style = style;
[self updateEnableRippleBehavior];
[self updateDisplayedTitle];
[self updateColors];
[self updateSubviews];
[self updateTitleLines];
[self updateTitleFont];
[self updateTransformsAnimated:NO];
[self setNeedsLayout];
}
}
- (void)updateTitleLines {
// The presence of an image restricts titles to a single line
_titleLabel.numberOfLines = _style.shouldDisplayImage ? 1 : _style.textOnlyNumberOfLines;
// Only permit smaller font sizes for two-line titles
_titleLabel.adjustsFontSizeToFitWidth = _titleLabel.numberOfLines == 1 ? NO : YES;
}
- (void)updateWithItem:(UITabBarItem *)item
atIndex:(NSInteger)itemIndex
count:(NSInteger)itemCount {
self.title = item.title;
self.selectedImage = item.selectedImage;
self.image = item.image;
self.badgeValue = item.badgeValue;
if (item.badgeColor) {
self.style.badgeColor = item.badgeColor;
self.badge.badgeColor = item.badgeColor;
}
self.accessibilityIdentifier = item.accessibilityIdentifier;
self.accessibilityLabel = item.accessibilityLabel;
_itemIndex = itemIndex;
_itemCount = itemCount;
}
#pragma mark - UIView
- (void)layoutSubviews {
[super layoutSubviews];
UIEdgeInsets insets = [[self class] minimumEdgeInsets];
CGRect contentBounds = UIEdgeInsetsInsetRect(self.contentView.bounds, insets);
CGPoint imageCenter = CGPointZero;
CGPoint titleCenter = CGPointZero;
CGPoint badgeCenter = CGPointZero;
CGRect imageBounds = CGRectZero;
CGRect titleBounds = CGRectZero;
CGRect badgeBounds = CGRectZero;
// Image has a fixed size and is horizontally centered, regardless of content style.
imageBounds.size = kImageSize;
imageCenter.x = CGRectGetMidX(contentBounds);
CGSize titleSize = [self.titleLabel sizeThatFits:contentBounds.size];
titleSize.width = MIN(titleSize.width, CGRectGetWidth(contentBounds));
// Title is a fixed height based on content and is placed full-width, regardless of content style.
titleCenter.x = CGRectGetMidX(contentBounds);
titleBounds.size = titleSize;
// Size badge.
CGSize badgeSize = [_badge sizeThatFits:contentBounds.size];
badgeBounds.size = badgeSize;
// Determine badge center
if (_style.shouldDisplayBadge) {
CGFloat badgeOffset = (imageBounds.size.width / 2) + (badgeSize.width / 2);
if (self.mdf_effectiveUserInterfaceLayoutDirection ==
UIUserInterfaceLayoutDirectionRightToLeft) {
badgeOffset *= -1;
}
badgeCenter.x = imageCenter.x + badgeOffset;
badgeCenter.y = kBadgeTopPadding + (badgeSize.height / 2);
}
// Place components vertically
if (_style.shouldDisplayTitle) {
if (_style.shouldDisplayImage) {
// Image and title, center both vertically together.
const CGFloat padding = _style.titleImagePadding;
const CGFloat totalHeight = titleSize.height + padding + kImageSize.height;
const CGFloat yOrigin = CGRectGetMidY(contentBounds) - (totalHeight / 2);
imageCenter.y = yOrigin + (kImageSize.height / 2);
titleCenter.y = yOrigin + kImageSize.height + padding + (titleSize.height / 2);
titleCenter.y = titleCenter.y;
} else {
titleCenter.y = CGRectGetMidY(contentBounds);
}
} else {
if (_style.shouldDisplayImage) {
// Image only, center image vertically
imageCenter.y = CGRectGetMidY(contentBounds);
} else {
// Nothing: NOP
}
}
UIScreen *screen = self.window.screen ?: UIScreen.mainScreen;
CGFloat scale = screen.scale;
_imageView.bounds = imageBounds;
_imageView.center = MDCRoundCenterWithBoundsAndScale(imageCenter, _imageView.bounds, scale);
_badge.bounds = MDCRectAlignToScale(badgeBounds, scale);
_badge.center = MDCRoundCenterWithBoundsAndScale(badgeCenter, _badge.bounds, scale);
self.titleLabel.bounds = MDCRectAlignToScale(titleBounds, scale);
self.titleLabel.center =
MDCRoundCenterWithBoundsAndScale(titleCenter, self.titleLabel.bounds, scale);
}
- (void)tintColorDidChange {
[super tintColorDidChange];
[self updateTitleTextColor];
[self updateImageTintColor];
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self updateTransformsAnimated:NO];
[self setNeedsLayout];
}
#pragma mark - UICollectionReusableView
- (void)prepareForReuse {
[super prepareForReuse];
[self updateTitleTextColor];
[self updateImageTintColor];
[self updateAccessibilityTraits];
[_inkTouchController cancelInkTouchProcessing];
[_rippleTouchController.rippleView cancelAllRipplesAnimated:YES completion:nil];
}
#pragma mark - UICollectionViewCell
- (void)setSelected:(BOOL)selected {
// Do not animate if selected is being set before the cell is in the view hierarchy.
BOOL animate = (self.window != nil);
[super setSelected:selected];
[self updateDisplayedImage];
[self updateTitleTextColor];
[self updateImageTintColor];
[self updateAccessibilityTraits];
[self updateTransformsAnimated:animate];
[self updateTitleFont];
}
- (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted];
[self updateTitleTextColor];
[self updateImageTintColor];
}
#pragma mark - UIAccessibility
- (nullable NSString *)accessibilityLabel {
NSMutableArray *labelComponents = [NSMutableArray array];
// If a custom accessibility label has not been set on UITabBarItem,
// then use untransformed title as accessibility label to ensure accurate reading.
NSString *titleComponent = [super accessibilityLabel] ?: _title;
if (titleComponent.length > 0) {
[labelComponents addObject:titleComponent];
}
if (_badgeValue.length > 0 && !_badge.hidden) {
[labelComponents addObject:_badgeValue];
}
// Speak components with a pause in between.
return [labelComponents componentsJoinedByString:@", "];
}
#pragma mark - Private
+ (UIEdgeInsets)minimumEdgeInsets {
const CGFloat outerPadding = 2;
return UIEdgeInsetsMake(0.0, outerPadding, 0.0, outerPadding);
}
+ (NSString *)localizedStringWithKey:(NSString *)key {
NSBundle *containingBundle = [NSBundle bundleForClass:self];
NSURL *resourceBundleURL = [containingBundle URLForResource:kResourceBundleName
withExtension:@"bundle"];
NSBundle *resourceBundle = [NSBundle bundleWithURL:resourceBundleURL];
return [resourceBundle localizedStringForKey:key value:nil table:kStringTableName];
}
+ (NSString *)displayedTitleForTitle:(NSString *)title style:(MDCItemBarStyle *)style {
NSString *displayedTitle = title;
if (style.displaysUppercaseTitles) {
displayedTitle = [displayedTitle uppercaseStringWithLocale:nil];
}
return displayedTitle;
}
/// Ensures that subviews exist and have the correct visibility for the current content style.
- (void)updateSubviews {
if (_style.shouldDisplayImage) {
// Create image view if needed.
if (!_imageView) {
_imageView = [[UIImageView alloc] initWithFrame:self.contentView.bounds];
_imageView.contentMode = UIViewContentModeCenter;
[self.contentView addSubview:_imageView];
// Display our image in the new image view.
[self updateDisplayedImage];
[self updateImageTintColor];
}
_imageView.hidden = NO;
} else {
_imageView.hidden = YES;
}
if (_style.shouldDisplayTitle) {
// Create title label if needed.
if (!_titleLabel) {
CGRect titleFrame = self.contentView.bounds;
_titleLabel = [[UILabel alloc] initWithFrame:titleFrame];
_titleLabel.autoresizingMask =
UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
// 0.85 is based on 12sp/14sp guidelines for single- or double-line text
_titleLabel.minimumScaleFactor = (CGFloat)0.85;
_titleLabel.textAlignment = NSTextAlignmentCenter;
[self.contentView addSubview:_titleLabel];
// Display title and update color for the new label.
[self updateTitleLines];
[self updateDisplayedTitle];
[self updateTitleTextColor];
[self updateTitleFont];
}
_titleLabel.hidden = NO;
} else {
_titleLabel.hidden = YES;
}
if (_style.shouldDisplayBadge) {
if (!_badge) {
_badge = [[MDCItemBarBadge alloc] initWithFrame:CGRectZero];
_badge.isAccessibilityElement = NO;
[self.contentView addSubview:_badge];
_badge.badgeValue = _badgeValue;
}
_badge.hidden = !_badgeValue;
} else {
_badge.hidden = YES;
}
}
- (void)updateColors {
[self updateTitleTextColor];
[self updateImageTintColor];
[self updateBadgeColor];
[self updateInk];
[self updateRipple];
}
- (void)updateTitleTextColor {
UIColor *textColor = _style.titleColor;
if (self.isHighlighted || self.isSelected) {
textColor = _style.selectedTitleColor;
}
_titleLabel.textColor = textColor;
}
- (void)updateImageTintColor {
UIColor *imageTintColor = _style.imageTintColor;
if (self.isHighlighted || self.isSelected) {
imageTintColor = _style.selectedImageTintColor;
}
_imageView.tintColor = imageTintColor;
}
- (void)updateBadgeColor {
_badge.badgeColor = _style.badgeColor;
}
- (void)updateTransformsAnimated:(BOOL)animated {
CGAffineTransform titleTransform = CGAffineTransformIdentity;
CGAffineTransform imageTransform = CGAffineTransformIdentity;
UIScreen *screen = self.window.screen ?: UIScreen.mainScreen;
const CGFloat screenScale = (screen ? screen.scale : 1);
CGFloat titleContentsScale = screenScale;
// Apply transforms to the selected item if appropriate.
if (_style.shouldGrowOnSelection) {
const CGFloat titleScaleFactor = self.selected ? kSelectedNavigationTitleScaleFactor : 1;
const CGFloat imageYTransform = self.selected ? kSelectedNavigationImageYOffset : 0;
/// Vertical offset in points from the bottom of the label to its baseline.
const CGFloat titleBaselineOffset = 3.5;
// Scale title up from the baseline.
titleTransform = CGAffineTransformMakeTranslation(0, titleBaselineOffset);
titleTransform = CGAffineTransformScale(titleTransform, titleScaleFactor, titleScaleFactor);
titleTransform = CGAffineTransformTranslate(titleTransform, 0, -titleBaselineOffset);
// Shift image up by a small amount.
imageTransform = CGAffineTransformMakeTranslation(0, imageYTransform);
// Render the title with a higher contents scale to reduce aliasing after the scale.
titleContentsScale = ceilf((float)(titleScaleFactor * screenScale));
}
void (^performAnimations)(void) = ^{
// Update the title scale and redraw if it'll be ending at a higher scale
// to minimize aliasing during animation.
if (titleContentsScale > self.titleLabel.layer.contentsScale) {
self.titleLabel.layer.contentsScale = titleContentsScale;
[self.titleLabel setNeedsDisplay];
}
// Set the transforms.
self.titleLabel.transform = titleTransform;
self->_badge.transform = imageTransform;
self->_imageView.transform = imageTransform;
};
void (^completeAnimations)(BOOL) = ^(__unused BOOL finished) {
if (titleContentsScale != self.titleLabel.layer.contentsScale) {
// Update the title with the final contents scale and redraw.
self.titleLabel.layer.contentsScale = titleContentsScale;
[self.titleLabel setNeedsDisplay];
}
};
if (animated) {
[CATransaction begin];
CAMediaTimingFunction *translateTimingFunction =
[CAMediaTimingFunction mdc_functionWithType:MDCAnimationTimingFunctionTranslate];
[CATransaction setAnimationTimingFunction:translateTimingFunction];
[UIView animateWithDuration:kSelectionAnimationDuration
delay:0
options:0
animations:performAnimations
completion:completeAnimations];
[CATransaction commit];
} else {
performAnimations();
completeAnimations(YES);
}
}
- (void)updateTitleFont {
_titleLabel.font = self.isSelected ? _style.selectedTitleFont : _style.unselectedTitleFont;
}
- (void)updateInk {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
MDCInkView *inkView = _inkTouchController.defaultInkView;
#pragma clang diagnostic pop
inkView.inkColor = _style.inkColor;
inkView.inkStyle = _style.inkStyle;
inkView.usesLegacyInkRipple = NO;
inkView.clipsToBounds = (inkView.inkStyle == MDCInkStyleBounded) ? YES : NO;
}
- (void)updateRipple {
self.rippleTouchController.shouldProcessRippleWithScrollViewGestures = NO;
MDCRippleView *rippleView = self.rippleTouchController.rippleView;
rippleView.rippleColor = _style.rippleColor;
rippleView.rippleStyle = _style.rippleStyle;
rippleView.clipsToBounds = (rippleView.rippleStyle == MDCRippleStyleBounded) ? YES : NO;
}
- (void)updateEnableRippleBehavior {
if (_style.enableRippleBehavior) {
[self.inkTouchController.defaultInkView removeFromSuperview];
[self.rippleTouchController addRippleToView:self];
} else {
[self.rippleTouchController.rippleView removeFromSuperview];
[self.inkTouchController addInkView];
}
}
- (void)updateAccessibilityTraits {
if (self.isSelected) {
self.accessibilityTraits |= UIAccessibilityTraitSelected;
} else {
self.accessibilityTraits &= ~UIAccessibilityTraitSelected;
}
}
- (void)updateDisplayedImage {
if (self.isSelected && self.selectedImage != nil) {
self.imageView.image = self.selectedImage;
} else {
self.imageView.image = self.image;
}
}
- (void)updateDisplayedTitle {
_titleLabel.text = [[self class] displayedTitleForTitle:_title style:_style];
}
@end