iOS之UITableView帶滑動操作菜單的Cell(下)

你同樣已經學了不少關于這個 Cell 如何工作的知識;亦即,那個UITableViewCellScrollView
,它包含 contentView 和 Disclosure Indicator (以及 Delete 按鈕,如果它被添加的話),明顯是要做某些事 。你可能已經從它的名字以及它是UIScrollView
的子類而猜到了。
你可以通過在tableView:cellForRowAtIndexPath:
下面添加一個簡單的for
循環來測試這個假設,就在recursiveDescription
那一行下面:

for (UIView *view in cell.subviews) { if ([view isKindOfClass:[UIScrollView class]]) { view.backgroundColor = [UIColor greenColor]; }}

再次編譯并允許應用;綠色高亮確認了這個私有類確實是UIScrollView
的子類,因為它覆蓋了 Cell 里所有的紫色。


回想剛才recursiveDescription
輸出的 log,UITableViewCellScrollView
的 Frame 和 Cell 本身的 Size 是一致的。
但是,這個視圖到底有什么用?繼續拖動 Cell 到左邊,你就會看到 Scroll View 在你拖動 Cell 并 釋放時提供了 “彈性(springy)”行為,如下所示:
swipeable-demo
swipeable-demo

在你創建你自己的自定義UITableViewCell
子類之前,還有一件事要注意,它出至 UITableViewCell Class Reference:
如果你想超越預定義樣式,你可以添加子視圖到 Cell 的contentView
上。在添加子視圖時,你自己要負責這些視圖的位置以及設置它們的內容。

直白的說,就是,任何對UITableViewCell
的自定義操作只能在contentView
中進行。你不能將自己的視圖加在 Cell 下面——而必須將它們加在 Cell 的contentView
上。
這就意味著你將找出你自己的解決方案以便添加自定義按鈕。但不要害怕,你可以很容易地復制出 Apple 所使用的方案。
可滑動 Table View Cell 的組成列表
這對你來說是什么意思?到了這里,你就有了一個組成列表來制造出一個UITableViewCell
子類,以便放上你自定義的按鈕。
我們從 View Stack 的最底部開始列出條目,你的列表如下:
contentView
是你的基礎視圖,因為你只能將子視圖添加到它上面。
在用戶滑動后,任何你想顯示的UIButon

一個位于按鈕之上的容器視圖來裝載你所有的內容。
你可以使用一個UIScrollView
來作為你的容器視圖,就像 Apple 使用的,或者使用一個UIPanGestureRecognizer
。這同樣能夠處理滑動去顯示/隱藏按鈕。你將在項目中采用后一種方案。
最后,一個裝有實際內容的視圖。

還有一個可能不那么明顯的成分:你必須確保系統提供的UIPanGestureRecognizer
—— 它能讓你滑動顯示 Delete 按鈕 —— 不可用。否則系統手勢會和自定義手勢沖突。
好消息是設置默認滑動手勢不可用的操作相當簡單。
打開MasterViewController.m
修改tableView:canEditRowAtIndexPath:
永遠返回NO
,如下所示:

- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { return NO;}

編譯并運行;試著滑動某個 Cell ,你會發現你不能再滑動去刪除了。
為了保持簡單,你將使用兩個按鈕來走完這個教程。但同樣的技術也可以再一個按鈕上工作,或者超過兩個按鈕的情況——作為提醒,你可能需要執行一些本文沒有涉及到的調整,如果你真的添加了多個按鈕,你必須將整個 Cell 滑出才能看到所有的按鈕。
創建一個自定義 Cell
你可以從基本視圖和手勢識別列表可以看到,在 Table View Cell 中有許多要做的事。你將創建一個自定義的UITableViewCell
子類,以將所有的邏輯放在同一個地方。
去往File\New\ File…
并選擇iOS\Cocoa Touch\Objective-C class
,將新類命名為SwipeableCell
,將它設置為UITableViewCell
的子類 ,如下所示:


在SwipeableCell.m
中設置下列類擴展和IBOutlet
,就在#import
語句后,@implementation
語句前:

@interface SwipeableCell()
@property (nonatomic, weak) IBOutlet UIButton *button1;
@property (nonatomic, weak) IBOutlet UIButton *button2;
@property (nonatomic, weak) IBOutlet UIView*myContentView;
@property (nonatomic, weak) IBOutlet UILabel *myTextLabel;@end

下一步,進入 Storyboard 選中UITableViewCell
原型,如下所示:


打開 Identity Inspector ,然后修改 Custom Class 為SwipeableCell
,如下所示:
Change Custom Class
Change Custom Class

現在UITableViewCell
原型的名字在左邊的 Document Outline 上會顯示為 “Swipeable Cell”。右鍵單擊Swipeable Cell – Cell
,你會看到一個你之前設置的IBOutlet
列表:
New Name and Outlets
New Name and Outlets

