leverTsui

要有狮子的力量, 菩萨的心肠

0%

iOS长按移动Table View Cells

前言

最近参与了事务流程工具化组件的开发,其中有一个模块需要通过长按移动Table View Cells,来达到调整任务的需求,再次记录下开发过程中的实现思路。完成后的效果如下图所示:

长按移动cell.gif

实现思路

添加手势

首先给 collection view 添加一个 UILongGestureRecognizer,在项目中一般使用懒加载的方式来对对象进行初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (UICollectionView *)collectionView {
if (!_collectionView) {
_collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:self.flowLayout];

_collectionView.backgroundColor = [UIColor whiteColor];
_collectionView.dataSource = self;
_collectionView.delegate = self;

[_collectionView registerClass:[TLCMainCollectionViewCell class] forCellWithReuseIdentifier:[TLCMainCollectionViewCell identifier]];

_collectionView.showsHorizontalScrollIndicator = NO;
_collectionView.showsVerticalScrollIndicator = NO;
_collectionView.bounces = YES;
_collectionView.decelerationRate = 0;

[_collectionView addGestureRecognizer:self.longPress];
}
return _collectionView;
}
1
2
3
4
5
6
- (UILongPressGestureRecognizer *)longPress {
if (!_longPress) {
_longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGestureRecognized:)];
}
return _longPress;
}

在用户长按后,触犯长按事件,先获取到当前手势所在的collection view位置,再做后续的处理。

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
- (void)longPressGestureRecognized:(UILongPressGestureRecognizer *)sender {

CGPoint location = [sender locationInView:sender.view];

UIGestureRecognizerState state = sender.state;
switch (state) {
case UIGestureRecognizerStateBegan: {
[self handleLongPressStateBeganWithLocation:location];
}
break;

case UIGestureRecognizerStateChanged: {
}
break;

case UIGestureRecognizerStateEnded:
case UIGestureRecognizerStateCancelled: {
[self longGestureEndedOrCancelledWithLocation:location];
}
break;

default:
break;
}
}
长按手势状态为开始

主要处理两个方面的事务,一为获取当前长按手势所对应的Table View Cell的镜像,将其添加到 Collection View上。二为一些初始状态的设置,后续在移动后网络请求出错及判断当前手势所处的Table View和上一次是否一致需要使用到。最后调用startPageEdgeScroll 开启定时器。

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
- (void)handleLongPressStateBeganWithLocation:(CGPoint)location {

TLCMainCollectionViewCell *selectedCollectionViewCell = [self currentTouchedCollectionCellWithLocation:location];

NSIndexPath *touchIndexPath = [self longGestureBeganIndexPathForRowAtPoint:location atTableView:selectedCollectionViewCell.tableView];

if (!selectedCollectionViewCell || !touchIndexPath) {
return ;
}
self.selectedCollectionViewCellRow = [self.collectionView indexPathForCell:selectedCollectionViewCell].row;

// 已完成的任务,不支持排序
TLPlanItem *selectedItem = [self.viewModel itemAtIndex:self.selectedCollectionViewCellRow
subItemIndex:touchIndexPath.section];
if (!selectedItem || selectedItem.finish) {
return;
}
selectedItem.isHidden = YES;

self.snapshotView = [self snapshotViewWithTableView:selectedCollectionViewCell.tableView
atIndexPath:touchIndexPath];
[self.collectionView addSubview:self.snapshotView];

self.selectedIndexPath = touchIndexPath;
self.originalSelectedIndexPathSection = touchIndexPath.section;
self.originalCollectionViewCellRow = self.selectedCollectionViewCellRow;
self.previousPoint = CGPointZero;

[self startPageEdgeScroll];
}
长按手势状态为改变

longPressGestureRecognized 方法中,可以发现,长按手势状态改变时,并未做任何的操作,主要原因是如果在此做Table View Cells的移动操作,如果数据超过一屏幕,无法自动将未在屏幕上的数据滚动显示出来。所以在长按手势状态为开始时,如果触摸点在Table View Cell上,开启定时器,来处理长按手势状态为改变时的情况。

1
2
3
4
- (void)startPageEdgeScroll {
self.edgeScrollTimer = [CADisplayLink displayLinkWithTarget:self selector:@selector(pageEdgeScrollEvent)];
[self.edgeScrollTimer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

在定时器触发的事件中,处理两个方面的事情,移动cell和滚动ScrollView

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)pageEdgeScrollEvent {
[self longGestureChanged:self.longPress];

CGFloat snapshotViewCenterOffsetX = [self touchSnapshotViewCenterOffsetX];

if (fabs(snapshotViewCenterOffsetX) > (TLCMainViewControllerFlowLayoutWidthOffset-20)) {
//横向滚动
[self handleScrollViewHorizontalScroll:self.collectionView viewCenterOffsetX:snapshotViewCenterOffsetX];
} else {
//垂直滚动
[self handleScrollViewVerticalScroll:[self selectedCollectionViewCellTableView]];
}
}

在长按手势触摸点位置改变时,处理对应cell的移除和插入动作。横向滚动和垂直滚动主要是根据不同情况设置对应的 Table ViewCollection View的内容偏移量。可以在文末的链接中查看源码。

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
- (void)longGestureChanged:(UILongPressGestureRecognizer *)sender {

CGPoint currentPoint = [sender locationInView:sender.view];
TLCMainCollectionViewCell *currentCollectionViewCell = [self currentTouchedCollectionCellWithLocation:currentPoint];
if (!currentCollectionViewCell) {
currentCollectionViewCell = [self collectionViewCellAtRow:self.selectedCollectionViewCellRow];
}

TLCMainCollectionViewCell *lasetSelectedCollectionViewCell = [self collectionViewCellAtRow:self.selectedCollectionViewCellRow];

//判断targetTableView是否改变
BOOL isTargetTableViewChanged = NO;
if (self.selectedCollectionViewCellRow != currentCollectionViewCell.indexPath.row) {
isTargetTableViewChanged = YES;
self.selectedCollectionViewCellRow = currentCollectionViewCell.indexPath.row;
}
//获取到需要移动到的目标indexpath
NSIndexPath *targetIndexPath = [self longGestureChangeIndexPathForRowAtPoint:currentPoint
collectionViewCell:currentCollectionViewCell];

NSIndexPath *lastSelectedIndexPath = self.selectedIndexPath;

TLCMainCollectionViewCell *selectedCollectionViewCell = [self collectionViewCellAtRow:self.selectedCollectionViewCellRow];
//判断跟上一次长按手势所处的Table View是否相同,如果相同,移动cell,
//如果不同,删除上一次所定义的cell,插入到当前位置
if (isTargetTableViewChanged) {
if ([[self selectedCollectionViewCellTableView] numberOfSections]>targetIndexPath.section) {
[[self selectedCollectionViewCellTableView] scrollToRowAtIndexPath:targetIndexPath
atScrollPosition:UITableViewScrollPositionNone animated:YES];
}

TLPlanItem *moveItem = [self.viewModel itemAtIndex:lasetSelectedCollectionViewCell.indexPath.row
subItemIndex:lastSelectedIndexPath.section];
[self.viewModel removeObject:moveItem
itemIndex:lasetSelectedCollectionViewCell.indexPath.row];
[self.viewModel insertItem:moveItem
index:self.selectedCollectionViewCellRow
subItemIndex:targetIndexPath.section];

[lasetSelectedCollectionViewCell updateCellWithData:[self planItemsAtIndex:lasetSelectedCollectionViewCell.indexPath.row]];
[lasetSelectedCollectionViewCell.tableView deleteSections:[NSIndexSet indexSetWithIndex:lastSelectedIndexPath.section]
withRowAnimation:UITableViewRowAnimationNone];

[selectedCollectionViewCell updateCellWithData:[self planItemsAtIndex:self.selectedCollectionViewCellRow]];
[selectedCollectionViewCell.tableView insertSections:[NSIndexSet indexSetWithIndex:targetIndexPath.section]
withRowAnimation:UITableViewRowAnimationNone];
} else {
BOOL isSameSection = lastSelectedIndexPath.section == targetIndexPath.section;
UITableViewCell *targetCell = [self tableView:[self selectedCollectionViewCellTableView]
selectedCellAtSection:targetIndexPath.section];
if (isSameSection || !targetCell ) {
[self modifySnapshotViewFrameWithTouchPoint:currentPoint];
return;
}

TLPlanItem *item = [self.viewModel itemAtIndex:self.selectedCollectionViewCellRow
subItemIndex:lastSelectedIndexPath.section];
[self.viewModel removeObject:item
itemIndex:self.selectedCollectionViewCellRow];
[self.viewModel insertItem:item
index:self.selectedCollectionViewCellRow
subItemIndex:targetIndexPath.section];

[selectedCollectionViewCell updateCellWithData:[self planItemsAtIndex:self.selectedCollectionViewCellRow]];
[selectedCollectionViewCell.tableView moveSection:lastSelectedIndexPath.section
toSection:targetIndexPath.section];
}

self.selectedIndexPath = targetIndexPath;
//改变长按cell镜像的位置
[self modifySnapshotViewFrameWithTouchPoint:currentPoint];
}
长按手势状态为取消或结束

取消计时器,设置Collection View的偏移量,让其Collection View Cell位于屏幕的中心,发送网络请求,去调整任务的排序,同时将镜像视图隐藏,并将其所对应的Table View Cell显示出来。

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
- (void)longGestureEndedOrCancelledWithLocation:(CGPoint)location {

[self stopEdgeScrollTimer];

CGPoint contentOffset = [self.flowLayout targetContentOffsetForProposedContentOffset:self.collectionView.contentOffset
withScrollingVelocity:CGPointZero];
[self.collectionView setContentOffset:contentOffset animated:YES];

UITableViewCell *targetCell = [[self selectedCollectionViewCellTableView] cellForRowAtIndexPath:self.selectedIndexPath];

if ([self canAdjustPlanRanking]) {
[self adjustPlanRanking];
}
TLPlanItem *slectedItem = [self.viewModel itemAtIndex:self.selectedCollectionViewCellRow subItemIndex:self.selectedIndexPath.section];
[UIView animateWithDuration:0.25 animations:^{
self.snapshotView.transform = CGAffineTransformIdentity;
self.snapshotView.frame = [self snapshotViewFrameWithCell:targetCell];

} completion:^(BOOL finished) {
targetCell.hidden = NO;
slectedItem.isHidden = NO;
[self.snapshotView removeFromSuperview];
self.snapshotView = nil;
}];
}
数据的处理

在移动和插入Table View Cell时,需要将其所对应的数据做响应的改变,数据相关的操作均放在TLCMainViewModel对象中。

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
@interface TLCMainViewModel : NSObject 

/**
今日要做、下一步要做和以后要做
*/
@property (nonatomic, readonly, strong) NSArray <NSString *> *titleArray;

/**
获取计划列表

@param completion TLTodoModel
*/
- (void)obtainTotalPlanListWithTypeCompletion:(TLSDKCompletionBlk)completion;

/**
添加计划

@param requestItem requestItem
@param completion 完成回调
*/
- (void)addPlanWithReq:(TLPlanItemReq *)requestItem
atIndexPath:(NSIndexPath *)indexPath
completion:(TLSDKCompletionBlk)completion;

/**
返回显示的collectionViewCell的个数

@return 数据的个数
*/
- (NSInteger)numberOfItems;

/**
根据type获取对应的数据

@param index 位置
@return 此计划所对应的数据
*/
- (NSMutableArray<TLPlanItem *> *)planItemsAtIndex:(NSInteger)index;

/**
删除某个计划

@param itemIndex 单项数据在数组中的位置,如今日计划中的数据,itemIndex为0
@param subItemIndex 单项数据数组中所在的位置
@param completion 完成回调
*/
- (void)deletePlanAtItemIndex:(NSInteger)itemIndex
subItemIndex:(NSInteger)subItemIndex
completion:(dispatch_block_t)completion;


/**
修改计划状态:完成与非完成

@param itemIndex 单项数据在数组中的位置,如今日计划中的数据,itemIndex为0
@param subItemIndex 单项数据数组中所在的位置
@param completion 完成回调
*/
- (void)modiflyPlanStateAtItemIndex:(NSInteger)itemIndex
subItemIndex:(NSInteger)subItemIndex
completion:(TLSDKCompletionBlk)completion;


/**
修改计划的title和重点标记状态

@param itemIndex 单项数据在数组中的位置,如今日计划中的数据,itemIndex为0
@param subItemIndex 单项数据数组中所在的位置
@param targetItem 目标对象
@param completion 完成回调
*/
- (void)modiflyItemAtIndex:(NSInteger)itemIndex
subItemIndex:(NSInteger)subItemIndex
targetItem:(TLPlanItem *)targetItem
completion:(dispatch_block_t)completion;


/**
移除数据

@param item item
@param itemIndex 单项数据在数组中的位置
*/
- (void)removeObject:(TLPlanItem *)item
itemIndex:(NSInteger)itemIndex;

/**
插入数据

@param item 插入的对象模型
@param itemIndex 单项数据在数组中的位置,如今日计划中的数据,itemIndex为0
@param subItemIndex 单项数据数组中所在的位置
*/
- (void)insertItem:(TLPlanItem *)item
index:(NSInteger)itemIndex
subItemIndex:(NSInteger)subItemIndex;

/**
获取数据

@param itemIndex 一级index
@param subItemIndex 二级index
@return 数据模型
*/
- (TLPlanItem *)itemAtIndex:(NSInteger)itemIndex
subItemIndex:(NSInteger)subItemIndex;

/**
重置数据
*/
- (void)reset;

/**
保存长按开始时的数据
*/
- (void)storePressBeginState;

@end

代码完善

cell未居中显示问题

2018年2月1号
在iPhone系统版本为iOS8.xiOS9.x时,会出现以后要做界面不会回弹的情况。如下图所示:
bug1.png

经排查,是在UICollectionViewFlowLayout类中的

1
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity

方法计算得出的proposedContentOffset有偏差,修改后如下所示:

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
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGFloat rawPageValue = self.collectionView.contentOffset.x / [self tlc_pageWidth];
CGFloat currentPage = (velocity.x > 0.0) ? floor(rawPageValue) : ceil(rawPageValue);
CGFloat nextPage = (velocity.x > 0.0) ? ceil(rawPageValue) : floor(rawPageValue);

BOOL pannedLessThanAPage = fabs(1 + currentPage - rawPageValue) > 0.5;
BOOL flicked = fabs(velocity.x) > [self tlc_flickVelocity];
CGFloat actualPage = 0.0;

if (pannedLessThanAPage && flicked) {
proposedContentOffset.x = nextPage * [self tlc_pageWidth];
actualPage = nextPage;
} else {
proposedContentOffset.x = round(rawPageValue) * [self tlc_pageWidth];
actualPage = round(rawPageValue);
}
if (lround(actualPage) >= 1) {
proposedContentOffset.x -= 4.5;
}
//下面为添加的代码
if (lround(actualPage) >= 2) {
proposedContentOffset.x = self.collectionView.contentSize.width - TLCScreenWidth;
}

return proposedContentOffset;
}
在系统版本为iOS9.x时,输入框会上一段距离问题

