blob: 90505a3d65feac3490b0c30a33c793d73456a5cb [file] [log] [blame]
// Copyright 2019-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 "MDCTabBarView.h"
#import "private/MDCTabBarViewIndicatorView.h"
#import "private/MDCTabBarViewItemView.h"
#import "private/MDCTabBarViewItemViewDelegate.h"
#import "private/MDCTabBarViewPrivateIndicatorContext.h"
#import "MaterialRipple.h"
#import "MDCTabBarItemCustomViewing.h"
#import "MDCTabBarViewCustomViewable.h"
#import "MDCTabBarViewDelegate.h"
#import "MDCTabBarViewIndicatorTemplate.h"
#import "MDCTabBarViewUnderlineIndicatorTemplate.h"
#import <CoreGraphics/CoreGraphics.h>
#import <QuartzCore/QuartzCore.h>
#import "MaterialAnimationTiming.h" // ComponentImport
#import <MDFInternationalization/MDFInternationalization.h>
// KVO contexts
static char *const kKVOContextMDCTabBarView = "kKVOContextMDCTabBarView";
/** Minimum (typical) height of a Material Tab bar. */
static const CGFloat kMinHeight = 48;
/** Default minimum width of an item in the Tab bar */
static const CGFloat kDefaultMinItemWidth = 90;
/// Outer edge padding from spec: https://material.io/go/design-tabs#spec.
static const UIEdgeInsets kDefaultItemViewContentInsetsTextAndImage = {
.top = 12, .right = 16, .bottom = 12, .left = 16};
/**
Edge insets for text-only Tabs. Although top and bottom are not specified, we insert some
minimal (8 points) padding so things don't look awful.
*/
static const UIEdgeInsets kDefaultItemViewContentInsetsTextOnly = {
.top = 8, .right = 16, .bottom = 8, .left = 16};
/** Edge insets for image-only Tabs. */
static const UIEdgeInsets kDefaultItemViewContentInsetsImageOnly = {
.top = 12, .right = 16, .bottom = 12, .left = 16};
/** The leading edge inset for scrollable tabs. */
static const CGFloat kScrollableTabsLeadingEdgeInset = 52;
/** The height of the bottom divider view. */
static const CGFloat kBottomDividerHeight = 1;
/// Default duration in seconds for selection change animations.
static const NSTimeInterval kSelectionChangeAnimationDuration = 0.3;
static NSString *const kSelectedImageKeyPath = @"selectedImage";
static NSString *const kImageKeyPath = @"image";
static NSString *const kTitleKeyPath = @"title";
static NSString *const kAccessibilityLabelKeyPath = @"accessibilityLabel";
static NSString *const kAccessibilityHintKeyPath = @"accessibilityHint";
static NSString *const kAccessibilityIdentifierKeyPath = @"accessibilityIdentifier";
static NSString *const kAccessibilityTraitsKeyPath = @"accessibilityTraits";
static NSString *const kTitlePositionAdjustment = @"titlePositionAdjustment";
static NSString *const kLargeContentSizeImage = @"largeContentSizeImage";
static NSString *const kLargeContentSizeImageInsets = @"largeContentSizeImageInsets";
#ifdef __IPHONE_13_4
@interface MDCTabBarView (PointerInteractions) <UIPointerInteractionDelegate,
MDCTabBarViewItemViewDelegate>
@end
#endif
@interface MDCTabBarView ()
/** The views representing each tab bar item. */
@property(nonnull, nonatomic, copy) NSArray<UIView *> *itemViews;
/** The bottom divider view shown behind the default indicator template. */
@property(nonnull, nonatomic, strong) UIView *bottomDividerView;
/** @c YES if the items are laid-out in a scrollable style. */
@property(nonatomic, readonly) BOOL isScrollableLayoutStyle;
/** Used to scroll to the selected item during the first call to @c layoutSubviews. */
@property(nonatomic, assign) BOOL needsScrollToSelectedItem;
/** The view that renders @c selectionIndicatorTemplate. */
@property(nonnull, nonatomic, strong) MDCTabBarViewIndicatorView *selectionIndicatorView;
/** The title colors for bar items. */
@property(nonnull, nonatomic, strong) NSMutableDictionary<NSNumber *, UIColor *> *stateToTitleColor;
/** The image tint colors for bar items. */
@property(nonnull, nonatomic, strong)
NSMutableDictionary<NSNumber *, UIColor *> *stateToImageTintColor;
/** The title font for bar items. */
@property(nonnull, nonatomic, strong) NSMutableDictionary<NSNumber *, UIFont *> *stateToTitleFont;
/**
The content padding (as UIEdgeInsets) for each layout style. The layout style is stored as an
@c NSNumber of the raw enumeration value. The padding @c UIEdgeInsets is stored as an @c NSValue.
*/
@property(nonnull, nonatomic, strong)
NSMutableDictionary<NSNumber *, NSValue *> *layoutStyleToContentPadding;
@property(nonatomic) BOOL useDefaultItemViewContentInsets;
#if defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
/**
The last large content viewer item displayed by the content viewer while the interaction is
running. When the interaction ends this property is nil.
*/
@property(nonatomic, nullable) id<UILargeContentViewerItem> lastLargeContentViewerItem
NS_AVAILABLE_IOS(13_0);
#endif // defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
@end
@implementation MDCTabBarView
// We're overriding UIScrollViewDelegate's delegate solely to change its type (we don't provide
// a getter or setter implementation), thus the @dynamic.
@dynamic delegate;
#pragma mark - Initialization
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self commonMDCTabBarViewInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self commonMDCTabBarViewInit];
}
return self;
}
- (void)commonMDCTabBarViewInit {
_rippleColor = [[UIColor alloc] initWithWhite:0 alpha:(CGFloat)0.16];
_needsScrollToSelectedItem = YES;
_shouldAdjustForSafeAreaInsets = YES;
_items = @[];
_stateToImageTintColor = [NSMutableDictionary dictionary];
_stateToTitleColor = [NSMutableDictionary dictionary];
_stateToTitleFont = [NSMutableDictionary dictionary];
_preferredLayoutStyle = MDCTabBarViewLayoutStyleFixed;
_layoutStyleToContentPadding = [NSMutableDictionary dictionary];
_layoutStyleToContentPadding[@(MDCTabBarViewLayoutStyleScrollable)] =
[NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, kScrollableTabsLeadingEdgeInset, 0, 0)];
_minItemWidth = kDefaultMinItemWidth;
_useDefaultItemViewContentInsets = YES;
self.backgroundColor = UIColor.whiteColor;
self.showsHorizontalScrollIndicator = NO;
_selectionIndicatorView = [[MDCTabBarViewIndicatorView alloc] init];
_selectionIndicatorView.translatesAutoresizingMaskIntoConstraints = NO;
_selectionIndicatorView.userInteractionEnabled = NO;
_selectionIndicatorView.tintColor = UIColor.blackColor;
_selectionIndicatorView.indicatorPathAnimationDuration = kSelectionChangeAnimationDuration;
_selectionIndicatorView.indicatorPathTimingFunction =
[CAMediaTimingFunction mdc_functionWithType:MDCAnimationTimingFunctionEaseInOut];
_selectionIndicatorTemplate = [[MDCTabBarViewUnderlineIndicatorTemplate alloc] init];
// The bottom divider is positioned behind the selection indicator.
_bottomDividerView = [[UIView alloc] init];
_bottomDividerView.backgroundColor = UIColor.clearColor;
[self addSubview:_bottomDividerView];
// The selection indicator is positioned behind the item views.
[self addSubview:_selectionIndicatorView];
// By default, inset the content within the safe area. This is generally the desired behavior,
// but clients can override it if they want.
if (@available(iOS 11.0, *)) {
[super setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentAlways];
}
#if defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
if (@available(iOS 13, *)) {
// If clients report conflicting gesture recognizers please see proposed solution in the
// internal document: go/mdc-ios-bottomnavigation-largecontentvieweritem
[self addInteraction:[[UILargeContentViewerInteraction alloc] initWithDelegate:self]];
}
#endif // defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
}
- (void)dealloc {
[self removeObserversFromTabBarItems];
}
#pragma mark - Properties
- (void)setBarTintColor:(UIColor *)barTintColor {
self.backgroundColor = barTintColor;
}
- (UIColor *)barTintColor {
return self.backgroundColor;
}
- (void)updateRippleColorForAllViews {
for (UIView *subview in self.itemViews) {
if (![subview isKindOfClass:[MDCTabBarViewItemView class]]) {
continue;
}
MDCTabBarViewItemView *itemView = (MDCTabBarViewItemView *)subview;
itemView.rippleTouchController.rippleView.rippleColor = self.rippleColor;
}
}
- (void)setRippleColor:(UIColor *)rippleColor {
_rippleColor = [rippleColor copy];
[self updateRippleColorForAllViews];
}
- (void)setSelectionIndicatorStrokeColor:(UIColor *)selectionIndicatorStrokeColor {
_selectionIndicatorStrokeColor = selectionIndicatorStrokeColor ?: UIColor.blackColor;
self.selectionIndicatorView.tintColor = self.selectionIndicatorStrokeColor;
}
- (void)setBottomDividerColor:(UIColor *)bottomDividerColor {
self.bottomDividerView.backgroundColor = bottomDividerColor;
}
- (UIColor *)bottomDividerColor {
return self.bottomDividerView.backgroundColor;
}
- (void)setPreferredLayoutStyle:(MDCTabBarViewLayoutStyle)preferredLayoutStyle {
_preferredLayoutStyle = preferredLayoutStyle;
[self setNeedsLayout];
[self invalidateIntrinsicContentSize];
}
- (void)setItemViewContentInsets:(UIEdgeInsets)itemViewContentInsets {
_itemViewContentInsets = itemViewContentInsets;
_useDefaultItemViewContentInsets = NO;
}
- (void)setItems:(NSArray<UITabBarItem *> *)items {
NSParameterAssert(items);
if (self.items == items || [self.items isEqual:items]) {
return;
}
[self removeObserversFromTabBarItems];
for (UIView *view in self.itemViews) {
[view removeFromSuperview];
}
_items = [items copy];
NSMutableArray<UIView *> *itemViews = [NSMutableArray array];
for (UITabBarItem *item in self.items) {
UIView *itemView;
if ([item conformsToProtocol:@protocol(MDCTabBarItemCustomViewing)]) {
UITabBarItem<MDCTabBarItemCustomViewing> *customItem =
(UITabBarItem<MDCTabBarItemCustomViewing> *)item;
UIView *customView = customItem.mdc_customView;
if (customView) {
itemView = customView;
}
}
if (!itemView) {
MDCTabBarViewItemView *mdcItemView = [[MDCTabBarViewItemView alloc] init];
mdcItemView.itemViewDelegate = self;
mdcItemView.titleLabel.text = item.title;
mdcItemView.accessibilityLabel = item.accessibilityLabel;
mdcItemView.accessibilityHint = item.accessibilityHint;
mdcItemView.accessibilityIdentifier = item.accessibilityIdentifier;
mdcItemView.accessibilityTraits = item.accessibilityTraits == UIAccessibilityTraitNone
? UIAccessibilityTraitButton
: item.accessibilityTraits;
mdcItemView.titleLabel.textColor = [self titleColorForState:UIControlStateNormal];
mdcItemView.image = item.image;
mdcItemView.selectedImage = item.selectedImage;
mdcItemView.rippleTouchController.rippleView.rippleColor = self.rippleColor;
#if defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
if (@available(iOS 13, *)) {
mdcItemView.largeContentImageInsets = item.largeContentSizeImageInsets;
mdcItemView.largeContentImage = item.largeContentSizeImage;
}
#endif // defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
itemView = mdcItemView;
}
UITapGestureRecognizer *tapGesture =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapItemView:)];
[itemView addGestureRecognizer:tapGesture];
#ifdef __IPHONE_13_4
if (@available(iOS 13.4, *)) {
// Because some iOS 13 betas did not have the UIPointerInteraction class, we need to verify
// that it exists before attempting to use it.
if (NSClassFromString(@"UIPointerInteraction")) {
UIPointerInteraction *pointerInteraction =
[[UIPointerInteraction alloc] initWithDelegate:self];
[itemView addInteraction:pointerInteraction];
}
}
#endif
[self addSubview:itemView];
[itemViews addObject:itemView];
}
self.itemViews = itemViews;
// Determine new selected item, defaulting to nil.
UITabBarItem *newSelectedItem = nil;
if (self.selectedItem && [self.items containsObject:self.selectedItem]) {
// Previously-selected item still around: Preserve selection.
newSelectedItem = self.selectedItem;
}
[self setSelectedItem:newSelectedItem animated:NO];
[self addObserversToTabBarItems];
[self updateTitleFontForAllViews];
[self invalidateIntrinsicContentSize];
[self setNeedsLayout];
}
- (void)setSelectedItem:(UITabBarItem *)selectedItem {
[self setSelectedItem:selectedItem animated:YES];
}
- (void)setSelectedItem:(UITabBarItem *)selectedItem animated:(BOOL)animated {
if (self.selectedItem == selectedItem) {
return;
}
// Sets the old selected item view's traits back.
NSUInteger oldSelectedItemIndex = [self.items indexOfObject:self.selectedItem];
if (oldSelectedItemIndex != NSNotFound) {
UIView *oldSelectedItemView = self.itemViews[oldSelectedItemIndex];
oldSelectedItemView.accessibilityTraits =
(oldSelectedItemView.accessibilityTraits & ~UIAccessibilityTraitSelected);
if ([oldSelectedItemView conformsToProtocol:@protocol(MDCTabBarViewCustomViewable)]) {
UIView<MDCTabBarViewCustomViewable> *customViewableView =
(UIView<MDCTabBarViewCustomViewable> *)oldSelectedItemView;
[customViewableView setSelected:NO animated:animated];
}
}
// Handle setting to `nil` without passing it to the nonnull parameter in `indexOfObject:`
if (!selectedItem) {
_selectedItem = selectedItem;
[self updateTitleColorForAllViewsAnimated:animated];
[self didSelectItemAtIndex:NSNotFound animateTransition:animated];
return;
}
NSUInteger itemIndex = [self.items indexOfObject:selectedItem];
// Don't crash, just ignore if `selectedItem` isn't present in `_items`. This is the same behavior
// as UITabBar.
if (itemIndex == NSNotFound) {
return;
}
_selectedItem = selectedItem;
UIView *newSelectedItemView = self.itemViews[itemIndex];
newSelectedItemView.accessibilityTraits =
(newSelectedItemView.accessibilityTraits | UIAccessibilityTraitSelected);
if ([newSelectedItemView conformsToProtocol:@protocol(MDCTabBarViewCustomViewable)]) {
UIView<MDCTabBarViewCustomViewable> *customViewableView =
(UIView<MDCTabBarViewCustomViewable> *)newSelectedItemView;
[customViewableView setSelected:YES animated:animated];
}
[self updateTitleColorForAllViewsAnimated:animated];
[self scrollToItem:self.items[itemIndex] animated:animated];
[self didSelectItemAtIndex:itemIndex animateTransition:animated];
}
- (void)updateImageTintColorForAllViews {
for (UITabBarItem *item in self.items) {
NSUInteger indexOfItem = [self.items indexOfObject:item];
// This is a significant error, but defensive coding is preferred.
if (indexOfItem == NSNotFound || indexOfItem >= self.itemViews.count) {
NSAssert(NO, @"Unable to find associated item view for (%@)", item);
continue;
}
UIView *itemView = self.itemViews[indexOfItem];
// Skip custom views
if (![itemView isKindOfClass:[MDCTabBarViewItemView class]]) {
continue;
}
MDCTabBarViewItemView *tabBarViewItemView = (MDCTabBarViewItemView *)itemView;
if (item == self.selectedItem) {
tabBarViewItemView.iconImageView.tintColor =
[self imageTintColorForState:UIControlStateSelected];
} else {
tabBarViewItemView.iconImageView.tintColor =
[self imageTintColorForState:UIControlStateNormal];
}
}
}
- (void)setImageTintColor:(UIColor *)imageTintColor forState:(UIControlState)state {
self.stateToImageTintColor[@(state)] = imageTintColor;
[self updateImageTintColorForAllViews];
}
- (UIColor *)imageTintColorForState:(UIControlState)state {
UIColor *color = self.stateToImageTintColor[@(state)];
if (color == nil) {
color = self.stateToImageTintColor[@(UIControlStateNormal)];
}
return color;
}
- (void)updateTitleColorForAllViewsAnimated:(BOOL)animated {
for (UITabBarItem *item in self.items) {
NSUInteger indexOfItem = [self.items indexOfObject:item];
// This is a significant error, but defensive coding is preferred.
if (indexOfItem == NSNotFound || indexOfItem >= self.itemViews.count) {
NSAssert(NO, @"Unable to find associated item view for (%@)", item);
continue;
}
UIView *itemView = self.itemViews[indexOfItem];
// Skip custom views
if (![itemView isKindOfClass:[MDCTabBarViewItemView class]]) {
continue;
}
MDCTabBarViewItemView *tabBarViewItemView = (MDCTabBarViewItemView *)itemView;
void (^animations)(void) = ^{
if (item == self.selectedItem) {
tabBarViewItemView.titleLabel.textColor = [self titleColorForState:UIControlStateSelected];
} else {
tabBarViewItemView.titleLabel.textColor = [self titleColorForState:UIControlStateNormal];
}
};
if (animated) {
// UILabel::textColor can't be implicitly animated, so we use a cross-fade dissolve transition
// on the label to accomplish the effect instead.
[UIView transitionWithView:tabBarViewItemView.titleLabel
duration:self.selectionChangeAnimationDuration
options:UIViewAnimationOptionTransitionCrossDissolve
animations:animations
completion:nil];
} else {
animations();
}
}
}
- (void)setTitleColor:(UIColor *)titleColor forState:(UIControlState)state {
self.stateToTitleColor[@(state)] = titleColor;
[self updateTitleColorForAllViewsAnimated:NO];
}
- (UIColor *)titleColorForState:(UIControlState)state {
UIColor *titleColor = self.stateToTitleColor[@(state)];
if (!titleColor) {
titleColor = self.stateToTitleColor[@(UIControlStateNormal)];
}
return titleColor;
}
- (void)updateTitleFontForAllViews {
for (UITabBarItem *item in self.items) {
NSUInteger indexOfItem = [self.items indexOfObject:item];
// This is a significant error, but defensive coding is preferred.
if (indexOfItem == NSNotFound || indexOfItem >= self.itemViews.count) {
NSAssert(NO, @"Unable to find associated item view for (%@)", item);
continue;
}
UIView *itemView = self.itemViews[indexOfItem];
// Skip custom views
if (![itemView isKindOfClass:[MDCTabBarViewItemView class]]) {
continue;
}
MDCTabBarViewItemView *tabBarViewItemView = (MDCTabBarViewItemView *)itemView;
if (item == self.selectedItem) {
tabBarViewItemView.titleLabel.font = [self titleFontForState:UIControlStateSelected];
} else {
tabBarViewItemView.titleLabel.font = [self titleFontForState:UIControlStateNormal];
}
[itemView invalidateIntrinsicContentSize];
[itemView setNeedsLayout];
}
}
- (void)setTitleFont:(UIFont *)titleFont forState:(UIControlState)state {
self.stateToTitleFont[@(state)] = titleFont;
[self updateTitleFontForAllViews];
}
- (UIFont *)titleFontForState:(UIControlState)state {
UIFont *titleFont = self.stateToTitleFont[@(state)];
if (!titleFont) {
titleFont = self.stateToTitleFont[@(UIControlStateNormal)];
}
return titleFont;
}
- (void)setSelectionIndicatorTemplate:
(id<MDCTabBarViewIndicatorTemplate>)selectionIndicatorTemplate {
_selectionIndicatorTemplate = selectionIndicatorTemplate;
if (self.selectedItem) {
[self.selectionIndicatorView setNeedsLayout];
}
}
- (void)setContentPadding:(UIEdgeInsets)contentPadding
forLayoutStyle:(MDCTabBarViewLayoutStyle)layoutStyle {
self.layoutStyleToContentPadding[@(layoutStyle)] = [NSValue valueWithUIEdgeInsets:contentPadding];
if ([self effectiveLayoutStyle] == layoutStyle) {
[self setNeedsLayout];
}
}
- (UIEdgeInsets)contentPaddingForLayoutStyle:(MDCTabBarViewLayoutStyle)layoutStyle {
NSValue *paddingValue = self.layoutStyleToContentPadding[@(layoutStyle)];
if (paddingValue) {
return paddingValue.UIEdgeInsetsValue;
}
return UIEdgeInsetsZero;
}
#pragma mark - MDCTabBarViewItemViewDelegate
- (UIEdgeInsets)contentInsetsForItemViewStyle:(MDCTabBarViewItemViewStyle)itemViewStyle {
if (self.useDefaultItemViewContentInsets) {
switch (itemViewStyle) {
case 0:
return kDefaultItemViewContentInsetsTextOnly;
case 1:
return kDefaultItemViewContentInsetsImageOnly;
case 2:
return kDefaultItemViewContentInsetsTextAndImage;
}
return self.itemViewContentInsets;
} else {
return self.itemViewContentInsets;
}
}
#pragma mark - UIAccessibility
- (BOOL)isAccessibilityElement {
return NO;
}
- (UIAccessibilityTraits)accessibilityTraits {
return [super accessibilityTraits] | UIAccessibilityTraitTabBar;
}
#pragma mark - Custom APIs
- (id)accessibilityElementForItem:(UITabBarItem *)item {
NSUInteger itemIndex = [self.items indexOfObject:item];
if (itemIndex == NSNotFound || itemIndex >= self.itemViews.count) {
return nil;
}
return self.itemViews[itemIndex];
}
- (CGRect)rectForItem:(UITabBarItem *)item
inCoordinateSpace:(id<UICoordinateSpace>)coordinateSpace {
if (item == nil) {
return CGRectNull;
}
NSUInteger index = [self.items indexOfObject:item];
if (index == NSNotFound || index >= self.itemViews.count) {
return CGRectNull;
}
CGRect frame = CGRectStandardize(self.itemViews[index].frame);
return [coordinateSpace convertRect:frame fromCoordinateSpace:self];
}
- (CFTimeInterval)selectionChangeAnimationDuration {
return kSelectionChangeAnimationDuration;
}
- (CAMediaTimingFunction *)selectionChangeAnimationTimingFunction {
return [CAMediaTimingFunction mdc_functionWithType:MDCAnimationTimingFunctionEaseInOut];
}
#pragma mark - Key-Value Observing (KVO)
- (void)addObserversToTabBarItems {
for (UITabBarItem *item in self.items) {
[item addObserver:self
forKeyPath:kImageKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kSelectedImageKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kTitleKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kAccessibilityLabelKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kAccessibilityHintKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kAccessibilityIdentifierKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kAccessibilityTraitsKeyPath
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kTitlePositionAdjustment
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kLargeContentSizeImage
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
[item addObserver:self
forKeyPath:kLargeContentSizeImageInsets
options:NSKeyValueObservingOptionNew
context:kKVOContextMDCTabBarView];
}
}
- (void)removeObserversFromTabBarItems {
for (UITabBarItem *item in self.items) {
[item removeObserver:self forKeyPath:kImageKeyPath context:kKVOContextMDCTabBarView];
[item removeObserver:self forKeyPath:kSelectedImageKeyPath context:kKVOContextMDCTabBarView];
[item removeObserver:self forKeyPath:kTitleKeyPath context:kKVOContextMDCTabBarView];
[item removeObserver:self
forKeyPath:kAccessibilityLabelKeyPath
context:kKVOContextMDCTabBarView];
[item removeObserver:self
forKeyPath:kAccessibilityHintKeyPath
context:kKVOContextMDCTabBarView];
[item removeObserver:self
forKeyPath:kAccessibilityIdentifierKeyPath
context:kKVOContextMDCTabBarView];
[item removeObserver:self
forKeyPath:kAccessibilityTraitsKeyPath
context:kKVOContextMDCTabBarView];
[item removeObserver:self forKeyPath:kTitlePositionAdjustment context:kKVOContextMDCTabBarView];
[item removeObserver:self forKeyPath:kLargeContentSizeImage context:kKVOContextMDCTabBarView];
[item removeObserver:self
forKeyPath:kLargeContentSizeImageInsets
context:kKVOContextMDCTabBarView];
}
}
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey, id> *)change
context:(void *)context {
if (context == kKVOContextMDCTabBarView) {
if (!object) {
return;
}
NSUInteger indexOfObject = [self.items indexOfObject:object];
if (indexOfObject == NSNotFound) {
return;
}
// Don't try to update custom views
UIView *updatedItemView = self.itemViews[indexOfObject];
if (![updatedItemView isKindOfClass:[MDCTabBarViewItemView class]]) {
return;
}
MDCTabBarViewItemView *tabBarItemView = (MDCTabBarViewItemView *)updatedItemView;
id newValue = [object valueForKey:keyPath];
if (newValue == [NSNull null]) {
newValue = nil;
}
if ([keyPath isEqualToString:kImageKeyPath]) {
tabBarItemView.image = newValue;
[self markIntrinsicContentSizeAndLayoutNeedingUpdateForSelfAndItemView:tabBarItemView];
} else if ([keyPath isEqualToString:kSelectedImageKeyPath]) {
tabBarItemView.selectedImage = newValue;
[self markIntrinsicContentSizeAndLayoutNeedingUpdateForSelfAndItemView:tabBarItemView];
} else if ([keyPath isEqualToString:kTitleKeyPath]) {
tabBarItemView.titleLabel.text = newValue;
[self markIntrinsicContentSizeAndLayoutNeedingUpdateForSelfAndItemView:tabBarItemView];
} else if ([keyPath isEqualToString:kAccessibilityLabelKeyPath]) {
tabBarItemView.accessibilityLabel = newValue;
} else if ([keyPath isEqualToString:kAccessibilityHintKeyPath]) {
tabBarItemView.accessibilityHint = newValue;
} else if ([keyPath isEqualToString:kAccessibilityIdentifierKeyPath]) {
tabBarItemView.accessibilityIdentifier = newValue;
} else if ([keyPath isEqualToString:kAccessibilityTraitsKeyPath]) {
tabBarItemView.accessibilityTraits = [change[NSKeyValueChangeNewKey] unsignedLongLongValue];
if (tabBarItemView.accessibilityTraits == UIAccessibilityTraitNone) {
tabBarItemView.accessibilityTraits = UIAccessibilityTraitButton;
}
if (object == self.selectedItem) {
tabBarItemView.accessibilityTraits =
(tabBarItemView.accessibilityTraits | UIAccessibilityTraitSelected);
}
#if defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
} else if ([keyPath isEqualToString:NSStringFromSelector(@selector(largeContentSizeImage))]) {
if (@available(iOS 13.0, *)) {
tabBarItemView.largeContentImage = newValue;
}
} else if ([keyPath
isEqualToString:NSStringFromSelector(@selector(largeContentSizeImageInsets))]) {
if (@available(iOS 13.0, *)) {
tabBarItemView.largeContentImageInsets = [newValue UIEdgeInsetsValue];
}
}
#endif // defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)markIntrinsicContentSizeAndLayoutNeedingUpdateForSelfAndItemView:(UIView *)itemView {
[itemView invalidateIntrinsicContentSize];
[itemView setNeedsLayout];
[self invalidateIntrinsicContentSize];
[self setNeedsLayout];
}
#pragma mark - UIView
- (void)layoutSubviews {
[super layoutSubviews];
MDCTabBarViewLayoutStyle layoutStyle = [self effectiveLayoutStyle];
switch (layoutStyle) {
case MDCTabBarViewLayoutStyleFixed: {
[self layoutSubviewsForJustifiedLayout];
break;
}
case MDCTabBarViewLayoutStyleFixedClusteredCentered:
case MDCTabBarViewLayoutStyleFixedClusteredTrailing:
case MDCTabBarViewLayoutStyleFixedClusteredLeading: {
[self layoutSubviewsForFixedClusteredLayout:layoutStyle];
break;
}
case MDCTabBarViewLayoutStyleScrollableCentered:
case MDCTabBarViewLayoutStyleScrollable: {
[self layoutSubviewsForScrollableLayout:layoutStyle];
break;
}
case MDCTabBarViewLayoutStyleNonFixedClusteredCentered: {
[self layoutSubviewsForNonFixedClusteredCentered];
break;
}
}
self.contentSize = [self calculatedContentSize];
[self updateSelectionIndicatorToIndex:[self.items indexOfObject:self.selectedItem] animated:NO];
if (self.needsScrollToSelectedItem) {
self.needsScrollToSelectedItem = NO;
// In RTL layouts, make sure we "begin" the selected item scroll offset from the leading edge.
if (self.mdf_effectiveUserInterfaceLayoutDirection ==
UIUserInterfaceLayoutDirectionRightToLeft) {
CGFloat viewWidth = CGRectGetWidth(self.bounds);
if (viewWidth < self.contentSize.width) {
self.contentOffset = CGPointMake(self.contentSize.width - viewWidth, self.contentOffset.y);
}
}
[self scrollToItem:self.selectedItem animated:NO];
}
// It's possible that after scrolling the minX of bounds could have changed. Positioning it last
// ensures that its frame matches the displayed content bounds.
self.bottomDividerView.frame =
CGRectMake(CGRectGetMinX(self.bounds), CGRectGetMaxY(self.bounds) - kBottomDividerHeight,
CGRectGetWidth(self.bounds), kBottomDividerHeight);
}
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (self.traitCollectionDidChangeBlock) {
self.traitCollectionDidChangeBlock(self, previousTraitCollection);
}
}
- (void)setBounds:(CGRect)bounds {
if (!CGSizeEqualToSize(bounds.size, self.bounds.size)) {
self.needsScrollToSelectedItem = YES;
}
[super setBounds:bounds];
}
- (BOOL)isScrollableLayoutStyle {
return [self effectiveLayoutStyle] == MDCTabBarViewLayoutStyleScrollable;
}
- (MDCTabBarViewLayoutStyle)effectiveLayoutStyle {
return [self effectiveLayoutStyleWithStyle:self.preferredLayoutStyle];
}
- (MDCTabBarViewLayoutStyle)effectiveLayoutStyleWithStyle:(MDCTabBarViewLayoutStyle)layoutStyle {
if (self.items.count == 0) {
return MDCTabBarViewLayoutStyleFixed;
}
CGSize availableSize = [self availableSizeForSubviewLayout];
switch (layoutStyle) {
case MDCTabBarViewLayoutStyleNonFixedClusteredCentered: {
CGFloat nonFixedClusteredCenteredWidth =
[self intrinsicContentSizeForNonFixedLayoutStyle:
MDCTabBarViewLayoutStyleNonFixedClusteredCentered]
.width;
BOOL tabBarIsTooNarrow = availableSize.width < nonFixedClusteredCenteredWidth;
if (tabBarIsTooNarrow) {
return MDCTabBarViewLayoutStyleScrollable;
} else {
return MDCTabBarViewLayoutStyleNonFixedClusteredCentered;
}
}
case MDCTabBarViewLayoutStyleScrollableCentered: {
if (UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning()) {
return MDCTabBarViewLayoutStyleScrollable;
}
CGFloat scrollableCenteredWidth =
[self
intrinsicContentSizeForNonFixedLayoutStyle:MDCTabBarViewLayoutStyleScrollableCentered]
.width;
BOOL tabBarIsTooWide = availableSize.width > scrollableCenteredWidth;
if (tabBarIsTooWide) {
return
[self effectiveLayoutStyleWithStyle:MDCTabBarViewLayoutStyleNonFixedClusteredCentered];
} else {
return MDCTabBarViewLayoutStyleScrollableCentered;
}
}
case MDCTabBarViewLayoutStyleScrollable: {
return MDCTabBarViewLayoutStyleScrollable;
}
case MDCTabBarViewLayoutStyleFixed: {
CGFloat requiredWidthForJustifiedLayout = [self intrinsicContentSizeForJustifiedLayout].width;
if (availableSize.width < requiredWidthForJustifiedLayout) {
return MDCTabBarViewLayoutStyleScrollable;
}
return MDCTabBarViewLayoutStyleFixed;
}
case MDCTabBarViewLayoutStyleFixedClusteredCentered: {
CGFloat requiredWidthForClusteredCenteredLayout =
[self
intrinsicContentSizeForClusteredLayout:MDCTabBarViewLayoutStyleFixedClusteredCentered]
.width;
if (availableSize.width < requiredWidthForClusteredCenteredLayout) {
return MDCTabBarViewLayoutStyleScrollable;
}
return MDCTabBarViewLayoutStyleFixedClusteredCentered;
}
case MDCTabBarViewLayoutStyleFixedClusteredLeading: {
CGFloat requiredWidthForClusteredLeadingLayout =
[self
intrinsicContentSizeForClusteredLayout:MDCTabBarViewLayoutStyleFixedClusteredLeading]
.width;
if (availableSize.width < requiredWidthForClusteredLeadingLayout) {
return MDCTabBarViewLayoutStyleScrollable;
}
return MDCTabBarViewLayoutStyleFixedClusteredLeading;
}
case MDCTabBarViewLayoutStyleFixedClusteredTrailing: {
CGFloat requiredWidthForClusteredTrailingLayout =
[self
intrinsicContentSizeForClusteredLayout:MDCTabBarViewLayoutStyleFixedClusteredTrailing]
.width;
if (availableSize.width < requiredWidthForClusteredTrailingLayout) {
return MDCTabBarViewLayoutStyleScrollable;
}
return MDCTabBarViewLayoutStyleFixedClusteredTrailing;
}
}
}
- (void)layoutSubviewsForJustifiedLayout {
if (self.itemViews.count == 0) {
return;
}
BOOL isRTL = [self isRTL];
CGSize contentSize = [self availableSizeForSubviewLayout];
UIEdgeInsets contentPadding = [self contentPaddingForLayoutStyle:MDCTabBarViewLayoutStyleFixed];
CGFloat itemLayoutWidth = contentSize.width - contentPadding.left - contentPadding.right;
CGFloat itemViewWidth = itemLayoutWidth / self.itemViews.count;
CGFloat itemViewOriginX = isRTL ? contentPadding.right : contentPadding.left;
CGFloat itemViewOriginY = contentPadding.top;
CGFloat itemViewHeight = contentSize.height - contentPadding.top - contentPadding.bottom;
NSEnumerator<UIView *> *itemViewEnumerator =
isRTL ? [self.itemViews reverseObjectEnumerator] : [self.itemViews objectEnumerator];
for (UIView *itemView in itemViewEnumerator) {
itemView.frame = CGRectMake(itemViewOriginX, itemViewOriginY, itemViewWidth, itemViewHeight);
itemViewOriginX += itemViewWidth;
}
[self updateItemViewsShouldProcessRippleWithScrollViewGestures:YES];
}
- (void)layoutSubviewsForFixedClusteredLayout:(MDCTabBarViewLayoutStyle)layoutStyle {
if (self.itemViews.count == 0) {
return;
}
BOOL isRTL = [self isRTL];
UIEdgeInsets contentPadding = [self contentPaddingForLayoutStyle:layoutStyle];
CGSize contentSize = [self availableSizeForSubviewLayout];
CGFloat itemViewWidth = [self itemViewSizeForClusteredFixedLayout].width;
CGFloat totalRequiredWidth = itemViewWidth * self.items.count;
// Start-out assuming left-aligned because it requires no computation.
CGFloat itemViewOriginX = isRTL ? contentPadding.right : contentPadding.left;
// Right-aligned
if ((isRTL && layoutStyle == MDCTabBarViewLayoutStyleFixedClusteredLeading) ||
(!isRTL && layoutStyle == MDCTabBarViewLayoutStyleFixedClusteredTrailing)) {
itemViewOriginX = (contentSize.width - totalRequiredWidth);
itemViewOriginX -= isRTL ? contentPadding.left : contentPadding.right;
}
// Centered
else if (layoutStyle == MDCTabBarViewLayoutStyleFixedClusteredCentered) {
itemViewOriginX =
(contentSize.width - totalRequiredWidth - contentPadding.left - contentPadding.right) / 2;
itemViewOriginX += isRTL ? contentPadding.right : contentPadding.left;
}
CGFloat itemViewOriginY = contentPadding.top;
CGFloat itemViewHeight = contentSize.height - contentPadding.top - contentPadding.bottom;
NSEnumerator<UIView *> *itemViewEnumerator =
isRTL ? [self.itemViews reverseObjectEnumerator] : [self.itemViews objectEnumerator];
for (UIView *itemView in itemViewEnumerator) {
itemView.frame = CGRectMake(itemViewOriginX, itemViewOriginY, itemViewWidth, itemViewHeight);
itemViewOriginX += itemViewWidth;
}
[self updateItemViewsShouldProcessRippleWithScrollViewGestures:YES];
}
- (void)layoutSubviewsForScrollableLayout:(MDCTabBarViewLayoutStyle)layoutStyle {
BOOL isRTL = [self isRTL];
UIEdgeInsets contentPadding = [self contentPaddingForLayoutStyle:layoutStyle];
// Default for LTR
CGFloat itemViewOriginX = contentPadding.left;
if (isRTL) {
itemViewOriginX = 0;
CGFloat requiredBarSize = [self intrinsicContentSizeForNonFixedLayoutStyle:layoutStyle].width;
CGFloat boundsBarDiff = [self availableSizeForSubviewLayout].width - requiredBarSize;
if (boundsBarDiff > 0) {
itemViewOriginX = boundsBarDiff;
}
itemViewOriginX += contentPadding.right;
}
CGFloat itemViewOriginY = contentPadding.top;
CGFloat itemViewHeight =
[self availableSizeForSubviewLayout].height - contentPadding.top - contentPadding.bottom;
NSEnumerator<UIView *> *itemViewEnumerator =
isRTL ? [self.itemViews reverseObjectEnumerator] : [self.itemViews objectEnumerator];
for (UIView *view in itemViewEnumerator) {
CGSize intrinsicContentSize = view.intrinsicContentSize;
view.frame =
CGRectMake(itemViewOriginX, itemViewOriginY, intrinsicContentSize.width, itemViewHeight);
itemViewOriginX += intrinsicContentSize.width;
}
[self updateItemViewsShouldProcessRippleWithScrollViewGestures:NO];
}
- (void)layoutSubviewsForNonFixedClusteredCentered {
UIEdgeInsets contentPadding =
[self contentPaddingForLayoutStyle:MDCTabBarViewLayoutStyleNonFixedClusteredCentered];
BOOL isRTL = [self isRTL];
if (isRTL) {
CGFloat right = contentPadding.right;
contentPadding.right = contentPadding.left;
contentPadding.left = right;
}
CGFloat availableSpaceMinX = contentPadding.left;
CGFloat availableSpaceMaxX = [self availableSizeForSubviewLayout].width - contentPadding.right;
CGFloat availableSpaceWidth = availableSpaceMaxX - availableSpaceMinX;
CGFloat centerOfAvailableSpace = availableSpaceMinX + availableSpaceWidth * 0.5f;
CGSize combineditemSize = [self nonFixedCombinedItemSize];
CGFloat halfOfCombinedItemSizeWidth = combineditemSize.width * 0.5f;
CGFloat itemViewMinX = centerOfAvailableSpace - halfOfCombinedItemSizeWidth;
CGFloat itemViewMinY = contentPadding.top;
NSEnumerator<UIView *> *itemViewEnumerator =
isRTL ? [self.itemViews reverseObjectEnumerator] : [self.itemViews objectEnumerator];
for (UIView *view in itemViewEnumerator) {
CGSize intrinsicContentSize = view.intrinsicContentSize;
view.frame =
CGRectMake(itemViewMinX, itemViewMinY, intrinsicContentSize.width, combineditemSize.height);
itemViewMinX += intrinsicContentSize.width;
}
[self updateItemViewsShouldProcessRippleWithScrollViewGestures:YES];
}
- (void)willMoveToSuperview:(UIView *)newSuperview {
[super willMoveToSuperview:newSuperview];
self.needsScrollToSelectedItem = YES;
}
- (CGSize)intrinsicContentSize {
switch (self.preferredLayoutStyle) {
case MDCTabBarViewLayoutStyleFixed: {
return [self intrinsicContentSizeForJustifiedLayout];
}
case MDCTabBarViewLayoutStyleScrollableCentered:
case MDCTabBarViewLayoutStyleNonFixedClusteredCentered:
case MDCTabBarViewLayoutStyleScrollable: {
return [self intrinsicContentSizeForNonFixedLayoutStyle:self.preferredLayoutStyle];
}
case MDCTabBarViewLayoutStyleFixedClusteredLeading:
case MDCTabBarViewLayoutStyleFixedClusteredCentered:
case MDCTabBarViewLayoutStyleFixedClusteredTrailing: {
return [self intrinsicContentSizeForClusteredLayout:self.preferredLayoutStyle];
}
}
}
/**
The content size of the tabs in their current layout style.
For @c FixedJustified: The content size is the maximum of the bounds within the safe area or the
intrinsic size of the tabs when all tabs have the widest tab's width.
For @c FixedClustered*: The bounds within the safe area. This ensures they are positionined
accurately within the content area.
For @c Scrollable: The intrinsic size size of the tabs.
*/
- (CGSize)calculatedContentSize {
MDCTabBarViewLayoutStyle layoutStyle = [self effectiveLayoutStyle];
switch (layoutStyle) {
case MDCTabBarViewLayoutStyleFixed: {
CGSize intrinsicContentSize = [self intrinsicContentSizeForJustifiedLayout];
CGSize boundsSize = CGSizeMake(CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds));
return CGSizeMake(MAX(boundsSize.width, intrinsicContentSize.width),
MAX(boundsSize.height, intrinsicContentSize.height));
}
case MDCTabBarViewLayoutStyleFixedClusteredCentered:
case MDCTabBarViewLayoutStyleFixedClusteredTrailing:
case MDCTabBarViewLayoutStyleFixedClusteredLeading: {
return [self availableSizeForSubviewLayout];
}
case MDCTabBarViewLayoutStyleScrollableCentered:
case MDCTabBarViewLayoutStyleNonFixedClusteredCentered:
case MDCTabBarViewLayoutStyleScrollable: {
return [self intrinsicContentSizeForNonFixedLayoutStyle:layoutStyle];
}
}
}
- (CGSize)intrinsicContentSizeForJustifiedLayout {
CGFloat maxWidth = 0;
CGFloat maxHeight = kMinHeight;
for (UIView *itemView in self.itemViews) {
CGSize contentSize = itemView.intrinsicContentSize;
maxHeight = MAX(maxHeight, contentSize.height);
maxWidth = MAX(maxWidth, contentSize.width);
}
CGSize contentSize = CGSizeMake(maxWidth * self.items.count, maxHeight);
UIEdgeInsets contentPadding = [self contentPaddingForLayoutStyle:MDCTabBarViewLayoutStyleFixed];
contentSize = CGSizeMake(contentSize.width + contentPadding.left + contentPadding.right,
contentSize.height + contentPadding.top + contentPadding.bottom);
return contentSize;
}
- (CGSize)nonFixedCombinedItemSize {
CGFloat totalWidth = 0;
CGFloat maxHeight = 0;
for (UIView *itemView in self.itemViews) {
CGSize contentSize = itemView.intrinsicContentSize;
if (contentSize.height > maxHeight) {
maxHeight = contentSize.height;
}
totalWidth += contentSize.width;
}
CGSize contentSize = CGSizeMake(totalWidth, MAX(kMinHeight, maxHeight));
return contentSize;
}
- (CGSize)intrinsicContentSizeForNonFixedLayoutStyle:(MDCTabBarViewLayoutStyle)layoutStyle {
CGSize contentSize = [self nonFixedCombinedItemSize];
UIEdgeInsets contentPadding = [self contentPaddingForLayoutStyle:layoutStyle];
contentSize = CGSizeMake(contentSize.width + contentPadding.left + contentPadding.right,
contentSize.height + contentPadding.top + contentPadding.bottom);
return contentSize;
}
- (CGSize)intrinsicContentSizeForClusteredLayout:(MDCTabBarViewLayoutStyle)layoutStyle {
if (self.items.count == 0) {
return CGSizeZero;
}
CGSize estimatedItemSize = [self itemViewSizeForClusteredFixedLayout];
CGSize contentSize =
CGSizeMake(estimatedItemSize.width * self.items.count, estimatedItemSize.height);
UIEdgeInsets contentPadding = [self contentPaddingForLayoutStyle:layoutStyle];
contentSize = CGSizeMake(contentSize.width + contentPadding.left + contentPadding.right,
contentSize.height + contentPadding.top + contentPadding.bottom);
return contentSize;
}
- (CGSize)sizeThatFits:(CGSize)size {
CGSize fitSize = [self intrinsicContentSizeForJustifiedLayout];
return CGSizeMake(size.width, fitSize.height);
}
#pragma mark - Helpers
- (BOOL)isRTL {
return self.mdf_effectiveUserInterfaceLayoutDirection ==
UIUserInterfaceLayoutDirectionRightToLeft;
}
- (void)scrollToItem:(UITabBarItem *)item animated:(BOOL)animated {
NSUInteger index = [self.items indexOfObject:item];
if (index == NSNotFound || index >= self.itemViews.count) {
index = 0;
}
if (self.itemViews.count == 0U) {
return;
}
if ([self effectiveLayoutStyle] == MDCTabBarViewLayoutStyleScrollableCentered) {
CGPoint contentOffset = [self contentOffsetNeededToCenterItemView:self.itemViews[index]];
void (^animationBlock)(void) = ^{
self.contentOffset = contentOffset;
};
if (animated) {
[self performAnimationBlockInCATransaction:animationBlock];
} else {
animationBlock();
}
} else {
CGRect estimatedItemFrame = [self estimatedFrameForItemAtIndex:index];
[self scrollRectToVisible:estimatedItemFrame animated:animated];
}
[self invalidateInteractionsForItemViews];
}
- (void)invalidateInteractionsForItemViews {
#ifdef __IPHONE_13_4
if (@available(iOS 13.4, *)) {
for (MDCTabBarView *view in self.itemViews) {
for (UIPointerInteraction *interaction in view.interactions) {
[interaction invalidate];
}
}
}
#endif
}
- (CGRect)estimatedFrameForItemAtIndex:(NSUInteger)index {
if (index == NSNotFound || index >= self.itemViews.count) {
return CGRectZero;
}
BOOL isRTL = [self isRTL];
CGFloat originAdjustment = self.isScrollableLayoutStyle ? kScrollableTabsLeadingEdgeInset : 0;
CGFloat viewOriginX = isRTL ? self.contentSize.width - originAdjustment : originAdjustment;
for (NSUInteger i = 0; i < index; ++i) {
CGSize viewSize = [self expectedSizeForView:self.itemViews[i]];
if (isRTL) {
viewOriginX -= viewSize.width;
} else {
viewOriginX += viewSize.width;
}
}
CGSize viewSize = [self expectedSizeForView:self.itemViews[index]];
if (isRTL) {
viewOriginX -= viewSize.width;
}
return CGRectMake(viewOriginX, 0, viewSize.width, viewSize.height);
}
- (CGPoint)contentOffsetNeededToCenterItemView:(UIView *)itemView {
CGFloat availableWidth = [self availableSizeForSubviewLayout].width;
CGRect itemFrame = itemView.frame;
if (CGSizeEqualToSize(itemView.frame.size, CGSizeZero)) {
NSUInteger index = [self.itemViews indexOfObject:itemView];
itemFrame = [self estimatedFrameForItemAtIndex:index];
}
CGFloat itemViewWidth = CGRectGetWidth(itemFrame);
CGFloat contentOffsetX = CGRectGetMinX(itemFrame) - ((availableWidth - itemViewWidth) / 2.f);
contentOffsetX = MAX(contentOffsetX, 0.f);
CGSize contentSize = [self calculatedContentSize];
contentOffsetX = MIN(contentOffsetX, MAX(contentSize.width - availableWidth, 0.f));
return CGPointMake(contentOffsetX, self.contentOffset.y);
}
- (CGSize)expectedSizeForView:(UIView *)view {
if (self.itemViews.count == 0) {
return CGSizeZero;
}
switch ([self effectiveLayoutStyle]) {
case MDCTabBarViewLayoutStyleFixed: {
if (CGRectGetWidth(self.bounds) > 0) {
CGSize contentSize = [self availableSizeForSubviewLayout];
return CGSizeMake(contentSize.width / self.itemViews.count, contentSize.height);
}
return [self intrinsicContentSizeForView:view];
}
case MDCTabBarViewLayoutStyleFixedClusteredCentered:
case MDCTabBarViewLayoutStyleFixedClusteredTrailing:
case MDCTabBarViewLayoutStyleFixedClusteredLeading: {
return [self itemViewSizeForClusteredFixedLayout];
}
case MDCTabBarViewLayoutStyleNonFixedClusteredCentered:
case MDCTabBarViewLayoutStyleScrollableCentered:
case MDCTabBarViewLayoutStyleScrollable: {
return [self intrinsicContentSizeForView:view];
}
}
}
- (CGSize)intrinsicContentSizeForView:(UIView *)view {
CGSize expectedItemSize = view.intrinsicContentSize;
if (expectedItemSize.width == UIViewNoIntrinsicMetric) {
NSAssert(expectedItemSize.width != UIViewNoIntrinsicMetric,
@"All tab bar item views must define an intrinsic content size.");
expectedItemSize = [view sizeThatFits:self.contentSize];
}
return expectedItemSize;
}
- (CGSize)itemViewSizeForClusteredFixedLayout {
CGFloat largestWidth = 0;
CGFloat largestHeight = 0;
for (UIView *view in self.itemViews) {
CGSize intrinsicContentSize = view.intrinsicContentSize;
if (intrinsicContentSize.width > largestWidth) {
largestWidth = intrinsicContentSize.width;
}
if (intrinsicContentSize.height > largestHeight) {
largestHeight = intrinsicContentSize.height;
}
}
return CGSizeMake(largestWidth, largestHeight);
}
- (CGRect)availableBoundsForSubviewLayout {
CGRect availableBounds = CGRectStandardize(self.bounds);
if (@available(iOS 11.0, *)) {
if (_shouldAdjustForSafeAreaInsets) {
availableBounds = UIEdgeInsetsInsetRect(availableBounds, self.safeAreaInsets);
}
}
return availableBounds;
}
- (CGSize)availableSizeForSubviewLayout {
return [self availableBoundsForSubviewLayout].size;
}
- (void)performAnimationBlockInCATransaction:(void (^)(void))animationBlock {
CAMediaTimingFunction *easeInOutFunction =
[CAMediaTimingFunction mdc_functionWithType:MDCAnimationTimingFunctionEaseInOut];
// Wrap in explicit CATransaction to allow layer-based animations with the correct duration.
[CATransaction begin];
[CATransaction setAnimationDuration:self.selectionChangeAnimationDuration];
[CATransaction setAnimationTimingFunction:easeInOutFunction];
[UIView animateWithDuration:self.selectionChangeAnimationDuration
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:animationBlock
completion:nil];
[CATransaction commit];
}
- (void)updateItemViewsShouldProcessRippleWithScrollViewGestures:(BOOL)shouldProcees {
for (UIView *itemView in self.itemViews) {
if ([itemView isKindOfClass:[MDCTabBarViewItemView class]]) {
MDCTabBarViewItemView *mdcItemView = (MDCTabBarViewItemView *)itemView;
mdcItemView.rippleTouchController.shouldProcessRippleWithScrollViewGestures = shouldProcees;
}
}
}
#pragma mark - Actions
- (void)didTapItemView:(UITapGestureRecognizer *)tap {
NSUInteger index = [self.itemViews indexOfObject:tap.view];
if (index == NSNotFound) {
return;
}
[self didReleaseTapOnTabBarItem:self.items[index]];
}
- (void)didReleaseTapOnTabBarItem:(UITabBarItem *)item {
if ([self.tabBarDelegate respondsToSelector:@selector(tabBarView:shouldSelectItem:)] &&
![self.tabBarDelegate tabBarView:self shouldSelectItem:item]) {
return;
}
self.selectedItem = item;
if ([self.tabBarDelegate respondsToSelector:@selector(tabBarView:didSelectItem:)]) {
[self.tabBarDelegate tabBarView:self didSelectItem:item];
}
}
/// Sets _selectionIndicator's bounds and center to display under the item at the given index.
- (void)updateSelectionIndicatorToIndex:(NSUInteger)index animated:(BOOL)animated {
if (index == NSNotFound || index >= self.items.count) {
// Hide selection indicator.
self.selectionIndicatorView.bounds = CGRectZero;
return;
}
// Place selection indicator under the item's cell.
CGRect selectedItemFrame = [self selectedItemView].frame;
if (CGRectEqualToRect(selectedItemFrame, CGRectZero)) {
selectedItemFrame =
[self estimatedFrameForItemAtIndex:[self.items indexOfObject:self.selectedItem]];
}
self.selectionIndicatorView.frame = selectedItemFrame;
CGRect selectionIndicatorBounds =
CGRectMake(0, 0, CGRectGetWidth(self.selectionIndicatorView.bounds),
CGRectGetHeight(self.selectionIndicatorView.bounds));
// Extract content frame from item view.
CGRect contentFrame = selectionIndicatorBounds;
UIView *itemView = self.itemViews[index];
if ([itemView conformsToProtocol:@protocol(MDCTabBarViewCustomViewable)]) {
UIView<MDCTabBarViewCustomViewable> *supportingView =
(UIView<MDCTabBarViewCustomViewable> *)itemView;
contentFrame = supportingView.contentFrame;
}
// Construct a context object describing the selected tab.
UITabBarItem *item = self.items[index];
MDCTabBarViewPrivateIndicatorContext *context =
[[MDCTabBarViewPrivateIndicatorContext alloc] initWithItem:item
bounds:selectionIndicatorBounds
contentFrame:contentFrame];
// Ask the template for attributes.
id<MDCTabBarViewIndicatorTemplate> template = self.selectionIndicatorTemplate;
MDCTabBarViewIndicatorAttributes *indicatorAttributes =
[template indicatorAttributesForContext:context];
// Update the selection indicator.
if (animated) {
[self.selectionIndicatorView applySelectionIndicatorAttributes:indicatorAttributes];
} else {
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.selectionIndicatorView applySelectionIndicatorAttributes:indicatorAttributes];
[CATransaction commit];
}
}
/**
Updates the selection indicator with or without animation. Passing @c NSNotFound for @c index will
cause the indicator to become invisible.
@param index The index of the selected item.
@param animate @c YES if the change should be animated, @c NO if it should be immediate.
*/
- (void)didSelectItemAtIndex:(NSUInteger)index animateTransition:(BOOL)animate {
void (^animationBlock)(void) = ^{
[self updateImageTintColorForAllViews];
[self updateTitleFontForAllViews];
[self updateSelectionIndicatorToIndex:index animated:animate];
// Force layout so any changes to the selection indicator are captured by the animation block.
[self.selectionIndicatorView layoutIfNeeded];
};
if (animate) {
[self performAnimationBlockInCATransaction:animationBlock];
} else {
animationBlock();
}
}
- (UIView *)selectedItemView {
if (!self.selectedItem) {
return nil;
}
return self.itemViews[[self.items indexOfObject:self.selectedItem]];
}
#pragma mark - UIPointerInteractionDelegate
#ifdef __IPHONE_13_4
- (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction
styleForRegion:(UIPointerRegion *)region API_AVAILABLE(ios(13.4)) {
UIPointerStyle *pointerStyle = nil;
if (interaction.view) {
UITargetedPreview *targetedPreview = [[UITargetedPreview alloc] initWithView:interaction.view];
UIPointerEffect *highlightEffect = [UIPointerHighlightEffect effectWithPreview:targetedPreview];
UIPointerShape *pointerShape = [UIPointerShape shapeWithRoundedRect:interaction.view.frame];
pointerStyle = [UIPointerStyle styleWithEffect:highlightEffect shape:pointerShape];
}
return pointerStyle;
}
#endif
#pragma mark - UILargeContentViewerInteractionDelegate
/** Returns the item view at the given point. Nil if there is no view at the given point. */
- (UIView *)itemViewForPoint:(CGPoint)point {
for (NSUInteger i = 0; i < self.itemViews.count; i++) {
UIView *itemView = self.itemViews[i];
if (CGRectContainsPoint(itemView.frame, point)) {
return itemView;
}
}
return nil;
}
#if MDC_AVAILABLE_SDK_IOS(13_0)
- (id<UILargeContentViewerItem>)largeContentViewerInteraction:
(UILargeContentViewerInteraction *)interaction
itemAtPoint:(CGPoint)point
NS_AVAILABLE_IOS(13_0) {
if (!CGRectContainsPoint(self.bounds, point)) {
// The touch has wandered outside of the view. Do not display the content viewer.
if ([self.lastLargeContentViewerItem isKindOfClass:[MDCTabBarViewItemView class]]) {
[((MDCTabBarViewItemView *)self.lastLargeContentViewerItem).rippleTouchController.rippleView
cancelAllRipplesAnimated:NO
completion:nil];
}
self.lastLargeContentViewerItem = nil;
return nil;
}
UIView *itemView = [self itemViewForPoint:point];
if (!itemView) {
// The touch is still within the navigation bar. Return the last seen item view.
return self.lastLargeContentViewerItem;
}
if (self.lastLargeContentViewerItem && self.lastLargeContentViewerItem != itemView) {
if ([self.lastLargeContentViewerItem isKindOfClass:[MDCTabBarViewItemView class]]) {
[((MDCTabBarViewItemView *)self.lastLargeContentViewerItem).rippleTouchController.rippleView
cancelAllRipplesAnimated:NO
completion:nil];
}
if ([itemView isKindOfClass:[MDCTabBarViewItemView class]]) {
[((MDCTabBarViewItemView *)itemView).rippleTouchController.rippleView
beginRippleTouchDownAtPoint:itemView.center
animated:NO
completion:nil];
}
}
self.lastLargeContentViewerItem = itemView;
return itemView;
}
- (void)largeContentViewerInteraction:(UILargeContentViewerInteraction *)interaction
didEndOnItem:(id<UILargeContentViewerItem>)item
atPoint:(CGPoint)point NS_AVAILABLE_IOS(13_0) {
if (item) {
for (NSUInteger i = 0; i < self.items.count; i++) {
UIView *itemView = self.itemViews[i];
if (item == itemView) {
if ([itemView isKindOfClass:[MDCTabBarViewItemView class]]) {
[((MDCTabBarViewItemView *)itemView).rippleTouchController.rippleView
beginRippleTouchUpAnimated:YES
completion:nil];
}
[self didReleaseTapOnTabBarItem:self.items[i]];
}
}
}
self.lastLargeContentViewerItem = nil;
}
#endif // MDC_AVAILABLE_SDK_IOS(13_0)
@end