动画特效十六:网易新闻之“排序删除”效果

详解网易新闻头部导航切换及排序删除动画功能
本文深入解析了网易新闻中头部导航切换和排序删除动画的实现细节,包括需求分析、代码实现以及相关算法解释。通过自定义View和UIButton,实现了小方块的拖拽、排序和删除等交互效果。

本人录制技术视频地址:https://edu.youkuaiyun.com/lecturer/1899 欢迎观看。

上一节中讲解了 动画特效十五:网易新闻之头部导航切换效果。 这一节讲解一下网易新闻中的 "排序删除" 动画,效果图如下(注:由于是flash录制,这里的小方块抖动并不明显,实际动画中是很流畅的):



一、需求分析:

1. 排序:长按某个小方块,等到它开始抖动,此时,我们就可以对小方块进行拖拽,当他拖拽到其他小方块的中心位置时,其余的小方块会发生位置移动的动画效果;当手松开这个正在拖拽的小方块后,它会自动跑到空缺的那个位置(关于其余小方块位置移动的动画及拖拽的小方块手松开后的动画算法,我会在下面进行详细的说明)。

2. 删除:点击右上方的 "删除" 按钮,所有的小方块都会都动起来,此时,当点击到某个方块后,这个小方块便会被删除,其他的小方块位置也会发生相应的移动(同样,这里的算法我也会在下面进行详细的说明)。

二、代码实现:

1. 布置小方块和小方块的背景,小方块本身是用来"被操作"的,而小方块的黑色背景就是用来占位的(标明这里有位置可以放置小方块)。很明显,小方块和小方块的背景需要一个容器容纳他们,所以我们可以自定义一个UIView来,而小方块有自己特定的样式及自定特定的行为(也就是方法来实现它的抖动及暂停),所以我们可以自定义UIButton来管理它。

1.1 自定义小方块的UIButton (LFOperationButton)。

LFOperationButton.h 文件:

@interface LFOperationButton : UIButton
@property (nonatomic, assign, getter=isShaking) BOOL shaking;
- (void)itemShake;
- (void)itemStop;
@end

LFOperationButton.m 文件:

代码中需要说明的是,这里的小方块的抖动动画是通过关键帧动画实现的,如果有什么不明的,请参照  

Core Animation 基本动画效果汇总我在这片博客中讲解的很详细。其次注意,在这个自定义View被销毁的时候,要将所有的动画效果移除掉,即在dealloc方法中,加上 [self.layer removeAllAnimation]; 这个方法。

#define angle2Radian(angle) ((angle) / 180.0 * M_PI)

#import "LFOperationButton.h"

@implementation LFOperationButton

- (void)dealloc {
    [self.layer removeAllAnimations];
}

- (instancetype)init {
    if (self = [super init]) {
        [self setup];
    }
    return self;
}

- (void)setup {
    self.layer.cornerRadius = 5;
    self.layer.borderWidth = 1;
    self.layer.borderColor = [UIColor colorWithRed:126/255.0 green:222/255.0 blue:184/255.0 alpha:0.6].CGColor;
    self.titleLabel.font = [UIFont boldSystemFontOfSize:20];
    self.backgroundColor = [UIColor colorWithRed:31/255.0 green:192/255.0 blue:120/255.0 alpha:0.6];
}

// 永远在抖动
- (void)itemShake {
    CAKeyframeAnimation *shakingAnim = [CAKeyframeAnimation animation];
    shakingAnim.keyPath = @"transform.rotation";
    shakingAnim.values = @[@(-angle2Radian(5)), @(angle2Radian(5)), @(-angle2Radian(5))];;
    shakingAnim.removedOnCompletion = NO;
    shakingAnim.fillMode = kCAFillModeForwards;
    shakingAnim.duration = 0.1;
    
    shakingAnim.repeatCount = MAXFLOAT;
    [self.layer addAnimation:shakingAnim forKey:nil];
}