2018年2月12号
在机型为iPhone SE,系统版本为iOS9.x时,新建计划时,新建窗口会上移一段,如下图所示:
适配问题.png
分析发现,应该是监听键盘高度变化时,输入框的高度计算在特定机型的特定版本上计算错误,将原有的计算frame的来布局的方式改为自动布局。监听键盘高度改变的代码修改后如下:

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

- (void)viewWillAppear:(BOOL)animated{

[super viewWillAppear:animated];
[self addObserverForKeybord];
}

- (void)viewWillDisappear:(BOOL)animated {

[super viewWillDisappear:animated];
[self.view endEditing:YES];
[self removeobserverForKeybord];
}
#pragma mark - keyboard observer

- (void)addObserverForKeybord {

[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillShow:)
name:UIKeyboardWillShowNotification
object:nil];

[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillHide:)
name:UIKeyboardWillHideNotification
object:nil];
}

- (void)removeobserverForKeybord {

[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIKeyboardWillShowNotification
object:nil];

[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIKeyboardWillHideNotification
object:nil];
}

- (void)keyboardWillShow:(NSNotification *)notification {

CGRect keyboardBounds;
[[notification.userInfo valueForKey:UIKeyboardFrameEndUserInfoKey] getValue:&keyboardBounds];
NSNumber *duration = [notification.userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey];
NSNumber *curve = [notification.userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey];

keyboardBounds = [self.view convertRect:keyboardBounds toView:nil];

[self.inputProjectView mas_updateConstraints:^(MASConstraintMaker *make) {
make.bottom.equalTo(self.view).offset(-CGRectGetHeight(keyboardBounds));
}];

//设置动画
[UIView beginAnimations:nil context:NULL];
[UIView setAnimationBeginsFromCurrentState:YES];
[UIView setAnimationDuration:[duration doubleValue]];
[UIView setAnimationCurve:[curve intValue]];

[self.inputProjectView layoutIfNeeded];

[UIView commitAnimations];
}

