在故事板中,如何为多个控制器创建一个自定义单元格?

219

我正在尝试在我的应用程序中使用 storyboards。应用程序中有列表(Lists)用户(Users),每个都包含另一个的集合(列表成员,由用户拥有的列表),因此,我有ListCellUserCell类。目标是使它们在整个应用程序中可重复使用(即在任何表视图控制器中)。

这就是我遇到问题的地方。

如何在 storyboard 中创建自定义表视图单元格以便在任何视图控制器中重复使用?

以下是我迄今为止尝试过的具体方法。

  • 在控制器 #1 中,添加了原型单元格,将类设置为我的UITableViewCell子类,设置重用ID,添加标签并将其连接到类的输出口。在控制器 #2 中,添加了一个空的原型单元格,将其设置为与之前相同的类和重用 ID。运行时,在控制器 #2 中显示单元格时标签从未出现。在控制器 #1 中工作正常。

  • 在不同的 NIB 中设计了每种单元格类型并连接到适当的单元格类。在 storyboard 中,添加了一个空的原型单元格,并将其类和重用 ID 设置为引用我的单元格类。在控制器的 viewDidLoad 方法中,为重用 ID 注册那些 NIB 文件。当显示时,两个控制器中的单元格都是空的,就像原型一样。

  • 将原型保留为空,在两个控制器中设置类和重用 ID 为我的单元格类。完全使用代码构建单元格的 UI。单元格在所有控制器中都可以正常工作。

在第二种情况下,我怀疑原型始终会覆盖 NIB,如果我杀死原型单元格,为重用 ID 注册我的 NIB 将起作用。但这样我就无法从单元格设置转场到其他框架,这确实是使用 storyboards 的整个意义所在。

说到底,我想要两件事情:在故事板中连接表视图流程,并可视化地定义单元格布局,而不是在代码中编写。到目前为止,我不知道如何同时实现这两个目标。

7个回答

206

据我所了解,您想要:

  1. 设计一个在多个Storyboard场景中可用的IB单元格。
  2. 根据单元格所在的场景配置唯一的Storyboard Segue。

不幸的是,目前还没有办法实现这一点。为了理解为什么之前的尝试没有奏效,您需要更多地了解故事板和原型表视图单元格的工作方式。(如果您不关心为什么这些其他尝试没有奏效,可以随时离开。除了建议您报告错误外,我没有任何神奇的解决方案)。

故事板本质上只是一组.xib文件集合。当您从故事板加载带有一些原型单元格的表视图控制器时,会发生以下情况:

  • 每个原型单元格实际上都是自己嵌入式的迷你nib。因此,当表视图控制器正在加载时,它会遍历每个原型单元格的nib并调用-[UITableView registerNib:forCellReuseIdentifier:]
  • 表视图请求控制器的单元格。
  • 您可能会调用-[UITableView dequeueReusableCellWithIdentifier:]
  • 当您请求具有给定重用标识符的单元格时,它会检查是否已注册nib。如果有,则实例化该单元格的实例。这由以下步骤组成:

    1. 查看单元格在nib中定义的类。调用[[CellClass alloc] initWithCoder:]
    2. -initWithCoder:方法遍历并添加子视图以及设置在nib中定义的属性。(也可能在-awakeFromNib中连接IBOutlet,但我没有测试过)
  • 您可以根据需要配置单元格。

需要注意的是,单元格的类别和其视觉外观之间存在差异。可以创建两个不同布局的相同类别的原型单元格。实际上,如果使用默认的UITableViewCell样式,这就是正在发生的事情。例如,“默认”样式和“副标题”样式都由同一个UITableViewCell类表示。

这很重要: 单元格的类别与特定视图层次结构之间没有一对一的关系。视图层次结构完全取决于在此特定控制器中注册的原型单元格中包含什么。

请注意,单元格的重用标识符并未在某些全局单元格存储库中注册。重用标识符仅在单个UITableView实例的上下文中使用。


了解了这些信息,让我们看看您之前的尝试中发生了什么。