// 结束抖动,不要立刻停止抖动
- (void)itemStop {
    CAKeyframeAnimation *shakingAnim = [CAKeyframeAnimation animation];
    shakingAnim.keyPath = @"transform.rotation";
    shakingAnim.values = @[@(-angle2Radian(5)), @(angle2Radian(5)), @(-angle2Radian(5)), @(0)];
    shakingAnim.removedOnCompletion = NO;
    shakingAnim.fillMode = kCAFillModeForwards;
    shakingAnim.duration = 0.1;
    
    shakingAnim.repeatCount = 4;
    [self.layer addAnimation:shakingAnim forKey:nil];
}

@end

1.2 自定义容器UIView (LFOpetationView)。(由于小方块的黑色背景只是用来占位,所以我们没有必要单独自定义一个View出来处理它,只需要直接将它添加到容器 LFOperationView上面就可以了)

LFOperationView.h 文件

@interface LFOperationView : UIView
@property (nonatomic, strong) NSArray *items;
- (void)shakeAll;
- (void)shakeNone;
@end

LFOperationView.m 文件

我们先看看初始化相关的代码,功能相关的代码,后面慢慢解析。

1.2.1 bgViews属性用来存放那些黑色背景的用来占位的View。

1.2.2 itemDicts字典用来保存小方块标题与小方块对象。

1.2.3 LFOperationView.h 文件中定义的items属性,就是用来接受传递过来的小方块标题信息,标准的定义这里属性的修饰符应该是NSArray而不是 NSMutableArray(因为它接受从外部传递进来的数据集合)。所以,我在LFOperationView.m 文件定义了dynamicItems 可变数组,并且在重写 items属性的时候,将传递进来的数据集合 "绑定" 到  dynamicItem身上,便于以后的排序及删除操作。

1.2.4 代码中的setup方法就是将小方块及小方块背景添加到LFOperationView上面。

1.2.5 代码中的layoutSubviews方法就是设置小方块及小方块黑色背景的位置,可以看出,他们的位置信息是一致的。由于放置他们的时候,涉及到方块的横竖排列,假设设置了列数为 column, 则可以通过公式row = (count + column -1) / column 快速计算出行数。

//
//  LFOperationView.m
//  11-仿网易新闻
//
//  Created by 1391 on 15/10/26.
//  Copyright © 2015年 1391. All rights reserved.
//

#define kButtonWidth 44
#define kButtonHeight 30

#import "LFOperationView.h"
#import "LFOperationButton.h"

@interface LFOperationView()
@property (nonatomic, strong) NSMutableArray *bgViews;
@property (nonatomic, strong) NSMutableDictionary *itemDicts;
@property (nonatomic, strong) NSMutableArray *dynamicItems;

@property (nonatomic, strong) LFOperationButton *selectedButton;
@end

@implementation LFOperationView

- (NSMutableArray *)bgViews {
    if (!_bgViews) {
        _bgViews = [NSMutableArray array];
    }
    return _bgViews;
}

- (NSMutableDictionary *)itemDicts {
    if (!_itemDicts) {
        _itemDicts = [NSMutableDictionary dictionary];
    }
    return _itemDicts;
}

- (void)setItems:(NSMutableArray *)items {
    _items = items;
    self.dynamicItems = [NSMutableArray array];
    [self.dynamicItems addObjectsFromArray:items];
    [self setup];
}

- (instancetype)init {
    if (self = [super init]) {
        self.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:0.4];
        [self addLongPressGesture];
    }
    return self;
}

- (void)addLongPressGesture {
    self.clipsToBounds = YES;
    UILongPressGestureRecognizer *longGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPress:)];
    self.userInteractionEnabled = YES;
    longGesture.minimumPressDuration = 1.0;
    [self addGestureRecognizer:longGesture];
}

- (void)setup {
    // Bg
    for (NSInteger i = 0; i < self.dynamicItems.count; i++) {
        UIView *bgView = [[UIView alloc] init];
        bgView.backgroundColor = [UIColor blackColor];
        bgView.layer.cornerRadius = 5;
        bgView.layer.masksToBounds = YES;
        [self addSubview:bgView];
        [self.bgViews addObject:bgView];
    }
    
    // Button
    for (NSInteger i = 0; i < self.dynamicItems.count; i++) {
        LFOperationButton *button = [[LFOperationButton alloc] init];
        NSString *title = self.dynamicItems[i];
        [button setTitle:title forState:UIControlStateNormal];
        button.tag = i;
        [button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchDown];
        [self addSubview:button];
        self.itemDicts[title] = button;
        if (i == 0) {
            [self chooseSelectedButton:button];
        }
    }
}


