blob: 43f29cecca8f5d9e60753b6611be4d4b78d627aa [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 "MDCFloatingButton.h"
#import "MaterialShadowElevations.h"
#import "private/MDCButton+Subclassing.h"
#import <MDFInternationalization/MDFInternationalization.h>
static const CGFloat MDCFloatingButtonDefaultDimension = 56;
static const CGFloat MDCFloatingButtonMiniDimension = 40;
static const CGFloat MDCFloatingButtonDefaultImageTitleSpace = 8;
static const UIEdgeInsets internalLayoutInsets = (UIEdgeInsets){0, 16, 0, 24};
@interface MDCFloatingButton ()
@property(nonatomic, readonly)
NSMutableDictionary<NSNumber *, NSMutableDictionary<NSNumber *, NSValue *> *>
*shapeToModeToMinimumSize;
@property(nonatomic, readonly)
NSMutableDictionary<NSNumber *, NSMutableDictionary<NSNumber *, NSValue *> *>
*shapeToModeToMaximumSize;
@property(nonatomic, readonly)
NSMutableDictionary<NSNumber *, NSMutableDictionary<NSNumber *, NSValue *> *>
*shapeToModeToContentEdgeInsets;
@property(nonatomic, readonly)
NSMutableDictionary<NSNumber *, NSMutableDictionary<NSNumber *, NSValue *> *>
*shapeToModeToHitAreaInsets;
@end
@implementation MDCFloatingButton {
MDCFloatingButtonShape _shape;
}
+ (void)initialize {
[[MDCFloatingButton appearance] setElevation:MDCShadowElevationFABResting
forState:UIControlStateNormal];
[[MDCFloatingButton appearance] setElevation:MDCShadowElevationFABPressed
forState:UIControlStateHighlighted];
}
+ (CGFloat)defaultDimension {
return MDCFloatingButtonDefaultDimension;
}
+ (CGFloat)miniDimension {
return MDCFloatingButtonMiniDimension;
}
+ (instancetype)floatingButtonWithShape:(MDCFloatingButtonShape)shape {
return [[[self class] alloc] initWithFrame:CGRectZero shape:shape];
}
- (instancetype)init {
return [self initWithFrame:CGRectZero shape:MDCFloatingButtonShapeDefault];
}
- (instancetype)initWithFrame:(CGRect)frame {
return [self initWithFrame:frame shape:MDCFloatingButtonShapeDefault];
}
- (instancetype)initWithFrame:(CGRect)frame shape:(MDCFloatingButtonShape)shape {
self = [super initWithFrame:frame];
if (self) {
_shape = shape;
[self commonMDCFloatingButtonInit];
// The superclass sets contentEdgeInsets from defaultContentEdgeInsets before the _shape is set.
// Set contentEdgeInsets again to ensure the defaults are for the correct shape.
[self updateShapeAndAllowResize:NO];
}
return self;
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-designated-initializers"
// https://stackoverflow.com/questions/24458608/convenience-initializer-missing-a-self-call-to-another-initializer
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
#pragma clang diagnostic pop
if (self) {
// Required to migrate any previously-archived FloatingButtons from .largeIcon shape value
if (@(_shape).integerValue >= 2) {
_shape = MDCFloatingButtonShapeDefault;
}
// Shape must be set first before the common initialization
[self commonMDCFloatingButtonInit];
[self updateShapeAndAllowResize:NO];
}
return self;
}
- (void)commonMDCFloatingButtonInit {
_imageTitleSpace = MDCFloatingButtonDefaultImageTitleSpace;
const CGSize miniNormalSize =
CGSizeMake(MDCFloatingButtonMiniDimension, MDCFloatingButtonMiniDimension);
const CGSize defaultNormalSize =
CGSizeMake(MDCFloatingButtonDefaultDimension, MDCFloatingButtonDefaultDimension);
const CGSize defaultExpandedMinimumSize = CGSizeMake(0, 48);
const CGSize defaultExpandedMaximumSize = CGSizeMake(328, 0);
// Minimum size values for different shape + mode combinations
NSMutableDictionary *miniShapeMinimumSizeDictionary =
[@{@(MDCFloatingButtonModeNormal) : [NSValue valueWithCGSize:miniNormalSize]} mutableCopy];
NSMutableDictionary *defaultShapeMinimumSizeDictionary = [@{
@(MDCFloatingButtonModeNormal) : [NSValue valueWithCGSize:defaultNormalSize],
@(MDCFloatingButtonModeExpanded) : [NSValue valueWithCGSize:defaultExpandedMinimumSize],
} mutableCopy];
_shapeToModeToMinimumSize = [@{
@(MDCFloatingButtonShapeMini) : miniShapeMinimumSizeDictionary,
@(MDCFloatingButtonShapeDefault) : defaultShapeMinimumSizeDictionary,
} mutableCopy];
// Maximum size values for different shape + mode combinations
NSMutableDictionary *miniShapeMaximumSizeDictionary =
[@{@(MDCFloatingButtonModeNormal) : [NSValue valueWithCGSize:miniNormalSize]} mutableCopy];
NSMutableDictionary *defaultShapeMaximumSizeDictionary = [@{
@(MDCFloatingButtonModeNormal) : [NSValue valueWithCGSize:defaultNormalSize],
@(MDCFloatingButtonModeExpanded) : [NSValue valueWithCGSize:defaultExpandedMaximumSize],
} mutableCopy];
_shapeToModeToMaximumSize = [@{
@(MDCFloatingButtonShapeMini) : miniShapeMaximumSizeDictionary,
@(MDCFloatingButtonShapeDefault) : defaultShapeMaximumSizeDictionary,
} mutableCopy];
// Content edge insets values for different shape + mode combinations
// .mini shape, .normal mode
const UIEdgeInsets miniNormalContentInsets = UIEdgeInsetsMake(8, 8, 8, 8);
NSMutableDictionary *miniShapeContentEdgeInsetsDictionary =
[@{@(MDCFloatingButtonModeNormal) : [NSValue valueWithUIEdgeInsets:miniNormalContentInsets]}
mutableCopy];
_shapeToModeToContentEdgeInsets =
[@{@(MDCFloatingButtonShapeMini) : miniShapeContentEdgeInsetsDictionary} mutableCopy];
// Hit area insets values for different shape + mode combinations
// .mini shape, .normal mode
const UIEdgeInsets miniNormalHitAreaInset = UIEdgeInsetsMake(-4, -4, -4, -4);
NSMutableDictionary *miniShapeHitAreaInsetsDictionary = [@{
@(MDCFloatingButtonModeNormal) : [NSValue valueWithUIEdgeInsets:miniNormalHitAreaInset],
} mutableCopy];
_shapeToModeToHitAreaInsets = [@{
@(MDCFloatingButtonShapeMini) : miniShapeHitAreaInsetsDictionary,
} mutableCopy];
}
#pragma mark - UIView
- (CGSize)sizeThatFits:(__unused CGSize)size {
return [self intrinsicContentSize];
}
- (CGSize)intrinsicContentSizeForModeNormal {
switch (_shape) {
case MDCFloatingButtonShapeDefault:
return CGSizeMake(MDCFloatingButtonDefaultDimension, MDCFloatingButtonDefaultDimension);
case MDCFloatingButtonShapeMini:
return CGSizeMake(MDCFloatingButtonMiniDimension, MDCFloatingButtonMiniDimension);
}
}
- (CGSize)intrinsicContentSizeForModeExpanded {
const CGSize intrinsicTitleSize =
[self.titleLabel sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
const CGSize intrinsicImageSize =
[self.imageView sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
CGFloat intrinsicWidth = intrinsicTitleSize.width + intrinsicImageSize.width +
self.imageTitleSpace + internalLayoutInsets.left +
internalLayoutInsets.right + self.contentEdgeInsets.left +
self.contentEdgeInsets.right;
CGFloat intrinsicHeight = MAX(intrinsicTitleSize.height, intrinsicImageSize.height) +
self.contentEdgeInsets.top + self.contentEdgeInsets.bottom +
internalLayoutInsets.top + internalLayoutInsets.bottom;
return CGSizeMake(intrinsicWidth, intrinsicHeight);
}
- (CGSize)intrinsicContentSize {
CGSize contentSize = CGSizeZero;
if (self.mode == MDCFloatingButtonModeNormal) {
contentSize = [self intrinsicContentSizeForModeNormal];
} else if (self.mode == MDCFloatingButtonModeExpanded) {
contentSize = [self intrinsicContentSizeForModeExpanded];
}
if (self.minimumSize.height > 0) {
contentSize.height = MAX(self.minimumSize.height, contentSize.height);
}
if (self.maximumSize.height > 0) {
contentSize.height = MIN(self.maximumSize.height, contentSize.height);
}
if (self.minimumSize.width > 0) {
contentSize.width = MAX(self.minimumSize.width, contentSize.width);
}
if (self.maximumSize.width > 0) {
contentSize.width = MIN(self.maximumSize.width, contentSize.width);
}
return contentSize;
}
/*
Performs custom layout when the FAB is in .expanded mode. Specifically, the layout algorithm is
as follows:
1. Inset the bounds by the value of `contentEdgeInsets` and use this as the layout bounds.
2. Determine the intrinsic sizes of the imageView and titleLabel.
3. Compute the space remaining for the titleLabel after accounting for the imageView and built-in
alignment guidelines (internalLayoutInsets).
4. Position the imageView along the leading (or trailing) edge of the button, inset by
internalLayoutInsets.left (flipped for RTL).
5. Position the titleLabel along the leading edge of its available space.
6. Apply the imageEdgeInsets and titleEdgeInsets to their respective views.
*/
- (void)layoutSubviews {
// We have to set cornerRadius before laying out subviews so that the boundingPath is correct.
self.layer.cornerRadius = CGRectGetHeight(self.bounds) / 2;
[super layoutSubviews];
if (self.mode == MDCFloatingButtonModeNormal) {
return;
}
// Position the imageView and titleView
//
// +------------------------------------+
// | | | | CEI TOP | |
// |CEI +--+ |+-----+ |CEI|
// | LT ||SP||Title| |RGT|
// | +--+ |+-----+ | |
// | | | | CEI BOT | |
// +------------------------------------+
//
// (A) The same spacing on either side of the label.
// (SP) The spacing between the image and title
// (CEI) Content Edge Insets
//
// The diagram above assumes an LTR user interface orientation
// and a .leadingIcon imageLocation for this button.
BOOL isLeadingIcon = self.imageLocation == MDCFloatingButtonImageLocationLeading;
UIEdgeInsets adjustedLayoutInsets =
isLeadingIcon ? internalLayoutInsets : MDFInsetsFlippedHorizontally(internalLayoutInsets);
const CGRect insetBounds = UIEdgeInsetsInsetRect(
UIEdgeInsetsInsetRect(self.bounds, adjustedLayoutInsets), self.contentEdgeInsets);
const CGFloat imageViewWidth = CGRectGetWidth(self.imageView.bounds);
const CGFloat boundsCenterY = CGRectGetMidY(insetBounds);
CGFloat titleWidthAvailable = CGRectGetWidth(insetBounds);
titleWidthAvailable -= imageViewWidth;
titleWidthAvailable -= self.imageTitleSpace;
const CGFloat availableHeight = CGRectGetHeight(insetBounds);
CGSize titleIntrinsicSize =
[self.titleLabel sizeThatFits:CGSizeMake(titleWidthAvailable, availableHeight)];
const CGSize titleSize = CGSizeMake(MAX(0, MIN(titleIntrinsicSize.width, titleWidthAvailable)),
MAX(0, MIN(titleIntrinsicSize.height, availableHeight)));
CGPoint titleCenter;
CGPoint imageCenter;
BOOL isLTR =
self.mdf_effectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionLeftToRight;
// If we are LTR with a leading image, the image goes on the left.
// If we are RTL with a trailing image, the image goes on the left.
if ((isLTR && isLeadingIcon) || (!isLTR && !isLeadingIcon)) {
const CGFloat imageCenterX = CGRectGetMinX(insetBounds) + (imageViewWidth / 2);
const CGFloat titleCenterX =
CGRectGetMaxX(insetBounds) - titleWidthAvailable + (titleSize.width / 2);
titleCenter = CGPointMake(titleCenterX, boundsCenterY);
imageCenter = CGPointMake(imageCenterX, boundsCenterY);
}
// If we are LTR with a trailing image, the image goes on the right.
// If we are RTL with a leading image, the image goes on the right.
else {
const CGFloat imageCenterX = CGRectGetMaxX(insetBounds) - (imageViewWidth / 2);
const CGFloat titleCenterX =
CGRectGetMinX(insetBounds) + titleWidthAvailable - (titleSize.width / 2);
imageCenter = CGPointMake(imageCenterX, boundsCenterY);
titleCenter = CGPointMake(titleCenterX, boundsCenterY);
}
self.imageView.center = imageCenter;
self.imageView.frame = UIEdgeInsetsInsetRect(self.imageView.frame, self.imageEdgeInsets);
self.titleLabel.center = titleCenter;
CGRect newBounds = CGRectStandardize(self.titleLabel.bounds);
self.titleLabel.bounds = (CGRect){newBounds.origin, titleSize};
self.titleLabel.frame = UIEdgeInsetsInsetRect(self.titleLabel.frame, self.titleEdgeInsets);
}
#pragma mark - Property Setters/Getters
- (void)setMode:(MDCFloatingButtonMode)mode {
BOOL needsShapeUpdate = self.mode != mode;
_mode = mode;
if (needsShapeUpdate) {
[self updateShapeAndAllowResize:YES];
}
}
- (void)setMinimumSize:(CGSize)size
forShape:(MDCFloatingButtonShape)shape
inMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToMinimumSize = self.shapeToModeToMinimumSize[@(shape)];
if (!modeToMinimumSize) {
modeToMinimumSize = [@{} mutableCopy];
self.shapeToModeToMinimumSize[@(shape)] = modeToMinimumSize;
}
modeToMinimumSize[@(mode)] = [NSValue valueWithCGSize:size];
if (shape == _shape && mode == self.mode) {
[self updateShapeAndAllowResize:YES];
}
}
- (CGSize)minimumSizeForMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToMinimumSize = self.shapeToModeToMinimumSize[@(_shape)];
if (!modeToMinimumSize) {
return CGSizeZero;
}
NSValue *sizeValue = modeToMinimumSize[@(mode)];
if (sizeValue) {
return [sizeValue CGSizeValue];
} else {
return CGSizeZero;
}
}
- (BOOL)updateMinimumSize {
CGSize newSize = [self minimumSizeForMode:self.mode];
if (CGSizeEqualToSize(newSize, self.minimumSize)) {
return NO;
}
super.minimumSize = newSize;
return YES;
}
- (void)setMaximumSize:(CGSize)size
forShape:(MDCFloatingButtonShape)shape
inMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToMaximumSize = self.shapeToModeToMaximumSize[@(shape)];
if (!modeToMaximumSize) {
modeToMaximumSize = [@{} mutableCopy];
self.shapeToModeToMaximumSize[@(shape)] = modeToMaximumSize;
}
modeToMaximumSize[@(mode)] = [NSValue valueWithCGSize:size];
if (shape == _shape && mode == self.mode) {
[self updateShapeAndAllowResize:YES];
}
}
- (CGSize)maximumSizeForMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToMaximumSize = self.shapeToModeToMaximumSize[@(_shape)];
if (!modeToMaximumSize) {
return CGSizeZero;
}
NSValue *sizeValue = modeToMaximumSize[@(mode)];
if (sizeValue) {
return [sizeValue CGSizeValue];
} else {
return CGSizeZero;
}
}
- (BOOL)updateMaximumSize {
CGSize newSize = [self maximumSizeForMode:self.mode];
if (CGSizeEqualToSize(newSize, self.maximumSize)) {
return NO;
}
super.maximumSize = newSize;
return YES;
}
- (void)setContentEdgeInsets:(UIEdgeInsets)contentEdgeInsets
forShape:(MDCFloatingButtonShape)shape
inMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToContentEdgeInsets = self.shapeToModeToContentEdgeInsets[@(shape)];
if (!modeToContentEdgeInsets) {
modeToContentEdgeInsets = [@{} mutableCopy];
self.shapeToModeToContentEdgeInsets[@(shape)] = modeToContentEdgeInsets;
}
modeToContentEdgeInsets[@(mode)] = [NSValue valueWithUIEdgeInsets:contentEdgeInsets];
if (shape == _shape && mode == self.mode) {
[self updateShapeAndAllowResize:YES];
}
}
- (UIEdgeInsets)contentEdgeInsetsForMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToContentEdgeInsets = self.shapeToModeToContentEdgeInsets[@(_shape)];
if (!modeToContentEdgeInsets) {
return UIEdgeInsetsZero;
}
NSValue *insetsValue = modeToContentEdgeInsets[@(mode)];
if (insetsValue) {
return [insetsValue UIEdgeInsetsValue];
} else {
return UIEdgeInsetsZero;
}
}
- (void)updateContentEdgeInsets {
super.contentEdgeInsets = [self contentEdgeInsetsForMode:self.mode];
}
- (void)setHitAreaInsets:(UIEdgeInsets)insets
forShape:(MDCFloatingButtonShape)shape
inMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToHitAreaInsets = self.shapeToModeToHitAreaInsets[@(shape)];
if (!modeToHitAreaInsets) {
modeToHitAreaInsets = [@{} mutableCopy];
self.shapeToModeToHitAreaInsets[@(shape)] = modeToHitAreaInsets;
}
modeToHitAreaInsets[@(mode)] = [NSValue valueWithUIEdgeInsets:insets];
if (shape == _shape && mode == self.mode) {
[self updateShapeAndAllowResize:NO];
}
}
- (UIEdgeInsets)hitAreaInsetsForMode:(MDCFloatingButtonMode)mode {
NSMutableDictionary *modeToHitAreaInsets = self.shapeToModeToHitAreaInsets[@(_shape)];
if (!modeToHitAreaInsets) {
return UIEdgeInsetsZero;
}
NSValue *insetsValue = modeToHitAreaInsets[@(mode)];
if (insetsValue) {
return [insetsValue UIEdgeInsetsValue];
} else {
return UIEdgeInsetsZero;
}
}
- (void)updateHitAreaInsets {
super.hitAreaInsets = [self hitAreaInsetsForMode:self.mode];
}
- (void)updateShapeAndAllowResize:(BOOL)allowsResize {
BOOL minimumSizeChanged = [self updateMinimumSize];
BOOL maximumSizeChanged = [self updateMaximumSize];
[self updateContentEdgeInsets];
[self updateHitAreaInsets];
if (allowsResize && (minimumSizeChanged || maximumSizeChanged)) {
[self invalidateIntrinsicContentSize];
[self setNeedsLayout];
}
}
@end