如何更好的自定义 UISegmentedControl

前几天因为业务需求,随手写了一个类似于 UISegmentedControl 的控件,写完后觉得代码没什么毛病,简洁好用,可读性也好,也仅仅只有 200 行代码。后来 Code Review 的时候,leader 说了一些观点让我觉得很受用。也让我觉得自定义 UI 控件其实需要更多的思考,而不仅仅是功能上面的实现。所以写篇文章记录下来。大致效果如下:

因为代码不多,就直接放到文章里了。

类的设计思路:
  1. View层面:一个 UIStackView,一堆 UILabel,一个用于滑动的 sliderview
    UIStackView 负责管理一堆 LabelLabel 的背景色均为透明,sliderView 作为滑块充当 Label 的背景效果。
  2. 交互层面:根据 segmentItems 的数量计算出每个 Label 的宽度,再通过触摸点算出 location 所在的位置的 Labelindex,然后更新 sliderView 的位置,同时更新 label 的文本颜色。

看一下我最早提交的版本。

类的声明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#import <UIKit/UIKit.h>

@class OUPCornerSegmentedControl;

@protocol OUPCornerSegmentedControlDelegate<NSObject>

@optional

- (void)segmentedControl:(OUPCornerSegmentedControl*)control didSelectedItemAtIndex:(NSUInteger)index;

@end

@interface OUPCornerSegmentedControl : UIControl

@property (nonatomic) UIFont* font;
@property (nonatomic) UIColor* normalTextColor;
@property (nonatomic) UIColor* highlightTextColor;
@property (nonatomic) UIColor* sliderColor;
@property (nonatomic) CGFloat margin;

@property (nonatomic) NSArray<NSString*>* segmentItems;
@property (nonatomic, readonly) NSUInteger selectedSegmentIndex;

@property (nonatomic, weak) id<OUPCornerSegmentedControlDelegate> delegate;

- (void)setSelectedSegmentIndex:(NSUInteger)selectedSegmentIndex animated:(BOOL)animated;

@end
类的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
@interface OUPCornerSegmentedControl ()

@property (nonatomic) UIStackView* stackView;
@property (nonatomic) UIView* slider;
@property (nonatomic) CGFloat segmentWidth;

@end

@implementation OUPCornerSegmentedControl

- (instancetype)initWithCoder:(NSCoder*)coder
{
self = [super initWithCoder:coder];
if (self)
{
[self commonInit];
}
return self;
}

- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self)
{
[self commonInit];
}
return self;
}

- (void)commonInit
{
_font = [UIFont systemFontOfSize:14];
_normalTextColor = UIColor.grayColor;
_highlightTextColor = UIColor.whiteColor;
_sliderColor = UIColor.darkGrayColor;
_margin = 2.0;
_selectedSegmentIndex = 0;

_slider = [[UIView alloc] init];
_slider.backgroundColor = _sliderColor;
[self addSubview:_slider];

_stackView = [[UIStackView alloc] init];
_stackView.axis = UILayoutConstraintAxisHorizontal;
_stackView.alignment = UIStackViewAlignmentFill;
_stackView.distribution = UIStackViewDistributionFillEqually;
[self addSubview:_stackView];
[_stackView mas_makeConstraints:^(MASConstraintMaker* make) {
make.edges.equalTo(self).insets(UIEdgeInsetsMake(_margin, _margin, _margin, _margin));
}];

[self addTapGesture];
}

- (void)layoutSubviews
{
[super layoutSubviews];

self.segmentWidth = (self.frame.size.width - 2 * self.margin) / self.segmentItems.count;

let sliderHeight = self.frame.size.height - 2 * self.margin;
self.slider.frame = CGRectMake(self.margin + self.selectedSegmentIndex * self.segmentWidth, self.margin, self.segmentWidth, sliderHeight);

[self setupCornerRadius];
}

- (void)setupCornerRadius
{
self.layer.cornerRadius = self.frame.size.height / 2;
self.slider.layer.cornerRadius = _slider.frame.size.height / 2;
}

