blob: c2a2ba4af648d5a96ede33a41dd2ffb6c4f331af [file] [log] [blame]
// 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 "MDCChipField.h"
#import <UIKit/UIKit.h>
#import "MDCChipFieldDelegate.h"
#import "MDCChipView.h"
#import "MDCChipViewDeleteButton.h"
#import <MDFInternationalization/MDFRTL.h>
NSString *const MDCEmptyTextString = @"";
NSString *const MDCChipDelimiterSpace = @" ";
NSString *const MDCChipFieldDidSetTextNotification = @"MDCChipFieldDidSetTextNotification";
/** Key for name of ChipField custom accessibility action that deletes a Chip. */
static NSString *const kAccessibilityActionDeleteNameKey = @"ChipFieldAccessibilityActionDelete";
/** The name of the accessibility table for localizations. */
static NSString *const kLocalizationAccessibilityTableName = @"Chips";
/** The name of the bundle. */
static NSString *const kBundle = @"Chips.bundle";
static const CGFloat MDCChipFieldDefaultFontSize = 14;
static const CGFloat MDCChipFieldHorizontalInset = 15;
static const CGFloat MDCChipFieldVerticalInset = 8;
static const CGFloat MDCChipFieldHorizontalMargin = 8;
static const CGFloat MDCChipFieldVerticalMargin = 8;
static const UIEdgeInsets MDCChipFieldTextFieldTextInsetsDefault = {16, 4, 16, 0};
static const UIKeyboardType MDCChipFieldDefaultKeyboardType = UIKeyboardTypeEmailAddress;
const CGFloat MDCChipFieldDefaultMinTextFieldWidth = 44;
const UIEdgeInsets MDCChipFieldDefaultContentEdgeInsets = {
MDCChipFieldVerticalInset, MDCChipFieldHorizontalInset, MDCChipFieldVerticalInset,
MDCChipFieldHorizontalInset};
NS_SWIFT_UI_ACTOR
@protocol MDCChipFieldTextFieldDelegate <NSObject>
- (void)textFieldDidDelete:(UITextField *)textField;
@end
@interface MDCChipFieldTextField : UITextField
@property(nonatomic, weak) id<MDCChipFieldTextFieldDelegate> deletionDelegate;
@property(nonatomic) UIEdgeInsets textFieldTextInsets;
@end
@implementation MDCChipFieldTextField
- (BOOL)isRTL {
return self.effectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft;
}
- (UIEdgeInsets)textEdgeInsets {
return [self isRTL] ? UIEdgeInsetsMake(_textFieldTextInsets.top, _textFieldTextInsets.right,
_textFieldTextInsets.bottom, _textFieldTextInsets.left)
: _textFieldTextInsets;
}
- (CGRect)textRectForBounds:(CGRect)bounds {
return [super textRectForBounds:UIEdgeInsetsInsetRect(bounds, [self textEdgeInsets])];
}
- (CGRect)editingRectForBounds:(CGRect)bounds {
return [super textRectForBounds:UIEdgeInsetsInsetRect(bounds, [self textEdgeInsets])];
}
- (CGRect)placeholderRectForBounds:(CGRect)bounds {
return [super textRectForBounds:UIEdgeInsetsInsetRect(bounds, [self textEdgeInsets])];
}
#pragma mark UIKeyInput
- (void)deleteBackward {
if ([self.delegate respondsToSelector:@selector(textFieldDidDelete:)] && self.text.length == 0) {
[self.deletionDelegate textFieldDidDelete:self];
}
[super deleteBackward];
}
- (CGRect)accessibilityFrame {
CGRect frame = [super accessibilityFrame];
UIEdgeInsets textEdgeInsets = [self textEdgeInsets];
return CGRectMake(frame.origin.x + textEdgeInsets.left, frame.origin.y,
frame.size.width - textEdgeInsets.left, frame.size.height);
}
- (void)setText:(NSString *)text {
[super setText:text];
if (!self.isFirstResponder) {
[[NSNotificationCenter defaultCenter] postNotificationName:MDCChipFieldDidSetTextNotification
object:self];
}
}
- (void)setAttributedText:(NSAttributedString *)attributedText {
[super setAttributedText:attributedText];
if (!self.isFirstResponder) {
[[NSNotificationCenter defaultCenter] postNotificationName:MDCChipFieldDidSetTextNotification
object:self];
}
}
@end
@interface MDCChipField () <MDCChipFieldTextFieldDelegate, UITextFieldDelegate>
@property(nullable, nonatomic, copy) NSString *accessibilityActionDeleteChipName;
@end
@implementation MDCChipField {
NSMutableArray<MDCChipView *> *_chips;
NSAttributedString *_attributedPlaceholder;
NSAttributedString *_emptyAttributedString;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self commonMDCChipFieldInit];
_chips = [NSMutableArray array];
_emptyAttributedString = [[NSAttributedString alloc] initWithString:@""
attributes:_placeholderAttributes];
MDCChipFieldTextField *chipFieldTextField =
[[MDCChipFieldTextField alloc] initWithFrame:self.bounds];
chipFieldTextField.adjustsFontForContentSizeCategory = YES;
UIFont *defaultFont = [UIFont systemFontOfSize:MDCChipFieldDefaultFontSize];
UIFont *scaledDefaultFont = [UIFontMetrics.defaultMetrics scaledFontForFont:defaultFont];
chipFieldTextField.font = scaledDefaultFont;
chipFieldTextField.delegate = self;
chipFieldTextField.deletionDelegate = self;
chipFieldTextField.accessibilityTraits = UIAccessibilityTraitNone;
chipFieldTextField.autocorrectionType = UITextAutocorrectionTypeNo;
chipFieldTextField.autocapitalizationType = UITextAutocapitalizationTypeNone;
chipFieldTextField.keyboardType = MDCChipFieldDefaultKeyboardType;
chipFieldTextField.textFieldTextInsets = MDCChipFieldTextFieldTextInsetsDefault;
// Listen for notifications posted when the text field is the first responder.
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(textFieldDidChange)
name:UITextFieldTextDidChangeNotification
object:chipFieldTextField];
// Also listen for notifications posted when the text field is not the first responder.
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(textFieldDidChange)
name:MDCChipFieldDidSetTextNotification
object:chipFieldTextField];
[self addSubview:chipFieldTextField];
[self updateTextFieldPlaceholderText];
_textField = chipFieldTextField;
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self commonMDCChipFieldInit];
[self updateTextFieldPlaceholderText];
}
return self;
}
- (void)commonMDCChipFieldInit {
_chips = [NSMutableArray array];
_delimiter = MDCChipFieldDelimiterDefault;
_minTextFieldWidth = MDCChipFieldDefaultMinTextFieldWidth;
_contentEdgeInsets = MDCChipFieldDefaultContentEdgeInsets;
_showPlaceholderWithChips = YES;
_chipHeight = 32;
_textFieldLeadingPaddingWhenChipIsAdded = 0;
_textFieldTextInsets = MDCChipFieldTextFieldTextInsetsDefault;
[self configureLocalizedAccessibilityActionName];
}
- (void)layoutSubviews {
[super layoutSubviews];
CGRect standardizedBounds = CGRectStandardize(self.bounds);
BOOL isRTL =
self.effectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft;
// Calculate the frames for all the chips and set them.
NSArray *chipFrames = [self chipFramesForSize:standardizedBounds.size];
for (NSUInteger index = 0; index < _chips.count; index++) {
MDCChipView *chip = _chips[index];
CGRect chipFrame = [chipFrames[index] CGRectValue];
if (isRTL) {
chipFrame = MDFRectFlippedHorizontally(chipFrame, CGRectGetWidth(self.bounds));
}
chip.frame = chipFrame;
}
// Get the last chip frame and calculate the text field frame from that.
CGRect lastChipFrame = [chipFrames.lastObject CGRectValue];
CGRect textFieldFrame = [self frameForTextFieldForLastChipFrame:lastChipFrame
chipFieldSize:standardizedBounds.size];
if (isRTL) {
textFieldFrame = MDFRectFlippedHorizontally(textFieldFrame, CGRectGetWidth(self.bounds));
}
BOOL heightChanged = CGRectGetMinY(textFieldFrame) != CGRectGetMinY(self.textField.frame);
self.textField.frame = textFieldFrame;
[self invalidateIntrinsicContentSize];
if (heightChanged && [self.delegate respondsToSelector:@selector(chipFieldHeightDidChange:)]) {
[self.delegate chipFieldHeightDidChange:self];
}
}
- (CGFloat)getChipHeight {
if (self.adjustChipHeightForTextSize) {
CGFloat chipDynamicHeight = self.textField.font.lineHeight;
return _chipHeight > chipDynamicHeight ? _chipHeight : chipDynamicHeight;
} else {
return _chipHeight;
}
}
- (CGFloat)getChipYCoordinateForHeight:(CGFloat)chipFullHeight row:(NSUInteger)row {
if (self.adjustChipHeightForTextSize) {
// Smaller chip heights need more vertical padding to accommodate a touch target size of at
// least 44. At larger text sizes, the padding can shrink to reduce wasted space.
return chipFullHeight >= 36
? self.contentEdgeInsets.top + (row * (chipFullHeight + MDCChipFieldVerticalMargin))
: self.contentEdgeInsets.top +
(row * (chipFullHeight + MDCChipFieldVerticalMargin + 4));
} else {
return self.contentEdgeInsets.top + (row * (_chipHeight + MDCChipFieldVerticalMargin));
}
}
- (void)updateTextFieldPlaceholderText {
// There should be no placeholder if showPlaceholderWithChips is NO and there are chips.
if (!self.showPlaceholderWithChips && self.chips.count > 0) {
self.textField.attributedPlaceholder = _emptyAttributedString;
} else if (_attributedPlaceholder) {
self.textField.attributedPlaceholder = _attributedPlaceholder;
} else {
self.textField.placeholder = _placeholder;
}
[self setNeedsLayout];
}
- (CGSize)intrinsicContentSize {
CGFloat minWidth =
MAX(self.minTextFieldWidth + self.contentEdgeInsets.left + self.contentEdgeInsets.right,
CGRectGetWidth(self.bounds));
return [self sizeThatFits:CGSizeMake(minWidth, CGFLOAT_MAX)];
}
- (void)setPlaceholder:(NSString *)string {
if (string == nil || string.length == 0) {
_placeholder = nil;
_attributedPlaceholder = nil;
} else {
_placeholder = [string copy];
// If `placeholderAttributes` is nil, create and set one with a default color.
if (!_placeholderAttributes) {
_placeholderAttributes = @{NSForegroundColorAttributeName : UIColor.placeholderTextColor};
}
NSAttributedString *attributedPlaceholder =
[[NSAttributedString alloc] initWithString:string attributes:_placeholderAttributes];
_attributedPlaceholder = attributedPlaceholder;
}
[self updateTextFieldPlaceholderText];
}
- (void)setPlaceholderAttributes:(NSDictionary<NSAttributedStringKey, id> *)placeholderAttributes {
// If `placeholderAttributes` parameter is not nil, make a mutable copy of it.
if (placeholderAttributes) {
NSMutableDictionary<NSAttributedStringKey, id> *mutableAttributes =
[placeholderAttributes mutableCopy];
// If no font name is passed in, use the current text field's font name.
// Other key:value pairs are overwritten by the new placeholder attributes.
if (!mutableAttributes[NSFontAttributeName]) {
mutableAttributes[NSFontAttributeName] = _textField.font;
}
_placeholderAttributes = mutableAttributes;
} else {
_placeholderAttributes = @{NSForegroundColorAttributeName : UIColor.placeholderTextColor};
}
if (_placeholder) {
NSAttributedString *attributedPlaceholder =
[[NSAttributedString alloc] initWithString:_placeholder attributes:_placeholderAttributes];
_attributedPlaceholder = attributedPlaceholder;
}
_emptyAttributedString = [[NSAttributedString alloc] initWithString:@""
attributes:_placeholderAttributes];
[self updateTextFieldPlaceholderText];
}
- (void)setTextFieldTextInsets:(UIEdgeInsets)textFieldTextInsets {
_textFieldTextInsets = textFieldTextInsets;
if ([_textField isKindOfClass:[MDCChipFieldTextField class]]) {
((MDCChipFieldTextField *)self.textField).textFieldTextInsets = _textFieldTextInsets;
}
}
- (CGSize)sizeThatFits:(CGSize)size {
NSArray *chipFrames = [self chipFramesForSize:size];
CGRect lastChipFrame = [chipFrames.lastObject CGRectValue];
CGRect textFieldFrame = [self frameForTextFieldForLastChipFrame:lastChipFrame chipFieldSize:size];
// Calculate the required size off the text field.
// To properly apply bottom inset: Calculate what would be the height if there were a chip
// instead of the text field. Then add the bottom inset.
CGFloat height = CGRectGetMaxY(textFieldFrame) + self.contentEdgeInsets.bottom +
([self getChipHeight] - textFieldFrame.size.height) / 2;
CGFloat width = MAX(size.width, self.minTextFieldWidth);
return CGSizeMake(width, height);
}
- (void)clearTextInput {
self.textField.text = MDCEmptyTextString;
[self updateTextFieldPlaceholderText];
}
- (void)setChips:(NSArray<MDCChipView *> *)chips {
if ([_chips isEqual:chips]) {
return;
}
for (MDCChipView *chip in _chips) {
[self removeChipSubview:chip];
}
_chips = [chips mutableCopy];
for (MDCChipView *chip in _chips) {
[self addChipSubview:chip];
}
[self setNeedsLayout];
}
- (NSArray<MDCChipView *> *)chips {
return [NSArray arrayWithArray:_chips];
}
- (BOOL)becomeFirstResponder {
return [self.textField becomeFirstResponder];
}
- (BOOL)resignFirstResponder {
[super resignFirstResponder];
return [self.textField resignFirstResponder];
}
- (void)addChip:(MDCChipView *)chip {
// Note that |chipField:shouldAddChip| is only called in |createNewChipFromInput| when it is
// necessary to restrict chip creation based on input text generated in the user interface.
// Clients calling |addChip| directly programmatically are expected to handle such restrictions
// themselves rather than using |chipField:shouldAddChip| to prevent chips from being added.
if (self.showChipsDeleteButton) {
[self addClearButtonToChip:chip];
// Set Chip's accessibilityTraits to `UIAccessibilityTraitNone` if there is a `clearButton`.
// A11y compliance is handled by a UIAccessibilityCustomAction.
chip.accessibilityTraits = UIAccessibilityTraitNone;
__weak __typeof__(self) weakSelf = self;
__weak MDCChipView *weakChip = chip;
UIAccessibilityCustomActionHandler actionHandler =
^BOOL(UIAccessibilityCustomAction *__unused customAction) {
__typeof__(self) strongSelf = weakSelf;
MDCChipView *strongChip = weakChip;
if (strongSelf) {
[strongSelf removeChip:strongChip];
}
return YES;
};
UIAccessibilityCustomAction *action =
[[UIAccessibilityCustomAction alloc] initWithName:_accessibilityActionDeleteChipName
actionHandler:actionHandler];
chip.accessibilityCustomActions = @[ action ];
}
[_chips addObject:chip];
[self addChipSubview:chip];
if ([self.delegate respondsToSelector:@selector(chipField:didAddChip:)]) {
[self.delegate chipField:self didAddChip:chip];
}
[self updateTextFieldPlaceholderText];
[self.textField setNeedsLayout];
[self setNeedsLayout];
}
- (void)removeChip:(MDCChipView *)chip {
[_chips removeObject:chip];
[self removeChipSubview:chip];
if ([self.delegate respondsToSelector:@selector(chipField:didRemoveChip:)]) {
[self.delegate chipField:self didRemoveChip:chip];
}
[self updateTextFieldPlaceholderText];
[self.textField setNeedsLayout];
[self setNeedsLayout];
}
- (void)removeSelectedChips {
NSMutableArray *chipsToRemove = [NSMutableArray array];
for (MDCChipView *chip in self.chips) {
if (chip.isSelected) {
[chipsToRemove addObject:chip];
}
}
for (MDCChipView *chip in chipsToRemove) {
[self removeChip:chip];
}
}
#pragma mark - Chip selection
- (void)didTapChipWithGestureRecognizer:(UITapGestureRecognizer *)gesture {
if (![gesture.view isKindOfClass:[MDCChipView class]]) {
return;
}
MDCChipView *chipView = (MDCChipView *)gesture.view;
chipView.selected = !chipView.selected;
}
- (void)selectChip:(MDCChipView *)chip {
[self deselectAllChipsExceptChip:chip];
chip.selected = YES;
}
- (void)selectLastChip {
MDCChipView *lastChip = self.chips.lastObject;
[self deselectAllChipsExceptChip:lastChip];
lastChip.selected = YES;
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification,
[lastChip accessibilityLabel]);
}
- (void)deselectAllChips {
[self deselectAllChipsExceptChip:nil];
}
- (void)deselectAllChipsExceptChip:(MDCChipView *)chip {
for (MDCChipView *otherChip in self.chips) {
if (chip != otherChip) {
otherChip.selected = NO;
}
}
}
- (void)setContentEdgeInsets:(UIEdgeInsets)contentEdgeInsets {
if (!UIEdgeInsetsEqualToEdgeInsets(_contentEdgeInsets, contentEdgeInsets)) {
_contentEdgeInsets = contentEdgeInsets;
[self setNeedsLayout];
}
}
- (void)setMinTextFieldWidth:(CGFloat)minTextFieldWidth {
if (_minTextFieldWidth != minTextFieldWidth) {
_minTextFieldWidth = minTextFieldWidth;
[self setNeedsLayout];
}
}
- (void)commitInput {
if (![self isTextFieldEmpty]) {
[self createNewChipFromInput];
}
}
- (void)createNewChipFromInput {
NSString *strippedTitle = [self.textField.text
stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (strippedTitle.length > 0) {
MDCChipView *chip = [[MDCChipView alloc] init];
UITapGestureRecognizer *tapGesture =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(didTapChipWithGestureRecognizer:)];
[chip addGestureRecognizer:tapGesture];
chip.titleLabel.text = strippedTitle;
BOOL shouldAddChip = YES;
if ([self.delegate respondsToSelector:@selector(chipField:shouldAddChip:)]) {
shouldAddChip = [self.delegate chipField:self shouldAddChip:chip];
}
if (shouldAddChip) {
[self addChip:chip];
[self clearTextInput];
}
} else {
[self clearTextInput];
}
}
- (void)addClearButtonToChip:(MDCChipView *)chip {
if (self.deleteButtonImage) {
UIButton *clearButton = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 12, 12)];
[clearButton setImage:self.deleteButtonImage forState:UIControlStateNormal];
chip.accessoryView = clearButton;
[clearButton addTarget:self
action:@selector(deleteChip:)
forControlEvents:UIControlEventTouchUpInside];
} else {
MDCChipViewDeleteButton *clearButton = [[MDCChipViewDeleteButton alloc] init];
chip.accessoryView = clearButton;
[clearButton addTarget:self
action:@selector(deleteChip:)
forControlEvents:UIControlEventTouchUpInside];
}
}
- (void)deleteChip:(id)sender {
UIControl *deleteButton = (UIControl *)sender;
MDCChipView *chip = (MDCChipView *)deleteButton.superview;
[self removeChip:chip];
[self clearTextInput];
}
- (void)chipTapped:(id)sender {
BOOL shouldBecomeFirstResponder = YES;
if ([self.delegate respondsToSelector:@selector(chipFieldShouldBecomeFirstResponder:)]) {
shouldBecomeFirstResponder = [self.delegate chipFieldShouldBecomeFirstResponder:self];
}
if (shouldBecomeFirstResponder) {
[self becomeFirstResponder];
}
MDCChipView *chip = (MDCChipView *)sender;
if ([self.delegate respondsToSelector:@selector(chipField:didTapChip:)]) {
[self.delegate chipField:self didTapChip:chip];
}
}
#pragma mark - MDCChipFieldTextFieldDelegate
- (void)textFieldDidDelete:(UITextField *)textField {
// If backspacing on an empty text field without a chip selected, select the last chip.
// If backspacing on an empty text field with a selected chip, delete the selected chip.
if (textField.text.length == 0) {
if ([self isAnyChipSelected]) {
[self removeSelectedChips];
[self deselectAllChips];
[self updateTextFieldPlaceholderText];
} else {
[self selectLastChip];
}
}
}
#pragma mark - UITextFieldDelegate
- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField {
BOOL shouldBeginEditing = YES;
if ([self.delegate respondsToSelector:@selector(chipFieldShouldBeginEditing:)]) {
shouldBeginEditing = [self.delegate chipFieldShouldBeginEditing:self];
}
return shouldBeginEditing;
}
- (BOOL)textFieldShouldEndEditing:(UITextField *)textField {
BOOL shouldEndEditing = YES;
if ([self.delegate respondsToSelector:@selector(chipFieldShouldEndEditing:)]) {
shouldEndEditing = [self.delegate chipFieldShouldEndEditing:self];
}
return shouldEndEditing;
}
- (void)textFieldDidBeginEditing:(UITextField *)textField {
if (textField == self.textField) {
[self deselectAllChips];
}
if ([self.delegate respondsToSelector:@selector(chipFieldDidBeginEditing:)]) {
[self.delegate chipFieldDidBeginEditing:self];
}
}
- (void)textFieldDidEndEditing:(UITextField *)textField {
if ((self.delimiter & MDCChipFieldDelimiterDidEndEditing) == MDCChipFieldDelimiterDidEndEditing) {
if (textField == self.textField) {
[self commitInput];
}
}
if ([self.delegate respondsToSelector:@selector(chipFieldDidEndEditing:)]) {
[self.delegate chipFieldDidEndEditing:self];
}
}
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
BOOL shouldReturn = YES;
// Chip field content view will handle |chipFieldShouldReturn| if the client is not using chip
// field directly. If the client uses chip field directly without the content view and has not
// implemented |chipFieldShouldReturn|, then a chip should always be created.
if ([self.delegate respondsToSelector:@selector(chipFieldShouldReturn:)]) {
shouldReturn = [self.delegate chipFieldShouldReturn:self];
}
if (shouldReturn) {
[self createNewChipWithTextField:textField delimiter:MDCChipFieldDelimiterReturn];
}
return shouldReturn;
}
- (void)textFieldDidChange {
[self deselectAllChips];
[self createNewChipWithTextField:self.textField delimiter:MDCChipFieldDelimiterSpace];
CGRect lastChipFrame = self.chips.lastObject.frame;
if (!CGRectIsEmpty(lastChipFrame)) {
BOOL isTextTooWide = [self textInputDesiredWidth] >= [self availableWidthForTextInput];
BOOL isTextFieldOnSameLineAsChips =
CGRectGetMidY(self.textField.frame) >= CGRectGetMinY(lastChipFrame) &&
CGRectGetMidY(self.textField.frame) < CGRectGetMaxY(lastChipFrame);
if (isTextTooWide && isTextFieldOnSameLineAsChips) {
// The text is on the same line as the chips and doesn't fit
// Trigger layout to move the text field down to the next line
[self setNeedsLayout];
} else if (!isTextTooWide && !isTextFieldOnSameLineAsChips) {
// The text is on the line below the chips but can fit on the same line
// Trigger layout to move the text field up to the previous line
[self setNeedsLayout];
}
}
if ([self.delegate respondsToSelector:@selector(chipField:didChangeInput:)]) {
[self.delegate chipField:self didChangeInput:[self.textField.text copy]];
}
if (_textField.text.length == 0) {
[self updateTextFieldPlaceholderText];
}
}
#pragma mark - Private
- (void)removeChipSubview:(MDCChipView *)chip {
[chip removeFromSuperview];
[chip removeTarget:chip.superview
action:@selector(chipTapped:)
forControlEvents:UIControlEventTouchUpInside];
}
- (void)addChipSubview:(MDCChipView *)chip {
if (chip.superview != self) {
[chip addTarget:self
action:@selector(chipTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:chip];
}
}
- (void)createNewChipWithTextField:(UITextField *)textField
delimiter:(MDCChipFieldDelimiter)delimiter {
if ((self.delimiter & delimiter) == delimiter && textField.text.length > 0) {
if (delimiter == MDCChipFieldDelimiterReturn) {
[self createNewChipFromInput];
} else if (delimiter == MDCChipFieldDelimiterSpace) {
NSString *lastChar = [textField.text substringFromIndex:textField.text.length - 1];
if ([lastChar isEqualToString:MDCChipDelimiterSpace]) {
[self createNewChipFromInput];
}
}
}
}
- (BOOL)isAnyChipSelected {
for (MDCChipView *chip in self.chips) {
if (chip.isSelected) {
return YES;
}
}
return NO;
}
- (BOOL)isTextFieldEmpty {
return self.textField.text.length == 0;
}
#pragma mark - Sizing
- (NSArray<NSValue *> *)chipFramesForSize:(CGSize)size {
NSMutableArray *chipFrames = [NSMutableArray arrayWithCapacity:self.chips.count];
CGFloat chipFieldMaxX = size.width - self.contentEdgeInsets.right;
CGFloat maxWidth = size.width - self.contentEdgeInsets.left - self.contentEdgeInsets.right;
NSUInteger row = 0;
CGFloat currentOriginX = self.contentEdgeInsets.left;
for (MDCChipView *chip in self.chips) {
CGSize chipSize = [chip sizeThatFits:CGSizeMake(maxWidth, [self getChipHeight])];
chipSize.width = MIN(chipSize.width, maxWidth);
CGFloat availableWidth = chipFieldMaxX - currentOriginX;
// Check if the chip will fit on the current line. If it won't fit and the available width
// is the maximum width, it won't fit on any line. Put it on the current one and move on.
if (chipSize.width > availableWidth &&
availableWidth < (chipFieldMaxX - self.contentEdgeInsets.right)) {
row++;
currentOriginX = self.contentEdgeInsets.left;
}
CGFloat currentOriginY = [self getChipYCoordinateForHeight:chipSize.height row:(row)];
CGRect chipFrame = CGRectMake(currentOriginX, currentOriginY, chipSize.width, chipSize.height);
[chipFrames addObject:[NSValue valueWithCGRect:chipFrame]];
currentOriginX = CGRectGetMaxX(chipFrame) + MDCChipFieldHorizontalMargin;
}
return [chipFrames copy];
}
- (CGRect)frameForTextFieldForLastChipFrame:(CGRect)lastChipFrame
chipFieldSize:(CGSize)chipFieldSize {
CGFloat availableWidth = [self availableWidthForTextInput];
CGFloat textFieldHeight = [self.textField sizeThatFits:chipFieldSize].height;
CGFloat originY = lastChipFrame.origin.y + ([self getChipHeight] - textFieldHeight) / 2;
// If no chip exists, make the text field the full width, adjusted for insets.
if (CGRectIsEmpty(lastChipFrame)) {
originY += self.contentEdgeInsets.top;
return CGRectMake(self.contentEdgeInsets.left, originY, availableWidth, textFieldHeight);
}
CGFloat originX = 0;
CGFloat textFieldWidth = 0;
CGFloat desiredTextWidth = [self textInputDesiredWidth];
if (availableWidth < desiredTextWidth) {
// The text field doesn't fit on the line with the last chip.
originY += [self getChipHeight] + MDCChipFieldVerticalMargin;
originX = self.contentEdgeInsets.left;
textFieldWidth =
chipFieldSize.width - self.contentEdgeInsets.left - self.contentEdgeInsets.right;
} else {
// The text field fits on the line with chips
originX += CGRectGetMaxX(lastChipFrame) + MDCChipFieldHorizontalMargin +
_textFieldLeadingPaddingWhenChipIsAdded;
textFieldWidth = availableWidth;
}
return CGRectMake(originX, originY, textFieldWidth, textFieldHeight);
}
- (CGFloat)availableWidthForTextInput {
NSArray *chipFrames = [self chipFramesForSize:self.bounds.size];
CGFloat boundsWidth = CGRectGetWidth(CGRectStandardize(self.bounds));
if (chipFrames.count == 0) {
return boundsWidth - (self.contentEdgeInsets.right + self.contentEdgeInsets.left);
}
CGRect lastChipFrame = [chipFrames.lastObject CGRectValue];
return boundsWidth - CGRectGetMaxX(lastChipFrame) - self.contentEdgeInsets.right;
}
// The width of the text input + the clear button.
- (CGFloat)textInputDesiredWidth {
CGFloat placeholderDesiredWidth = [self placeholderDesiredWidth];
if (!self.textField.text || [self.textField.text isEqualToString:@""]) {
return placeholderDesiredWidth;
}
UIFont *font = self.textField.font;
CGRect desiredRect = [self.textField.text
boundingRectWithSize:CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric)
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{
NSFontAttributeName : font,
}
context:nil];
return MAX(placeholderDesiredWidth, CGRectGetWidth(desiredRect) + MDCChipFieldHorizontalMargin +
self.contentEdgeInsets.right +
[MDCChipViewDeleteButton imageSideLength]);
}
- (CGFloat)placeholderDesiredWidth {
NSString *placeholder = self.textField.placeholder;
if (!self.showPlaceholderWithChips && self.chips.count > 0) {
placeholder = nil;
}
if (placeholder.length == 0) {
return self.minTextFieldWidth;
}
UIFont *placeholderFont = _placeholderAttributes[NSFontAttributeName];
if (!placeholderFont) {
placeholderFont = self.textField.font;
}
CGRect placeholderDesiredRect =
[placeholder boundingRectWithSize:CGRectStandardize(self.bounds).size
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{
NSFontAttributeName : placeholderFont,
}
context:nil];
CGFloat placeholderDesiredWidth =
CGRectGetWidth(placeholderDesiredRect) + self.contentEdgeInsets.right;
return MAX(placeholderDesiredWidth, self.minTextFieldWidth);
}
#pragma mark - UIAccessibilityContainer
- (BOOL)isAccessibilityElement {
return NO;
}
- (id)accessibilityElementAtIndex:(NSInteger)index {
if (index < (NSInteger)self.chips.count) {
return self.chips[index];
} else if (index == (NSInteger)self.chips.count) {
return self.textField;
}
return nil;
}
- (NSInteger)accessibilityElementCount {
return self.chips.count + 1;
}
- (NSInteger)indexOfAccessibilityElement:(id)element {
if (element == self.textField) {
return self.chips.count;
}
return [self.chips indexOfObject:element];
}
#pragma mark - Accessibility
- (void)focusTextFieldForAccessibility {
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, self.textField);
}
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
[self.textField setNeedsLayout];
[self setNeedsLayout];
}
// Sets the localized accessibility action name for deleting a Chip.
- (void)configureLocalizedAccessibilityActionName {
NSBundle *resourceBundle = [[self class] bundle];
_accessibilityActionDeleteChipName =
[resourceBundle localizedStringForKey:kAccessibilityActionDeleteNameKey
value:@"Delete"
table:kLocalizationAccessibilityTableName];
}
#pragma mark - Resource Bundle
+ (NSBundle *)bundle {
static NSBundle *bundle = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
bundle = [NSBundle bundleWithPath:[self bundlePathWithName:kBundle]];
});
return bundle;
}
+ (NSString *)bundlePathWithName:(NSString *)bundleName {
NSBundle *bundle = [NSBundle bundleForClass:[MDCChipField class]];
NSString *resourcePath = [(nil == bundle ? [NSBundle mainBundle] : bundle) resourcePath];
return [resourcePath stringByAppendingPathComponent:bundleName];
}
@end