- (void)chooseSelectedButton:(LFOperationButton *)btn {
    self.selectedButton.selected = NO;
    btn.selected = YES;
    self.selectedButton = btn;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    NSInteger count = 0;
    NSInteger column = 4;
    NSInteger row = (self.dynamicItems.count + column - 1) / column;
    CGFloat x, y;
    CGFloat marginH = (self.frame.size.width - column * kButtonWidth) / (column + 1);
    CGFloat marginV = (self.frame.size.height - row * kButtonHeight) / (row + 1);
    for (NSInteger i = 0; i < row; i++) {
        y = marginV + (kButtonHeight + marginV) * i;
        for (NSInteger j = 0; j < column; j++) {
            x = marginH + (kButtonWidth + marginH) * j;
            UIView *bgView = self.bgViews[count];
            bgView.frame = CGRectMake(x, y, kButtonWidth, kButtonHeight);
            
            LFOperationButton *btn = self.itemDicts[self.dynamicItems[count]];
            btn.frame = CGRectMake(x, y, kButtonWidth, kButtonHeight);
            count++;
        }
    }
}

@end

1.2.6 addLongPressGesture方法中,longPress: 这个手势方法的实现。

由于这里的算法有一点复杂,我们先考虑简单的情况。手指长按一个小方块而不进行拖拽操作。

简单动画效果:

手指长按到一个小方块,这个小方块便开始抖动,并且它的中心点移动到手指按下的地方;当手指松开的时候,小方块回到原位,并且停止抖动。

由于手势操作结束后,需要将小方块移动到原来的位置,所以在执行抖动动画之前,需要记录下小方块的初始frame,所以定义属性tempFrame;

@property (nonatomic, assign) CGRect tempFrame;


实现 longPress: 手势方法

如果长按手势处于 UIGestureRecognizerStateBegan状态, 首先判断,手指按下的地方是否在某个按钮上面。如果在,则执行按钮的抖动动画,并且将按钮中心移动到手指按下的点。

手指松开时处于UIGestureRecognizerStateEnd状态,根据在手指长按时候记录的tempFrame,将按钮移动到原来的位置,并且停止抖动。

- (void)longPress:(UILongPressGestureRecognizer *)longGesture {
    UIGestureRecognizerState state = longGesture.state;
    switch (state) {
        case UIGestureRecognizerStateBegan: {
            CGPoint point = [longGesture locationInView:longGesture.view];
            [self.itemDicts enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, LFOperationButton * _Nonnull btn, BOOL * _Nonnull stop) {
                if (CGRectContainsPoint(btn.frame, point)) {
                    *stop = YES;
                    self.selectedButton = btn;
                    self.tempFrame = btn.frame;
                    [self.selectedButton itemShake];
                    [UIView animateWithDuration:0.3 animations:^{
                        self.selectedButton.center = point;
                    }];
                }
            }];
            break;
        }
        case UIGestureRecognizerStateChanged: {
            break;
        }
        case UIGestureRecognizerStateEnded: {
            [UIView animateWithDuration:0.3 animations:^{
                self.selectedButton.frame = self.tempFrame;
            } completion:nil];
            [self.selectedButton itemStop];
            break;
        }
        default:
            break;
    }
}

紧接着,我们考虑拖动按钮的情况,即手势处于UIGestureRecognizerStateChanged状态。

在longPress: 手势方法中,填写 UIGestureRecognizerStateChanged状态对应的代码:

在拖拽过程中,当前选中的那个按钮,它的位置肯定是伴随着手指的移动而移动的,所以代码一开始就设置 self.selectedButton.center = point;

由于拖拽过程中,有可能很快速,所以我们应该限定如果有一个按钮已经处在移动状态了,就应该禁止其他按钮的动画操作,所以设置了moving属性用来进行判断。

// 单个方块在移动中
@property (nonatomic, assign, getter=isMoving) BOOL moving;


