blob: 6d51f2e7bbdf652d456a073a90300e85ab5ca5fc [file] [log] [blame] [edit]
// Copyright 2017-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 "MDCBadgeView.h"
#import <CoreGraphics/CoreGraphics.h>
#import "MDCBadgeAppearance.h"
// TODO(featherless): Remove the dependency on MDCPalette.
#import "MDCPalettes.h"
NS_ASSUME_NONNULL_BEGIN
static const CGFloat kBadgeFontSize = 8;
static const CGFloat kBadgeYPadding = 2;
static const CGFloat kMinDiameter = 9;
// A note on layout considerations for the border.
//
// CALayer's borderColor and borderWidth properties will create an inner border, meaning the border
// will be drawn within the bounds of the view. This will, in effect, cause the content of the badge
// to be constrained by the border; this is rarely what is intended when it comes to badges.
//
// Instead, we want the border to be drawn on the outer edge of the badge. To do so, we "fake" an
// outer border by expanding the fitted size of the badge by the border width. We then use the same
// standard CALayer borderColor and borderWidth properties under the hood, but due to the expansion
// of the badge's size it gives the impression of being drawn on the outside of the border.
//
// To add an outer border, use borderColor and borderWidth instead of self.layer's equivalent
// properties. Using borderColor enables the color to react to trait collections, and modifying
// borderWidth invalidates size considerations.
//
// Note that adding an outer border will cause the badge's origin to effectively shift on both the x
// and y axis by `borderWidth` units. While technically accurate, it can be conceptually unexpected
// because the border is supposed to be on the outer edge of the view. To compensate for this, be
// sure to adjust your badge's x/y values by -borderWidth.
@implementation MDCBadgeView {
UIColor *_Nullable _borderColor;
UILabel *_Nonnull _label;
CGFloat _dotBadgeInnerRadius;
BOOL _dotBadgeEnabled;
}
@synthesize appearance = _appearance;
- (nonnull instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_appearance = [[MDCBadgeAppearance alloc] init];
// TODO(featherless): Remove the dependency on MDCPalette.
_appearance.backgroundColor = MDCPalette.redPalette.tint700;
_appearance.textColor = [UIColor whiteColor];
_appearance.font = [UIFont systemFontOfSize:kBadgeFontSize];
_label = [[UILabel alloc] initWithFrame:self.bounds];
_label.textAlignment = NSTextAlignmentCenter;
_label.isAccessibilityElement = NO;
_label.hidden = _appearance.dotBadgeEnabled;
[self addSubview:_label];
}
return self;
}
- (CGFloat)badgeXPaddingForRadius:(CGFloat)radius {
CGFloat badgeXPadding = (CGFloat)(sin(M_PI_4) * (radius)); // sin(ΓΈ) = badgeXPadding / radius
badgeXPadding += 1; // Extra point to ensure some background extends beyond the label.
// Align to the nearest pixel
badgeXPadding = round(badgeXPadding * self.contentScaleFactor) / self.contentScaleFactor;
return badgeXPadding;
}
- (void)layoutSubviews {
[super layoutSubviews];
if (_appearance.dotBadgeEnabled) {
[self layoutDotBadge];
} else {
[self layoutTextBadge];
}
}
- (void)layoutDotBadge {
self.layer.cornerRadius = CGRectGetHeight(self.bounds) / 2;
}
- (void)layoutTextBadge {
CGFloat badgeRadius = CGRectGetHeight(self.bounds) / 2;
CGRect availableContentRect = CGRectStandardize(
CGRectInset(self.bounds, [self badgeXPaddingForRadius:badgeRadius], kBadgeYPadding));
CGSize labelFitSize = [_label sizeThatFits:availableContentRect.size];
_label.bounds = CGRectMake(0, 0, labelFitSize.width, labelFitSize.height);
_label.center =
CGPointMake(CGRectGetMidX(availableContentRect), CGRectGetMidY(availableContentRect));
self.layer.cornerRadius = badgeRadius;
}
- (CGSize)sizeThatFits:(CGSize)size {
if (_appearance.dotBadgeEnabled) {
return [self sizeThatFitsDotBadge:size];
} else {
return [self sizeThatFitsTextBadge:size];
}
}
- (CGSize)sizeThatFitsDotBadge:(CGSize)size {
CGFloat squareDimension = (_dotBadgeInnerRadius + self.layer.borderWidth) * 2;
return CGSizeMake(squareDimension, squareDimension);
}
- (CGSize)sizeThatFitsTextBadge:(CGSize)size {
if (_label.text == nil) {
return CGSizeZero;
}
// Calculate the badge and label heights
CGSize labelSize = [_label sizeThatFits:size];
CGFloat badgeHeight = labelSize.height + kBadgeYPadding;
CGFloat contentXPadding = [self badgeXPaddingForRadius:badgeHeight / 2];
CGFloat badgeWidth = labelSize.width + contentXPadding;
badgeWidth = MAX(kMinDiameter, badgeWidth);
badgeHeight = MAX(kMinDiameter, badgeHeight);
if (badgeWidth < badgeHeight) {
badgeWidth = badgeHeight;
}
// CALayer borders are inset. To make them outset, we need to expand the size of our label to
// account for the content covered by the border.
const CGFloat borderWidth = self.layer.borderWidth;
return CGSizeMake(badgeWidth + borderWidth * 2, badgeHeight + borderWidth * 2);
}
- (CGSize)intrinsicContentSize {
return [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self applyAppearance];
}
#pragma mark - Responding to appearance changes
- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (!_appearance.dotBadgeEnabled) {
[self updateFont];
[self updateBorderColor];
}
}
- (void)updateFont {
_label.font = _appearance.font;
[self setNeedsLayout];
[self invalidateIntrinsicContentSize];
}
- (void)updateBorderColor {
self.layer.borderColor = _borderColor.CGColor;
}
- (void)applyAppearance {
BOOL intrinsicSizeAffected = NO;
// Background
self.backgroundColor = _appearance.backgroundColor ?: self.tintColor;
// Content
_label.textColor = _appearance.textColor;
if (![_label.font isEqual:_appearance.font]) {
[self updateFont];
intrinsicSizeAffected = !_appearance.dotBadgeEnabled;
}
// Layout
if (_dotBadgeInnerRadius != _appearance.dotBadgeInnerRadius) {
_dotBadgeInnerRadius = _appearance.dotBadgeInnerRadius;
intrinsicSizeAffected = intrinsicSizeAffected || _appearance.dotBadgeEnabled;
}
if (_dotBadgeEnabled != _appearance.dotBadgeEnabled) {
_dotBadgeEnabled = _appearance.dotBadgeEnabled;
_label.hidden = _appearance.dotBadgeEnabled;
intrinsicSizeAffected = YES;
}
// Border
if (self.layer.borderWidth != _appearance.borderWidth) {
self.layer.borderWidth = _appearance.borderWidth;
intrinsicSizeAffected = YES;
}
if (![_borderColor isEqual:_appearance.borderColor]) {
_borderColor = _appearance.borderColor;
[self updateBorderColor];
}
// When a CALayer has both a background color and a border, we need to clip the bounds otherwise
// the layer's background color will bleed through on the outer edges of the border.
self.clipsToBounds = _appearance.borderWidth > 0;
if (intrinsicSizeAffected) {
[self setNeedsLayout];
[self invalidateIntrinsicContentSize];
}
}
#pragma mark - Displaying a value in the badge
- (void)setText:(nullable NSString *)text {
_label.text = text;
[self setNeedsLayout];
[self invalidateIntrinsicContentSize];
}
- (nullable NSString *)text {
return _label.text;
}
#pragma mark - Configuring the badge's visual appearance
- (void)setAppearance:(nonnull MDCBadgeAppearance *)appearance {
if (appearance == _appearance) {
return;
}
_appearance = [appearance copy];
[self applyAppearance];
}
- (nonnull MDCBadgeAppearance *)appearance {
// Ensure that the appearance can't be directly modified once assigned.
return [_appearance copy];
}
@end
NS_ASSUME_NONNULL_END