【仿小红书】为图片加上带动效的小标签

前言

最近项目中需要模仿小红书中发笔记的相关功能,其中允许用户对图片打标签的功能是其中的重点,下面是实现这个功能的一些思路。

小红书打标签功能

功能需求分析

把玩了一下小红书,总结了一下这个打标签功能的一些需求。

  1. 点击图片弹出输入框,输入标签信息后在点击位置生成一个标签。
  2. 点击标签上的小圆点可以切换不同样式,分别有左右两个方向,直线和斜线两种样式,标签数量1~3个,最多一共12种样式。
  3. 点击标签上的文字可以对标签内容进行编辑。
  4. 长按标签任意部分可以删除标签。
  5. 拖动标签任意部分可以移动标签的位置。
  6. 在不可编辑的状态看下,点击图片可以隐藏/显示所有标签。

思路

有了需求,下面就逐个来分析实现。

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、 图层绘制

标签视图作为提供给外部的最小单位,而其内部的组件(文本、线条、原心)由于不需要响应事件,则选择用图层来绘制,这样做比用视图来绘制会稍微更高效。
一个标签视图主要由文本、线条、圆心组成,可以使用CATextLayerCAShapeLayer来实现,结构参考下面的示意图。绘制的步骤是首先确定标签视图的宽高,视图高度由斜线半径与文本的高度决定,而视图宽度主要由斜线半径与文本的宽度决定。

灰色区域为文本区域

然后就可以在这个确定宽高的视图中分别画出圆心、文本下的下划线图层,最后根据下划线定位文本图层的位置。其中斜线的绘制方法是根据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];
}
  1. 指定时间开始动画
    animation.beginTime = CACurrentMediaTime()+duration*2 ;可以让动画在指定时间后才开始.
  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
  1. 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

推荐阅读更多精彩内容