首先,你要在 Attributes Inspector 里修改兩個地方以便自定義視圖。設置 Style 為Custom
, Selection 為None
, Accessory 也為None
,截圖如下:
Reset Cell Items
Reset Cell Items

然后,拖兩個按鈕到 Cell 的 Content View 里。在視圖的 Attributes Inspector 區設置每個按鈕的背景色為比較鮮艷的顏色,并設置每個按鈕的文字顏色為比較易讀的顏色,這樣你就可以清楚地看到按鈕。
將第一個按鈕放在右邊,和contentView
的上下邊緣接觸。將第二個按鈕放在第一個按鈕的左邊緣處,也和contentView
的上下邊緣接觸。當你做好后,Cell 看起來如下,可能顏色少有差異:
Buttons Added to Prototype Cell
Buttons Added to Prototype Cell

接下來,將每個按鈕和對應的 Outlet 關聯起來。右鍵單擊到可滑動Cell上打開它的 Outlets,然后將 button1 拖動到到右邊的按鈕, button2 拖動到左邊的按鈕,如下:
swipeable-button1
swipeable-button1

你需要創建一個方法來處理對每個按鈕的點擊。
打開SwipeableCell.m
添加如下方法:

- (IBAction)buttonClicked:(id)sender 
{ 
      if (sender == self.button1) 
      { 
      NSLog(@"Clicked button 1!"); 
      } else if (sender == self.button2)
         { 
          NSLog(@"Clicked button 2!");
         } else { NSLog(@"Clicked unknown button!");
     }
}

這個方法處理對兩個按鈕的點擊,通過在控制臺打印記錄,你就能確定按鈕被點擊了。
再次打開 Storyboard ,將兩個按鈕都連接上 Action 。右鍵單擊Swipeable Cell – Cell
出現 Outlet 和 Action 的列表。從buttonClicked:
Action 拖動到你的按鈕,如下:


從事件列表中選擇Touch Up Inside
,如下所示:
swipeable-touchupinside
swipeable-touchupinside

重復上述步驟,用于第二個按鈕。現在隨便按照任何一個按鈕上,都會調用buttonClicked:

打開SwipeableCell.m
添加如下屬性:

@property (nonatomic, strong) NSString *itemText;

稍后你將更多的和itemText
打交道,但目前,這就是所有你要做的。
打開MasterViewController.m
并在頂部添加如下一行:

import "SwipeableCell.h"

這將保證這個類知道你自定義的 Cell 子類。
替換tableView:cellForRowAtIndexPath:
的內容為:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath 
{
     SwipeableCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath]; 
    NSString *item = _objects[indexPath.row]; cell.itemText = item; return cell;
  }

現在該使用你的新 Cell 而不是標準的UITableViewCell

編譯并運行;你會看到如下界面:


添加一個 Delegate
歐耶~ 你的按鈕已經出現了!如果你點擊任何一個按鈕,你都會在控制臺看到合適的信息輸出。然而,你不能指望 Cell 本身去處理任何直接的 Action 。
比如說,一個 Cell 不能 Present 其他的 View Controller 或直接將其 push 到 Navigation Stack 里。你必須要設置一個 Delegate 來傳遞按鈕的點擊事件回到 View Controller 中去處理那個事件。
打開SwipeableCell.h
并在@interface
之上添加如下 Delegate 協議:

@protocol SwipeableCellDelegate <NSObject>
- (void)buttonOneActionForItemText:(NSString *)itemText;
- (void)buttonTwoActionForItemText:(NSString *)itemText;
@end

添加如下 Delegate 屬性到SwipeableCell.h
,就在itemText
屬性下面:

@property (nonatomic, weak) id <SwipeableCellDelegate> delegate;

更新SwipeableCell.m
中的buttonClicked:
為如下所示:

- (IBAction)buttonClicked:(id)sender
 { 
      if (sender == self.button1)
       {
           [self.delegate buttonOneActionForItemText:self.itemText]; 
        } else if (sender == self.button2) 
            { 
                  self.delegate buttonTwoActionForItemText:self.itemText];
             } else { 
                      (@"Clicked unknown button!"); 
                      }
}

這個更新使得這個方法去調用合適的 Delegate 方法,而不僅僅是打印一句 log。
現在打開MasterViewController.m
并添加如下 delegate 方法:

#pragma mark - SwipeableCellDelegate
- (void)buttonOneActionForItemText:(NSString *)itemText { 
      NSLog(@"In the delegate, Clicked button one for %@", itemText);
    }
- (void)buttonTwoActionForItemText:(NSString *)itemText {
     NSLog(@"In the delegate, Clicked button two for %@", itemText);
    }

這個方法目前還是簡單的打印到控制臺,以確保一切傳遞都工作正常。
接下來,添加如下協議到MasterViewController.m
頂部的類擴展上以符合協議申明:

@interface MasterViewController () <SwipeableCellDelegate>
 { 
       NSMutableArray *_objects;
 }
@end