在控制器#1中添加了一个原型单元格,设置其类别为我的UITableViewCell子类,设置了重用标识符,添加了标签并将它们连接到该类的插座中。在控制器#2中添加了一个空的原型单元格,将其设置为与之前相同的类别和重用标识符。运行时,在控制器#2中显示单元格时,标签从未出现。在控制器#1中工作得很好。

这是可以预料的。虽然两个单元格都具有相同的类别,但在控制器#2传递给单元格的视图层次结构完全没有子视图。因此,您得到了一个空单元格,这正是您放入原型单元格中的内容。

在不同的NIB中设计每个单元格类型,并连接到相应的单元格类。在Storyboard中,添加了一个空原型单元格,并将其类别和重用标识符设置为引用我的单元格类。在控制器的viewDidLoad方法中,为重用标识符注册了那些NIB文件。在显示时,两个控制器中的单元格都像原型一样为空。

这是预料之中的。重用标识符不会在故事板场景或nib文件之间共享,因此所有这些不同的单元格具有相同的重用标识符毫无意义。您从tableview获取的单元格将具有与故事板该场景中的原型单元格相匹配的外观。

虽然这个解决方案非常接近。正如您所注意到的,您可以通过以编程方式调用-[UITableView registerNib:forCellReuseIdentifier:]来传递包含单元格的UINib,并且您将得到相同的单元格。(这不是因为原型“覆盖”了nib;您只是没有向tableview注册nib,因此它仍在查看嵌入在故事板中的nib。)不幸的是,这种方法存在缺陷-目前无法将故事板segue连接到独立的nib中的单元格。

在两个控制器中都保留原型为空,并设置类和重用标识符 为我的单元格类。完全使用代码构建单元格的UI。单元格 在所有控制器中都可以完美工作。

自然而然。希望这不会让人感到意外。


所以,这就是为什么它没有起作用的原因。您可以在独立的nib中设计单元格,并在多个故事板场景中使用它们;您只是目前无法将故事板segue连接到这些单元格。但希望在阅读此过程中您已经学到了一些东西。


啊,我明白了。你完全理解了我的误解——视图层次结构与我的类完全独立。回想起来很明显!非常感谢你的精彩答案。 - Cliff W
似乎不再是不可能的了:https://dev59.com/OWoy5IYBdhLWcg3wa9Qj - Rich Apodaca
7
我在我的回答中提到了这个解决方案。但它不在故事板中,而是在一个单独的 Nib 文件中。所以你无法连接segues或执行其他故事板相关的操作。因此,它并没有完全解决最初的问题。 - BJ Homer
从XCode8开始,如果您想要一个仅基于Storyboard的解决方案,则以下解决方法似乎有效。 步骤1)在ViewController#1中的tableview中创建原型单元格,并与自定义UITableViewCell类相关联步骤2)将该单元格复制/粘贴到ViewController#2的tableview中。 随着时间的推移,您必须记住手动传播副本更新,通过删除您在storyboard中制作的副本,并将最新原型黏贴回去。 - jengelsma

58
尽管BJ Homer的答案很好,但我感觉我有一个解决方案。根据我的测试,它有效。
概念:为xib单元格创建自定义类。在那里,您可以等待触摸事件并以编程方式执行segue。现在我们需要的只是对执行Segue的控制器的引用。我的解决方案是在tableView:cellForRowAtIndexPath:中设置它。
示例
我有一个包含表格单元格的DetailedTaskCell.xib,我想在多个表格视图中使用: DetailedTaskCell.xib 该单元格有一个自定义类TaskGuessTableCell: enter image description here 这就是魔法发生的地方。
// TaskGuessTableCell.h
#import <Foundation/Foundation.h>

@interface TaskGuessTableCell : UITableViewCell
@property (nonatomic, weak) UIViewController *controller;
@end

// TashGuessTableCell.m
#import "TaskGuessTableCell.h"

@implementation TaskGuessTableCell

@synthesize controller;

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    NSIndexPath *path = [controller.tableView indexPathForCell:self];
    [controller.tableView selectRowAtIndexPath:path animated:NO scrollPosition:UITableViewScrollPositionNone];
    [controller performSegueWithIdentifier:@"FinishedTask" sender:controller];
    [super touchesEnded:touches withEvent:event];
}

