blob: 2d4a61862e42505d3266bbc95ed6da63ccca5499 [file] [log] [blame]
// 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 "MDCAppBarButtonBarBuilder.h"
#import <objc/runtime.h>
#import "MaterialInk.h"
#import "MaterialAvailability.h"
#import "MDCButtonBar.h"
#import "MDCButtonBarButton.h"
#import "MDCButtonBar+Private.h"
#import "MaterialButtons.h"
// Additional insets for the left-most or right-most items.
static const CGFloat kEdgeButtonAdditionalMargin = 4;
// The default MDCButton's alpha for display state is 0.1 which in the context of bar buttons makes
// it practically invisible. Setting button to a higher opacity is closer to what the button should
// look like when it is disabled.
static const CGFloat kDisabledButtonAlpha = (CGFloat)0.38;
// Default content inset for buttons.
static const UIEdgeInsets kButtonInset = {0, 12, 0, 12};
// Indiana Jones style placeholder view for UINavigationBar. Ownership of UIBarButtonItem.customView
// and UINavigationItem.titleView are normally transferred to UINavigationController but we plan to
// steal them away. In order to avoid crashing during KVO updates, we steal the view away and
// replace it with a sandbag view.
@interface MDCButtonBarSandbagView : UIView
@end
@interface UIBarButtonItem (MDCHeaderInternal)
// Internal version of the standard -customView property. When an item is pushed onto a
// UINavigationController stack, any -customView object is moved over to this property. This
// prevents UINavigationController from adding the customView to its own view hierarchy.
@property(nonatomic, strong, setter=mdc_setCustomView:) UIView *mdc_customView;
@end
@implementation MDCAppBarButtonBarBuilder {
NSMutableDictionary<NSNumber *, UIFont *> *_fonts;
NSMutableDictionary<NSNumber *, UIColor *> *_titleColors;
}
- (instancetype)init {
self = [super init];
if (self) {
_fonts = [NSMutableDictionary dictionary];
_titleColors = [NSMutableDictionary dictionary];
}
return self;
}
- (nullable UIFont *)titleFontForState:(UIControlState)state {
UIFont *font = _fonts[@(state)];
if (!font && state != UIControlStateNormal) {
font = _fonts[@(UIControlStateNormal)];
}
return font;
}
- (void)setTitleFont:(UIFont *)font forState:(UIControlState)state {
_fonts[@(state)] = font;
}
- (UIColor *)titleColorForState:(UIControlState)state {
UIColor *color = _titleColors[@(state)];
if (!color && state != UIControlStateNormal) {
color = _titleColors[@(UIControlStateNormal)];
}
return color;
}
- (void)setTitleColor:(UIColor *)color forState:(UIControlState)state {
_titleColors[@(state)] = color;
}
- (void)updateTitleColorForButton:(UIButton *)button withItem:(UIBarButtonItem *)item {
// Apply title colors to the button in order of descending priority (last one wins).
// General buttonTitleColor (proxy for contextual tint color)
[button setTitleColor:self.buttonTitleColor forState:UIControlStateNormal];
// Explicit -setTitleColor:forState:
for (NSNumber *state in _titleColors) {
UIColor *color = _titleColors[state];
[button setTitleColor:color forState:(UIControlState)state.intValue];
}
// The item's explicit tintColor
if (item.tintColor) {
[button setTitleColor:item.tintColor forState:UIControlStateNormal];
}
}
#pragma mark - MDCBarButtonItemBuilding
- (UIView *)buttonBar:(MDCButtonBar *)buttonBar
viewForItem:(UIBarButtonItem *)buttonItem
layoutHints:(MDCBarButtonItemLayoutHints)layoutHints {
if (buttonItem == nil) {
return nil;
}
// Transfer custom view ownership if necessary.
[self transferCustomViewOwnershipForBarButtonItem:buttonItem];
// Take the real custom view if it exists instead of sandbag view.
UIView *customView =
buttonItem.mdc_customView ? buttonItem.mdc_customView : buttonItem.customView;
if (customView) {
return customView;
}
// NOTE: This assertion does not occur in release builds because it is accessing a private api.
#if DEBUG
NSAssert(![[buttonItem valueForKey:@"isSystemItem"] boolValue],
@"Instances of %@ must not be initialized with %@ when working with %@."
@" This is because we cannot extract the system item type from the item.",
NSStringFromClass([buttonItem class]),
NSStringFromSelector(@selector(initWithBarButtonSystemItem:target:action:)),
NSStringFromClass([MDCButtonBar class]));
#endif
#ifdef __IPHONE_14_0
MDCButtonBarButton *button;
if (@available(iOS 14.0, *)) {
button = [MDCButtonBarButton buttonWithType:UIButtonTypeCustom
primaryAction:buttonItem.primaryAction];
} else {
button = [[MDCButtonBarButton alloc] init];
}
#else
MDCButtonBarButton *button = [[MDCButtonBarButton alloc] init];
#endif
[button setBackgroundColor:[UIColor clearColor] forState:UIControlStateNormal];
button.disabledAlpha = kDisabledButtonAlpha;
button.enableRippleBehavior = buttonBar.enableRippleBehavior;
if (buttonBar.inkColor) {
button.inkColor = buttonBar.inkColor;
}
button.exclusiveTouch = YES;
[MDCAppBarButtonBarBuilder configureButton:button fromButtonItem:buttonItem];
button.uppercaseTitle = buttonBar.uppercasesButtonTitles;
[button setUnderlyingColorHint:self.buttonUnderlyingColor];
for (NSNumber *state in _fonts) {
UIFont *font = _fonts[state];
[button setTitleFont:font forState:(UIControlState)state.intValue];
}
[self updateTitleColorForButton:button withItem:buttonItem];
#if MDC_AVAILABLE_SDK_IOS(13_0)
if (@available(iOS 13.0, *)) {
button.largeContentImage = self.largeContentImage;
button.largeContentTitle = self.largeContentTitle;
}
#endif
[self updateButton:button withItem:buttonItem barMetrics:UIBarMetricsDefault];
#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:buttonBar];
[button addInteraction:pointerInteraction];
}
}
#endif
// Contrary to intuition, UIKit provides the UIBarButtonItem as the action's first argument when
// bar buttons are tapped, NOT the button itself. Simply adding the item's target/action to the
// button does not allow us to pass the expected argument to the target.
//
// MDCButtonBar provides didTapButton:event: to which we can pass button events
// so that the correct argument is ultimately passed along.
[button addTarget:buttonBar
action:@selector(didTapButton:event:)
forControlEvents:UIControlEventTouchUpInside];
#if MDC_AVAILABLE_SDK_IOS(14_0)
if (@available(iOS 14.0, *)) {
if (buttonItem.menu) {
// Setting the menu as primary action will result in the target / action pair not being
// called. Setting the primaryAction on a menu item will result in it not having a target /
// action pair anymore and not taking a new one on until primaryAction is cleared again.
button.menu = buttonItem.menu;
if (!buttonItem.primaryAction) {
button.showsMenuAsPrimaryAction = YES;
}
}
}
#endif
UIEdgeInsets contentInsets = [MDCAppBarButtonBarBuilder
contentInsetsForButton:button
layoutPosition:buttonBar.layoutPosition
layoutHints:layoutHints
layoutDirection:[buttonBar effectiveUserInterfaceLayoutDirection]
userInterfaceIdiom:[self usePadInsetsForButtonBar:buttonBar] ? UIUserInterfaceIdiomPad
: UIUserInterfaceIdiomPhone];
button.contentEdgeInsets = contentInsets;
button.enabled = buttonItem.enabled;
button.accessibilityLabel = buttonItem.accessibilityLabel;
button.accessibilityHint = buttonItem.accessibilityHint;
button.accessibilityValue = buttonItem.accessibilityValue;
button.accessibilityIdentifier = buttonItem.accessibilityIdentifier;
return button;
}
#pragma mark - Private
// Used to determine whether or not to apply insets relevant for iPad or use smaller iPhone size
// Because only widths are affected, we use horizontal size class
- (BOOL)usePadInsetsForButtonBar:(MDCButtonBar *)buttonBar {
const BOOL isPad = [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad;
if (isPad && buttonBar.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular) {
return YES;
}
return NO;
}
+ (UIEdgeInsets)contentInsetsForButton:(MDCButton *)button
layoutPosition:(MDCButtonBarLayoutPosition)layoutPosition
layoutHints:(MDCBarButtonItemLayoutHints)layoutHints
layoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
userInterfaceIdiom:(UIUserInterfaceIdiom)userInterfaceIdiom {
UIEdgeInsets contentInsets = kButtonInset;
if ([button currentImage] || [button currentTitle].length) {
CGFloat additionalInset = kEdgeButtonAdditionalMargin;
BOOL isFirstButton = (layoutHints & MDCBarButtonItemLayoutHintsIsFirstButton) ==
MDCBarButtonItemLayoutHintsIsFirstButton;
BOOL isLastButton = (layoutHints & MDCBarButtonItemLayoutHintsIsLastButton) ==
MDCBarButtonItemLayoutHintsIsLastButton;
if (isFirstButton && layoutPosition == MDCButtonBarLayoutPositionLeading) {
// Left-most button in LTR, and right-most button in RTL.
if (layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
contentInsets.left += additionalInset;
} else {
contentInsets.right += additionalInset;
}
} else if (isFirstButton && layoutPosition == MDCButtonBarLayoutPositionTrailing) {
// Right-most button in LTR, and left-most button in RTL.
if (layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
contentInsets.right += additionalInset;
} else {
contentInsets.left += additionalInset;
}
}
if (isLastButton && layoutPosition == MDCButtonBarLayoutPositionTrailing) {
// Left-most button in LTR, and right-most button in RTL.
if (layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
contentInsets.left += additionalInset;
} else {
contentInsets.right += additionalInset;
}
} else if (isLastButton && layoutPosition == MDCButtonBarLayoutPositionLeading) {
// Right-most button in LTR, and left-most button in RTL.
if (layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
contentInsets.right += additionalInset;
} else {
contentInsets.left += additionalInset;
}
}
} else {
NSAssert(0, @"No button title or image");
}
return contentInsets;
}
+ (void)configureButton:(MDCButton *)destinationButton
fromButtonItem:(UIBarButtonItem *)sourceButtonItem {
if (sourceButtonItem == nil || destinationButton == nil) {
return;
}
if (sourceButtonItem.title != nil) {
[destinationButton setTitle:sourceButtonItem.title forState:UIControlStateNormal];
}
if (sourceButtonItem.image != nil) {
[destinationButton setImage:sourceButtonItem.image forState:UIControlStateNormal];
}
if (sourceButtonItem.tintColor != nil) {
destinationButton.tintColor = sourceButtonItem.tintColor;
}
if (sourceButtonItem.title) {
destinationButton.inkStyle = MDCInkStyleBounded;
} else {
destinationButton.inkStyle = MDCInkStyleUnbounded;
}
destinationButton.tag = sourceButtonItem.tag;
#if MDC_AVAILABLE_SDK_IOS(13_0)
if (@available(iOS 13.0, *)) {
destinationButton.largeContentImageInsets = sourceButtonItem.largeContentSizeImageInsets;
}
#endif
}
- (void)updateButton:(UIButton *)button
withItem:(UIBarButtonItem *)item
barMetrics:(UIBarMetrics)barMetrics {
[self updateButton:button withItem:item forState:UIControlStateNormal barMetrics:barMetrics];
[self updateButton:button withItem:item forState:UIControlStateHighlighted barMetrics:barMetrics];
[self updateButton:button withItem:item forState:UIControlStateDisabled barMetrics:barMetrics];
}
- (void)updateButton:(UIButton *)button
withItem:(UIBarButtonItem *)item
forState:(UIControlState)state
barMetrics:(UIBarMetrics)barMetrics {
NSString *title = item.title ? item.title : @"";
if ([UIButton instancesRespondToSelector:@selector(setAttributedTitle:forState:)]) {
NSMutableDictionary<NSString *, id> *attributes = [NSMutableDictionary dictionary];
// UIBarButtonItem's appearance proxy values don't appear to come "for free" like they do with
// typical UIView instances, so we're attempting to recreate the behavior here.
NSArray *appearanceProxies = @ [[item.class appearance]];
for (UIBarButtonItem *appearance in appearanceProxies) {
[attributes addEntriesFromDictionary:[appearance titleTextAttributesForState:state]];
}
[attributes addEntriesFromDictionary:[item titleTextAttributesForState:state]];
if ([attributes count] > 0) {
[button setAttributedTitle:[[NSAttributedString alloc] initWithString:title
attributes:attributes]
forState:state];
}
} else {
[button setTitle:title forState:state];
}
UIImage *backgroundImage = [item backgroundImageForState:state barMetrics:barMetrics];
if (backgroundImage) {
[button setBackgroundImage:backgroundImage forState:state];
}
}
- (void)transferCustomViewOwnershipForBarButtonItem:(UIBarButtonItem *)barButtonItem {
UIView *customView = barButtonItem.customView;
if (customView && ![customView isKindOfClass:[MDCButtonBarSandbagView class]]) {
// Transfer ownership of any UIBarButtonItem.customView to the internal property
// so that UINavigationController won't steal the view from us.
barButtonItem.mdc_customView = customView;
barButtonItem.customView = [[MDCButtonBarSandbagView alloc] init];
}
}
@end
@implementation MDCButtonBarSandbagView
@end
@implementation UIBarButtonItem (MDCHeaderInternal)
@dynamic mdc_customView;
- (UIView *)mdc_customView {
return objc_getAssociatedObject(self, _cmd);
}
- (void)mdc_setCustomView:(UIView *)customView {
objc_setAssociatedObject(self, @selector(mdc_customView), customView,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@end