這只是簡單地確認這個類會實現SwipeableCellDelegate
協議。
最后,你要設置這個 View Controller 為 Cell 的 delegate。
添加如下語句到tableView:cellForRowAtIndexPath:
,就在最后的 return 語句之前:
cell.delegate = self;

編譯并運行;當你點擊按鈕時,你就會看到合適的“In the delegate”消息。
為按鈕添加 Action
如果你看到log消息很很高興了,也可以跳過下一節。然而,如果你喜歡更加實在的東西,你可以添加一些處理,這樣當 delegate 方法被調用時,你就可以顯示已經引入的DetailViewController

添加如下兩個方法到MasterViewController.m

- (void)showDetailWithText:(NSString *)detailText
{ 
      //1 UIStoryboard *storyboard = [UIStoryboard  storyboardWithName:@"Main" bundle:nil]; 
DetailViewController *detail = [storyboard instantiateViewControllerWithIdentifier:@"DetailViewController"]; 
detail.title = @"In the delegate!"; 
detail.detailItem = detailText; 
//2 UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:detail]; 
//3 UIBarButtonItem *done = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(closeModal)];
 [detail.navigationItem setRightBarButtonItem:done]; 
[self presentViewController:navController animated:YES completion:nil];
}
//4
- (void)closeModal
  { 
        [self dismissViewControllerAnimated:YES completion:nil]; 
  }

在上面的代碼里,你執行了四個操作:
從 Storyboard 里取出 Detail View Controller 并設置其 title 和 detailItem 。
設置一個UINavigationController
作為包含 Detail View Controller 的容器,并給你放置 close 按鈕的地方。
添加 close 按鈕,關聯MasterViewController
里的一個 Action。
設置這個 Action 的響應方法,它將 dismiss 任何以 Modal 方式顯示 View Controller

接下來,用下列版本替換你之前添加的兩個方法:

- (void)buttonOneActionForItemText:(NSString *)itemText
{ 
[self showDetailWithText:[NSString stringWithFormat:@"Clicked button one for %@", itemText]];
}
- (void)buttonTwoActionForItemText:(NSString *)itemText
{
 [self showDetailWithText:[NSString stringWithFormat:@"Clicked button two for %@", itemText]];
}

最后,打開Main.storyboard
并選中Detail View Controller
。找到 Identity Inspector 并設置Storyboard ID
為DetailViewController
以匹配類名,如下所示:


如果你忘了這一步,instantiateViewControllerWithIdentifier
將會因為不合法的參數而 Crash,其異常表示具有這個標識符的 View Controller 并不存在。
編譯并運行;點擊某個 Cell 中的按鈕,然后看著 Modal View Controller 出現,如下面的截圖所示:
View Launched from Delegate
View Launched from Delegate

添加頂層視圖并添加滑動 Action
現在你到了視圖工作的后段部分,是時候讓頂層部分啟動并運行起來了。
打開Main.storyboard
并拖一個UIView
到SwipeableTableCell
上,這個視圖將占據整個 Cell 的高和寬,并覆蓋按鈕,所以在Swipe手勢能工作之前,你不會再看到它們了。
如果你要精確地控制,打開 Size Inspector 并設置這個視圖地寬和高,分別為 320 和 43:
swipeable-320-43
swipeable-320-43

你同樣需要一個約束來將視圖釘在 contentView 的邊緣。選中視圖并點擊Pin
按鈕,選擇所有四個間隔約束并設置它們的值為 0 ,如下所示:
swipeable-constraint
swipeable-constraint

連接好這個視圖的 Outlet,按照之前介紹的步驟:在左邊的導航器里右鍵單擊這個可滑動 Cell 并拖動myContentView
到這個新的視圖上。
下一步,拖動一個UILabel
到視圖里;設置其距離左邊 20 點,并設置其垂直劇中。再將其連接到myTextLabel
Outlet 上。
編譯并運行;你的 Cell 看起來有正常了:
Back to cells
Back to cells

添加數據
但為何實際的文本數據沒有顯示出來?那是因為你只是設置了itemText
屬性,而沒有做會影響myTextLabel
的事情。
打開SwipeableCell.m
并添加如下方法:

- (void)setItemText:(NSString *)itemText 
{ 
//Update the instance variable _itemText = itemText; 
//Set the text to the custom label. 
self.myTextLabel.text = _itemText;}

這個方法覆寫了itemText
屬性的 setter 方法。除了更新后面的實例變量,它還會更新可見的 Label。
最后,為了讓接下來的幾步的結果更易看到,你將把 item 的 title 變長一點,以便在 Cell 滑動后依然有一些文本可見。
轉到MasterViewController.m
并更新viewDidLoad
中的這一行,這是 item title 生成的地方:

NSString *item = [NSString stringWithFormat:@"Longer Title Item #%d", i];

編譯并運行;你就會看到合適的 item title 顯示如下:


