制作一個可以滑動操作的 Table View Cell
原作者:Ellen Shapiro
Apple 通過 iOS 7 的郵件(Mail)應用介紹了一種新的用戶界面方案——向左滑動以顯示一個有着多個操作的菜單。本教程將會向你展示如何制作一個這樣的 Table View Cell,而不用因嵌套的 Scroll View 陷入困境。如果你還不知道一個可滑動的 Table View Cell 意味着什么,那么看看 Apple 的郵件應用:
可能你會想,既然 Apple 展示了這種方案,那它應該已將其開放給開發者使用了。畢竟,這能有多難呢?但不幸的是,他們只讓開發者使用 Delete 按鈕——至少暫時是這樣。如果你要添加其他的按鈕,或者改變 Delete 按鈕上的文字或顏色,那你就必須自己去實現。
譯者注:其實文字是可以修改的,但是顏色真的不行!
在本教程中,你將先學習如何實現簡單的滑動以刪除操作(swipe-to-delete action),之后我們再實現滑動以執行操作(swipe-to-perform-actions)。這會要求你深入研究 iOS 7 UITableViewCell
的結構,以便復制出我們需要的行為。你將使用到一些我個人非常喜歡的技術用於檢查視圖層次結構:為視圖上色以及使用 recursiveDescription
方法來打印出視圖層次結構。
開始
打開 Xcode,去往 File\New\Project…
並選擇 Master-Detail Application
,如下所示:
將項目命名為 SwipeableCell
並填好你自己的 Organization Name 和 Company Identifier 。選擇 iPhone
為目標設備並確保 Use Core Data
沒有被選中,如所示:
對於這樣的概念項目的證明,你最好保證數據模型盡量簡單。
打開 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]; } }
這個方法做了兩件事:
- 這一行創建並初始化一個
NSMutableArray
實例,以后你就可以添加對象到它里面了。如果你的數組沒有被初始化,那不論你調用addObject:
多少次,你的那些對象都不會被存儲起來。譯者注:讀者還是盡量用 Lazy Load 來實現吧! - 這個循環添加了一些字符串到
_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
對象去填充 UITableViewCell
的 textLabel
。
往下滾動到 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
。這個方法現在不正確,因為插入已經不再被支持了。
編譯並運行應用;你會看到一個簡單列表,如下所示:
滑動某一行到左邊,你就會看到一個 “Delete” 按鈕,如下所示:
喔~——這很簡單。但現在是時候弄臟雙手,深挖進視圖層次結構,看看里面到底發了什么。
深入視圖層次結構(View Hierarchy)
首先:你要找到 Delete 按鈕在視圖層次結構里的位置,然后你才能決定是否可以將其用於你自定義的 Cell 。
最容易做到這一點的方式是將 View 的各個部分分別染色,以便清楚地看到它們地位置和范圍。
繼續在 MasterViewController.m
里工作,添加如下兩行到 tableView:cellForRowAtIndexPath:
里,就在最后的 return
語句之上:
cell.backgroundColor = [UIColor purpleColor];
cell.contentView.backgroundColor = [UIColor blueColor];
這些顏色足夠讓我們看清這些視圖在 Cell 中的位置。
再次編譯並運行,你會看到着色后的元素,如下面的截圖所示:
你會清楚地看到藍色的 contentView
停止在 Accessory Indicator 之前,但整個 Cell 自身以紫色高亮,填滿了到 UITableView
的邊緣。
往左邊拖動 Cell ,你會看到類似下面的的界面:
看起來 Delete 按鈕實際上隱藏在 Cell 的下面。唯一能 100% 確保的方式是在視圖層次結構中再挖深一點。
為了輔助你的視圖考古,你可以用一個只能用於調試的方法,叫做 recursiveDescription
,它能打印出任意視圖的視圖層次結構。注意這是一個私有方法, `不應該被包含在任何會被放到 App Store 的代碼里`,但它對與視圖層次結構實在非常有用。
Note:目前有兩個付費應用能讓你用可視化的方式檢查視圖層次結構:Reveal 和 Spark 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 里有六個視圖:
UITableViewCell
這是最高層的視圖。 Frame 顯示它有 320 點寬和 44 點高——寬度和高度都喝預期的一致,因為它和屏幕一樣寬,而高度就是 44 點。UITableViewCellScrollView
雖然你不能直接使用這個私有類,但它的名字很好地暗示了它的功能。它的 Size 和 Cell 的一樣。據此我們推斷它的作用是在 Delete 按鈕之上裝載滑動出來的內容。UIButton
它在 Cell 的最右邊,就是 Disclosure Indicator 按鈕。注意這不是 Delete 按鈕。UIImageView
是上面UIButton
的子視圖,裝載着 Disclosure Indicator 的圖像。UITableViewCellContentView
另外一個私有類,它包含 Cell 的內容。這個類對於開發者來說就是UITableViewCell
的contentView
屬性。但它只作為一個UIView
來暴露在外,這就意味着你只在其上調用使用公開的UIView
方法;而不能使用任何與這個類關聯的任何私有方法。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 里所有的紫色。
回想剛才 recursiveDescription
輸出的 log, UITableViewCellScrollView
的 Frame 和 Cell 本身的 Size 是一致的。
但是,這個視圖到底有什么用?繼續拖動 Cell 到左邊,你就會看到 Scroll View 在你拖動 Cell 並 釋放時提供了 “彈性(springy)”行為,如下所示:
在你創建你自己的自定義 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
,如下所示:
現在 UITableViewCell
原型的名字在左邊的 Document Outline 上會顯示為 “Swipeable Cell”。右鍵單擊 Swipeable Cell – Cell
,你會看到一個你之前設置的 IBOutlet
列表:
首先,你要在 Attributes Inspector 里修改兩個地方以便自定義視圖。設置 Style 為 Custom
, Selection 為 None
, Accessory 也為 None
,截圖如下:
然后,拖兩個按鈕到 Cell 的 Content View 里。在視圖的 Attributes Inspector 區設置每個按鈕的背景色為比較鮮艷的顏色,並設置每個按鈕的文字顏色為比較易讀的顏色,這樣你就可以清楚地看到按鈕。
將第一個按鈕放在右邊,和 contentView
的上下邊緣接觸。將第二個按鈕放在第一個按鈕的左邊緣處,也和 contentView
的上下邊緣接觸。當你做好后,Cell 看起來如下,可能顏色少有差異:
接下來,將每個按鈕和對應的 Outlet 關聯起來。右鍵單擊到可滑動Cell上打開它的 Outlets,然后將 button1 拖動到到右邊的按鈕, button2 拖動到左邊的按鈕,如下:
你需要創建一個方法來處理對每個按鈕的點擊。
打開 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
,如下所示:
重復上述步驟,用於第二個按鈕。現在隨便按照任何一個按鈕上,都會調用 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 { 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]; }
在上面的代碼里,你執行了四個操作:
- 從 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 出現,如下面的截圖所示:
添加頂層視圖並添加滑動 Action
現在你到了視圖工作的后段部分,是時候讓頂層部分啟動並運行起來了。
打開 Main.storyboard
並拖一個 UIView
到 SwipeableTableCell
上,這個視圖將占據整個 Cell 的高和寬,並覆蓋按鈕,所以在Swipe手勢能工作之前,你不會再看到它們了。
如果你要精確地控制,打開 Size Inspector 並設置這個視圖地寬和高,分別為 320 和 43:
你同樣需要一個約束來將視圖釘在 contentView 的邊緣。選中視圖並點擊 Pin
按鈕,選擇所有四個間隔約束並設置它們的值為 0 ,如下所示:
連接好這個視圖的 Outlet,按照之前介紹的步驟:在左邊的導航器里右鍵單擊這個可滑動 Cell 並拖動 myContentView
到這個新的視圖上。
下一步,拖動一個 UILabel
到視圖里;設置其距離左邊 20 點,並設置其垂直劇中。再將其連接到 myTextLabel
Outlet 上。
編譯並運行;你的 Cell 看起來有正常了:
添加數據
但為何實際的文本數據沒有顯示出來?那是因為你只是設置了 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
,如下圖所示:
遵循同樣的步驟,連接好 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 ,以明確的圖示表明你所要關注的方面:
幸好,感謝 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
並添加如下方法:
- (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