最重要的一点就是,当手指拖拽某个按钮到其他按钮的中心位置的时候,就会触发其他按钮位置移动的动画,正如下面 if (CGRectContainsPoint(self.selectedButton.frame, btn.center) && self.selectedButton != self.otherButton) 判断所示。在动画操作之前,我们先获取到开始移动按钮的索引(oldIndex)及最终碰撞到的按钮的索引(newIndex)。当按钮拖拽到某个其他按钮的中心位置的时候,此时的tempFrame就不在是被拖拽的按钮的frame了,而应该是拖拽过程中碰撞到的那个按钮的frame,所以我们需要用一个变量记录下来那个拖拽按钮移动之前的frame,所以定义了一个oldFrame属性。

@property (nonatomic, assign) CGRect oldFrame;

case UIGestureRecognizerStateChanged: {
            CGPoint point = [longGesture locationInView:longGesture.view];
            self.selectedButton.center = point;
            
            if (self.isMoving) {
                return;
            }
            [self.itemDicts enumerateKeysAndObjectsUsingBlock:^(NSString *  _Nonnull key, LFOperationButton *  _Nonnull btn, BOOL * _Nonnull stop) {
                self.otherButton = btn;
                if (CGRectContainsPoint(self.selectedButton.frame, btn.center)
                    && self.selectedButton != self.otherButton) {
                    *stop = YES;
                    
                    self.moving = YES;
                    NSInteger oldIndex = [self.dynamicItems indexOfObject:self.selectedButton.titleLabel.text];
                    NSInteger newIndex = [self.dynamicItems indexOfObject:self.otherButton.titleLabel.text];
                    self.tempFrame = self.otherButton.frame;
                    [self adjustPositionFromIndex:oldIndex toIndex:newIndex];
                    self.oldFrame = self.tempFrame;
                } else {
                    self.tempFrame = self.oldFrame;
                }
            }];
            break;
        }

adjustPositionFromIndex: toIndex 方法分析:

先看看算法分析图:


代码清单:

- (void)adjustPositionFromIndex:(NSInteger)from toIndex:(NSInteger)to {
    
    NSMutableArray *needMoveItems = [NSMutableArray array];
    NSMutableArray *comingBgItems = [NSMutableArray array];
    if (from < to) {
        for (NSInteger i = from + 1; i <= to; i++) {
            LFOperationButton *btn = self.itemDicts[self.dynamicItems[i]];
            [needMoveItems addObject:btn];
            UIView *bgView = self.bgViews[i - 1];
            [comingBgItems addObject:bgView];
        }
        
        NSInteger j = 0;
        for (LFOperationButton *btn in needMoveItems) {
            UIView *bgView = comingBgItems[j];
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.02 * j * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                [UIView animateWithDuration:0.3 animations:^{
                    btn.frame = bgView.frame;
                } completion:^(BOOL finished) {
                    if (j == needMoveItems.count - 1) {
                        self.moving = NO;
                    }
                }];
            });
            j++;
        }
        
        NSInteger num1 = [self.dynamicItems indexOfObject:self.selectedButton.titleLabel.text];
        NSInteger num2 = [self.dynamicItems indexOfObject:self.otherButton.titleLabel.text];
        [self.dynamicItems removeObjectAtIndex:num1];
        [self.dynamicItems insertObject:self.selectedButton.titleLabel.text atIndex:num2];
        
    } else {
        for (NSInteger i = to; i < from; i++) {
            LFOperationButton *btn = self.itemDicts[self.dynamicItems[i]];
            [needMoveItems addObject:btn];
            UIView *bgView = self.bgViews[i + 1];
            [comingBgItems addObject:bgView];
        }
        
        NSInteger j = needMoveItems.count - 1;
        for (NSInteger i = j; i >= 0; i--) {
            UIView *view = comingBgItems[i];
            LFOperationButton *item = needMoveItems[i];
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((j - i) * 0.02 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                [UIView animateWithDuration:0.3 animations:^{
                    item.frame = view.frame;
                }completion:^(BOOL finished) {
                    if (i == 0) {
                        self.moving = NO;
                    }
                }];
            });
        }
        
        NSInteger num1 = [self.dynamicItems indexOfObject:self.selectedButton.titleLabel.text];
        NSInteger num2 = [self.dynamicItems indexOfObject:self.otherButton.titleLabel.text];
        [self.dynamicItems removeObjectAtIndex:num1];
        [self.dynamicItems insertObject:self.selectedButton.titleLabel.text atIndex:num2];

    }
}