手勢識別——GO!
終于到了“有趣的”部分——將數學、約束以及手勢識別攪和在一起,以方便地處理滑動操作。
首先,在SwipeableCell
的類擴展里添加如下這些屬性:

@property (nonatomic, strong) UIPanGestureRecognizer *panRecognizer;
@property (nonatomic, assign) CGPoint panStartPoint;
@property (nonatomic, assign) CGFloat startingRightLayoutConstraintConstant;
@property (nonatomic, weak) IBOutlet NSLayoutConstraint *contentViewRightConstraint;
@property (nonatomic, weak) IBOutlet NSLayoutConstraint *contentViewLeftConstraint;

關于你所要做的事情,簡短版本是這樣的:記錄一個 Pan 手勢并調整你的View的左右約束,根據 a) 用戶將 Cell Pan 了多遠 b) Cell 在何處以及合適開始移動。
為了做到這一點,你首先要將這個 IBOutlet 連接到myContentView
的左右約束上。這兩個約束將視圖 釘在 Cell 的contentView
中。
通過打開約束列表,你可以找出這兩個約束。通過檢查每個約束在 Cell 上的高亮你就能找到那合適的兩個。在這個例子中,是contentView
右邊和contentView
之間的約束,如下所示:


一旦你定位到合適的約束,就將其連接到合適的 Outlet 上——在本例中,是contentViewRightConstraint
,如下圖所示:
Hook Up Constraint to IBOutlet
Hook Up Constraint to IBOutlet

遵循同樣的步驟,連接好contentViewLeftConstraint
,它代表contentView
左邊和contentView
之間的約束。
下一步,打開SwipeableCell.m
并修改@interface
語句的類擴展,添加UIGestureRecognizerDelegate
協議:

@interface SwipeableCell() <UIGestureRecognizerDelegate>

然后,依然在SwipeableCell.m
里,添加如下方法:

- (void)awakeFromNib
 {
 [super awakeFromNib]; 
self.panRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panThisCell:)]; 
self.panRecognizer.delegate = self;
 [self.myContentView addGestureRecognizer:self.panRecognizer];
}

這里設置了 Pan 手勢并將其添加到 Cell 上:
再添加如下方法:

- (void)panThisCell:(UIPanGestureRecognizer *)recognizer 
{ 
switch (recognizer.state)
 {
 case UIGestureRecognizerStateBegan: self.panStartPoint = [recognizer translationInView:self.myContentView]; 
NSLog(@"Pan Began at %@", NSStringFromCGPoint(self.panStartPoint)); 
break; 
case UIGestureRecognizerStateChanged: { CGPoint currentPoint = [recognizer translationInView:self.myContentView]; 
CGFloat deltaX = currentPoint.x - self.panStartPoint.x;
 NSLog(@"Pan Moved %f", deltaX); } 
break; 
case UIGestureRecognizerStateEnded: NSLog(@"Pan Ended"); 
break;
 case UIGestureRecognizerStateCancelled: NSLog(@"Pan Cancelled");
 break; 
default: break;
 }
}

這個方法會在 Pan 手勢識別器發動時執行,暫時,它只簡單地打印 Pan 手勢的細節。
編譯并運行;用手指拖動 Cell ,你就會看到如下log記錄了移動信息:


如果你往初始點的右邊滑動,你會看到正數,往初始點的左邊滑動就會看到負數。這些數字將用于調整myContentView
的約束。
移動這些約束
從本質上將,你需要通過調整將 Cell 的contentView
釘住的左、右約束來推動myContentView
到左邊。右約束將會接受一個正值,而左約束將接受一個絕對值相等的負值。
舉例來說,如果myContentView
需要往左移動 5 點,那么 右約束將會接受的值是 5,而左約束將接受的值是 -5 。這將會將整個視圖往左邊滑動 5 點,而不會改變他的寬度。
聽起來蠻容易的——但還有許多移動相關的事情要注意。根據 Cell 是否已經打開和用戶 Pan 的方向,你要處理不同的一大把事情。
你同樣需要知道 Cell 最遠可以滑動多遠。你將通過計算被按鈕覆蓋的區域的寬度來確定這一點。最簡單的方法是用視圖的整個寬度減去最左邊的按鈕的最小 X 位置。
為了闡明,下面來個 sneak peek ,以明確的圖示表明你所要關注的方面:
Minimum x of button 2
Minimum x of button 2

幸好,感謝 CGRect
CGGeometry 函數 ,這些很容易被轉換為代碼:
添加如下方法到SwipeableCell.m

  • (CGFloat)buttonTotalWidth { return CGRectGetWidth(self.frame) - CGRectGetMinX(self.button2.frame);}

添加如下兩個骨架方法到SwipeableCell.m

- (void)resetConstraintContstantsToZero:(BOOL)animated notifyDelegateDidClose:(BOOL)endEditing
{ 
//TODO: Build.
}
- (void)setConstraintsToShowAllButtons:(BOOL)animated notifyDelegateDidOpen:(BOOL)notifyDelegate
{ 
//TODO: Build
}

