UIStackView 中的 UITableView - 流畅的展开和折叠动画

UITableView in UIStackView - smooth expand and collapse animation

我正在尝试实现一个 table 视图,该视图允许用户“显示更多”行以切换完整行数或较少行数。我在加载视图时预先拥有所有数据,因此我不需要获取任何额外数据。

我能够让它工作,但我遇到的问题是我的 table 视图在展开或折叠时不能流畅地动画。发生的情况是,如果触发“显示更多”操作,table 的大小会立即更新为包含所有数据的 table 的完整高度。然后行将动画。

同样,当隐藏行时,table 高度会立即收缩到结束高度,然后行会动画。

这是正在发生的事情的图片。它只是“跳”到全高,然后为行设置动画。但我希望发生的事情是它平滑地扩展 table 的高度,在它平滑地向下扩展时显示数据。我想要相反的,当按下“show less”时它平滑地向上滑动。

我的应用布局方式如下:

基本上,我有一个包含不同数据部分的可滚动列表。有些部分是 table,有些只是 ad-hoc 使用 AutoLayout 排列的视图,其他部分有 collection 视图或页面视图控制器或其他类型的视图。

但是这部分是由前面列表中的 UITableView 表示的。

#import "MyTableView.h"
#import "MyAutosizingTableView.h"

@interface MyTableView ()

@property (strong, nonatomic) UILabel *titleLabel;
@property (strong, nonatomic) MyAutosizingTableView *tableView;

@end

@implementation MyTableView

- (instancetype)init {
    return [self initWithFrame:CGRectZero];
}

- (instancetype)initWithCoder:(NSCoder *)coder {
    if (self = [super initWithCoder:coder]) {
        [self initialize];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        [self initialize];
    }
    return self;
}

