blob: 9936e0916ea7212ead528169d403824ec8bd3d5d [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 "MDCItemBar.h"
#import <MDFInternationalization/MDFInternationalization.h>
#import "MDCTabBar.h"
#import "MDCItemBarAlignment.h"
#import "MDCTabBarDisplayDelegate.h"
#import "MDCTabBarIndicatorTemplate.h"
#import "MDCTabBarSizeClassDelegate.h"
#import "MDCItemBarCell.h"
#import "MDCItemBarDelegate.h"
#import "MDCItemBarStyle.h"
#import "MDCTabBarIndicatorView.h"
#import "MDCTabBarPrivateIndicatorContext.h"
/// Cell reuse identifier for item bar cells.
static NSString *const kItemReuseID = @"MDCItem";
/// Default duration in seconds for selection change animations.
static const NSTimeInterval kDefaultAnimationDuration = 0.3;
/// Placeholder width for cells, which get per-item sizing.
static const CGFloat kPlaceholderCellWidth = 10;
/// Horizontal insets in regular size class layouts.
static const CGFloat kRegularInset = 56;
/// Horizontal insets in compact size class layouts.
static const CGFloat kCompactInset = 8;
/// KVO context pointer identifying changes in MDCItemBarItem properties.
static void *kItemPropertyContext = &kItemPropertyContext;
/// Custom flow layout for item content. Selectively works around bugs with RTL and flow layout:
/// Radar 22828797: "UICollectionView with variable-sized items does not reverse item order in RTL."
/// - On iOS 9.0 and later when a UICollectionViewFlow layout has custom-sized items via
/// collectionView:layout:sizeForItemAtIndexPath:, it does not perform automatic right-to-left
/// layout of items. We work around this by detecting incorrect ordering by the superclass and
/// correcting the layout attributes.
/// Radar 22828629, 22828529
/// - On iOS 9 and later, the default scroll location for horizontally-scrolling collection views in
/// RTL is correct, but resets to the left when scrolling an item to visible or manually scrolling
/// the content. We work around this by padding collection content to always fill the scroll view.
@interface MDCItemBarFlowLayout : UICollectionViewFlowLayout
@end
#pragma mark -
#ifdef __IPHONE_13_4
@interface MDCItemBar (PointerInteraction) <UIPointerInteractionDelegate>
@end
#endif
@interface MDCItemBar () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
// Current style properties.
@property(nonatomic, strong, nullable) MDCItemBarStyle *style;
// Collection view for items.
@property(nonatomic, strong, nullable) UICollectionView *collectionView;
@end
@implementation MDCItemBar {
// Collection layout for items.
UICollectionViewFlowLayout *_flowLayout;
/// Indicator layered under the active item.
MDCTabBarIndicatorView *_selectionIndicator;
/// Size of the view at last layout, for deduplicating changes.
CGSize _lastSize;
/// Width of the collection view accounting for SafeAreaInsets at last layout.
CGFloat _lastAdjustedCollectionViewWidth;
/// The current alignment to use for item bar. This may vary from `_alignment` in cases where
/// the actual alignment is determined on-the-fly.
MDCItemBarAlignment _currentAlignment;
}
+ (CGFloat)defaultHeightForStyle:(nonnull MDCItemBarStyle *)style {
return style.defaultHeight;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self commonItemBarInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self commonItemBarInit];
}
return self;
}
- (void)commonItemBarInit {
_alignment = MDCItemBarAlignmentLeading;
_style = [[MDCItemBarStyle alloc] init];
_items = @[];
// Configure the collection view.
_flowLayout = [self generatedFlowLayout];
UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.bounds
collectionViewLayout:_flowLayout];
collectionView.backgroundColor = [UIColor clearColor];
collectionView.clipsToBounds = NO;
collectionView.scrollsToTop = NO;
collectionView.showsHorizontalScrollIndicator = NO;
collectionView.showsVerticalScrollIndicator = NO;
collectionView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentScrollableAxes;
collectionView.dataSource = self;
collectionView.delegate = self;
[collectionView registerClass:[MDCItemBarCell class] forCellWithReuseIdentifier:kItemReuseID];
_collectionView = collectionView;
[self addSubview:_collectionView];
// Configure the selection indicator view.
_selectionIndicator = [[MDCTabBarIndicatorView alloc] initWithFrame:CGRectZero];
[_collectionView addSubview:_selectionIndicator];
// Set initial properties.
[self updateAlignmentAnimated:NO];
[self updateScrollProperties];
[self updateColors];
[self updateSelectionIndicatorVisibility];
}
- (void)dealloc {
// Clear out item observations.
[self stopObservingItems];
_collectionView.delegate = nil;
}
#pragma mark - Public
- (void)applyStyle:(MDCItemBarStyle *)style {
if (style != _style && ![style isEqual:_style]) {
_style = [style copy];
// Update all style-dependent properties.
[self updateColors];
[self updateAlignmentAnimated:NO];
[self updateSelectionIndicatorVisibility];
[self updateSelectionIndicatorToIndex:[self indexForItem:_selectedItem]];
[self configureVisibleCells];
[self invalidateIntrinsicContentSize];
}
}
- (void)setItems:(NSArray<UITabBarItem *> *)items {
NSAssert([NSThread isMainThread], @"Item array may only be set on the main thread");
NSParameterAssert(items != nil);
if (_items != items && ![_items isEqual:items]) {
// Stop observing old items.
[self stopObservingItems];
_items = [items copy];
// Determine new selected item, defaulting to the first item.
UITabBarItem *newSelectedItem = _items.firstObject;
if (_selectedItem && [_items containsObject:_selectedItem]) {
// Previously-selected item still around: Preserve selection.
newSelectedItem = _selectedItem;
}
// Update _selectedItem directly so it's available for -reload.
_selectedItem = newSelectedItem;
// Update collection with new items
[self reload];
// Select tab for current item.
[self selectItemAtIndex:[self indexForItem:_selectedItem] animated:NO];
// Start observing new items for changes.
[self startObservingItems];
}
}
- (void)setAlignment:(MDCItemBarAlignment)alignment {
[self setAlignment:alignment animated:NO];
}
- (void)setAlignment:(MDCItemBarAlignment)alignment animated:(BOOL)animated {
if (_alignment != alignment) {
_alignment = alignment;
[self updateAlignmentAnimated:animated];
}
}
- (void)setSelectedItem:(nullable UITabBarItem *)selectedItem {
[self setSelectedItem:selectedItem animated:NO];
}
- (void)setSelectedItem:(nullable UITabBarItem *)selectedItem animated:(BOOL)animated {
if (_selectedItem != selectedItem) {
NSUInteger itemIndex = [self indexForItem:selectedItem];
if (selectedItem && (itemIndex == NSNotFound)) {
[[NSException exceptionWithName:NSInvalidArgumentException
reason:@"Invalid item"
userInfo:nil] raise];
}
_selectedItem = selectedItem;
[self selectItemAtIndex:itemIndex animated:animated];
}
}
#pragma mark - Accessibility
- (UIAccessibilityTraits)accessibilityTraits {
return [super accessibilityTraits] | UIAccessibilityTraitTabBar;
}
- (id)accessibilityElementForItem:(UITabBarItem *)item {
NSUInteger index = [_items indexOfObject:item];
if (index != NSNotFound) {
NSArray<NSIndexPath *> *visibleItems = [_collectionView indexPathsForVisibleItems];
NSIndexPath *indexPath = [self indexPathForItemAtIndex:index];
if ([visibleItems containsObject:indexPath]) {
MDCItemBarCell *itemCell =
(MDCItemBarCell *)[_collectionView cellForItemAtIndexPath:indexPath];
return itemCell;
}
}
return nil;
}
#pragma mark - NSObject
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
if (context == kItemPropertyContext) {
// MDCItemBarItem change, must be on the main thread.
NSAssert([NSThread isMainThread], @"Item bar items may only be updated on the main thread.");
NSAssert([object isKindOfClass:[UITabBarItem class]], @"Change in unexpected object type");
UITabBarItem *item = object;
NSInteger itemIndex = [_items indexOfObject:item];
NSAssert(itemIndex != NSNotFound, @"Inconsistency: Change in unowned item bar item.");
// Update the cell for the given item if it's visible.
if (itemIndex != NSNotFound) {
NSIndexPath *indexPath = [self indexPathForItemAtIndex:itemIndex];
UICollectionViewCell *cell = [_collectionView cellForItemAtIndexPath:indexPath];
if (cell) {
NSAssert([cell isKindOfClass:[MDCItemBarCell class]], @"All cells must be MDCItemBarCell");
MDCItemBarCell *itemCell = (MDCItemBarCell *)cell;
[itemCell updateWithItem:item atIndex:itemIndex count:_items.count];
}
}
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
#pragma mark - UIView
- (void)layoutSubviews {
[super layoutSubviews];
const CGRect bounds = self.bounds;
_collectionView.frame = bounds;
// Update collection metrics if the size has changed.
if (!CGSizeEqualToSize(bounds.size, _lastSize) ||
[self adjustedCollectionViewWidth] != _lastAdjustedCollectionViewWidth) {
[self updateAlignmentAnimated:NO];
// Ensure selected item is aligned properly on resize, forcing the new layout to take effect.
[_collectionView layoutIfNeeded];
[self selectItemAtIndex:[self indexForItem:_selectedItem] animated:NO];
}
_lastSize = bounds.size;
_lastAdjustedCollectionViewWidth = [self adjustedCollectionViewWidth];
// The selection indicator must be behind all cells, regardless of the collection view's layout.
[_collectionView sendSubviewToBack:_selectionIndicator];
}
- (CGSize)sizeThatFits:(CGSize)size {
size.height = [[self class] defaultHeightForStyle:_style];
return size;
}
- (CGSize)intrinsicContentSize {
return CGSizeMake(UIViewNoIntrinsicMetric, [[self class] defaultHeightForStyle:_style]);
}
- (void)safeAreaInsetsDidChange {
[super safeAreaInsetsDidChange];
[self setNeedsLayout];
}
- (void)didMoveToWindow {
[super didMoveToWindow];
// New window: Update for potentially updated size class.
[self updateAlignmentAnimated:NO];
}
- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
// Update alignment and layout metrics for potentially updated size class.
[self updateAlignmentAnimated:NO];
}
- (void)tintColorDidChange {
[super tintColorDidChange];
[self updateColors];
}
- (void)setSemanticContentAttribute:(UISemanticContentAttribute)semanticContentAttribute {
if (semanticContentAttribute == self.semanticContentAttribute) {
return;
}
super.semanticContentAttribute = semanticContentAttribute;
_collectionView.semanticContentAttribute = semanticContentAttribute;
[_collectionView.collectionViewLayout invalidateLayout];
}
#pragma mark - UICollectionViewDelegate
- (BOOL)collectionView:(UICollectionView *)collectionView
shouldSelectItemAtIndexPath:(NSIndexPath *)indexPath {
if (_collectionView == collectionView) {
// Notify delegate of impending selection.
id<MDCItemBarDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(itemBar:shouldSelectItem:)]) {
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (item) {
return [delegate itemBar:self shouldSelectItem:item];
}
}
}
return YES;
}
- (void)collectionView:(UICollectionView *)collectionView
didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
if (_collectionView == collectionView) {
// Update selected item.
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (!item) {
return;
}
_selectedItem = item;
// Notify delegate of new selection.
id<MDCItemBarDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(itemBar:didSelectItem:)]) {
[delegate itemBar:self didSelectItem:item];
}
// Update UI to reflect newly selected item.
[self didSelectItemAtIndex:indexPath.item animateTransition:YES];
[_collectionView scrollToItemAtIndexPath:indexPath
atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally
animated:YES];
}
}
#pragma mark - UICollectionViewDataSource
- (NSInteger)collectionView:(UICollectionView *)collectionView
numberOfItemsInSection:(NSInteger)section {
if ((_collectionView == collectionView) && section == 0) {
return [_items count];
}
return 0;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath {
NSParameterAssert(_collectionView == collectionView);
MDCItemBarCell *itemCell = [collectionView dequeueReusableCellWithReuseIdentifier:kItemReuseID
forIndexPath:indexPath];
[self configureCell:itemCell];
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (item) {
[itemCell updateWithItem:item atIndex:indexPath.item count:_items.count];
}
return itemCell;
}
- (void)collectionView:(UICollectionView *)collectionView
willDisplayCell:(UICollectionViewCell *)cell
forItemAtIndexPath:(NSIndexPath *)indexPath {
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (item) {
[self.tabBar.displayDelegate tabBar:self.tabBar willDisplayItem:item];
if ([cell isKindOfClass:[MDCItemBarCell class]]) {
cell.selected = (item == self.selectedItem);
}
}
}
- (void)collectionView:(UICollectionView *)collectionView
didEndDisplayingCell:(UICollectionViewCell *)cell
forItemAtIndexPath:(NSIndexPath *)indexPath {
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (item) {
[self.tabBar.displayDelegate tabBar:self.tabBar didEndDisplayingItem:item];
}
}
#pragma mark - UICollectionViewDelegateFlowLayout
- (CGSize)collectionView:(UICollectionView *)collectionView
layout:(__unused UICollectionViewLayout *)collectionViewLayout
sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
NSParameterAssert(_collectionView == collectionView);
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (!item) {
return CGSizeZero;
}
const CGFloat itemHeight = CGRectGetHeight(self.bounds);
CGSize size = CGSizeMake(CGFLOAT_MAX, itemHeight);
// Size cell to fit content.
size = [MDCItemBarCell sizeThatFits:size item:item style:_style];
// Divide justified items evenly across the view.
if (_currentAlignment == MDCItemBarAlignmentJustified) {
NSInteger count = [self collectionView:_collectionView numberOfItemsInSection:0];
size.width = [self adjustedCollectionViewWidth] / MAX(count, 1);
}
// Constrain to style-based width if necessary.
if (_style.maximumItemWidth > 0) {
size.width = MIN(size.width, _style.maximumItemWidth);
}
// Constrain to view width
size.width = MIN(size.width, [self adjustedCollectionViewWidth]);
// Force height to our height.
size.height = itemHeight;
return size;
}
#pragma mark - Private
- (CGFloat)adjustedCollectionViewWidth {
return CGRectGetWidth(
UIEdgeInsetsInsetRect(_collectionView.bounds, _collectionView.adjustedContentInset));
return CGRectGetWidth(_collectionView.bounds);
}
+ (NSArray *)observableItemKeys {
static dispatch_once_t onceToken;
static NSArray *s_keys = nil;
// clang-format off
dispatch_once(&onceToken, ^{
s_keys = @[
NSStringFromSelector(@selector(title)),
NSStringFromSelector(@selector(image)),
NSStringFromSelector(@selector(selectedImage)),
NSStringFromSelector(@selector(badgeValue)),
NSStringFromSelector(@selector(badgeColor)),
NSStringFromSelector(@selector(accessibilityIdentifier)),
NSStringFromSelector(@selector(accessibilityLabel))
];
});
// clang-format on
return s_keys;
}
- (void)startObservingItems {
NSAssert([NSThread isMainThread], @"Main thread required for KVO registration");
for (UITabBarItem *item in _items) {
for (NSString *key in [[self class] observableItemKeys]) {
[item addObserver:self forKeyPath:key options:0 context:kItemPropertyContext];
}
}
}
- (void)stopObservingItems {
NSAssert([NSThread isMainThread], @"Main thread required for KVO unregistration");
for (UITabBarItem *item in _items) {
for (NSString *key in [[self class] observableItemKeys]) {
[item removeObserver:self forKeyPath:key context:kItemPropertyContext];
}
}
}
- (UIUserInterfaceSizeClass)horizontalSizeClass {
NSObject<MDCTabBarSizeClassDelegate> *tabBarSizeClassDelegate = self.tabBar.sizeClassDelegate;
if ([tabBarSizeClassDelegate respondsToSelector:@selector(horizontalSizeClassForObject:)]) {
return [tabBarSizeClassDelegate horizontalSizeClassForObject:self];
}
return self.traitCollection.horizontalSizeClass;
}
- (void)selectItemAtIndex:(NSUInteger)index animated:(BOOL)animated {
if (index != NSNotFound) {
NSParameterAssert(index < [_items count]);
[_collectionView selectItemAtIndexPath:[self indexPathForItemAtIndex:index]
animated:animated
scrollPosition:UICollectionViewScrollPositionCenteredHorizontally];
} else {
// Deselect all
for (NSIndexPath *path in [_collectionView indexPathsForSelectedItems]) {
[_collectionView deselectItemAtIndexPath:path animated:NO];
}
}
[self didSelectItemAtIndex:index animateTransition:animated];
}
- (UITabBarItem *)itemAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath && indexPath.section == 0 && indexPath.item >= 0 &&
(NSInteger)_items.count > indexPath.item) {
return _items[indexPath.item];
}
return nil;
}
- (NSInteger)indexForItem:(nullable UITabBarItem *)item {
if (item) {
return [_items indexOfObject:item];
}
return NSNotFound;
}
- (NSIndexPath *)indexPathForItemAtIndex:(NSInteger)index {
return [NSIndexPath indexPathForItem:index inSection:0];
}
- (void)invalidateItemCellPointerInteractions {
#ifdef __IPHONE_13_4
if (@available(iOS 13.4, *)) {
for (MDCItemBarCell *cell in self.collectionView.visibleCells) {
for (UIPointerInteraction *interaction in cell.interactions) {
[interaction invalidate];
}
}
}
#endif
}
- (void)reload {
[_collectionView reloadData];
[self updateAlignmentAnimated:NO];
}
- (UICollectionViewFlowLayout *)generatedFlowLayout {
UICollectionViewFlowLayout *flowLayout = [[MDCItemBarFlowLayout alloc] init];
CGFloat itemHeight = CGRectGetHeight(self.bounds);
flowLayout.itemSize = CGSizeMake(kPlaceholderCellWidth, itemHeight);
flowLayout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
flowLayout.sectionInset = UIEdgeInsetsZero;
flowLayout.minimumInteritemSpacing = 0;
flowLayout.minimumLineSpacing = 0;
return flowLayout;
}
- (void)didSelectItemAtIndex:(NSInteger)index animateTransition:(BOOL)animate {
void (^animationBlock)(void) = ^{
[self updateSelectionIndicatorToIndex:index];
// Force layout so any changes to the selection indicator are captured by the animation block.
[self->_selectionIndicator layoutIfNeeded];
};
void (^completionBlock)(void) = ^{
[self invalidateItemCellPointerInteractions];
};
if (animate) {
CAMediaTimingFunction *easeInOutFunction =
[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
// Wrap in explicit CATransaction to allow layer-based animations with the correct duration.
[CATransaction begin];
[CATransaction setAnimationDuration:kDefaultAnimationDuration];
[CATransaction setAnimationTimingFunction:easeInOutFunction];
[CATransaction setCompletionBlock:completionBlock];
[UIView animateWithDuration:kDefaultAnimationDuration
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:animationBlock
completion:nil];
[CATransaction commit];
} else {
animationBlock();
completionBlock();
}
}
- (void)updateAlignmentAnimated:(BOOL)animated {
[self updateCurrentAlignment];
[self updateScrollProperties];
[self updateFlowLayoutMetricsAnimated:animated];
}
- (void)updateCurrentAlignment {
if (_alignment != MDCItemBarAlignmentBestEffortJustified) {
_currentAlignment = _alignment;
return;
}
// Calculate the "best" alignment for MDCItemBarAlignmentBestEffortJustified. Begin with
// Justified alignment, but calculate to see if Leading alignment would be a better fit.
_currentAlignment = MDCItemBarAlignmentJustified;
const CGFloat widthPerJustifiedItem = [self adjustedCollectionViewWidth] / MAX(_items.count, 1ul);
CGSize size = CGSizeMake(CGFLOAT_MAX, self.bounds.size.height);
for (UITabBarItem *item in _items) {
const CGSize itemSize = [MDCItemBarCell sizeThatFits:size item:item style:_style];
const CGFloat itemWidth = itemSize.width;
// If any item cannot fit nicely in its portion of the width, fallback to Leading alignment.
if (itemWidth >= widthPerJustifiedItem) {
_currentAlignment = MDCItemBarAlignmentLeading;
break;
}
}
}
/// Sets _selectionIndicator's bounds and center to display under the item at the given index with
/// no animation. May be called from an animation block to animate the transition.
- (void)updateSelectionIndicatorToIndex:(NSInteger)index {
if (index == NSNotFound) {
// Hide selection indicator.
_selectionIndicator.bounds = CGRectZero;
return;
}
// Use layout attributes as the cell may not be visible or loaded yet.
NSIndexPath *indexPath = [self indexPathForItemAtIndex:index];
UICollectionViewLayoutAttributes *layoutAttributes =
[_flowLayout layoutAttributesForItemAtIndexPath:indexPath];
// Place selection indicator under the item's cell.
CGRect selectionIndicatorBounds = layoutAttributes.bounds;
CGPoint selectionIndicatorCenter = layoutAttributes.center;
_selectionIndicator.bounds = selectionIndicatorBounds;
_selectionIndicator.center = selectionIndicatorCenter;
// Extract content frame from cell.
CGRect contentFrame = selectionIndicatorBounds;
UICollectionViewCell *cell = [_collectionView cellForItemAtIndexPath:indexPath];
if ([cell isKindOfClass:[MDCItemBarCell class]]) {
MDCItemBarCell *itemBarCell = (MDCItemBarCell *)cell;
contentFrame = [cell convertRect:itemBarCell.contentFrame fromView:cell];
}
// Construct a context object describing the selected tab.
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (!item) {
return;
}
MDCTabBarPrivateIndicatorContext *context =
[[MDCTabBarPrivateIndicatorContext alloc] initWithItem:item
bounds:selectionIndicatorBounds
contentFrame:contentFrame];
// Ask the template for attributes.
id<MDCTabBarIndicatorTemplate> template = _style.selectionIndicatorTemplate;
MDCTabBarIndicatorAttributes *indicatorAttributes =
[template indicatorAttributesForContext:context];
// Update the selection indicator.
[_selectionIndicator applySelectionIndicatorAttributes:indicatorAttributes];
}
- (void)updateFlowLayoutMetricsAnimated:(BOOL)animate {
void (^animationBlock)(void) = ^{
[self updateFlowLayoutMetrics];
};
if (animate) {
[CATransaction begin];
CAMediaTimingFunction *easeInOut =
[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
[CATransaction setAnimationTimingFunction:easeInOut];
[UIView animateWithDuration:kDefaultAnimationDuration
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:animationBlock
completion:nil];
[CATransaction commit];
} else {
animationBlock();
}
}
- (void)updateScrollProperties {
_collectionView.alwaysBounceHorizontal = (_currentAlignment != MDCItemBarAlignmentJustified);
}
- (void)updateFlowLayoutMetrics {
UIUserInterfaceSizeClass horizontalSizeClass = [self horizontalSizeClass];
UIEdgeInsets newSectionInset = UIEdgeInsetsZero;
switch (_currentAlignment) {
case MDCItemBarAlignmentLeading:
newSectionInset = [self leadingAlignedInsetsForHorizontalSizeClass:horizontalSizeClass];
break;
case MDCItemBarAlignmentJustified:
newSectionInset = [self justifiedInsets];
break;
case MDCItemBarAlignmentCenter:
newSectionInset = [self centeredInsetsForHorizontalSizeClass:horizontalSizeClass];
break;
case MDCItemBarAlignmentCenterSelected:
newSectionInset = [self centerSelectedInsets];
break;
case MDCItemBarAlignmentBestEffortJustified:
// This case should never be possible, since the _currentAlignment will never be set to
// this value.
break;
}
// Rather than just updating the sectionInset on the existing flowLayout, a new layout object
// is created. This gives more control over whether the change is animated or not - as there is
// no control when updating flow layout sectionInset (it's always animated).
UICollectionViewFlowLayout *flowLayout = [self generatedFlowLayout];
flowLayout.sectionInset = newSectionInset;
_flowLayout = flowLayout;
// This is not animated because -updateFlowLayoutMetrics may be called in an animation block and
// the change will be still get animated anyway - using NO avoids 'double' animation and allows
// this method to be used without animation.
[_collectionView setCollectionViewLayout:_flowLayout animated:NO];
// Force immediate layout so the selection indicator can be placed accurately.
[_collectionView layoutIfNeeded];
// Update selection indicator to potentially new location and size
// Not animated for the same reason as mentioned above.
[self updateSelectionIndicatorToIndex:[self indexForItem:_selectedItem]];
}
- (UIEdgeInsets)leadingAlignedInsetsForHorizontalSizeClass:(UIUserInterfaceSizeClass)sizeClass {
const BOOL isRegular = (sizeClass == UIUserInterfaceSizeClassRegular);
CGFloat inset = isRegular ? kRegularInset : kCompactInset;
// If the collection view has Safe Area insets, we don't want to add an extra horizontal inset.
if (_collectionView.safeAreaInsets.left > 0 || _collectionView.safeAreaInsets.right > 0) {
inset = 0;
}
return UIEdgeInsetsMake(0, inset, 0, inset);
}
- (UIEdgeInsets)justifiedInsets {
// Center items, which will be at most the width of the view.
CGFloat itemWidths = [self totalWidthOfAllItems];
CGFloat sideInsets = floorf((float)([self adjustedCollectionViewWidth] - itemWidths) / 2);
return UIEdgeInsetsMake(0.0, sideInsets, 0.0, sideInsets);
}
- (UIEdgeInsets)centeredInsetsForHorizontalSizeClass:(UIUserInterfaceSizeClass)sizeClass {
CGFloat itemWidths = [self totalWidthOfAllItems];
CGFloat viewWidth = [self adjustedCollectionViewWidth];
UIEdgeInsets insets = [self leadingAlignedInsetsForHorizontalSizeClass:sizeClass];
if (itemWidths <= (viewWidth - insets.left - insets.right)) {
CGFloat sideInsets = ([self adjustedCollectionViewWidth] - itemWidths) / 2;
return UIEdgeInsetsMake(0.0, sideInsets, 0.0, sideInsets);
}
return insets;
}
- (UIEdgeInsets)centerSelectedInsets {
UIEdgeInsets sectionInset = UIEdgeInsetsZero;
NSInteger count = [self collectionView:_collectionView numberOfItemsInSection:0];
if (count > 0) {
CGFloat halfBoundsWidth = [self adjustedCollectionViewWidth] / 2;
CGSize firstSize = [self collectionView:_collectionView
layout:_flowLayout
sizeForItemAtIndexPath:[self indexPathForItemAtIndex:0]];
CGSize lastSize = [self collectionView:_collectionView
layout:_flowLayout
sizeForItemAtIndexPath:[self indexPathForItemAtIndex:count - 1]];
// Left inset is equal to the space to the left of the first item when centered.
CGFloat halfFirstWidth = firstSize.width / 2;
sectionInset.left = halfBoundsWidth - halfFirstWidth;
// Right inset is equal to the space to the right of the last item when centered.
CGFloat halfLastWidth = lastSize.width / 2;
sectionInset.right = halfBoundsWidth - halfLastWidth;
}
return sectionInset;
}
- (CGFloat)totalWidthOfAllItems {
CGFloat itemWidths = 0;
NSInteger count = [self collectionView:_collectionView numberOfItemsInSection:0];
for (NSInteger itemIndex = 0; itemIndex < count; itemIndex++) {
CGSize itemSize = [self collectionView:_collectionView
layout:_flowLayout
sizeForItemAtIndexPath:[self indexPathForItemAtIndex:itemIndex]];
itemWidths += itemSize.width;
}
return itemWidths;
}
- (void)configureCell:(MDCItemBarCell *)cell {
// Configure content style
[cell applyStyle:_style];
#ifdef __IPHONE_13_4
if (@available(iOS 13.4, *)) {
// Add a pointer interaction if necessary
if (cell.interactions.count == 0) {
// 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];
[cell addInteraction:pointerInteraction];
}
}
}
#endif
}
- (void)configureVisibleCells {
NSArray<NSIndexPath *> *indexPaths = [self.collectionView indexPathsForVisibleItems];
for (NSIndexPath *indexPath in indexPaths) {
UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath];
if ([cell isKindOfClass:[MDCItemBarCell class]]) {
MDCItemBarCell *itemCell = (MDCItemBarCell *)cell;
[self configureCell:itemCell];
UITabBarItem *item = [self itemAtIndexPath:indexPath];
if (item) {
[itemCell updateWithItem:item atIndex:indexPath.item count:_items.count];
}
}
}
}
- (void)updateColors {
[self configureVisibleCells];
_selectionIndicator.tintColor = _style.selectionIndicatorColor;
}
- (void)updateSelectionIndicatorVisibility {
_selectionIndicator.hidden = !_style.shouldDisplaySelectionIndicator;
}
#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];
pointerStyle = [UIPointerStyle styleWithEffect:highlightEffect shape:nil];
}
return pointerStyle;
}
#endif
@end
#pragma mark - MDCItemBarFlowLayout
@implementation MDCItemBarFlowLayout {
/// Map from item index paths to RTL-corrected layout attributes. If no RTL correction is in
/// effect, this will be set to nil.
NSDictionary *_correctedAttributesForIndexPath;
/// Controls the use of a padded collection view content size.
BOOL _isPaddingCollectionViewContentSize;
/// Collection view content size which will be used if padding is in effect.
CGSize _paddedCollectionViewContentSize;
}
- (void)prepareLayout {
[super prepareLayout];
// Post-process the superclass layout to determine which workarounds need to be applied.
BOOL shouldRelayoutAttributesForRTL = [self shouldRelayoutAttributesForRTL];
BOOL shouldPadContentSizeForRTL = [self shouldPadContentSizeForRTL];
// Build a new map of adjusted attributes if needed.
if (shouldRelayoutAttributesForRTL || shouldPadContentSizeForRTL) {
NSMutableDictionary *newAttributes = [NSMutableDictionary dictionary];
const NSInteger sectionCount = self.collectionView.numberOfSections;
for (NSInteger sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) {
const NSInteger itemCount = [self.collectionView numberOfItemsInSection:sectionIndex];
for (NSInteger itemIndex = 0; itemIndex < itemCount; itemIndex++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:itemIndex inSection:sectionIndex];
// Must call super here to ensure we get un-corrected attributes.
UICollectionViewLayoutAttributes *attributes =
[super layoutAttributesForItemAtIndexPath:indexPath];
// Flip attribute order for RTL. This must happen before padding because attributes are
// flipped using the original un-padded bounds.
if (shouldRelayoutAttributesForRTL) {
attributes = [self flippedAttributesFromAttributes:attributes];
}
// Apply per-item content size padding.
if (shouldPadContentSizeForRTL) {
attributes = [self paddedAttributesFromAttributes:attributes];
}
newAttributes[indexPath] = attributes;
}
}
_correctedAttributesForIndexPath = newAttributes;
} else {
// Clear out the map to indicate that no corrections are in effect.
_correctedAttributesForIndexPath = nil;
}
// Apply global content size padding.
if (shouldPadContentSizeForRTL) {
_isPaddingCollectionViewContentSize = YES;
_paddedCollectionViewContentSize = [self adjustedCollectionViewBounds].size;
} else {
_isPaddingCollectionViewContentSize = NO;
}
}
- (nullable UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:
(NSIndexPath *)indexPath {
if (_correctedAttributesForIndexPath) {
return _correctedAttributesForIndexPath[indexPath];
}
// No RTL correction needed.
return [super layoutAttributesForItemAtIndexPath:indexPath];
}
- (nullable NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
if (_correctedAttributesForIndexPath) {
NSPredicate *predicate =
[NSPredicate predicateWithBlock:^BOOL(UICollectionViewLayoutAttributes *layoutAttributes,
__unused NSDictionary *__nullable bindings) {
return CGRectIntersectsRect(layoutAttributes.frame, rect);
}];
return [_correctedAttributesForIndexPath.allValues filteredArrayUsingPredicate:predicate];
}
// No RTL correction needed.
return [super layoutAttributesForElementsInRect:rect];
}
- (CGSize)collectionViewContentSize {
if (_isPaddingCollectionViewContentSize) {
return _paddedCollectionViewContentSize;
}
return [super collectionViewContentSize];
}
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
if (!CGSizeEqualToSize(newBounds.size, self.collectionView.bounds.size)) {
// Padding depends on the size of the collection view, need to relayout for size changes.
return YES;
}
return [super shouldInvalidateLayoutForBoundsChange:newBounds];
}
#pragma mark - Private
/// Computes RTL-flipped attributes given superclass-calculated attributes.
- (UICollectionViewLayoutAttributes *)flippedAttributesFromAttributes:
(UICollectionViewLayoutAttributes *)attributes {
UICollectionViewLayoutAttributes *newAttributes = [attributes copy];
CGRect itemFrame = attributes.frame;
// Must call super here to ensure we have the original collection bounds.
CGRect collectionBounds = {CGPointZero, [super collectionViewContentSize]};
newAttributes.frame = MDFRectFlippedHorizontally(itemFrame, CGRectGetWidth(collectionBounds));
return newAttributes;
}
- (BOOL)shouldEnforceRightToLeftLayout {
// Prior to iOS 9 RTL was not automatically applied, so we don't need to apply any fixes.
NSOperatingSystemVersion iOS9Version = {9, 0, 0};
UIUserInterfaceLayoutDirection rtl = UIUserInterfaceLayoutDirectionRightToLeft;
NSProcessInfo *processInfo = [NSProcessInfo processInfo];
return [processInfo isOperatingSystemAtLeastVersion:iOS9Version] &&
self.collectionView.effectiveUserInterfaceLayoutDirection == rtl;
}
/// Indicates if the superclass' layout appears to have been layed out in a left-to-right order. If
/// there are zero or one items total, this always returns NO.
/// Note: This is used to detect incorrect layouts due to radar 22828797 by detecting the actual
/// layout ordering from superclass-generated layout frames. When there's no error (item frames are
/// already arranged RTL), this returns NO. We use this approach for two purposes:
/// * Robustly detecting the error condition. The bug occurs under specific conditions that would be
/// difficult to detect reliably, and given item bars are simple linear layouts it's more robust
/// to detect the error and correct it.
/// * Automatically disabling this workaround if the underlying bug is fixed in an unknown future OS
/// version. At time of writing (iOS 9.2) the bug has not been fixed. Detecting the error directly
/// and correcting it should allow this implementation to continue to produce correct results
/// without an immediate need for changes.
- (BOOL)shouldRelayoutAttributesForRTL {
// The logic contained here only applies to horizontally-scrolling flow layouts. This should
// always be the case for MDCItemBar, but check for safety.
NSParameterAssert(self.scrollDirection == UICollectionViewScrollDirectionHorizontal);
if (![self shouldEnforceRightToLeftLayout]) {
return NO;
}
CGRect previousFrame = CGRectNull;
const NSInteger sectionCount = self.collectionView.numberOfSections;
for (NSInteger sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) {
const NSInteger itemCount = [self.collectionView numberOfItemsInSection:sectionIndex];
for (NSInteger itemIndex = 0; itemIndex < itemCount; itemIndex++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:itemIndex inSection:sectionIndex];
// Must call super here to ensure we get un-corrected attributes.
UICollectionViewLayoutAttributes *attributes =
[super layoutAttributesForItemAtIndexPath:indexPath];
CGRect frame = attributes.frame;
// If any adjacent pairs of attributes have centers that are ordered left-to-right, assume the
// whole layout is ordered left-to-right.
if (!CGRectIsNull(previousFrame) && (CGRectGetMidX(previousFrame) < CGRectGetMidX(frame))) {
return YES;
}
// Set up for next iteration.
previousFrame = frame;
}
}
// There are either fewer than 2 items (in which case order doesn't matter) or the whole thing is
// ordered RTL.
return NO;
}
/// Indicates if the collection's content is narrower than the collection view.
/// Note: This is used to detect conditions under which collection view scrolling bugs happen in RTL
/// layouts. If and when the underlying bugs are fixed in an unknown future iOS version, this method
/// will continue to return YES and trigger harmless but unnecessary workarounds.
- (BOOL)shouldPadContentSizeForRTL {
if (![self shouldEnforceRightToLeftLayout]) {
return NO;
}
// When the content is narrower than the scroll view bounds, we need to pad all attribute frames
// on the left to prevent the layout from "jumping" to the origin under various situations.
// Must call super here to ensure we have the original collection content size.
CGSize contentSize = [super collectionViewContentSize];
CGRect scrollBounds = [self adjustedCollectionViewBounds];
return contentSize.width < CGRectGetWidth(scrollBounds);
}
- (UICollectionViewLayoutAttributes *)paddedAttributesFromAttributes:
(UICollectionViewLayoutAttributes *)attributes {
// Must call super here to ensure we have the original collection content size.
CGSize contentSize = [super collectionViewContentSize];
CGRect scrollBounds = [self adjustedCollectionViewBounds];
CGFloat leftPadding = CGRectGetWidth(scrollBounds) - contentSize.width;
UICollectionViewLayoutAttributes *newAttributes = [attributes copy];
// Shift frame to the right to counteract the content size width expansion.
CGRect itemFrame = attributes.frame;
itemFrame.origin.x += leftPadding;
newAttributes.frame = itemFrame;
return newAttributes;
}
- (CGRect)adjustedCollectionViewBounds {
return UIEdgeInsetsInsetRect(self.collectionView.bounds,
self.collectionView.adjustedContentInset);
return self.collectionView.bounds;
}
@end