- (void)insertAllSegments
{
let labelMaker = ^UILabel*(NSString* title, UIColor* textColor, UIFont* font)
{
let label = [[UILabel alloc] init];
label.text = title;
label.textColor = textColor;
label.font = font;
label.textAlignment = NSTextAlignmentCenter;
label.backgroundColor = UIColor.clearColor;

return label;
};

for (int i = 0; i < _segmentItems.count; I++)
{
let title = _segmentItems[I];
let label = labelMaker(title, self.normalTextColor, self.font);
[_stackView addArrangedSubview:label];
}
}

- (void)removeAllSegments
{
for (UIView* v in _stackView.arrangedSubviews)
{
[v removeFromSuperview];
}

_segmentItems = nil;
}

- (void)updateLabelsColor
{
for (UIView* v in _stackView.subviews)
{
if ([v isKindOfClass:[UILabel class]])
{
UILabel* label = (UILabel*)v;
label.textColor = self.normalTextColor;
}
}

UILabel* label = _stackView.arrangedSubviews[self.selectedSegmentIndex];
label.textColor = self.highlightTextColor;
}

- (void)addTapGesture
{
UITapGestureRecognizer* tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)];
[self addGestureRecognizer:tap];
}

- (NSUInteger)segmentIndexWithLocation:(CGPoint)point
{
NSUInteger index = (point.x - self.margin) / self.segmentWidth;

if (index < 0)
{
index = 0;
}
if (index > self.segmentItems.count - 1)
{
index = self.segmentItems.count - 1;
}

return index;
}