- (void)keyboardWillHide:(NSNotification *)notification {

if([self.inputProjectView inputText].length > 0) {
[self.inputProjectView resetText];
}

CGRect keyboardBounds;
[[notification.userInfo valueForKey:UIKeyboardFrameEndUserInfoKey] getValue:&keyboardBounds];
NSNumber *duration = [notification.userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey];
NSNumber *curve = [notification.userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey];

keyboardBounds = [self.view convertRect:keyboardBounds toView:nil];

[self.inputProjectView mas_updateConstraints:^(MASConstraintMaker *make) {
if (@available(iOS 11.0, *)) {
make.bottom.equalTo(self.view).offset(self.view.safeAreaInsets.bottom+88);
} else {
make.bottom.equalTo(self.view).offset(88);
}
make.height.mas_equalTo(88);
}];

//设置动画
[UIView beginAnimations:nil context:NULL];
[UIView setAnimationBeginsFromCurrentState:YES];
[UIView setAnimationDuration:[duration doubleValue]];
[UIView setAnimationCurve:[curve intValue]];

[self.inputProjectView layoutIfNeeded];

[UIView commitAnimations];
}
切换输入法时,输入框被键盘遮住问题

在修复此问题后,自测时发现,输入法由简体拼音切换为表情符号时,输入框会被键盘挡住,在代码中打断点发现UIKeyboardWillShowNotificationUIKeyboardWillChangeFrameNotification通知均未被触发,同时对比微信发现,切换输入法时,同时开启了自动校正功能,所以参考添加如下代码:

1
_textView.internalTextView.autocorrectionType = UITextAutocorrectionTypeYes;

解决切换输入法时,输入框被键盘遮住的问题。

总结

除了上述Table View Cell移动的操作,在项目中还处理了创建事务和事务详情相关的业务。在整个过程中,比较棘手的还是Table View Cell的移动,在开发过程中,有时数据的移动和Table View Cell的移动未对应上,造成Table View Cell布局错乱,排查了很久。在项目开发过程中,还是需要仔细去分析需求。

文章所对应的Demo请点这里