-
Notifications
You must be signed in to change notification settings - Fork 34
/
Copy pathATSDragToReorderTableViewController.m
1524 lines (1205 loc) · 51.4 KB
/
ATSDragToReorderTableViewController.m
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//
// ATSDragToReorderTableViewController.m
//
// Created by Daniel Shusta on 11/28/10.
// Copyright 2010 Acacia Tree Software. All rights reserved.
//
// Permission is given to use this source code file, free of charge, in any
// project, commercial or otherwise, entirely at your risk, with the condition
// that any redistribution (in part or whole) of source code must retain
// this copyright and permission notice. Attribution in compiled projects is
// appreciated but not required.
//
// THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/*
Implementation Overview
Press-and-drag-to-reorder is really just two UIGestureRecognizers
working in concert. First a UILongPressGestureRecognizer decides that
you're not merely tapping a tableView cell but pressing and holding,
then a UIPanGestureRecognizer tracks the touch and moves the touched
cell accordingly.
The cell following the touch is not the original cell highlighted but an
imposter. The actual cell is hidden. While the imposter cell follows the
touch, the tableView's dataSource is asked to "move" the hidden cell to
follow the imposter, thus "shuffling" the other cells out of the way.
This allows us to not have any information about the data model and
prevents against data loss due to crashes or interruptions -- at worst,
the actual cell is still there, only hidden.
In addition to dragging a cell, ATSDragToReorderTableViewController will
autoscroll when the top or bottom is approached. This is done by a
CADisplayLink, which fires just before rendering a frame, every 1/60th
of a second. The method it calls will adjust the contentOffset of
the tableView and the cell accordingly to move the tableview without
moving the visible position of the cell.
A little bit more detail:
There are 5 main states:
Dormant, long press has activated, dragGesture moves cell,
autoscroll moves cell, and touch has ended.
-> Dormant
Nothing happens until a long press occurs on the UITableView. When
that happens, UILongPressGestureRecognizer calls -longPressRecognized.
-> Long press occurs
-longPressRecognized
At that point, conditions are checked to make sure we can legitimately
allow dragging. If so, we ask for a cell and set it to self.draggedCell.
-> From here on out, if touch ends…
-completeGesturesForTranslationPoint:
-fastCompleteGesturesWithTranslationPoint:
If we release a touch or the app resigns active after this point, the
cell slides back to the proper position and the legitimate cell is made
visible.
-> meanwhile, if touch moves…
-dragGestureRecognized
UIPanGestureRecognizer calls -dragGestureRecognized whenever any touch
movement occurs, but normally short-circuits if self.draggedCell == nil.
Now that self.draggedCell is established, the translation data of the
UIPanGestureRecognizer is used to update draggedCell’s position.
After updating the position it checks whether the tableview needs to
shuffle cells out of the way of the blank cell and checks whether
autoscrolling should begin or end.
-> autoscrolling
-fireAutoscrollTimer:
Autoscrolling needs to be on a timer because UIPanGestureRecognizer only
responds to movement. The timer calculates the distance the tableView
should scroll based on proximity of the cell to the tableView bounds and
adjusts the tableview and the cell so it appears that the cell doesn't
move. It then checks whether the tableview should reorder cells out of
the way of the blank cell.
To clarify, autoscrolling happens simulatneously with
UIPanGestureRecognizer. Autoscrolling only moves the cell enough so it
looks like it isn't moving. UIPanGestureRecognizer continues to move
the cell in response to touch movement.
*/
#import "ATSDragToReorderTableViewController.h"
#define TAG_FOR_ABOVE_SHADOW_VIEW_WHEN_DRAGGING 100
#define TAG_FOR_BELOW_SHADOW_VIEW_WHEN_DRAGGING 200
@interface ATSDragToReorderTableViewController ()
typedef enum {
AutoscrollStatusCellInBetween,
AutoscrollStatusCellAtTop,
AutoscrollStatusCellAtBottom
} AutoscrollStatus;
/*
* Not a real interface. Just forward declarations to get the compiler to shut up.
*/
- (void)establishGestures;
- (void)longPressRecognized;
- (void)dragGestureRecognized;
- (void)shuffleCellsOutOfWayOfDraggedCellIfNeeded;
- (void)keepDraggedCellVisible;
- (void)fastCompleteGesturesWithTranslationPoint:(CGPoint)translation;
- (BOOL)touchCanceledAfterDragGestureEstablishedButBeforeDragging;
- (void)completeGesturesForTranslationPoint:(CGPoint)translationPoint;
- (NSIndexPath *)anyIndexPathFromLongPressGesture;
- (NSIndexPath *)indexPathOfSomeRowThatIsNotIndexPath:(NSIndexPath *)selectedIndexPath;
- (void)disableInterferingAspectsOfTableViewAndNavBar;
- (UITableViewCell *)cellPreparedToAnimateAroundAtIndexPath:(NSIndexPath *)indexPath;
- (void)updateDraggedCellWithTranslationPoint:(CGPoint)translation;
- (CGFloat)distanceOfCellCenterFromEdge;
- (CGFloat)autoscrollDistanceForProximityToEdge:(CGFloat)proximity;
- (AutoscrollStatus)locationOfCellGivenSignedAutoscrollDistance:(CGFloat)signedAutoscrollDistance;
- (void)resetDragIVars;
- (void)resetTableViewAndNavBarToTypical;
@property (strong) UITableViewCell *draggedCell;
@property (strong) NSIndexPath *indexPathBelowDraggedCell;
@property (strong) CADisplayLink *timerToAutoscroll;
@end
#pragma mark -
@implementation ATSDragToReorderTableViewController
@synthesize dragDelegate, indicatorDelegate;
@synthesize reorderingEnabled=_reorderingEnabled;
@synthesize draggedCell, indexPathBelowDraggedCell, timerToAutoscroll;
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:resignActiveObserver];
resignActiveObserver = nil;
}
- (void)commonInit {
_reorderingEnabled = YES;
distanceThresholdToAutoscroll = -1.0;
self.indicatorDelegate = self;
// tableView's dataSource _must_ implement moving rows
// bug: calling self.view (or self.tableview) in -init causes -viewDidLoad to be called twice
// NSAssert(self.tableView.dataSource && [self.tableView.dataSource respondsToSelector:@selector(tableView:moveRowAtIndexPath:toIndexPath:)], @"tableview's dataSource must implement moving rows");
}
- (id)initWithStyle:(UITableViewStyle)style {
self = [super initWithStyle:style];
if (self)
[self commonInit];
return self;
}
- (id)init {
self = [super init];
if (self)
[self commonInit];
return self;
}
- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
if (self)
[self commonInit];
return self;
}
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self)
[self commonInit];
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
if ( self.reorderingEnabled )
[self establishGestures];
/*
* If app resigns active while we're dragging, safely complete the drag.
*/
__weak ATSDragToReorderTableViewController *blockSelf = self;
if ( resignActiveObserver == nil )
resignActiveObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:nil usingBlock:^(NSNotification *arg1) {
if ( [blockSelf isDraggingCell] ) {
ATSDragToReorderTableViewController *strongBlockSelf = blockSelf;
CGPoint currentPoint = [strongBlockSelf->dragGestureRecognizer translationInView:blockSelf.tableView];
[strongBlockSelf fastCompleteGesturesWithTranslationPoint:currentPoint];
}
}];
}
#pragma mark -
#pragma mark Setters and getters
/*
* Initializes gesture recognizers and adds them to self.tableView
*/
- (void)establishGestures {
if (self.tableView == nil)
return;
if (longPressGestureRecognizer == nil || [self.tableView.gestureRecognizers containsObject:longPressGestureRecognizer] == NO) {
longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressRecognized)];
longPressGestureRecognizer.delegate = self;
[self.tableView addGestureRecognizer:longPressGestureRecognizer];
/*
* Default allowable movement is greater than that for cell highlighting.
* That is, you can move your finger far enough to cancel highlight of a cell but still trigger the long press.
* Number was decided on by a rigorous application of trial and error.
*/
longPressGestureRecognizer.allowableMovement = 5.0;
}
if (dragGestureRecognizer == nil || [self.tableView.gestureRecognizers containsObject:dragGestureRecognizer] ) {
dragGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(dragGestureRecognized)];
dragGestureRecognizer.delegate = self;
[self.tableView addGestureRecognizer:dragGestureRecognizer];
}
}
/*
* Currently completely releases the gesture recognizers. Might consider merely disabling them.
*/
- (void)removeGestures {
if ( [self isDraggingCell] ) {
CGPoint currentPoint = [dragGestureRecognizer translationInView:self.tableView];
[self fastCompleteGesturesWithTranslationPoint:currentPoint];
}
[self.tableView removeGestureRecognizer:longPressGestureRecognizer];
longPressGestureRecognizer = nil;
[self.tableView removeGestureRecognizer:dragGestureRecognizer];
dragGestureRecognizer = nil;
}
- (void)setReorderingEnabled:(BOOL)newEnabledStatus {
if (_reorderingEnabled == newEnabledStatus)
return;
_reorderingEnabled = newEnabledStatus;
if ( _reorderingEnabled )
[self establishGestures];
else
[self removeGestures];
}
/*
* Getters, because of some stupid compiler bug about not mixing synthesize with hand-made setters. Feel free to remove if that goes away.
*/
- (BOOL)reorderingEnabled {
return _reorderingEnabled;
}
- (BOOL)isReorderingEnabled {
return [self reorderingEnabled];
}
- (BOOL)isDraggingCell {
return (self.draggedCell != nil);
}
#pragma mark -
#pragma mark UIGestureRecognizerDelegate methods
/*
* Defaults to NO, needs to be YES for press and drag to be one continuous action.
*/
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return (gestureRecognizer == dragGestureRecognizer || otherGestureRecognizer == dragGestureRecognizer);
}
/*
* Insure that only one touch and only the same touch reaches both gesture recognizers.
*/
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
if( gestureRecognizer == longPressGestureRecognizer || gestureRecognizer == dragGestureRecognizer ) {
static UITouch *longPressTouch = nil;
if ( gestureRecognizer == longPressGestureRecognizer && longPressGestureRecognizer.state == UIGestureRecognizerStatePossible ) {
longPressTouch = touch; // never retain a UITouch
veryInitialTouchPoint = [touch locationInView:self.tableView];
}
/*
* Only allow either gesture to receive that longPressTouch
*/
return ( touch == longPressTouch );
}
return YES;
}
#pragma mark -
#pragma mark UIGestureRecognizer targets and CADisplayLink target
/*
* Description:
* Target for longPress.
* If conditions are proper, establishes data that allows dragGesture to do work.
*
* UILongPressGestureRecognizer calls this after a certain about of time if the touch doesn't move too far, and then calls it every frame (1/60th sec).
*/
- (void)longPressRecognized {
/*
* ****************************
* Find reasons to return early.
* ****************************
*/
/*
* One potential reason: this was called because dragGesture never activated despite being allowed by former longPressGesture.
* If this is true, undo state and data established by said former longPressGesture.
*/
if ([self touchCanceledAfterDragGestureEstablishedButBeforeDragging]) {
[self completeGesturesForTranslationPoint:CGPointZero];
return;
}
/*
* Not a reason to return early.
* Instead, it prevents touches from going to the tableview.
*
* Has to occur after state == UIGestureRecognizerStateBegan else the touched cell will be "stuck" highlighted
*/
if ( self.draggedCell && longPressGestureRecognizer.state == UIGestureRecognizerStateChanged && self.tableView.allowsSelection )
self.tableView.allowsSelection = NO;
/*
* Another potential reason to return early is because the state isn't appropriate.
* This method is called whenever longPressGesture's state is changed, including when the state ends and when your finger moves.
* So we only want to actually do anything when the longPress first begins, or we'll quickly have dozens, hundreds of fake cells and only one we can get rid of.
*/
if (longPressGestureRecognizer.state != UIGestureRecognizerStateBegan)
return;
/*
* Get a valid indexPath to work with from the longPressGesture
* Reason to end -- longPressGesture isn't actually touching a tableView row.
*/
NSIndexPath *indexPathOfRow = [self anyIndexPathFromLongPressGesture];
if ( !indexPathOfRow )
return;
/*
* If touch has moved across the boundaries to act on a different cell than the one selected, use the original selection.
*/
NSIndexPath *selectedPath = [self.tableView indexPathForRowAtPoint:veryInitialTouchPoint];
if ( !(indexPathOfRow.section == selectedPath.section && indexPathOfRow.row == selectedPath.row) )
indexPathOfRow = selectedPath;
/*
* For some other reason the cell isn't highlighed
*/
UITableViewCell *highlightedCell = [self.tableView cellForRowAtIndexPath:indexPathOfRow];
if ( ![highlightedCell isHighlighted] )
return;
/*
* Check to see if the tableView's data source will let us move this cell.
* Return if the data source says NO.
*
* This will likely look weird because UILongPressGestureRecognizer will still cancel the highlight touch.
*/
if ([self.tableView.dataSource respondsToSelector:@selector(tableView:canMoveRowAtIndexPath:)]) {
if (![self.tableView.dataSource tableView:self.tableView canMoveRowAtIndexPath:indexPathOfRow])
return;
}
/*
* ****************************
* Situtation is good. Go ahead and allow dragGesture.
* ****************************
*
* Establish state and data for dragGesture to work properly
*/
[self disableInterferingAspectsOfTableViewAndNavBar];
/*
* Create a cell to move with finger for drag gesture.
* This dragged cell is not actually the cell selected, but a copy on on top of the real cell. Actual cell is hidden.
*
* Important: (draggedCell != nil) is the flag that allows dragGesture to proceed.
*/
/*
* If -tableView:cellForRowAtIndexPath: (in -cellPreparedToAnimateAroundAtIndexPath) has to create a new cell, the separator style wiil be set to the default.
* This might cause a distratcting 1 px line at the bottom of the cell if the style is not the default.
*
* In what has to be a bug, -reloadRowsAtIndexPaths:withRowAnimation: will cause a new cell to be created, so we do that first.
* Then the separatorStyle will be set properly.
*
* In what has to be another bug, -reloadRowsAtIndexPaths:withRowAnimation: will cause the row to stay highlighted if you chose the selected row.
* Pick a non-selected row, which is one reason it is recommended to disable reordering on tableviews with <= 1 rows.
*
* Why not just [self.draggedCell setSeparatorStyle:self.tableView.separatorStyle] ourselves? That's a private method.
*/
NSIndexPath *indexPathOfSomeOtherRow = [self indexPathOfSomeRowThatIsNotIndexPath:indexPathOfRow];
if (indexPathOfSomeOtherRow != nil)
[self.tableView reloadRowsAtIndexPaths:@[indexPathOfSomeOtherRow] withRowAnimation:UITableViewRowAnimationNone];
self.draggedCell = [self cellPreparedToAnimateAroundAtIndexPath:indexPathOfRow];
[self.draggedCell setHighlighted:YES animated:NO];
[UIView animateWithDuration:0.23 delay:0 options:(UIViewAnimationOptionAllowUserInteraction|UIViewAnimationOptionCurveEaseInOut) animations:^{
[self.indicatorDelegate dragTableViewController:self addDraggableIndicatorsToCell:self.draggedCell forIndexPath:indexPathOfRow];
} completion:^(BOOL finished) {
/*
* We're not changing the cell after this so go ahead and rasterize.
* Rasterization scale seems to default to 1 if layer is rasterized offscreen or something.
*
* If it didn't complete, it was likely interrupted by another animation. Don't rasterize the cell on it.
*/
if (finished) {
self.draggedCell.layer.rasterizationScale = [[UIScreen mainScreen] scale];
self.draggedCell.layer.shouldRasterize = YES;
}
}];
/*
* Save initial y offset so that we can move it with the dragGesture.
* Drag gesture gives translation data, not so much points relative to screen.
* Though it *does* give points, and we could consider translating them to [self.tableView superview] for absolute on screen position.
* (would need to save touchIndex for gesture's -locationOfTouch:inView:)
*/
initialYOffsetOfDraggedCellCenter = self.draggedCell.center.y - self.tableView.contentOffset.y;
/*
* Set needed threshold to autoscroll to be the distance from the center of the cell to just beyond an edge
* This way we reflect official drag behavior where it hits maximum speed at the center and starts scrolling just before the edge.
*/
distanceThresholdToAutoscroll = self.draggedCell.frame.size.height / 2.0 + 6;
/*
* Grab index path of selected cell.
* To be used for moving the blank cell around to create illusion that cells are shuffling out of the way of the draggedCell.
* And finally, tell the delegate we're going to start dragging
*/
self.indexPathBelowDraggedCell = indexPathOfRow;
if ([self.dragDelegate respondsToSelector:@selector(dragTableViewController:didBeginDraggingAtRow:)])
[self.dragDelegate dragTableViewController:self didBeginDraggingAtRow:indexPathOfRow];
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, NSLocalizedString(@"Now dragging.", @"Voiceover annoucement"));
}
/*
* Description:
* target for dragGesture.
* Requires longPressRecognized to set up data first.
*
* UIPanGestureRecognizer calls this when position changes.
* Remember that on retina displays these are 0.5 pixel increments.
*/
- (void)dragGestureRecognized {
/*
* If no draggedCell, nothing to drag.
* Also means that longPress probably hasn't fired.
*/
if ( !self.draggedCell )
return;
/*
* If dragGesture has ended (finger has lifted), clean up data and put cell "back" into tableview.
* Otherwise use translation to update position of cell.
*/
CGPoint translation = [dragGestureRecognizer translationInView:self.tableView];
if (dragGestureRecognizer.state == UIGestureRecognizerStateEnded || dragGestureRecognizer.state == UIGestureRecognizerStateCancelled)
[self completeGesturesForTranslationPoint:translation];
else
[self updateDraggedCellWithTranslationPoint:translation];
}
/*
* Description:
* Determines whether and how much to scroll the tableView due to proximity of draggedCell to the edge.
* Updates the contentOffset and the draggedCell with the same value so that the "visible location" of dragged cell isn't changed by scrolling.
* DraggedCell continues to follow touch elsewhere, not here.
*/
- (void)fireAutoscrollTimer:(CADisplayLink *)sender {
/*
* Ensure blank cell is actually blank. There are some legit cases where this might not be so, particularly with large row heights.
*/
UITableViewCell *blankCell = [self.tableView cellForRowAtIndexPath:self.indexPathBelowDraggedCell];
if (blankCell != nil && blankCell.hidden == NO)
blankCell.hidden = YES;
/*****
*
* Determine how far to autoscroll based on current position.
*
*****/
// Signed distance has negative values if near the top.
CGFloat signedDistance = [self distanceOfCellCenterFromEdge];
CGFloat absoluteDistance = fabs(signedDistance);
CGFloat autoscrollDistance = [self autoscrollDistanceForProximityToEdge:absoluteDistance];
// negative values means going up
if (signedDistance < 0)
autoscrollDistance *= -1;
/*****
*
* Move tableView and dragged cell
*
*****/
AutoscrollStatus autoscrollOption = [self locationOfCellGivenSignedAutoscrollDistance:autoscrollDistance];
CGPoint tableViewContentOffset = self.tableView.contentOffset;
if ( autoscrollOption == AutoscrollStatusCellAtTop ) {
/*
* In this case, set the tableview content offset y to 0.
* The change in autoscroll is only how far it is to 0.
*/
CGFloat scrollDistance = tableViewContentOffset.y;
tableViewContentOffset.y = 0;
draggedCell.center = CGPointMake(draggedCell.center.x, draggedCell.center.y - scrollDistance);
/*
* Can't move any further up, and if we start moving down it'll create a new timer anyway.
* Leave as != nil so we aren't constantly creating and releasing CADisplayLinks.
* It'll be nil'ed when we move out of distanceThresholdToAutoscroll.
*/
[self.timerToAutoscroll invalidate];
} else if ( autoscrollOption == AutoscrollStatusCellAtBottom ) {
/*
* Similarly, set the tableview content offset y to the full offset.
* Set to 0 if full offset is less than the tableview bounds.
* Scroll distance is the change in content offset.
*/
CGFloat yOffsetForBottomOfTableViewContent = MAX(0, (self.tableView.contentSize.height - self.tableView.frame.size.height));
CGFloat scrollDistance = yOffsetForBottomOfTableViewContent - tableViewContentOffset.y;
tableViewContentOffset.y = yOffsetForBottomOfTableViewContent;
draggedCell.center = CGPointMake(draggedCell.center.x, draggedCell.center.y + scrollDistance);
/*
* Can't move any further down, and if we start moving up it'll create a new timer anyway.
* Leave as != nil so we aren't constantly creating and releasing CADisplayLinks.
* It'll be nil'ed when we move out of distanceThresholdToAutoscroll.
*/
[self.timerToAutoscroll invalidate];
} else {
/*
* Neither at the top of the contentOffset nor the bottom so we just
* update the content offset with the needed change and
* update the dragged cell with the same value so that it maintains the same visible location.
*/
tableViewContentOffset.y += autoscrollDistance;
draggedCell.center = CGPointMake(draggedCell.center.x, draggedCell.center.y + autoscrollDistance);
}
self.tableView.contentOffset = tableViewContentOffset;
[self keepDraggedCellVisible];
[self shuffleCellsOutOfWayOfDraggedCellIfNeeded];
}
#pragma mark -
#pragma mark longPressRecognized helper methods
/*
* Description:
* Can happen if you press down for a while but don't drag in a direction.
* Returns:
* If YES, you should undo changes maded by -longPressRecognized.
*/
- (BOOL)touchCanceledAfterDragGestureEstablishedButBeforeDragging {
return (self.draggedCell != nil && longPressGestureRecognizer.state == UIGestureRecognizerStateEnded && dragGestureRecognizer.state == UIGestureRecognizerStateFailed);
}
///*
// Description:
// Crash to prevent potential data corruption.
// Called before establishing new draggedCell and indexPathOfBlankItem
// */
//- (void)assertPreviousDraggingEndedProperly {
// NSAssert(self.draggedCell == nil, @"Dragged cell overlap");
// NSAssert(self.indexPathOfBlankItem == nil, @"Index path wasn't properly whatevered");
//}
/*
Description:
Return a valid indexPath from longPressGesture.
There might be multiple touches, some of which aren't actually touching valid indexPath rows.
…Though I'm not sure UILongPressGestureRecognizer will ever trigger if there are more than 1 touches.
And in any case, how would we know which one UIPanGestureRecognizer is following?
Returns:
An NSIndexPath of a touched row or nil if none of the touches are on rows.
*/
- (NSIndexPath *)anyIndexPathFromLongPressGesture {
/*
Iterate through touches. A little bit roundabout because there's no simple array of points.
*/
for (NSUInteger pointIndex = 0; pointIndex < [longPressGestureRecognizer numberOfTouches]; ++pointIndex) {
CGPoint touchPoint = [longPressGestureRecognizer locationOfTouch:pointIndex inView:self.tableView];
/*
See if tableView thinks that point is a real row. If it is, return that.
*/
NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:touchPoint];
if (indexPath != nil)
return indexPath;
}
/*
No indexPaths were found, return nil.
*/
return nil;
}
/*
Description:
Create a cell on top of the actual cell at indexPath.
Used for creating the illusion that cell is moving independantly of tableView.
Needed for use of deleteRowAtIndexPath and insertRowAtIndexPath without affecting moving cell.
Parameter:
indexPath of cell to replicate.
Return:
A cell generated by tableView's dataSource without being specifically connected to the tableView.
*/
- (UITableViewCell *)cellPreparedToAnimateAroundAtIndexPath:(NSIndexPath *)indexPath {
/*
Get a new cell and put it on top of actual cell for that index path.
*/
UITableViewCell *cellCopy;
if ( [self.indicatorDelegate respondsToSelector:@selector(cellIdenticalToCellAtIndexPath:forDragTableViewController:)])
cellCopy = [self.indicatorDelegate cellIdenticalToCellAtIndexPath:indexPath forDragTableViewController:self];
else
cellCopy = [self.tableView.dataSource tableView:self.tableView cellForRowAtIndexPath:indexPath];
cellCopy.frame = [self.tableView rectForRowAtIndexPath:indexPath];
[self.tableView addSubview:cellCopy];
[self.tableView bringSubviewToFront:cellCopy];
/*
Adjust actual cell so it is blank when cell copy moves off of it
Hidden is set back to NO when reused.
*/
UITableViewCell *actualCell = [self.tableView cellForRowAtIndexPath:indexPath];
if (actualCell != nil)
actualCell.hidden = YES;
return cellCopy;
}
/*
Description:
Gives us some other index path to work around a stupid bug.
Perhaps more complicated that it needs to be because we're avoiding assumptions about how the tableview works.
*/
- (NSIndexPath *)indexPathOfSomeRowThatIsNotIndexPath:(NSIndexPath *)selectedIndexPath {
NSArray *arrayOfVisibleIndexPaths = [self.tableView indexPathsForVisibleRows];
/*
if there's only one cell, then return nil.
Remember you can't insert nil into an array.
*/
if (arrayOfVisibleIndexPaths.count <= 1)
return nil;
NSIndexPath *indexPathOfSomeOtherRow = [arrayOfVisibleIndexPaths lastObject];
/*
Check if they're the same
*/
if (indexPathOfSomeOtherRow.row == selectedIndexPath.row && indexPathOfSomeOtherRow.section == selectedIndexPath.section)
indexPathOfSomeOtherRow = [arrayOfVisibleIndexPaths objectAtIndex:0];
return indexPathOfSomeOtherRow;
}
#pragma mark -
#pragma mark dragGestureRecognized helper methods
/*
Description:
Stop the cell from going off tableview content frame. Instead stops flush with top or bottom of tableview.
Matches behavior of editing's drag control.
*/
- (void)keepDraggedCellVisible {
/*
Prevent it from going above the top.
*/
if (draggedCell.frame.origin.y <= 0) {
CGRect newDraggedCellFrame = draggedCell.frame;
newDraggedCellFrame.origin.y = 0;
draggedCell.frame = newDraggedCellFrame;
/*
Return early. Flush with the top is exclusive with flush with the bottom, short of odd coincidence.
*/
return;
}
/*
Prevent it from going off the bottom.
Make a content rect which is a frame of the entire content.
*/
CGRect contentRect = {
.origin = self.tableView.contentOffset,
.size = self.tableView.contentSize
};
/*
Height of content minus height of cell. Means the bottom of the cell is flush with the bottom of the tableview.
*/
CGFloat maxYOffsetOfDraggedCell = contentRect.origin.x + contentRect.size.height - draggedCell.frame.size.height;
if (draggedCell.frame.origin.y >= maxYOffsetOfDraggedCell) {
CGRect newDraggedCellFrame = draggedCell.frame;
newDraggedCellFrame.origin.y = maxYOffsetOfDraggedCell;
draggedCell.frame = newDraggedCellFrame;
}
}
/*
Description:
Set frame for dragged cell based on translation.
Translation point is distance from original press down.
Official drag control keeps the cell's center visible at all times.
*/
- (void)updateFrameOfDraggedCellForTranlationPoint:(CGPoint)translation {
CGFloat newYCenter = initialYOffsetOfDraggedCellCenter + translation.y + self.tableView.contentOffset.y;
/*
draggedCell.center shouldn't go offscreen.
Check that it's at least the contentOffset and no further than the contentoffset plus the contentsize.
*/
newYCenter = MAX(newYCenter, self.tableView.contentOffset.y);
newYCenter = MIN(newYCenter, self.tableView.contentOffset.y + self.tableView.bounds.size.height);
CGPoint newDraggedCellCenter = {
.x = draggedCell.center.x,
.y = newYCenter
};
draggedCell.center = newDraggedCellCenter;
/*
Don't let the cell go off of the tableview
*/
[self keepDraggedCellVisible];
}
/*
Description:
Checks if the draggedCell is close to an edge and makes tableView autoscroll or not depending.
*/
- (void)setTableViewToAutoscrollIfNeeded {
/*
Get absolute distance from edge.
*/
CGFloat absoluteDistance = [self distanceOfCellCenterFromEdge];
if (absoluteDistance < 0)
absoluteDistance *= -1;
/*
If cell is close enough, create a timer to autoscroll.
*/
if (absoluteDistance < distanceThresholdToAutoscroll) {
/*
dragged cell is close to the top or bottom edge, so create an autoscroll timer if needed.
*/
if (self.timerToAutoscroll == nil) {
/*
Timer is actually a CADisplayLink, which fires everytime Core Animation wants to draw, aka, every frame.
Using an NSTimer with 1/60th of a second hurts frame rate because it might update in between drawing and force it to try to draw again.
*/
self.timerToAutoscroll = [CADisplayLink displayLinkWithTarget:self selector:@selector(fireAutoscrollTimer:)];
[self.timerToAutoscroll addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
}
} else {
/*
If we move our cell out of the autoscroll threshold, remove the timer and stop autoscrolling.
*/
if (self.timerToAutoscroll != nil) {
[timerToAutoscroll invalidate];
self.timerToAutoscroll = nil;
}
}
}
/*
Description:
Animates the dragged cell sliding back into the tableview.
Tells the data model to update as appropriate.
*/
- (void)endedDragGestureWithTranslationPoint:(CGPoint)translation {
/*
Get final frame of cell.
*/
[self updateFrameOfDraggedCellForTranlationPoint:translation];
/*
If the row changes at the last minute, update so we don't put it away in the wrong spot
*/
[self shuffleCellsOutOfWayOfDraggedCellIfNeeded];
/*
Notify the delegate that we're about to finish
*/
if ([self.dragDelegate respondsToSelector:@selector(dragTableViewController:willEndDraggingToRow:)])
[self.dragDelegate dragTableViewController:self willEndDraggingToRow:self.indexPathBelowDraggedCell];
/*
Save pointer to dragged cell so we can remove it from superview later. Same with blank item index path.
By the time the completion block is called, self.draggedCell == nil, which is proper behavior to prevent overlapping drags or whatnot.
Probably not necessary to retain, because the superview retains it.
But I'm going to be safe, and modifying retainCount is trivial anyway.
*/
UITableViewCell *oldDraggedCell = self.draggedCell;
NSIndexPath *blankIndexPath = self.indexPathBelowDraggedCell;
CGRect rectForIndexPath = [self.tableView rectForRowAtIndexPath:self.indexPathBelowDraggedCell];
BOOL hideDragIndicator = YES;
if( [self.dragDelegate respondsToSelector:@selector(dragTableViewController:shouldHideDraggableIndicatorForDraggingToRow:)] )
hideDragIndicator = [self.dragDelegate dragTableViewController:self shouldHideDraggableIndicatorForDraggingToRow:blankIndexPath];
/*
Dehighlight the cell while moving it to the expected location for that indexPath's cell.
*/
self.draggedCell.layer.shouldRasterize = NO;
if( hideDragIndicator )
[(UITableViewCell *)self.draggedCell setHighlighted:NO animated:YES];
[UIView animateWithDuration:0.25 delay:0 options:(UIViewAnimationOptionAllowUserInteraction|UIViewAnimationOptionBeginFromCurrentState) animations:^{
oldDraggedCell.frame = rectForIndexPath;
/*
Hides the draggable appearance.
*/
if( hideDragIndicator )
[self.indicatorDelegate dragTableViewController:self hideDraggableIndicatorsOfCell:oldDraggedCell];
} completion:^(BOOL finished) {
/*
Update tableView to show the real cell. Reload to reflect any changes caused by dragDelegate.
*/
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:blankIndexPath] withRowAnimation:UITableViewRowAnimationNone];
/*
Removes the draggable appearance so cell can be reused.
*/
[self.indicatorDelegate dragTableViewController:self removeDraggableIndicatorsFromCell:oldDraggedCell];
[oldDraggedCell removeFromSuperview];
if( [self.dragDelegate respondsToSelector:@selector(dragTableViewController:didEndDraggingToRow:)] )
[self.dragDelegate dragTableViewController:self didEndDraggingToRow:blankIndexPath];
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, NSLocalizedString(@"Drag completed.", @"Voiceover annoucement"));
}];
/*
If the cell is at the top or bottom of the view, bring that location visible.
*/
[self.tableView scrollRectToVisible:rectForIndexPath animated:YES];
}
/*
Description:
Cleanup and complete data from gestures.
Do the same thing as -completeGesturesForTranslationPoint:, but for when we can't wait for animations.
Largely copied from -endedDragGestureWithTranslationPoint:
*/
- (void)fastCompleteGesturesWithTranslationPoint:(CGPoint) translation {
[self updateFrameOfDraggedCellForTranlationPoint:translation];
/*
If it happens to change at the last minute we don't put it away in the wrong spot
*/
[self shuffleCellsOutOfWayOfDraggedCellIfNeeded];
/*
Reset tableView and delegate back to normal
*/
if ([self.dragDelegate respondsToSelector:@selector(dragTableViewController:willEndDraggingToRow:)])
[self.dragDelegate dragTableViewController:self willEndDraggingToRow:self.indexPathBelowDraggedCell];
[self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:self.indexPathBelowDraggedCell] withRowAnimation:UITableViewRowAnimationNone];
/*
Revert dragged cell selection color to normal
*/
self.draggedCell.layer.shouldRasterize = NO;
[self.indicatorDelegate dragTableViewController:self removeDraggableIndicatorsFromCell:self.draggedCell];
[self.draggedCell removeFromSuperview];
/*
Remaining cleanup.
*/
[self resetDragIVars];
[self resetTableViewAndNavBarToTypical];
}
/*
Description:
Should be called when gestures have ended.
Cleanup ivars and return tableView to former state.
*/
- (void)completeGesturesForTranslationPoint:(CGPoint)translation {
/*
Put dragged cell back into proper place.
Should probably short circuit if translation.y is zero.
*/
[self endedDragGestureWithTranslationPoint:translation];