- (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated
{
let frame = self.slider.frame;
let x = index * self.segmentWidth + self.margin;

let duration = animated ? kUPAnimationDurationShort : 0.0;
[UIView animateWithDuration:duration
animations:^{
self.slider.frame = CGRectMake(x, frame.origin.y, frame.size.width, frame.size.height);
}
completion:^(BOOL finished) {
_selectedSegmentIndex = index;
[self updateLabelsColor];
}];
}

#pragma mark - UIGestureRecognizer Actions

- (void)handleTapGesture:(UITapGestureRecognizer*)tapGR
{
CGPoint location = [tapGR locationInView:self];
NSUInteger index = [self segmentIndexWithLocation:location];
[self moveToIndex:index animated:YES];

if ([self.delegate respondsToSelector:@selector(segmentedControl:didSelectedItemAtIndex:)])
{
[self.delegate segmentedControl:self didSelectedItemAtIndex:index];
}
}

#pragma mark - getters && setters

- (void)setSliderColor:(UIColor*)sliderColor
{
_sliderColor = sliderColor;
_slider.backgroundColor = sliderColor;
}

- (void)setSelectedSegmentIndex:(NSUInteger)selectedSegmentIndex animated:(BOOL)animated;
{
_selectedSegmentIndex = selectedSegmentIndex;

[self moveToIndex:selectedSegmentIndex animated:animated];
}

- (void)setSegmentItems:(NSArray<NSString*>*)segmentItems
{
if (_segmentItems)
{
[self removeAllSegments];
}

if (segmentItems.count == 0)
{
return;
}

_segmentItems = [NSArray arrayWithArray:segmentItems];

[self insertAllSegments];
[self updateLabelsColor];
}

@end

现在让我们来看看有什么问题。

实现机制
  1. segmentWidth 这个属性真的有存在的必要吗?通过算坐标值来确定点击到的 UILabel 当然可行,但是计算性的代码写的再好毕竟也不能一目了然。有没有可读性更好的做法?
  2. 既然是 UIControl 的子类,是不是有比 Delegate 更好的机制来传递事件?
  3. 既然重写了selectedSegmentIndexsetter 函数,为什么还存在使用下划线变量赋值的代码?
命名问题
  1. Corner 代表的是拐角,并不能描述出圆角的概念
  2. highlightTextColor 是高亮字体色,并不能描述出选中字体色的概念
  3. margin 代表的是外间距,并不是内间距

其实 margincss 布局中的概念,放张图科普一下

  1. slider 是滑动的意思,如果你将一个对象命名为 sliderView。那么至少这个类可以支持拖动,类似于 UISlider 的效果。
  2. - (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated 是不是说清楚 move 什么 to index 比较好?
接口设计
  1. - (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated 的实现中为什么会调用 updateLabelsColor 和更新 _selectedSegmentIndex 的值?简简单单的 move 接口应该做这么多事么?
  2. 声明的可读属性并没有全部重写 setter 函数 。不能默认调用者知道先传入 segmentItems 再设置其他属性才能生效。

当时一下子抛出这么多问题,我有点哑口无言,本来想着一个只有200行代码的类,使用一些 frame 不是很方便么?Delegate 也很好用。然后仔细想了想,这些问题确实很有道理。不知道读到这里,你们的想法是什么?

现在我们来看一下修改之后的代码

修改后的代码:

命名问题
  1. Renamed CornerSegmentedControl -> RoundCornerSegmentedControl
  2. Renamed highlightTextColor -> selectedTextColor
  3. Renamed margin -> padding
  4. Renamed slider -> indicator
  5. Renamed - (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated -> - (void)moveIndicatorToSegmentAtIndex:(NSInteger)index animated:(BOOL)animated
实现机制
  1. 去掉了segmentWidth 属性。既然已经用了 UIStackView 来管理视图,那么通过 UIStackView + AutoLayout 完全可以省去坐标计算的问题 。当用户点击的时候,遍历所有的 label ,判断坐标点在哪一个 label 上,然后更新 indicatorView的布局和 label 的字体颜色。
  2. 使用 Target-Action 机制。在恰当的时机调用
    - (void)sendActionsForControlEvents:(UIControlEvents)controlEvents
    来触发 action。这样既和系统的行为保持一致,对于调用方而言,成本也会小很多。
接口设计

- (void)updateLabelsColorAnimated:(BOOL)animated_selectedSegmentIndex = index;- (void)moveIndicatorToSegmentAtIndex:(NSInteger)index animated:(BOOL)animated 中拿出来,保证了接口的单一原则:move 应该只做和视图移动有关的事情,它不应该去更新文本颜色,也不应该去更新当前选中的索引值。

类的声明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@interface OUPRoundCornerSegmentedControl : UIControl

@property (nonatomic) UIFont* font; // default [UIFont systemFontOfSize:14]
@property (nonatomic) UIColor* normalTextColor; // default UIColor.grayColor
@property (nonatomic) UIColor* selectedTextColor; // default UIColor.whiteColor
@property (nonatomic) UIColor* indicatorColor; // default UIColor.darkGrayColor
@property (nonatomic) CGFloat edgeInset; // default 2.0

@property (nonatomic) NSArray<NSString*>* segmentItems;
@property (nonatomic) NSInteger selectedSegmentIndex; // default 0

- (void)setSelectedSegmentIndex:(NSInteger)selectedSegmentIndex animated:(BOOL)animated;
- (void)setNormalTextColor:(UIColor*)normalTextColor animated:(BOOL)animated;
- (void)setSelectedTextColor:(UIColor*)selectedTextColor animated:(BOOL)animated;

@end
类的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
@interface OUPRoundCornerSegmentedControl ()

@property (nonatomic) UIStackView* stackView;
@property (nonatomic) UIView* indicatorView;

@end

@implementation OUPRoundCornerSegmentedControl

- (instancetype)initWithCoder:(NSCoder*)coder
{
self = [super initWithCoder:coder];
if (self)
{
[self commonInit];
}
return self;
}

- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self)
{
[self commonInit];
}
return self;
}

- (void)commonInit
{
_font = [UIFont systemFontOfSize:14];
_normalTextColor = UIColor.grayColor;
_selectedTextColor = UIColor.whiteColor;
_indicatorColor = UIColor.darkGrayColor;
_edgeInset = 2.0;
_selectedSegmentIndex = 0;

_indicatorView = [[UIView alloc] init];
_indicatorView.backgroundColor = _indicatorColor;
_indicatorView.userInteractionEnabled = NO;
[self addSubview:_indicatorView];

_stackView = [[UIStackView alloc] init];
_stackView.axis = UILayoutConstraintAxisHorizontal;
_stackView.alignment = UIStackViewAlignmentFill;
_stackView.distribution = UIStackViewDistributionFillEqually;
_stackView.userInteractionEnabled = NO;
[self addSubview:_stackView];

[_stackView mas_makeConstraints:^(MASConstraintMaker* make) {
make.edges.equalTo(self).insets(UIEdgeInsetsMake(_edgeInset, _edgeInset, _edgeInset, _edgeInset));
}];
}

- (void)layoutSubviews
{
[super layoutSubviews];

self.layer.cornerRadius = self.frame.size.height / 2;
_indicatorView.layer.cornerRadius = _indicatorView.frame.size.height / 2;
}

- (void)insertAllSegments
{
let labelMaker = ^UILabel*(NSString* title, UIColor* textColor, UIFont* font)
{
let label = [[UILabel alloc] init];
label.userInteractionEnabled = NO;
label.text = title;
label.textColor = textColor;
label.font = font;
label.textAlignment = NSTextAlignmentCenter;
label.backgroundColor = UIColor.clearColor;

return label;
};

foreach (title, _segmentItems)
{
let label = labelMaker(title, _normalTextColor, _font);
[_stackView addArrangedSubview:label];
}
}

- (void)removeAllSegments
{
foreach (segment, _stackView.arrangedSubviews)
{
[segment removeFromSuperview];
}

_segmentItems = nil;
}

- (void)updateLabelsColorAnimated:(BOOL)animated
{
UILabel* selectedLabel = nil;
if (_selectedSegmentIndex >= 0 && _selectedSegmentIndex < _stackView.arrangedSubviews.count)
{
selectedLabel = _stackView.arrangedSubviews[_selectedSegmentIndex];
}

let duration = animated ? kUPAnimationDurationShort : 0.0;

[UIView animateWithDuration:duration
animations:^{
foreach (segment, _stackView.arrangedSubviews)
{
let label = SC_STATIC_CAST(segment, UILabel);
let isSelected = label == selectedLabel;
label.textColor = isSelected ? _selectedTextColor : _normalTextColor;
}
}
completion:nil];
}

- (NSUInteger)segmentIndexAtPoint:(CGPoint)point
{
for (int i = 0; i < _stackView.arrangedSubviews.count; I++)
{
let segment = _stackView.arrangedSubviews[I];
let frame = segment.frame;
let containsPoint = point.x >= CGRectGetMinX(frame) && point.x <= CGRectGetMaxX(frame);
if (containsPoint)
{
return I;
}
}

if (point.x < 0)
{
return 0;
}
else
{
return _segmentItems.count - 1;
}
}

- (void)moveIndicatorToSegmentAtIndex:(NSInteger)index animated:(BOOL)animated
{
if (index == UISegmentedControlNoSegment)
{
SCNotImplementedAssert();
return;
}

let selectedSegment = _stackView.arrangedSubviews[index];
let duration = animated ? kUPAnimationDurationShort : 0.0;

[UIView animateWithDuration:duration
animations:^{
[_indicatorView mas_remakeConstraints:^(MASConstraintMaker* make) {
make.edges.equalTo(selectedSegment);
}];

[self layoutIfNeeded];
}
completion:nil];
}

#pragma mark - Touch

- (void)endTrackingWithTouch:(UITouch*)touch withEvent:(UIEvent*)event
{
let location = [touch locationInView:self];
let index = [self segmentIndexAtPoint:location];

if (index != _selectedSegmentIndex)
{
[self setSelectedSegmentIndex:index animated:YES];
[self sendActionsForControlEvents:UIControlEventValueChanged];
}

[super endTrackingWithTouch:touch withEvent:event];
}

#pragma mark - getters && setters

- (void)setFont:(UIFont*)font
{
_font = font;

foreach (segment, _stackView.arrangedSubviews)
{
SC_STATIC_CAST(segment, UILabel).font = font;
}
}

- (void)setNormalTextColor:(UIColor*)normalTextColor
{
[self setNormalTextColor:normalTextColor animated:NO];
}

- (void)setNormalTextColor:(UIColor*)normalTextColor animated:(BOOL)animated
{
_normalTextColor = normalTextColor;

[self updateLabelsColorAnimated:animated];
}

- (void)setSelectedTextColor:(UIColor*)selectedTextColor
{
[self setSelectedTextColor:selectedTextColor animated:NO];
}

- (void)setSelectedTextColor:(UIColor*)selectedTextColor animated:(BOOL)animated
{
_selectedTextColor = selectedTextColor;

[self updateLabelsColorAnimated:animated];
}

- (void)setIndicatorColor:(UIColor*)indicatorColor
{
_indicatorColor = indicatorColor;
_indicatorView.backgroundColor = indicatorColor;
}

- (void)setEdgeInset:(CGFloat)edgeInset
{
_edgeInset = edgeInset;

[_stackView mas_remakeConstraints:^(MASConstraintMaker* make) {
make.edges.equalTo(self).insets(UIEdgeInsetsMake(_edgeInset, _edgeInset, _edgeInset, _edgeInset));
}];
}

- (void)setSelectedSegmentIndex:(NSInteger)selectedSegmentIndex
{
[self setSelectedSegmentIndex:selectedSegmentIndex animated:NO];
}

- (void)setSelectedSegmentIndex:(NSInteger)selectedSegmentIndex animated:(BOOL)animated;
{
if (selectedSegmentIndex == UISegmentedControlNoSegment)
{
SCNotImplementedAssert();
return;
}

if (selectedSegmentIndex < UISegmentedControlNoSegment || selectedSegmentIndex >= _segmentItems.count)
{
SCParameterAssert(selectedSegmentIndex > UISegmentedControlNoSegment && selectedSegmentIndex < _segmentItems.count);
return;
}

_selectedSegmentIndex = selectedSegmentIndex;

dispatch_async(dispatch_get_main_queue(), ^{
[self updateLabelsColorAnimated:animated];
[self moveIndicatorToSegmentAtIndex:selectedSegmentIndex animated:animated];
});
}

- (void)setSegmentItems:(NSArray<NSString*>*)segmentItems
{
if (_segmentItems)
{
[self removeAllSegments];
}

NSInteger selectedSegmentIndex = _selectedSegmentIndex;

if (_selectedSegmentIndex >= segmentItems.count)
{
selectedSegmentIndex = UISegmentedControlNoSegment;
}

if (segmentItems.count == 0)
{
SCNotImplementedAssert();
return;
}

_segmentItems = [NSArray arrayWithArray:segmentItems];

[self insertAllSegments];

[self setSelectedSegmentIndex:selectedSegmentIndex animated:NO];
}

@end

改完之后的代码的可读性明显比之前要好很多,无论从接口设计,还是命名上都比之前要规范
(虽然我觉得之前的代码可读性也不差哈哈)。

其实目前的代码看起来还有一些扩展性问题。不过因为业务目前并不需要,我就没有再调整代码了。这里说一下问题,有兴趣的朋友可以自己实现一下。
因为现在仅仅支持两种文本颜色,选中和未选中。如果日后需要支持更多的文本颜色,肯定需要增加一些额外的参数,变量和接口来维护不同的样式。

考虑到整个类是继承自 UIControl ,所以对于字体颜色的设置如果改成和 UIButton 相同的行为就会简单很多:

- (void)setTextColor:(UIColor*)color forState:(UIControlState)state

日后如果需要支持图片,那么接口设计和内部实现也会变得非常简单

- (void)setImage:(UIColor*)color forState:(UIControlState)state
- (void)setImage:(UIColor*)color forState:(UIControlState)state atIndex:(NSInteger)index

所以,如果你要设计一个继承自 UIControl 的类,那么应该注意以下几点:

  1. 使用 Target-Action 机制
  2. 善用 UIControlState 来管理 UI 的行为

脱离 UIControl 的设计原则,来讲讲一些其他基本的注意点:

  1. 如果你重新实现了一个属性的 setter 函数,那么务必确保该变量的值只会在其 setter 函数中更改,在其他接口中你不应该直接使用点语法赋值,应该通过调用 setter 函数来赋值。
  2. 苹果为我们提供了很好的 AutoLayout 机制,应当好好使用,并且 WWCD2018 上也提到了 AutoLayout 性能会得到本质性的改善。重复计算 frame 这种事情除非必要,比如设置 layer 的圆角等,应该学会尽量用 AutoLayout 去解决问题。
0%