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


制作一個可以滑動操作的 Table View Cell

本文翻譯自 http://www.raywenderlich.com/62435/make-swipeable-table-view-cell-actions-without-going-nuts-scroll-views

原作者:Ellen Shapiro

Apple 通過 iOS 7 的郵件(Mail)應用介紹了一種新的用戶界面方案——向左滑動以顯示一個有着多個操作的菜單。本教程將會向你展示如何制作一個這樣的 Table View Cell,而不用因嵌套的 Scroll View 陷入困境。如果你還不知道一個可滑動的 Table View Cell 意味着什么,那么看看 Apple 的郵件應用:

Multiple Options

可能你會想,既然 Apple 展示了這種方案,那它應該已將其開放給開發者使用了。畢竟,這能有多難呢?但不幸的是,他們只讓開發者使用 Delete 按鈕——至少暫時是這樣。如果你要添加其他的按鈕,或者改變 Delete 按鈕上的文字或顏色,那你就必須自己去實現。

譯者注:其實文字是可以修改的,但是顏色真的不行!

在本教程中,你將先學習如何實現簡單的滑動以刪除操作(swipe-to-delete action),之后我們再實現滑動以執行操作(swipe-to-perform-actions)。這會要求你深入研究 iOS 7 UITableViewCell 的結構,以便復制出我們需要的行為。你將使用到一些我個人非常喜歡的技術用於檢查視圖層次結構:為視圖上色以及使用 recursiveDescription 方法來打印出視圖層次結構。

開始

打開 Xcode,去往 File\New\Project… 並選擇 Master-Detail Application ,如下所示:

Master-Detail Application

將項目命名為 SwipeableCell 並填好你自己的 Organization Name 和 Company Identifier 。選擇 iPhone 為目標設備並確保 Use Core Data 沒有被選中,如所示:

Set Up Project

對於這樣的概念項目的證明,你最好保證數據模型盡量簡單。

打開 MasterViewController.m 並找到 viewDidLoad 。將默認設置 Navigation Bar Items 的方法替換為如下實現:

- (void)viewDidLoad {
  [super viewDidLoad]; //1 _objects = [NSMutableArray array]; //2 NSInteger numberOfItems = 30; for (NSInteger i = 1; i <= numberOfItems; i++) { NSString *item = [NSString stringWithFormat:@"Item #%d", i]; [_objects addObject:item]; } }

這個方法做了兩件事:

  1. 這一行創建並初始化一個 NSMutableArray 實例,以后你就可以添加對象到它里面了。如果你的數組沒有被初始化,那不論你調用 addObject: 多少次,你的那些對象都不會被存儲起來。譯者注:讀者還是盡量用 Lazy Load 來實現吧!
  2. 這個循環添加了一些字符串到 _objects 數組,應用運行時,這些字符串將用於顯示在 Table View 里。你可以修改 numberOfItems 的值,以存儲適合你的更多或更少的字符串。

下一步。找到 tableView:cellForRowAtIndexPath: 並替換其實現為:

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

原本 tableView:cellForRowAtIndexPath: 的樣板使用日期字符串作為簡單數據;而你的實現使用你的數組里的 NSString 對象去填充 UITableViewCelltextLabel

往下滾動到 tableView:canEditRowAtIndexPath: ;你會看到這個方法已經設置為返回 YES ,也就是說, Table View 的每一行都支持編輯。

就在這個方法下邊,tableView:commitEditingStyle:forRowAtIndexPath: 處理對象的刪除。然而,因為你還不能添加任何東西到這個應用里,那就先稍微修改它一下以適應你的需求。

用下面的代碼替換 tableView:commitEditingStyle:forRowAtIndexPath:

- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { if (editingStyle == UITableViewCellEditingStyleDelete) { [_objects removeObjectAtIndex:indexPath.row]; [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade]; } else { NSLog(@"Unhandled editing style! %d", editingStyle); } }

當用戶刪除某行時,你就用傳入的 Index 將那一行的對象從后面的數組中移除,並告知 Table View 它需要移除同一個 indexPath 所表示的那一行 Cell,一確保模型和視圖的匹配。

你的應用只允許“delete”這一種編輯方式,但在 else 分支里用 log 記錄你沒有在處理什么也不錯。如果有某個詭異的事情發生,你將會在控制台得到一個提示消息,這比方法靜悄悄地返回要好。

最后,還有一些清理要做。依然在 MasterViewController.m 里,刪除 insertNewObject 。這個方法現在不正確,因為插入已經不再被支持了。

編譯並運行應用;你會看到一個簡單列表,如下所示:

Closed Easy

滑動某一行到左邊,你就會看到一個 “Delete” 按鈕,如下所示:

Easy delete button

喔~——這很簡單。但現在是時候弄臟雙手,深挖進視圖層次結構,看看里面到底發了什么。

深入視圖層次結構(View Hierarchy)

首先:你要找到 Delete 按鈕在視圖層次結構里的位置,然后你才能決定是否可以將其用於你自定義的 Cell 。

最容易做到這一點的方式是將 View 的各個部分分別染色,以便清楚地看到它們地位置和范圍。

繼續在 MasterViewController.m 里工作,添加如下兩行到 tableView:cellForRowAtIndexPath: 里,就在最后的 return 語句之上:

cell.backgroundColor = [UIColor purpleColor];
cell.contentView.backgroundColor = [UIColor blueColor];

這些顏色足夠讓我們看清這些視圖在 Cell 中的位置。

再次編譯並運行,你會看到着色后的元素,如下面的截圖所示:

Colored Cells

你會清楚地看到藍色的 contentView 停止在 Accessory Indicator 之前,但整個 Cell 自身以紫色高亮,填滿了到 UITableView 的邊緣。

往左邊拖動 Cell ,你會看到類似下面的的界面:

Start to drag cell

看起來 Delete 按鈕實際上隱藏在 Cell 的下面。唯一能 100% 確保的方式是在視圖層次結構中再挖深一點。

為了輔助你的視圖考古,你可以用一個只能用於調試的方法,叫做 recursiveDescription ,它能打印出任意視圖的視圖層次結構。注意這是一個私有方法, `不應該被包含在任何會被放到 App Store 的代碼里`,但它對與視圖層次結構實在非常有用。

Note:目前有兩個付費應用能讓你用可視化的方式檢查視圖層次結構:RevealSpark Inspector。另外,還有一個開源項目也可以很好地做到這件事:iOS-Hierarchy-Viewer
這些應用的價格和質量各有不同,但它們全都要求在你的項目中添加一個庫以便支持它們的產品。但如果你不想在項目里安裝任何庫的話,那 recursiveDescription 絕對是得到這些信息的最好的方式。

添加如下打印語句到 tableView:cellForRowAtIndexPath: 中,放在 return 語句之前:

#ifdef DEBUG
  NSLog(@"Cell recursive description:\n\n%@\n\n", [cell performSelector:@selector(recursiveDescription)]); #endif

一旦添加了這一行代碼,你就會得到一個警告,也就是 recursiveDescription 未被申明;因為它是一個私有方法,編譯器並不知道它的存在,ifdef / endif 包裝器將會額外確保這行代碼不會被編譯進最終的 release 版里。

編譯並運行;你會看到控制台全都是 log 語句,類似下面這樣:

2014-02-01 09:56:15.587 SwipeableCell[46989:70b] Cell recursive description: <UITableViewCell: 0x8e25350; frame = (0 396; 320 44); text = 'Item #10'; autoresize = W; layer = <CALayer: 0x8e254e0>> | <UITableViewCellScrollView: 0x8e636e0; frame = (0 0; 320 44); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x8e1d7d0>; layer = <CALayer: 0x8e1d960>; contentOffset: {0, 0}> | | <UIButton: 0x8e22a70; frame = (302 16; 8 12.5); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x8e22d10>> | | | <UIImageView: 0x8e20ac0; frame = (0 0; 8 12.5); clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x8e5efc0>> | | <UITableViewCellContentView: 0x8e23aa0; frame = (0 0; 287 44); opaque = NO; gestureRecognizers = <NSArray: 0x8e29c20>; layer = <CALayer: 0x8e62220>> | | | <UILabel: 0x8e23d70; frame = (15 0; 270 43); text = 'Item #10'; clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x8e617d0>>

又要哇~——信息真不少。你所看到的是遞歸的描述log語句,在每次 Cell 被創建或回收時都會打印。所以你會看到好幾個這種消息,因為初始的屏幕上有好幾個 Cell 。recursiveDescription 會走遍特定視圖的每個子視圖,輸出子視圖的描述,並按照視圖層次結構排列。它會遞歸地做這件事,所以對於每個子視圖,它也會再去尋找它們的子視圖。

雖然信息很多,但它是根據視圖層次結構在每個視圖上都調用了 recursiveDescription 。因此如果你單獨打印每個子視圖的描述,你會看到同樣的信息,但這個方法在子視圖的輸出前加了一個 | 符號和一些空格,以便反映出視圖的結構。

為了更加易讀,下面光拿出類名和 Frame 來看:

<UITableViewCell; frame = (0 396; 320 44);> //1 | <UITableViewCellScrollView; frame = (0 0; 320 44); > //2 | | <UIButton; frame = (302 16; 8 12.5)> //3 | | | <UIImageView; frame = (0 0; 8 12.5);> //4 | | <UITableViewCellContentView; frame = (0 0; 287 44);> //5 | | | <UILabel; frame = (15 0; 270 43);> //6

目前 Cell 里有六個視圖:

  1. UITableViewCell 這是最高層的視圖。 Frame 顯示它有 320 點寬和 44 點高——寬度和高度都喝預期的一致,因為它和屏幕一樣寬,而高度就是 44 點。
  2. UITableViewCellScrollView 雖然你不能直接使用這個私有類,但它的名字很好地暗示了它的功能。它的 Size 和 Cell 的一樣。據此我們推斷它的作用是在 Delete 按鈕之上裝載滑動出來的內容。
  3. UIButton 它在 Cell 的最右邊,就是 Disclosure Indicator 按鈕。注意這不是 Delete 按鈕。
  4. UIImageView 是上面 UIButton 的子視圖,裝載着 Disclosure Indicator 的圖像。
  5. UITableViewCellContentView 另外一個私有類,它包含 Cell 的內容。這個類對於開發者來說就是 UITableViewCellcontentView 屬性。但它只作為一個 UIView 來暴露在外,這就意味着你只在其上調用使用公開的 UIView 方法;而不能使用任何與這個類關聯的任何私有方法。
  6. UILabel 顯示 “Item #” 文本。

你會注意到 Delete 按鈕並沒有顯示在上面的視圖層次結構排列里。嗯~。可能它只在滑動開始時才被添加到層次結構里。對於優化來說這樣做很合理。在不需要 Delete 按鈕的時候實在沒有必要將其放在那里。要驗證這個猜想,就添加如下代碼到 tableView:commitEditingStyle:forRowAtIndexPath: ,就在處理 delete editing style 的 if 語句中:

#ifdef DEBUG
    NSLog(@"Cell recursive description:\n\n%@\n\n", [[tableView cellForRowAtIndexPath:indexPath] performSelector:@selector(recursiveDescription)]); #endif

這和之前添加的一樣,除了這次我們需要滑動 Cell 以便調用 tableView:commitEditingStyle:forRowAtIndexPath:

譯者注:上面這一段的原文是“This is the same as before, except this time we need to grab the cell from the table view using cellForRowAtIndexPath:.”,按照我的理解,滑動應該調用 tableView:commitEditingStyle:forRowAtIndexPath: ,這樣才能執行我們新添加的語句。

編譯並運行;滑動第一個 Cell,並點擊 Delete。然后看看控制台的輸出,找到最后一個遞歸描述,即第一個 Cell 的視圖層次結構。你知道它是第一個 Cell ,因為它的 text 屬性被設置為 Item #1 。你應該看到類型下面的打印:

<UITableViewCell: 0xa816140; frame = (0 0; 320 44); text = 'Item #1'; autoresize = W; gestureRecognizers = <NSArray: 0x8b635d0>; layer = <CALayer: 0xa816310>> | <UITableViewCellScrollView: 0xa817070; frame = (0 0; 320 44); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0xa8175e0>; layer = <CALayer: 0xa817260>; contentOffset: {82, 0}> | | <UITableViewCellDeleteConfirmationView: 0x8b62d40; frame = (320 0; 82 44); layer = <CALayer: 0x8b62e20>> | | | <UITableViewCellDeleteConfirmationButton: 0x8b61b60; frame = (0 0; 82 43.5); opaque = NO; autoresize = LM; layer = <CALayer: 0x8b61c90>> | | | | <UILabel: 0x8b61e60; frame = (15 11; 52 22); text = 'Delete'; clipsToBounds = YES; userInteractionEnabled = NO; layer = <CALayer: 0x8b61f00>> | | <UITableViewCellContentView: 0xa816500; frame = (0 0; 287 43.5); opaque = NO; gestureRecognizers = <NSArray: 0xa817d40>; layer = <CALayer: 0xa8165b0>> | | | <UILabel: 0xa8167a0; frame = (15 0; 270 43.5); text = 'Item #1'; clipsToBounds = YES; layer = <CALayer: 0xa816840>> | | <_UITableViewCellSeparatorView: 0x8a2b6e0; frame = (97 43.5; 305 0.5); layer = <CALayer: 0x8a2b790>> | | <UIButton: 0xa8166a0; frame = (297 16; 8 12.5); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0xa8092b0>> | | | <UIImageView: 0xa812d50; frame = (0 0; 8 12.5); clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0xa8119c0>>

喔~ 看到 Delete 按鈕了!在 Content View 下面, 有一個視圖的類名為 UITableViewCellDeleteConfirmationView 。所那里就是 Delete 按鈕被放置的位置。注意到它的 Frame 的 x 值是 320。這就意味着它被放置在 Scroll View 的最遠端。但這個 Delete 按鈕在你滑動時並沒有移動。所以 Apple 必須在每次 Scroll View 滾動的同時移動這個 Delete 按鈕。雖然這不是特別重要,但它很有趣!

現在回到 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 里所有的紫色。

Visible Scrollview

回想剛才 recursiveDescription 輸出的 log, UITableViewCellScrollView 的 Frame 和 Cell 本身的 Size 是一致的。

但是,這個視圖到底有什么用?繼續拖動 Cell 到左邊,你就會看到 Scroll View 在你拖動 Cell 並 釋放時提供了 “彈性(springy)”行為,如下所示:

swipeable-demo

在你創建你自己的自定義 UITableViewCell 子類之前,還有一件事要注意,它出至 UITableViewCell Class Reference

如果你想超越預定義樣式,你可以添加子視圖到 Cell 的 contentView 上。在添加子視圖時,你自己要負責這些視圖的位置以及設置它們的內容。

直白的說,就是,任何對 UITableViewCell 的自定義操作只能在 contentView 中進行。你不能將自己的視圖加在 Cell 下面——而必須將它們加在 Cell 的 contentView 上。

這就意味着你將找出你自己的解決方案以便添加自定義按鈕。但不要害怕,你可以很容易地復制出 Apple 所使用的方案。

可滑動 Table View Cell 的組成列表

這對你來說是什么意思?到了這里,你就有了一個組成列表來制造出一個 UITableViewCell 子類,以便放上你自定義的按鈕。

我們從 View Stack 的最底部開始列出條目,你的列表如下:

  1. contentView 是你的基礎視圖,因為你只能將子視圖添加到它上面。
  2. 在用戶滑動后,任何你想顯示的 UIButon
  3. 一個位於按鈕之上的容器視圖來裝載你所有的內容。
  4. 你可以使用一個 UIScrollView 來作為你的容器視圖,就像 Apple 使用的,或者使用一個 UIPanGestureRecognizer 。這同樣能夠處理滑動去顯示/隱藏按鈕。你將在項目中采用后一種方案。
  5. 最后,一個裝有實際內容的視圖。

還有一個可能不那么明顯的成分:你必須確保系統提供的 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 的子類 ,如下所示:

Creating custom cell

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 原型,如下所示:

Select Table View Cell

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

Change Custom Class

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

New Name and Outlets

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

Reset Cell Items

然后,拖兩個按鈕到 Cell 的 Content View 里。在視圖的 Attributes Inspector 區設置每個按鈕的背景色為比較鮮艷的顏色,並設置每個按鈕的文字顏色為比較易讀的顏色,這樣你就可以清楚地看到按鈕。

將第一個按鈕放在右邊,和 contentView 的上下邊緣接觸。將第二個按鈕放在第一個按鈕的左邊緣處,也和 contentView 的上下邊緣接觸。當你做好后,Cell 看起來如下,可能顏色少有差異:

Buttons Added to Prototype Cell

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

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 拖動到你的按鈕,如下:

swipeable-buttonClicked

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

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

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

ALL THE BUTTONS!

添加一個 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 { NSLog(@"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]; }

在上面的代碼里,你執行了四個操作:

  1. 從 Storyboard 里取出 Detail View Controller 並設置其 title 和 detailItem 。
  2. 設置一個 UINavigationController 作為包含 Detail View Controller 的容器,並給你放置 close 按鈕的地方。
  3. 添加 close 按鈕,關聯 MasterViewController 里的一個 Action。
  4. 設置這個 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 IDDetailViewController 以匹配類名,如下所示:

Add Storyboard Identifier

如果你忘了這一步, instantiateViewControllerWithIdentifier 將會因為不合法的參數而 Crash,其異常表示具有這個標識符的 View Controller 並不存在。

編譯並運行;點擊某個 Cell 中的按鈕,然后看着 Modal View Controller 出現,如下面的截圖所示:

View Launched from Delegate

添加頂層視圖並添加滑動 Action

現在你到了視圖工作的后段部分,是時候讓頂層部分啟動並運行起來了。

打開 Main.storyboard 並拖一個 UIViewSwipeableTableCell 上,這個視圖將占據整個 Cell 的高和寬,並覆蓋按鈕,所以在Swipe手勢能工作之前,你不會再看到它們了。

如果你要精確地控制,打開 Size Inspector 並設置這個視圖地寬和高,分別為 320 和 43:

swipeable-320-43

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

swipeable-constraint

連接好這個視圖的 Outlet,按照之前介紹的步驟:在左邊的導航器里右鍵單擊這個可滑動 Cell 並拖動 myContentView 到這個新的視圖上。

下一步,拖動一個 UILabel 到視圖里;設置其距離左邊 20 點,並設置其垂直劇中。再將其連接到 myTextLabel Outlet 上。

編譯並運行;你的 Cell 看起來有正常了:

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 顯示如下:

Longer Item Titles displayed in custom label

手勢識別——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 之間的約束,如下所示:

Highlighting Constraints

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

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記錄了移動信息:

Pan Logs

如果你往初始點的右邊滑動,你會看到正數,往初始點的左邊滑動就會看到負數。這些數字將用於調整 myContentView 的約束。

移動這些約束

從本質上將,你需要通過調整將 Cell 的 contentView 釘住的左、右約束來推動 myContentView 到左邊。右約束將會接受一個正值,而左約束將接受一個絕對值相等的負值。

舉例來說,如果 myContentView 需要往左移動 5 點,那么 右約束將會接受的值是 5,而左約束將接受的值是 -5 。這將會將整個視圖往左邊滑動 5 點,而不會改變他的寬度。

聽起來蠻容易的——但還有許多移動相關的事情要注意。根據 Cell 是否已經打開和用戶 Pan 的方向,你要處理不同的一大把事情。

你同樣需要知道 Cell 最遠可以滑動多遠。你將通過計算被按鈕覆蓋的區域的寬度來確定這一點。最簡單的方法是用視圖的整個寬度減去最左邊的按鈕的最小 X 位置。

為了闡明,下面來個 sneak peek ,以明確的圖示表明你所要關注的方面:

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手勢識別器,下面是細節說明:

  1. 判斷 pan 手勢是往左還是往右。
  2. 如果右約束常量為 0 ,意味着 myContentView 完全擋住 contentView 。因此 Cell 在這里一定已經關閉,而用戶准備打開它。
  3. 這是處理用戶從做到右滑動以關閉 Cell 的 情況。除了說“你不能做那個”之外,你還要處理的情況是,當用戶滑動 Cell 只打開一點點,然后他們希望不必抬起他們的手指來結束此手勢就可以滑動它關閉。譯者注:就是說,打開一點點不會完全顯示出后面的按鈕,Cell 會自動關閉。

    因為一個從左到右的滑動會導致 deltaX 為正值,而從右到左的滑動回到導致 deltaX 為負值,你必須根據負的 deltaX 計算出常量以設置到右約束上。因為是從它與0中找出最大值,所以視圖不可能往右邊走多遠。

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

喲!處理得真不少… 而這個只是處理了 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 原本就打開的情況。

再一次,下面說明你要處理的幾個情況:

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

編譯並運行;現在你可以來回滑動 Cell !它不是非常流暢,而且它在你希望的地方之前的一點就停下了。這是因為你還沒有真正實現那兩個用於處理打開和關閉 Cell 的方法。

Note:你可以也注意到,Table View 本身已經不會 scroll 了。不要擔心,一旦你正確處理好 Cell 的滑動,你就能修復它。

Snap!

接下來,你要讓 Cell Snao 進入合適的位置。你會注意到,如果你放手 Cell 會停到合適的位置。

在你進入方法開始處理之前,你需要一個單獨的生成動畫的方法。

打開 SwipeableCell.m 並添加如下方法:

- (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 撞到邊界時給它一點彈性。你同樣要確保 contentViewmyContentView 有同樣的 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 完全打開時執行。下面解釋發生了什么:

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

如下更新 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 的位置在執行不同的處理。具體來講:

  1. 通過檢查開始右約束值,得知手勢開始時 Cell 是否已經打開或關閉。
  2. 如果 Cell 是關閉的,那你就正在打開它,你要讓 Cell 自動滑動到打開,至少需要先滑動右邊按鈕(self.button1)一半的寬度。因為你在測量約束的常量,你只需要計算實際的按鈕寬度,而不是它在視圖中的 X 位置。
  3. 接下來,測試約束是否已被打開至超過你希望讓 Cell 自動打開的點。如果已經超過,那就自動打開 Cell。如果沒有,那就自動關閉 Cell。
  4. 此處表示 Cell 從打開的狀態開始,你需要那個能讓 Cell 自動 snap 關閉的點,至少需要超過最左邊按鈕的一半。 將不是最左邊的按鈕的那些按鈕的寬度加起來,在這個情況里,只有 self.button1 而已,再加上最左邊按鈕的一半——也就是 self.button2 —— 以便找到需要的檢查點。
  5. 測試約束是否以及超過這個點,即你希望 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 到打開或關閉,而不論你的手指再哪里,如下所示:

swipeable-bounce

更好地處理 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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM