前言
最近项目中需要模仿小红书中发笔记的相关功能,其中允许用户对图片打标签的功能是其中的重点,下面是实现这个功能的一些思路。
功能需求分析
把玩了一下小红书,总结了一下这个打标签功能的一些需求。
- 点击图片弹出输入框,输入标签信息后在点击位置生成一个标签。
- 点击标签上的小圆点可以切换不同样式,分别有左右两个方向,直线和斜线两种样式,标签数量1~3个,最多一共12种样式。
- 点击标签上的文字可以对标签内容进行编辑。
- 长按标签任意部分可以删除标签。
- 拖动标签任意部分可以移动标签的位置。
- 在不可编辑的状态看下,点击图片可以隐藏/显示所有标签。
思路
有了需求,下面就逐个来分析实现。
1、比例坐标、ViewModel
首先这个小标签需要响应触摸事件,所以打算以继承UIView的方式来实现它,显然这个标签由原点、线条、文本等部件组成。由于上面的第一个需求中在创建一个视图时,需要确定这个标签视图的位置,考虑到图片本身是有可能缩放来适配不同尺寸设备的,所以标签视图的位置可以用比例坐标来表示,即坐标的取值为0~1,最后乘以父视图的宽高得到父视图坐标系中的准确坐标。
同时也单独用一个TagViewModel来保存表示一个标签所需的数据,创建一个标签就是创建一个TagViewModel,标签视图只需要接受并处理这个ViewModel即可生成标签。
@interface TagViewModel : NSObject
//文本数组
@property (nonatomic, strong) NSMutableArray<TagModel *> *tagModels;
//标签相对于父视图坐标系中的相对坐标,例如(0.5, 0.5)即代表位于父视图中心
@property (nonatomic, assign) CGPoint coordinate;
//样式
@property (nonatomic, assign) TagViewStyle style;
//顺序标志
@property (nonatomic, assign) NSUInteger index;
//初始化
- (instancetype)initWithArray:(NSArray<TagModel *> *)tagModels coordinate:(CGPoint)coordinate;
//样式相关
- (void)resetStyle;
- (void)styleToggle;
- (void)synchronizeAngle;
@end
一个TagViewModel代表一个标签,一个标签中有可能含有几段文本,而且在绘制时需要用到相应样式中角度等数据,因此再定义一个TagModel表示一段文字。
@interface TagModel : NSObject
//文本
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *value;
//角度
@property (nonatomic, assign) CGFloat angle;
//文本位置
@property (nonatomic, assign) CGSize textSize;
@property (nonatomic, assign) CGPoint textPosition;
//初始化
- (instancetype)initWithName:(NSString *)name value:(NSString *)value;
@end
2、 图层绘制
标签视图作为提供给外部的最小单位,而其内部的组件(文本、线条、原心)由于不需要响应事件,则选择用图层来绘制,这样做比用视图来绘制会稍微更高效。
一个标签视图主要由文本、线条、圆心组成,可以使用CATextLayer
和CAShapeLayer
来实现,结构参考下面的示意图。绘制的步骤是首先确定标签视图的宽高,视图高度由斜线半径与文本的高度决定,而视图宽度主要由斜线半径与文本的宽度决定。
然后就可以在这个确定宽高的视图中分别画出圆心、文本下的下划线图层,最后根据下划线定位文本图层的位置。其中斜线的绘制方法是根据TagModel中的角度和斜线的半径用三角函数算出起点(圆心)和终点(圆上的一点)的坐标画直线得到。
3、事件响应链
画出了图层,接下来处理触摸。在需求的第二到第五点中,都涉及到事件响应链。由于需要处理不同类型的触摸事件,在这里我用了UIGestureRecognize,给视图添加了点击、长按、滑动手势。当一个标签视图接收到点击事件时,它需要判断自己是否得处理这个点击事件,例如单击了原点区域、长按了文本区域或者只是点到了视图中的空白区域。所以在标签视图类中需要重写UIView的-pointInside: withEvent:
方法和-hitTest: withEvent:
方法,前者判断事件的点是否落在本视图内,后者用于向上级视图返回需要接受这个事件的视图是什么——视图自己本身、子视图或者nil。
代码中在-pointInside: withEvent:
方法中判断点是否落在了圆心或者文本上:
//标签视图没有子视图,相当于结果由piontInside:withEvent:方法决定
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView *view = [super hitTest:point withEvent:event];
return view;
}
//重写父类方法
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
if(![self centerContainsPoint:point inset:0] && ![self textLayerContainsPoint:point inset:CGPointMake(-5, -5)]){
return NO;
}
return [super pointInside:point withEvent:event];
}
//判断position是否在圆心区域内
- (BOOL)centerContainsPoint:(CGPoint)position inset:(CGFloat)insetRadius
{
CGPoint centerPosition = CGPointMake(self.layer.bounds.size.width/2, self.layer.bounds.size.height/2);
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:centerPosition radius:kUnderLineLayerRadius+insetRadius startAngle:0 endAngle:M_PI*2 clockwise:YES];
return [path containsPoint:position];
}
//点position是否在某一个textLayer内
- (BOOL)textLayerContainsPoint:(CGPoint)point inset:(CGPoint)insetXY
{
BOOL cantainsPoint = NO;
for(CATextLayer *textLayer in _textLayers){
if(textLayer.presentationLayer.opacity == 0){
continue;
}
CGRect textRect = CGRectInset(textLayer.frame, insetXY.x, insetXY.y);
if(CGRectContainsPoint(textRect, point)){
cantainsPoint = YES;
break;
}
}
return cantainsPoint;
}
如果最后判断标签视图需要处理这个触摸时间,只需要用一开始给这个标签视图加上的各个手势,配合判断点是否在圆心和文本的方法,即可实现需求中第二到第五点的功能。
4、图层动画
在需求的第二点和第六点中,需要以动画的方式切换标签视图的样式和显示\隐藏标签。动画的思路是同时给需要动画的图层添加CAAnimation,可以用CAKeyFrameAnimation或者CABasicAnimation的beginTime属性实现动画的先后次序,并用CAAnimationTransaction来做一些动画后的处理。
注意
本身在CAAnimation中对图层的可动画属性直接赋值,就会产生默认的动画,但这个动画没法进行自定义配置,像刚才提到的不同属性的先后次序就没法实现,直接修改这些图层的属性值会让动画一齐执行。例如上图中的线条是一个CAShapeLayer,代码layer.strokeEnd = 0
就已经可以产生线条收回的动画,但没法让文字消失后,这个动画才发生,所以只好换个方式实现。
隐藏动画的代码:
- (void)hideWithAnimate:(BOOL)animate
{
if(_viewHidden || _animating){
return;
}
_animating = YES;
CGFloat duration = 1.f;
[self animateWithDuration:duration*3 AnimationBlock:^{
NSTimeInterval currentTime = CACurrentMediaTime();
//原点
CABasicAnimation *animation = [CABasicAnimation animation];
animation.beginTime = currentTime+duration*2;
animation.duration = duration;
animation.keyPath = @"opacity";
animation.removedOnCompletion = NO;
animation.fillMode = kCAFillModeBoth;
animation.fromValue = @1;
animation.toValue = @0;
[_centerPointShapeLayer addAnimation:animation forKey:kAnimationKeyShow];
animation.fromValue = @0.3;
[_shadowPointShapeLayer addAnimation:animation forKey:kAnimationKeyShow];
//下划线
CABasicAnimation *lineAnimation = [CABasicAnimation animation];
lineAnimation.beginTime = currentTime+duration;
lineAnimation.duration = duration;
lineAnimation.keyPath = @"strokeEnd";
lineAnimation.removedOnCompletion = NO;
lineAnimation.fillMode = kCAFillModeBoth;
lineAnimation.fromValue = @1;
lineAnimation.toValue = @0;
for(CAShapeLayer *shapeLayer in _underLineLayers){
[shapeLayer addAnimation:lineAnimation forKey:kAnimationKeyShow];
}
//文字
CABasicAnimation *textAnimation = [CABasicAnimation animation];
textAnimation.beginTime = 0;
textAnimation.duration = duration;
textAnimation.keyPath = @"opacity";
textAnimation.removedOnCompletion = NO;
textAnimation.fillMode = kCAFillModeBoth;
textAnimation.fromValue = @1;
textAnimation.toValue = @0;
for(CATextLayer *textLayer in _textLayers){
[textLayer addAnimation:textAnimation forKey:kAnimationKeyShow];
}
} completeBlock:^{
_animating = NO;
_viewHidden = YES;
}];
}
- (void)animateWithDuration:(CGFloat)duration
AnimationBlock:(void(^)())doBlock
completeBlock:(void(^)())completeBlock
{
[CATransaction begin];
[CATransaction setDisableActions:NO];
[CATransaction setAnimationDuration:duration];
[CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]];
[CATransaction setCompletionBlock:^{
[CATransaction begin];
[CATransaction setDisableActions:YES];
if(completeBlock){
completeBlock();
}
[CATransaction commit];
}];
if(doBlock){
doBlock();
}
[CATransaction commit];
}
- 指定时间开始动画
animation.beginTime = CACurrentMediaTime()+duration*2 ;
可以让动画在指定时间后才开始. - animation.fillMode
这是一个很有意思的属性,它的可选值定义在CAMediaTiming.h中
CA_EXTERN NSString * const kCAFillModeForwards
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAFillModeBackwards
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAFillModeBoth
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAFillModeRemoved
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
默认情况下当给一个layer添加CAAnimation后,动画时实际上改变的是layer.presentationLayer中的属性,动画让layer得以在呈现层(presentation)上做动画,当动画完成后,动画会从layer上移除,在没有改变layer.modelLayer的属性下,layer也会在presentation上被移除。而如果设置了animation.rmovedOnCompletion = NO;
,动画在完成后就不会从layer上移出,并根据fillMode
属性决定如何显示layer。
kCAFillModeForwards//直到动画开始时layer都隐藏,动画完成后保持动画最后的状态
kCAFillModeBackwards//直到动画开始时layer都保持当前的状态,动画完成后移出presentation
kCAFillModeBoth//上面两者合体,直到动画开始时layer都保持当前的状态,动画完成后保持动画最后的状态
kCAFillModeRemoved//直到动画开始时layer都隐藏,动画完成后移出presentation
- CATransaction
用[CATransaction begin]
开始一个动画事务,并可以在完成时执行自己的代码。另外在CATransaction中可以组合多个CAAnimation,也可以嵌套CATransaction。
5、标签样式切换
在绘制图层时,主要靠每个文本(TagModel)上的角度属性来画不同方向的线条,改变标签的样式实际上就是改变文本的角度,所以实现样式切换这个功能需要做的就是配置一个不同样式、不同文本数量下对应的文本角度的配置文件。
在这里选择在plist中写好相关的样式角度,在ViewTagModel中拿自己本身的标签数据与plist中的样式数据做一个匹配,以此来确认当前的标签样式。切换样式时只需要直接改变样式,再根据样式到plist中查找对应的角度并赋值就ok了。这里的plist可以用json代替,思路是一样的,使用这种方式,可以让app访问服务器获取最新的样式来随时改变样式配置。
#pragma mark - 判断当前style
//根据标签数量进行判断
- (void)resetStyle
{
NSInteger count = _tagModels.count;
if(count == 0){
NSLog(@"_tagModels.count = 0");
return;
}
//根据标签条数拿出对应的样式数据
NSDictionary *countStyleDict = styleDict[[NSString stringWithFormat:@"%@", @(count)]];
if(!countStyleDict){
NSLog(@"styleDict not found");
return;
}
//allKeys为所有TagViewStyle
NSArray *allKeys = [countStyleDict allKeys];
//遍历TagViewStyle
for(NSInteger i=0; i<allKeys.count; i++){
NSString *styleStr = allKeys[i];
//以此为key拿出对应style的角度
NSArray *styleArray = countStyleDict[styleStr];
if(styleArray.count == 0){
//没有角度数据
continue;
}
//无论有多少条标签,这里都只判断了第一条标签的角度
//可以考虑改为验证所有标签的角度来判断数据的合法性
NSNumber *angleNumber = (NSNumber*)styleArray[0];
if(_tagModels[0].angle == [angleNumber floatValue]){
_style = [styleStr integerValue];
NSLog(@"_style reset:%@", @(_style));
return;
}
}
}
#pragma mark - 切换当前style
- (void)styleToggle
{
//切换
_style = (_style+1)%maxStyle;
[self synchronizeAngle];
}
#pragma mark - 根据当前style更新角度
- (void)synchronizeAngle
{
NSInteger count = _tagModels.count;
if(count == 0){
NSLog(@"_tagModels.count = 0");
return;
}
//根据标签条数拿出对应的样式数据
NSDictionary *countStyleDict = styleDict[[NSString stringWithFormat:@"%@", @(count)]];
if(!countStyleDict){
NSLog(@"styleDict not found");
return;
}
//根据样式拿出角度数据数组
NSArray *styleArray = countStyleDict[[NSString stringWithFormat:@"%@", @(_style)]];
if(styleArray.count < _tagModels.count){
NSLog(@"styleArray doesn't long enough");
return;
}
//更新角度
for(NSInteger i=0; i<_tagModels.count; i++){
NSNumber *angleNumber = (NSNumber*)styleArray[i];
_tagModels[i].angle = [angleNumber floatValue];
}
}
这个小标签功能的介绍就到此结束,demo有空再上传。
2017.06.06更新:
抱歉这么久才更新demo,最近有点忙,没什么时间打理这里,希望还能帮上评论区的朋友。
https://github.com/Tidusww/CommonDemo.git
参考资料
http://stackoverflow.com/questions/6482517/whats-the-effect-of-fillmode-being-kcafillmodebackwards