@end

我有多个Segue,但它们都有相同的名称:"FinishedTask"。如果您需要在此处灵活处理,则建议添加另一个属性。

视图控制器看起来像这样:

// LogbookViewController.m
#import "LogbookViewController.h"
#import "TaskGuessTableCell.h"

@implementation LogbookViewController

- (void)viewDidLoad
{
    [super viewDidLoad]

    // register custom nib
    [self.tableView registerNib:[UINib nibWithNibName:@"DetailedTaskCell" bundle:[NSBundle mainBundle]] forCellReuseIdentifier:@"DetailedTaskCell"];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    TaskGuessTableCell *cell;

    cell = [tableView dequeueReusableCellWithIdentifier:@"DetailedTaskCell"];
    cell.controller = self; // <-- the line that matters
    // if you added the seque property to the cell class, set that one here
    // cell.segue = @"TheSegueYouNeedToTrigger";
    cell.taskTitle.text  = [entry title];
    // set other outlet values etc. ...

    return cell;
}

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if([[segue identifier] isEqualToString:@"FinishedTask"])
    {
        // do what you have to do, as usual
    }

}

@end

可能有更优雅的方法实现相同的功能,但这种方法也能正常工作!:)


1
谢谢,我会在我的项目中实现这种方法。您可以重写此方法,以便无需获取indexPath并自行选择行:-(void)setSelected:(BOOL)selected animated:(BOOL)animated { [super setSelected:selected animated:animated];if(selected) [self.controller performSegueWithIdentifier:self.segue sender:self];}我认为当调用[super touchesEnded:touches withEvent:event];时,super会选择单元格。如果不是那里,您知道它何时被选中吗? - thejaz
9
请注意,使用此解决方案会在每次用户在单元格内结束触摸时触发转场。这包括如果您只是滚动单元格,而不是真正尝试选择它。您可以尝试覆盖单元格的-setSelected:方法,并仅在从“NO”到“YES”进行转换时触发转场以获得更好的效果。 - BJ Homer
我在setSelected:方面运气更好,BJ。谢谢。确实,这是一个不太优雅的解决方案(感觉不对),但同时它能够工作,所以在这个问题得到修复(或者苹果公司做出改变)之前我会继续使用它。 - Ben Kreeger

16

我正在寻找相关信息,然后发现了Richard Venable的这个回答。它对我很有用。

iOS 5包含一个新方法:registerNib:forCellReuseIdentifier:

要使用它,请将UITableViewCell放入nib中。它必须是nib中唯一的根对象。

您可以在加载tableView后注册该nib,然后当您使用cell identifier调用dequeueReusableCellWithIdentifier:时,它将从nib中获取它,就像您使用故事板原型单元格一样。


10

BJ Homer已经对正在发生的事情进行了很好的解释。

从实用的角度来看,我想补充说,考虑到您不能把单元格作为xib并连接segue,最好选择将单元格作为xib - 转换比跨多个地方维护单元格布局和属性要容易得多,并且您的segue可能与不同的控制器不同。您可以直接从您的表视图控制器定义segue到下一个控制器,并在代码中执行它。

另一个需要注意的问题是,将单元格作为单独的xib文件可以防止您能够将任何操作等直接连接到表视图控制器(我还没有解决这个问题-您无法将文件的所有者定义为任何有意义的东西)。 我通过定义一个协议来解决这个问题,该协议期望单元格的表视图控制器符合并将控制器添加为weak property,类似于委托,在cellForRowAtIndexPath中。


10

Swift 3

BJ Homer给出了很好的解释,帮助我理解概念。为了在任何TableViewController中使用自定义单元格,我们必须将Storyboard和xib相结合的方法混合起来。
假设我们有一个名为CustomCell的单元格,它将在TableViewControllerOne和TableViewControllerTwo中使用。我将按步骤进行。
1. 文件 > 新建 > 点击文件 > 选择Cocoa Touch类 > 点击下一步 > 给出您的类名称(例如CustomCell) > 选择子类为UITableVieCell > 选中创建XIB文件复选框并按下一步。
2. 根据您的要求自定义单元格,并在属性检查器中设置单元格标识符,这里我们将设置为CellIdentifier。此标识符将在您的ViewController中用于标识和重用单元格。
3. 现在我们只需要在ViewController的viewDidLoad中注册此单元格即可。不需要任何初始化方法。
4. 现在我们可以在任何tableView中使用此自定义单元格。