這兩個骨架方法——一旦你填上血肉——將 snap 打開 Cell 并 snap 關閉 Cell。在你對 pan 手勢識別起添加更多處理后,你會回到這兩個方法。
替換panThisCell:
中的UIGestureRecognizerStateBegan
case 為下列代碼:

case UIGestureRecognizerStateBegan: self.panStartPoint = [recognizer translationInView:self.myContentView]; self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant;
 break;

你需要存儲 Cell 的初始位置(例如,約束值)以確定 Cell 是要打開還是關閉。
下一步你需要添加更多處理以應對 pan 手勢識別器的改變。還是在panThisCell:
里,修改UIGestureRecognizerStateChanged
case ,如下所示:

case UIGestureRecognizerStateChanged: { CGPoint currentPoint = [recognizer translationInView:self.myContentView];
 CGFloat deltaX = currentPoint.x - self.panStartPoint.x; 
BOOL panningLeft = NO; 
if (currentPoint.x < self.panStartPoint.x)
 {
 //1 panningLeft = YES; 
} if (self.startingRightLayoutConstraintConstant == 0) 
{ 
//2 //The cell was closed and is now opening
 if (!panningLeft) 
{ 
CGFloat constant = MAX(-deltaX, 0); 
//3
 if (constant == 0) 
{
 //4 [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:NO]; 
} else { 
//5 self.contentViewRightConstraint.constant = constant;
 } 
} else {
 CGFloat constant = MIN(-deltaX, [self buttonTotalWidth]); 
//6 if (constant == [self buttonTotalWidth]) { 
//7 [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:NO];
 } else { 
//8 self.contentViewRightConstraint.constant = constant; 
}
 }
 }

上面大部分代碼都在 Cell 默認的“關閉”狀態下 處理pan手勢識別器,下面是細節說明:
判斷 pan 手勢是往左還是往右。
如果右約束常量為 0 ,意味著myContentView
完全擋住contentView
。因此 Cell 在這里一定已經關閉,而用戶準備打開它。
這是處理用戶從做到右滑動以關閉 Cell 的 情況。除了說“你不能做那個”之外,你還要處理的情況是,當用戶滑動 Cell 只打開一點點,然后他們希望不必抬起他們的手指來結束此手勢就可以滑動它關閉。譯者注:就是說,打開一點點不會完全顯示出后面的按鈕,Cell 會自動關閉。
因為一個從左到右的滑動會導致deltaX
為正值,而從右到左的滑動回到導致deltaX
為負值,你必須根據負的deltaX
計算出常量以設置到右約束上。因為是從它與0中找出最大值,所以視圖不可能往右邊走多遠。

如果常量為 0,Cell 就是完全關閉的。調用處理關閉的方法——它(如你回憶起的)在目前還什么也不會做。
如果常量為不為 0,那么你就將其設置到右手邊的約束上。
否者,如果是從右往做滑動,那么用戶試圖打開 Cell 。這在個情況里,常量將會小于負deltaX
或兩個按鈕的寬度之和。
如果目標常量是兩個按鈕的寬度之和,那么 Cell 就被打開至捕捉點(catch point),你應該調用方法來處理這個打開狀態。
如果常量不是兩個按鈕的寬度之和,那就將其設置到右約束上。

喲!處理得真不少… 而這個只是處理了 Cell 已經關閉得情況。你現在還要編寫代碼處理當手勢開始時 Cell 就已經部分開啟的情況。
就在剛在添加的代碼之下添加如下代碼:

else { 
//The cell was at least partially open. CGFloat adjustment = self.startingRightLayoutConstraintConstant - deltaX; 
//1 if (!panningLeft) { CGFloat constant = MAX(adjustment, 0); 
//2 if (constant == 0) { 
//3 [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:NO];
 } else { 
//4 self.contentViewRightConstraint.constant = constant; 
} 
} else { 
CGFloat constant = MIN(adjustment, [self buttonTotalWidth]); 
//5 if (constant == [self buttonTotalWidth]) { 
//6 [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:NO]; 
} else {
 //7 self.contentViewRightConstraint.constant = constant; 
}
 }
 } self.contentViewLeftConstraint.constant = -self.contentViewRightConstraint.constant; //8
} break;

這是 if 語句的后半段。因此它用于處理 Cell 原本就打開的情況。
再一次,下面說明你要處理的幾個情況:
在這個情況下,你只是接受deltaX
,你就用 rightLayoutConstraint 的原始位置減去deltaX
以便得知要做多少調整。
如果用戶從做往右滑動,你必須接受 adjustment 與 0 中的較大值。如果 adjustment 已變成負值,那就說明用戶已經把 Cell 滑到邊界之外了,Cell 就關閉了,這就讓你進入下一個情況。
如果常量為 0,那么 Cell 已經關閉,你就調用處理其關閉的方法。
否則,將常量設置到右約束上。
對于從右到左的滑動,你將接受 adjustment 與 兩個按鈕寬度之和 中的較小值。如果 adjustment 更大,那就表示用戶已經滑出超過捕捉點了。
如果常量剛好等于兩個按鈕寬度之和,那么 Cell 就打開了,你必須調用處理 Cell 打開的方法。
否則,將常量設置到右約束上。
現在,你已經處理完“Cell關閉”和“Cell部分開啟”的情況,在這兩個情況里,你都可對左約束做同樣的事情:將其設置為右約束常量的負值。這就保證了myContentView
的寬度一直保持不變。

