前几天因为业务需求,随手写了一个类似于 UISegmentedControl 的控件,写完后觉得代码没什么毛病,简洁好用,可读性也好,也仅仅只有 200 行代码。后来 Code Review 的时候,leader 说了一些观点让我觉得很受用。也让我觉得自定义 UI 控件其实需要更多的思考,而不仅仅是功能上面的实现。所以写篇文章记录下来。大致效果如下:
因为代码不多,就直接放到文章里了。
类的设计思路:
- View层面:一个 UIStackView,一堆 UILabel,一个用于滑动的 sliderview。
UIStackView 负责管理一堆 Label ,Label 的背景色均为透明,sliderView 作为滑块充当 Label 的背景效果。 - 交互层面:根据
segmentItems的数量计算出每个 Label 的宽度,再通过触摸点算出 location 所在的位置的 Label 和 index,然后更新 sliderView 的位置,同时更新 label 的文本颜色。
看一下我最早提交的版本。
类的声明:
1 | #import <UIKit/UIKit.h> |
类的实现:
1 | @interface OUPCornerSegmentedControl () |
现在让我们来看看有什么问题。
实现机制
segmentWidth这个属性真的有存在的必要吗?通过算坐标值来确定点击到的 UILabel 当然可行,但是计算性的代码写的再好毕竟也不能一目了然。有没有可读性更好的做法?- 既然是 UIControl 的子类,是不是有比 Delegate 更好的机制来传递事件?
- 既然重写了
selectedSegmentIndex的 setter 函数,为什么还存在使用下划线变量赋值的代码?
命名问题
Corner代表的是拐角,并不能描述出圆角的概念highlightTextColor是高亮字体色,并不能描述出选中字体色的概念margin代表的是外间距,并不是内间距
其实 margin 是 css 布局中的概念,放张图科普一下
slider是滑动的意思,如果你将一个对象命名为sliderView。那么至少这个类可以支持拖动,类似于 UISlider 的效果。- (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated是不是说清楚 move 什么 to index 比较好?
接口设计
- (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated的实现中为什么会调用updateLabelsColor和更新_selectedSegmentIndex的值?简简单单的 move 接口应该做这么多事么?- 声明的可读属性并没有全部重写 setter 函数 。不能默认调用者知道先传入
segmentItems再设置其他属性才能生效。
当时一下子抛出这么多问题,我有点哑口无言,本来想着一个只有200行代码的类,使用一些 frame 不是很方便么?Delegate 也很好用。然后仔细想了想,这些问题确实很有道理。不知道读到这里,你们的想法是什么?
现在我们来看一下修改之后的代码
修改后的代码:
命名问题
- Renamed
CornerSegmentedControl->RoundCornerSegmentedControl - Renamed
highlightTextColor->selectedTextColor - Renamed
margin->padding - Renamed
slider->indicator - Renamed
- (void)moveToIndex:(NSUInteger)index animated:(BOOL)animated->- (void)moveIndicatorToSegmentAtIndex:(NSInteger)index animated:(BOOL)animated
实现机制
- 去掉了
segmentWidth属性。既然已经用了 UIStackView 来管理视图,那么通过 UIStackView + AutoLayout 完全可以省去坐标计算的问题 。当用户点击的时候,遍历所有的label,判断坐标点在哪一个label上,然后更新indicatorView的布局和 label 的字体颜色。 - 使用 Target-Action 机制。在恰当的时机调用
- (void)sendActionsForControlEvents:(UIControlEvents)controlEvents
来触发 action。这样既和系统的行为保持一致,对于调用方而言,成本也会小很多。
接口设计
将 - (void)updateLabelsColorAnimated:(BOOL)animated 和 _selectedSegmentIndex = index;从 - (void)moveIndicatorToSegmentAtIndex:(NSInteger)index animated:(BOOL)animated 中拿出来,保证了接口的单一原则:move 应该只做和视图移动有关的事情,它不应该去更新文本颜色,也不应该去更新当前选中的索引值。
类的声明
1 | @interface OUPRoundCornerSegmentedControl : UIControl |
类的实现
1 | @interface OUPRoundCornerSegmentedControl () |
改完之后的代码的可读性明显比之前要好很多,无论从接口设计,还是命名上都比之前要规范
(虽然我觉得之前的代码可读性也不差哈哈)。
其实目前的代码看起来还有一些扩展性问题。不过因为业务目前并不需要,我就没有再调整代码了。这里说一下问题,有兴趣的朋友可以自己实现一下。
因为现在仅仅支持两种文本颜色,选中和未选中。如果日后需要支持更多的文本颜色,肯定需要增加一些额外的参数,变量和接口来维护不同的样式。
考虑到整个类是继承自 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 的类,那么应该注意以下几点:
- 使用
Target-Action机制 - 善用
UIControlState来管理 UI 的行为
脱离 UIControl 的设计原则,来讲讲一些其他基本的注意点:
- 如果你重新实现了一个属性的 setter 函数,那么务必确保该变量的值只会在其 setter 函数中更改,在其他接口中你不应该直接使用点语法赋值,应该通过调用 setter 函数来赋值。
- 苹果为我们提供了很好的 AutoLayout 机制,应当好好使用,并且 WWCD2018 上也提到了 AutoLayout 性能会得到本质性的改善。重复计算 frame 这种事情除非必要,比如设置 layer 的圆角等,应该学会尽量用 AutoLayout 去解决问题。