在TableViewControllerOne中:

let reuseIdentifier = "CellIdentifier"

override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "CustomCell", bundle: nil), forCellReuseIdentifier: reuseIdentifier)
} 

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier:reuseIdentifier, for: indexPath) as! CustomCell
    return cell!
}

5
我找到了一种方法来加载同一个VC的单元格,但对于segues尚未进行测试。这可能是在单独的nib中创建单元格的解决方法。
假设您有一个VC和2个表,并且您想要在storyboard中设计一个单元格并在两个表中使用它。
(例如:一个带有UISearchController的表和搜索字段,其中包含结果表格,您希望在两者中使用相同的单元格)
当控制器请求单元格时,请执行以下操作:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString * identifier = @"CELL_ID";

    ContactsCell *cell = [self.YOURTABLEVIEW dequeueReusableCellWithIdentifier:identifier];
  // Ignore the "tableView" argument
}

这里是你从故事板中得到的单元格


我尝试了这个方法,似乎是有效的,但是单元格从未被重用。每次系统都会创建和释放新的单元格。 - GilroyKilroy
这是与使用Storyboards为表视图添加搜索栏中相同的建议。如果您感兴趣,可以在那里找到更详细的解释(搜索tableView:cellForRowAtIndexPath:)。 - Senseful
但这是较少的文本且回答了问题。 - João Nunes

1
如果我正确理解您的问题,这是相当容易的。在您的故事板中创建一个UIViewController来容纳原型单元格,并创建一个静态共享实例,它从故事板中加载自己。要处理视图控制器segue,请使用手动segue输出并在表视图delegate didSelectRow上触发(手动segue输出是故事板中视图控制器顶部的中间图标,在“第一响应者”和“退出”之间)。
XCode 12.5,iOS 13.6
// A cell with a single UILabel

class UILabelCell: UITableViewCell {
    @IBOutlet weak var label: UILabel!
}

// A cell with a signle UISwitch

class UISwitchCell: UITableViewCell {
    @IBOutlet weak var uiSwitch: UISwitch!
}


// The TableViewController to hold the prototype cells.    

class CellPrototypeTableViewController: UITableViewController {
    
    // Loads the view controller from the storyboard
    
    static let shared: CellPrototypeTableViewController = {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let viewController = storyboard.instantiateViewController(withIdentifier: "cellProtoypeVC") as! CellPrototypeTableViewController
        viewController.loadViewIfNeeded()  // Make sure to force view controller to load the view!
        return viewController
    }()

    
    // Helper methods to deque the cells
    
    func dequeUILabeCell() -> UILabelCell {
        let cell = self.tableView.dequeueReusableCell(withIdentifier: "uiLabelCell") as! UILabelCell
        return cell
    }
    
    func dequeUISwitchCell() -> UISwitchCell {
        let cell = self.tableView.dequeueReusableCell(withIdentifier: "uiSwitchCell") as! UISwitchCell
        return cell
    }
}

使用:

class MyTableViewController: UITableViewController {
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 2
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        // Dequeue the cells from the shared instance
        
        switch indexPath.row {
        case 0:
            let uiLabelCell = CellPrototypeTableViewController.shared.dequeUILabeCell()
            uiLabelCell.label.text = "Hello World"
            return uiLabelCell
        case 1:
            let uiSwitchCell = CellPrototypeTableViewController.shared.dequeUISwitchCell()
            uiSwitchCell.uiSwitch.isOn = false
            return uiSwitchCell
        default:
            fatalError("IndexPath out of bounds")
        }
    }
    
    // Handling Segues
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        switch indexPath.row {
        case 0: self.performSegue(withIdentifier: "first", sender: nil)
        case 1: self.performSegue(withIdentifier: "second", sender: nil)
        default:
            fatalError("IndexPath out of bounds")
        }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接