編譯并運行;現在你可以來回滑動 Cell !它不是非常流暢,而且它在你希望的地方之前的一點就停下了。這是因為你還沒有真正實現那兩個用于處理打開和關閉 Cell 的方法。
Note:你可以也注意到,Table View 本身已經不會 scroll 了。不要擔心,一旦你正確處理好 Cell 的滑動,你就能修復它。

Snap!
接下來,你要讓 Cell Snao 進入合適的位置。你會注意到,如果你放手 Cell 會停到合適的位置。
在你進入方法開始處理之前,你需要一個單獨的生成動畫的方法。
打開SwipeableCell.m
并添加如下方法:
```objc
- (void)updateConstraintsIfNeeded:(BOOL)animated completion:(void (^)(BOOL finished))completion {
 float duration = 0;
 if (animated)
 { 
duration = 0.1; 
} 
[UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ 
[self layoutIfNeeded];
 } completion:completion];
}

Note:0.1 秒的間隔和 ease-out curve 動畫都是我從實踐和錯誤中總結出來的。如果你找到其他更讓你看著愉悅的速度或動畫類型,可以自由修改它們。

接下來,你將填充那兩個處理打開和關閉的骨架方法。記得在 Apple 的原始實現里,因為使用了UIScrollView
子類作為最底層的試圖,所以會有一點彈性。
要讓事情看起來正確,你將在 Cell 撞到邊界時給它一點彈性。你同樣要確保contentView
和myContentView
有同樣的backgroundColor
以造成彈性非常順滑的錯覺。
添加如下常量到SwipeableCell.m
頂部,就在 import 語句之下:
static CGFloat const kBounceValue = 20.0f;

這個常量存儲了彈性值,將用于你的彈性動畫中。
如下更新setConstraintsToShowAllButtons:notifyDelegateDidOpen:

- (void)setConstraintsToShowAllButtons:(BOOL)animated notifyDelegateDidOpen:(BOOL)notifyDelegate
 { 
//TODO: Notify delegate. 
//1 if (self.startingRightLayoutConstraintConstant == [self buttonTotalWidth] && self.contentViewRightConstraint.constant == [self buttonTotalWidth])
 { 
return; 
} 
//2 self.contentViewLeftConstraint.constant = -[self buttonTotalWidth] - kBounceValue; 
self.contentViewRightConstraint.constant = [self buttonTotalWidth] + kBounceValue; 
[self updateConstraintsIfNeeded:animated completion:^(BOOL finished)
 { 
//3 self.contentViewLeftConstraint.constant = -[self buttonTotalWidth]; 
self.contentViewRightConstraint.constant = [self buttonTotalWidth];
 [self updateConstraintsIfNeeded:animated completion:^(BOOL finished) 
{ 
//4 self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; 
}];
 }];
}

這個方法在 Cell 完全打開時執行。下面解釋發生了什么:
如果 Cell 已經開啟,約束已經到達完全開啟值,那就返回——否則彈性操作將會一次又一次的發生,就像你繼續滑動超過總按鈕寬度那樣。
你初始設置約束值為按鈕總寬度和彈性值的結合值,它將 Cell 拉到左邊一點點,這樣才好 snap 回來。然后你就調用動畫來實現這個設置。
當第一個動畫完成,發動第二個動畫,它將 Cell 正好打開在從按鈕寬度的位置。
當第二個動畫完成,重設起始約束否則你會看到多次彈跳。

如下更新resetConstraintContstantsToZero:notifyDelegateDidClose:

- (void)resetConstraintContstantsToZero:(BOOL)animated notifyDelegateDidClose:(BOOL)notifyDelegate { 
//TODO: Notify delegate. if (self.startingRightLayoutConstraintConstant == 0 && self.contentViewRightConstraint.constant == 0) { 
//Already all the way closed, no bounce necessary return;
 }
 self.contentViewRightConstraint.constant = -kBounceValue; self.contentViewLeftConstraint.constant = kBounceValue; 
[self updateConstraintsIfNeeded:animated completion:^(BOOL finished) { self.contentViewRightConstraint.constant = 0; self.contentViewLeftConstraint.constant = 0;
 [self updateConstraintsIfNeeded:animated completion:^(BOOL finished) { self.startingRightLayoutConstraintConstant = self.contentViewRightConstraint.constant; 
}];
 }];
}

如你所見,這類似于setConstraintsToShowAllButtons:notifyDelegateDidOpen:
,但它的邏輯是關閉 Cell 而不是打開。
編譯并運行;隨意滑動 Cell 到它的捕捉點,你就會在放手時看到彈性行為。
然而,如果你在 Cell 完全開啟或完全關閉之前將釋放手指,它將會卡在中間。Whoops! 你還沒有處理觸摸結束或被取消的情況。
找到panThisCell:
用下列代碼替換UIGestureRecognizerStateEnded

case :
case UIGestureRecognizerStateEnded: 
if (self.startingRightLayoutConstraintConstant == 0) { 
//1 //Cell was opening 
CGFloat halfOfButtonOne = CGRectGetWidth(self.button1.frame) / 2; 
//2 
if (self.contentViewRightConstraint.constant >= halfOfButtonOne) { 
//3
 //Open all the way
 [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:YES];
 } else {
//Re-close [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:YES]; 
} 
} else { 
//Cell was closing CGFloat buttonOnePlusHalfOfButton2 = CGRectGetWidth(self.button1.frame) + (CGRectGetWidth(self.button2.frame) / 2); 
//4 
if (self.contentViewRightConstraint.constant >= buttonOnePlusHalfOfButton2) { 
//5 //Re-open all the way 
[self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:YES];
 } else { 
//Close
 [self resetConstraintContstantsToZero:YES notifyDelegateDidClose:YES]; 
} 
} 
break;

在這里,你根據 Cell 是否已經打開或關閉以及手勢結束時 Cell 的位置在執行不同的處理。具體來講:
通過檢查開始右約束值,得知手勢開始時 Cell 是否已經打開或關閉。
如果 Cell 是關閉的,那你就正在打開它,你要讓 Cell 自動滑動到打開,至少需要先滑動右邊按鈕(self.button1)一半的寬度。因為你在測量約束的常量,你只需要計算實際的按鈕寬度,而不是它在視圖中的 X 位置。
接下來,測試約束是否已被打開至超過你希望讓 Cell 自動打開的點。如果已經超過,那就自動打開 Cell。如果沒有,那就自動關閉 Cell。
此處表示 Cell 從打開的狀態開始,你需要那個能讓 Cell 自動 snap 關閉的點,至少需要超過最左邊按鈕的一半。 將不是最左邊的按鈕的那些按鈕的寬度加起來,在這個情況里,只有 self.button1 而已,再加上最左邊按鈕的一半——也就是 self.button2 —— 以便找到需要的檢查點。
測試約束是否以及超過這個點,即你希望 Cell 自動關閉的那個點。如果超過了,關閉 Cell。如果沒有,那就重新打開 Cell。

最后,你還要處理一下手勢被取消的情況。用如下代碼替換UIGestureRecognizerStateCancelled

case :
case UIGestureRecognizerStateCancelled:
 if (self.startingRightLayoutConstraintConstant == 0) {
 //Cell was closed - reset everything to 0 
[self resetConstraintContstantsToZero:YES notifyDelegateDidClose:YES]; 
} else { 
//Cell was open - reset to the open state
 [self setConstraintsToShowAllButtons:YES notifyDelegateDidOpen:YES];
 } 
break;

這個處理相當直白;由于用戶取消了觸摸,表示他們不想改變 Cell 當前的狀態,所以你只需要將一切都設置為它們原本的樣子即可。
編譯并運行;滑動 Cell ,你會看到 Cell Snap 到打開或關閉,而不論你的手指再哪里,如下所示:


更好地處理 Table View
在最終完成前,只有少數幾步了!
首先,你的UIPanGestureRecognizer
有時候會影響UITableView
的 Scroll 操作。由于你已經設置了 Cell 的 Pan 手勢識別器 的UIGestureRecognizerDelegate
,你只需要實現一個(有些滑稽且冗長命名的) delegate 方法即可將一切恢復正常。
添加如下方法到SwipeableCell.m

#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{ 
return YES;
}

這個方法告知各手勢識別器,它們可以同時工作。
編譯并運行;打開第一個 Cell 然后你依然可以 Scroll tableView 。
還有一個 Cell 重用引起的小問題:各個行不記得它們的狀態,看起來是因為 Cell 重用了它們的視圖的 開啟/關閉 狀態,然后它們的視圖就不能正確反應用戶的操作了。要查看這一情況,打開一個 Cell ,然后將 Table Scroll 一點點。你就會注意每次都有一個 Cell 始終保持打開狀態,但每次都不同。
要修復這個問題頭一半,添加如下方法到SwipeableCell.m

- (void)prepareForReuse {
 [super prepareForReuse]; 
[self resetConstraintContstantsToZero:NO notifyDelegateDidClose:NO];
}

這個方法確保 Cell 在其回收重利用時再次關閉。
要解決這個問題的后一半,你將添加一個公共方法給 Cell 以促使其打開。然后你會添加一些 delegate 方法以允許MasterViewController
去管理那個 Cell 是打開的。
打開SwipeableCell.h
。在SwipeableCellDelegate
協議的申明里,添加如下兩個新的方法,就在已存在的那兩個下面:

- (void)cellDidOpen:(UITableViewCell *)cell;
- (void)cellDidClose:(UITableViewCell *)cell;

這些方法將會通知 delegate —— 在你的情況里,就是 Master View Controller —— 某個 Cell 被打開或關閉了。
添加如下公共方法申明到SwipeableCell
的@interface
里:

- (void)openCell;

接下來,打開SwipeableCell.m
并添加openCell
的實現:

- (void)openCell { 
[self setConstraintsToShowAllButtons:NO notifyDelegateDidOpen:NO];
}

這個方法允許 delegate 修改 Cell 的狀態。
依然在用一個文件里,找到resetConstraintsToZero:notifyDelegateDidOpen:
并替換其中TODO
為如下代碼:

if (notifyDelegate) {
 [self.delegate cellDidClose:self];
}

接下來,找到setConstraintsToShowAllButtons:notifyDelegateDidClose:
并替換其中TODO
為如下代碼:

if (notifyDelegate) { [self.delegate cellDidOpen:self];}

這兩個修改會在一個 swipe 手勢完成時通知 delegate ,無論 Cell 是否以及打開或關閉。
添加如下屬性申明到MasterViewController.m
頂部的類擴展里:

@property (nonatomic, strong) NSMutableSet *cellsCurrentlyEditing;

它將存儲當前已被打開的 Cell 的列表。
添加如下代碼到viewDidLoad
的最后:

self.cellsCurrentlyEditing = [NSMutableSet new];

這個初始化保證了之后你可以正常使用數組。
現在在同一個文件里添加如下方法實現:

- (void)cellDidOpen:(UITableViewCell *)cell {
 NSIndexPath *currentEditingIndexPath = [self.tableView indexPathForCell:cell];
 [self.cellsCurrentlyEditing addObject:currentEditingIndexPath];
}
- (void)cellDidClose:(UITableViewCell *)cell { 
[self.cellsCurrentlyEditing removeObject:[self.tableView indexPathForCell:cell]];
}

注意到你添加的時 Index Path 而不是 Cell 本身到列表里。如果你直接添加 Cell 對象,那么之后你就會看到同樣的問題,在 Cell 被回收后再次被打開。用了這個方法,你就可以使用合適 的 Index Path 來打開 Cell 了。
最后,添加下面幾行到tableView:cellForRowAtIndexPath:
,就在 return 語句之前:

if ([self.cellsCurrentlyEditing containsObject:indexPath]) { [cell openCell];}

如果當前的 Cell 的 Index Path 在列表里,它就會將其設置為打開。
編譯并運行;全都搞定了!你現在有了一個能夠 Scroll 的 Table View,還能處理 Cell 的打開和關閉狀態,并在 Cell 的任意被點擊時,使用 delegate 方法來加載任何任務。
下一步怎么走?
譯者注:吐血,終于翻譯到這一句了!
最終的項目可以在此處下載。我還會繼續我在此所開發的東西,并組成一個開源項目,以便讓事情更有靈活性——在準備好推出時,我會在論壇里貼個鏈接。
任何時候,如你在不知道他們如何做到的情況下復制出 Apple 所做的某些效果,你都會發現有許多許多的方式去做到這樣的效果。所以這里的方案只是這個效果的實現辦法之一;然而,它是我所發現的唯一一個不需要處理嵌套 Scroll View 的辦法,產生的手勢識別沖突也可以非常簡單地解決! :]
寫這篇文章時有一些很有用的資源,但文章里最終使用了非常不同的辦法。這些資源是 Ash Furrow 的文章 能讓一切都工作起來,以及 Massimiliano Bigatti’s BMXSwipeableCell 項目,它現實通過UIScrollView
這條路可以挖到多深。
如果你有任何建議、問題或相關的代碼,請在評論區講出來吧!
譯者:@nixzhu
轉載自:
https://github.com/nixzhu/dev-blog

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容

  • 本文翻譯自 http://www.raywenderlich.com/62435/make-swipeable-t...
    Obsession丶執閱讀 2,435評論 0 3
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,147評論 4 61
  • 介紹: 使用Tensorflow做線性回歸 codes: References [1] TensorFlow入門一
    BillLeee閱讀 1,049評論 0 1
  • 暮春四月,和孩子們一起做了戚風蛋糕。陽光透過窗戶灑在窗臺上,耳邊縈繞著孩子們嘰嘰喳喳的細碎聲音,教室的小黑板上寫著...
    蘅丁閱讀 355評論 0 1
  • 小r吃飯的時候問:媽媽,你小的時候你的爸爸媽媽有沒有考慮過你的未來? 我答:沒有哪個父母不希望孩子有個好的未來。 ...
    小r念經閱讀 206評論 0 1