- (void)initialize {
    [self.titleLabel setText:@"Details"];
    self.numberOfRows = 3;
    [self addSubview:self.titleLabel];
    [self addSubview:self.tableView];
    
    [NSLayoutConstraint activateConstraints:@[
        [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.leadingAnchor],
        [self.titleLabel.topAnchor constraintEqualToAnchor:self.layoutMarginsGuide.topAnchor],
        [self.titleLabel.trailingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.trailingAnchor],
        
        [self.tableView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
        [self.tableView.topAnchor constraintEqualToSystemSpacingBelowAnchor:self.titleLabel.bottomAnchor multiplier:1.0f],
        [self.tableView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
        [self.tableView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
        [self.tableView.widthAnchor constraintEqualToAnchor:self.widthAnchor]
    ]];
}

- (UITableView *)tableView {
    if (!self->_tableView) {
        self->_tableView = [[MyAutosizingTableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
        self->_tableView.translatesAutoresizingMaskIntoConstraints = NO;
        self->_tableView.rowHeight = UITableViewAutomaticDimension;
        self->_tableView.estimatedRowHeight = UITableViewAutomaticDimension;
        self->_tableView.allowsSelection = NO;
        self->_tableView.scrollEnabled = NO;
        self->_tableView.delegate = self;
        self->_tableView.dataSource = self;
        [self->_tableView registerClass:[MyTableViewCell class] forCellReuseIdentifier:@"dataCell"];
    }
    return self->_tableView;
}

- (UILabel *)titleLabel {
    if (!self->_titleLabel) {
        self->_titleLabel = [[UILabel alloc] init];
        self->_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
        self->_titleLabel.numberOfLines = 0;
        self->_titleLabel.userInteractionEnabled = YES;
        self->_titleLabel.textAlignment = NSTextAlignmentNatural;
        self->_titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
        self->_titleLabel.baselineAdjustment = UIBaselineAdjustmentAlignBaselines;
        self->_titleLabel.adjustsFontSizeToFitWidth = NO;
        
        UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showMore)];
        [self->_titleLabel addGestureRecognizer:tapGesture];
    }
    return self->_titleLabel;
}

- (void)showMore {
    if (self.numberOfRows == 0) {
        self.numberOfRows = 3;
    } else {
        self.numberOfRows = 0;
    }
    
    NSIndexSet *indexes = [NSIndexSet indexSetWithIndex:0];
    [self.tableView reloadSections:indexes withRowAnimation:UITableViewRowAnimationFade];
}

- (void)setData:(NSArray<NSString *> *)data {
    self->_data = data;
    [self.tableView reloadData];
}


- (void)didMoveToSuperview {
    //this has no effect unless the view is already in the view hierarchy
    self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
}

#pragma mark - UITableViewDataSource
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    if (self.numberOfRows <= 0) {
        return self.data.count;
    }
    return MIN(self.data.count, self.numberOfRows);
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSString *data = self.data[indexPath.row];
    MyTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"dataCell" forIndexPath:indexPath];
    cell.data = data;
    return cell;
}

@end

我有一个在前面的代码中使用的 UITableView 子类。我拥有 table 视图子类的原因是让 table 在内容更改时调整大小。如果有人知道在没有子类的情况下执行此操作的更好方法,我也会非常有兴趣学习它。

#import "MyAutosizingTableView.h"

@implementation MyAutosizingTableView

- (CGSize)intrinsicContentSize {
    return self.contentSize;
}

- (void)setContentSize:(CGSize)contentSize {
    [super setContentSize:contentSize];
    [self invalidateIntrinsicContentSize];
}

@end

有谁知道我可以做些什么来获得流畅的展开动画?我真的希望 table 能够顺利扩展并显示新行中的数据。


更新 - 对我正在尝试做的事情的高级概述

在这里,我将尝试描述我要完成的工作以及我将如何进行,所以如果有人有任何替代方法或改进来解决这个问题,我会非常乐意接受建议.预先感谢您阅读本文。

所以我正在研究一个显示产品数据的视图控制器,例如想象一辆汽车。视图控制器有不同的部分负责显示不同的数据。例如,第一部分显示商品的图片,而对于这一部分,我使用的是 UIPageView,它基本上显示产品图片的轮播。

另一部分显示有关当前产品的选项。例如,有人可能会为汽车或不同的车轮选择不同的颜色。对于此部分,我使用 table 视图来显示可用属性列表,其中每个属性位于 table 的不同部分。按 header 部分后,table 视图会添加一行,显示该属性的可用选项。例如,假设汽车有不同的颜色(红色、绿色、蓝色和黄色)。按下 header 部分后,table 视图会添加一行并为其添加动画(使用“编程 iOS 12”第 8 章中讨论的技术)。然后显示的行包含一个水平滚动的 UICollectionView,允许用户在颜色(或滚轮选项或正在更改的任何属性)之间进行选择。

另一部分显示其他客户对此产品的评价。例如,如果有人在汽车上留下了评论,那么它就会出现在本节中。此部分有一个 table 显示产品在不同标准下的表现。仍然以汽车为例,它可能有一行用于舒适度,另一行用于油耗,另一行用于性能等等。然后还有一个水平的 UICollectionView 显示关于产品的评论。

还有一个部分是产品的属性列表。例如,对于汽车,它可能会在左侧显示发动机尺寸,在右侧显示 V12。

还有其他部分,但为了将它们联系在一起,我有一个滚动视图,其中有一个垂直的 UIStackView。这就是我真正好奇的地方。显示或布局所有这些部分的最佳方式是什么?堆栈视图是要走的路还是应该使用 table 视图,或者我应该直接使用 AutoLayout 将这些视图连接在一起的滚动视图,还是有比所有这些更好的方法来做到这一点?

一件非常重要的事情是,每个部分在产品之间可以有不同的尺寸。前面例如,某些部分对于一种产品可能是一个高度,但对于其他产品则可能是完全不同的高度。所以我需要它能够动态调整每个部分的大小,我还需要能够为一些大小变化设置动画(例如,它向 table 添加一行然后删除它的部分,这需要动画并以动画方式使整个部分变大)。

此外,我希望每个部分都是模块化的,而不是最终拥有一个管理所有内容的巨大视图控制器。我希望每个部分基本上都是独立的。我只是将数据传递给那个部分,然后它处理与布局和交互相关的所有事情。但同样,真正的问题是让这些视图能够调整大小并在 parent 视图(当前堆栈视图)中进行更新。

但我真的是 iOS 开发的新手,虽然我一直在努力尽可能快地学习,但我仍然不确定我目前这样做的方式(滚动视图里面有堆栈视图)是最好的方法,或者如果有更好的方法。我最初在 table 视图中尝试了此操作,其中每行都是不同的部分的静态单元格。但是让 child 视图控制器调整 parent table 视图中的行的大小并没有那么好用。从那时起,我也改变了,不再让每个部分成为一个单独的视图控制器,而是让每个部分成为一个视图(而不是视图控制器),希望能够更轻松地调整大小,但我仍然 运行 遇到问题。我也考虑过 collection 视图,但我还需要能够支持 iOS 12(或者我可以放弃 iOS 12 支持,如果我真的需要并且只支持 iOS 13+).

但重申一下,我的总体目标是通过某种方式来布局此界面,该界面由处理不同数据的不同视图组成。我希望每个视图都能够调整大小并且能够平滑。而且我希望界面的每个部分都是模块化的,以避免一个大视图控制器。

更新 2

按照建议将 table 视图包装在 UIView 中,然后设置两个具有不同优先级的底部约束后,这就是我得到的结果。每当我尝试为诸如视图的高度限制之类的东西设置动画时,我也会得到这种效果。我认为这是因为这里的整体视图包含在堆栈视图中,所以我认为堆栈视图在其排列的子视图更改大小时正在做一些事情。

此处的图片同时显示了 table 视图的展开和折叠。折叠时显示三行,展开时显示五行。

黄色和蓝色视图代表页面上的其他视图。它们不是该视图的一部分,它们所在的堆栈视图也不是。它们只是此处包含的页面的其他部分以显示此问题。这里的所有视图都包含在堆栈视图中,我认为这是导致动画问题的原因,但我不确定如何解决它。

您在这里确实有很多问题 - 但要具体解决尝试 expand/collapse 您的 table 视图的问题...

我认为您将以这种方法打一场必败仗。通过尝试使用“自动调整大小”table 视图,您正在抵消 table 视图的大部分行为/而且,由于您无法通过可重用单元获得内存管理的好处,您最好使用垂直堆栈视图来格式化您的“规格列表”。

在任何一种情况下,要创建具有“显示/隐藏”效果的折叠/展开动画,您可能需要将 tableView 或 stackView 嵌入“容器”视图中,然后切换约束容器高度(底部)的优先级。

这是一个简单的例子...

  • 创建一个“容器”UIView
  • 向容器添加垂直堆栈视图
  • 向堆栈视图添加标签
  • 在容器上方添加一个红色 UIView
  • 添加蓝色 UIView 放置在容器下方
  • 定义容器高度的约束条件

每次点击,容器都会展开/折叠以显示/隐藏标签。

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController
@end

@interface ViewController ()

@property (strong, nonatomic) NSLayoutConstraint *collapsedConstraint;
@property (strong, nonatomic) NSLayoutConstraint *expandedConstraint;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIStackView *labelsStackView = [UIStackView new];
    labelsStackView.axis = UILayoutConstraintAxisVertical;
    labelsStackView.spacing = 4;
    
    // let's add 12 labels to the stack view (12 "rows")
    for (int i = 1; i < 12; i++) {
        UILabel *v = [UILabel new];
        v.text = [NSString stringWithFormat:@"Label %d", i];
        [labelsStackView addArrangedSubview:v];
    }
    
    // add a view to show above the stack view
    UIView *redView = [UIView new];
    redView.backgroundColor = [UIColor redColor];
    
    // add a view to show below the stack view
    UIView *blueView = [UIView new];
    blueView.backgroundColor = [UIColor blueColor];

    // add a "container" view for the stack view
    UIView *cView = [UIView new];
    
    // clip the container's subviews
    cView.clipsToBounds = YES;
    
    for (UIView *v in @[redView, cView, blueView]) {
        v.translatesAutoresizingMaskIntoConstraints = NO;
        [self.view addSubview:v];
    }
    
    // add the stackView to the container
    labelsStackView.translatesAutoresizingMaskIntoConstraints = NO;
    [cView addSubview:labelsStackView];
    
    // constraints
    UILayoutGuide *g = [self.view safeAreaLayoutGuide];
    
    // when expanded, we'll use the full height of the stack view
    _expandedConstraint = [cView.bottomAnchor constraintEqualToAnchor:labelsStackView.bottomAnchor];
    
    // when collapsed, we'll use the bottom of the 3rd label in the stack view
    UILabel *v = labelsStackView.arrangedSubviews[2];
    _collapsedConstraint = [cView.bottomAnchor constraintEqualToAnchor:v.bottomAnchor];
    
    // start collapsed
    _expandedConstraint.priority = UILayoutPriorityDefaultLow;
    _collapsedConstraint.priority = UILayoutPriorityDefaultHigh;
    
    [NSLayoutConstraint activateConstraints:@[
        
        // redView Top / Leading / Trailing / Height=120
        [redView.topAnchor constraintEqualToAnchor:g.topAnchor constant:20.0],
        [redView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        [redView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
        [redView.heightAnchor constraintEqualToConstant:120.0],
        
        // container Top==redView.bottom / Leading / Trailing / no height
        [cView.topAnchor constraintEqualToAnchor:redView.bottomAnchor constant:0.0],
        [cView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        [cView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
        
        // blueView Top==stackView.bottom / Leading / Trailing / Height=160
        [blueView.topAnchor constraintEqualToAnchor:cView.bottomAnchor constant:0.0],
        [blueView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        [blueView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
        [blueView.heightAnchor constraintEqualToConstant:160.0],
        
        // stackView Top / Leading / Trailing
        [labelsStackView.topAnchor constraintEqualToAnchor:cView.topAnchor],
        [labelsStackView.leadingAnchor constraintEqualToAnchor:cView.leadingAnchor],
        [labelsStackView.trailingAnchor constraintEqualToAnchor:cView.trailingAnchor],

        _expandedConstraint,
        _collapsedConstraint,
        
    ]];

}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    // toggle priority between expanded / collapsed constraints
    if (_expandedConstraint.priority == UILayoutPriorityDefaultHigh) {
        _expandedConstraint.priority = UILayoutPriorityDefaultLow;
        _collapsedConstraint.priority = UILayoutPriorityDefaultHigh;
    } else {
        _collapsedConstraint.priority = UILayoutPriorityDefaultLow;
        _expandedConstraint.priority = UILayoutPriorityDefaultHigh;
    }
    // animate the change
    [UIView animateWithDuration:0.5 animations:^{
        [self.view layoutIfNeeded];
    }];
    
}

@end

可能能够通过您的“自动调整大小”table视图来实现这一点,但是同样,因为您没有使用“正常”table 查看行为(滚动、可重复使用的单元格等),这可能是更好的方法。

希望它还可以让您了解布局中其他视图的大小 - 特别是如果您想要在视图内或视图外设置动画。

编辑 - 在此处添加了一个更复杂的示例:https://github.com/DonMag/Scratch2021

它使用垂直滚动视图中的垂直堆栈视图作为“主视图”UI,在堆栈视图中添加子视图控制器作为“组件”。