1.2.7 删除操作

先在主控制器中添加删除按钮:

- (void)deleteFunction:(UIButton *)btn {
    btn.selected = !btn.isSelected;
    if (btn.isSelected) {
        [self.operationView shakeAll];
    } else {
        [self.operationView shakeNone];
    }
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIButton *deleteButton = [UIButton buttonWithType:UIButtonTypeCustom];
    deleteButton.frame = CGRectMake(self.view.frame.size.width - 88, 15, 60, 30);
    [deleteButton setTitle:@"Delete" forState:UIControlStateNormal];
    [deleteButton setTitle:@"Done" forState:UIControlStateSelected];
    deleteButton.backgroundColor = [UIColor redColor];
    deleteButton.layer.cornerRadius = 5;
    deleteButton.layer.masksToBounds = YES;
    [deleteButton addTarget:self action:@selector(deleteFunction:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:deleteButton];
}

如果按钮处于删除状态,则调用shakeAll方法,抖动所有的按钮;如果处于完成状态,则停止所有按钮的抖动。

LFOperationView的 shakeAll 和 shakeNone代码如下:

- (void)shakeAll {
    self.shaking = YES;
    [self.itemDicts enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, LFOperationButton *  _Nonnull btn, BOOL * _Nonnull stop) {
        [btn itemShake];
    }];
}

- (void)shakeNone {
    self.shaking = NO;
    [self.itemDicts enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, LFOperationButton *  _Nonnull btn, BOOL * _Nonnull stop) {
        [btn itemStop];
    }];
}

现在我们来实现 LFOperationView中 buttonClicked: 方法,来完成删除工作。

- (void)buttonClicked:(LFOperationButton *)btn {
    if (self.isShaking) {
        [self removeButton:btn];
    }
}

可以看出,只有所有按钮处于抖动状态的时候,才可以执行按钮的删除操作。

先看看算法分析示意图:


代码清单:

- (void)removeButton:(LFOperationButton *)btn {
    NSInteger index = [self.dynamicItems indexOfObject:btn.titleLabel.text];
    NSMutableArray *needMoveItems = [NSMutableArray array];
    NSMutableArray *comingBgViews = [NSMutableArray array];
    for (NSInteger i = index + 1; i < self.dynamicItems.count; i++) {
        [needMoveItems addObject:self.itemDicts[self.dynamicItems[i]]];
        UIView *bgView = self.bgViews[i - 1];
        [comingBgViews addObject:bgView];
    }
    
    // 在执行动画之前, 点击的那个button应该先隐藏; 如果直接移除的话,在动画执行完毕后,执行[self.bgViews removeLastObject];报错了。
    
    btn.hidden = YES;
    UIView *lastBgView = [self.bgViews lastObject];
    lastBgView.hidden = YES;
    
    NSInteger j = 0;
    for (LFOperationButton *button in needMoveItems) {
        UIView *bgView = comingBgViews[j];
        [UIView animateWithDuration:0.3 animations:^{
            button.frame = bgView.frame;
        }];
        j++;
    }
    
    [self.dynamicItems removeObject:btn.titleLabel.text];
    [self.itemDicts removeObjectForKey:btn.titleLabel.text];
    [self.bgViews removeLastObject];
}

既然考虑到删除操作,所以在排序操作的时候,应该判断当前的状态,如果当前所有的小方块均处于抖动状态,即删除状态时候,则排序操作的相关长按操作应该失效,所以在longPress: 方法的开始添加如下代码,用于区分:

if (self.isShaking) { // 处于编辑状态(即点击了Delete按钮)的时候,禁止长按事件
        return;
    }

至此,仿网易新闻的 "头部导航切换" 及 "排序删除" 动画功能计算讲解完毕了。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秋恨雪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值