Posted at

UITableViewCell内のVertical UIStackViewをアニメーションさせる

UITableViewCellの高さが変わるアニメーションを行うには、UITableView.beginUpdates UITableView.endUpdatesでCell高さ変更処理を挟むことで実現できます。

    tableView.beginUpdates()

// Cell高さが変更されるような処理
tableView.endUpdates()

ここで、Cell内部にある、VerticalなUIStackViewの一部要素の表示非表示を切り替えてアニメーションする場合、UITableView側のアニメーション設定と合わせて、UIStackView側のアニメーション設定が必要で、Duration設定を正しく設定しないと、アニメーションが安定しません。

結論から言うと、UITableView側のアニメーションのDuration: 0.3と同じ時間となるよう、UIStackView側のDurationを調整すると、アニメーションが安定します。

上記Gifの実装では、VerticalなUIStackViewへ5つのUILabelを追加し、そのうち、2〜4番目のUILabelの表示非表示を切り替えています。5つのCellに対して、0.1~0.5のDurationを設定しています。

UITableView側のDurationと揃っていないCellのアニメーションはご覧のとおり、アニメーション中に一部の'UILabel`が消えたり上下にぐらついて見えます。

Githubにサンプルコードをおいておきました。

https://github.com/iincho/UITableViewAnimation

以下、サンプルコードの一部です。


ViewController.swift

import UIKit

struct CellState {
var isOpen: Bool
let backgoundColor: UIColor
let duration: TimeInterval
}

class ViewController: UIViewController {

@IBOutlet weak var tableView: UITableView!

private let cellStates: [CellState] = {
return [
CellState(isOpen: true, backgoundColor: .white, duration: 0.1),
CellState(isOpen: true, backgoundColor: .lightGray, duration: 0.2),
CellState(isOpen: true, backgoundColor: .white, duration: 0.3),
CellState(isOpen: true, backgoundColor: .lightGray, duration: 0.4),
CellState(isOpen: true, backgoundColor: .white, duration: 0.5),
]
}()

override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self

let className = "AnimationCell"
let nib = UINib(nibName: className, bundle: nil)
tableView.register(nib, forCellReuseIdentifier: className)

tableView.estimatedRowHeight = 200
tableView.rowHeight = UITableView.automaticDimension
tableView.tableFooterView = UIView()
tableView.reloadData()
}
}

extension ViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return cellStates.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "AnimationCell", for: indexPath) as! AnimationCell
var state = cellStates[indexPath.row]
cell.refresh(isOpen: state.isOpen, backgroundColor: state.backgoundColor, duration: state.duration)
cell.toggleButtonTapHandler = { [unowned self] in
state.isOpen = !state.isOpen
self.tableView.beginUpdates()
cell.toggle(isOpen: state.isOpen)
self.tableView.endUpdates()
}
return cell
}
}



AnimationCell.swift


import UIKit

class AnimationCell: UITableViewCell {

var toggleButtonTapHandler: (() -> Void)?

@IBOutlet private weak var toggleButton: UIButton!
@IBOutlet private weak var stackView: UIStackView!
@IBOutlet private weak var label1: UILabel!
@IBOutlet private weak var label2: UILabel!
@IBOutlet private weak var label3: UILabel!
@IBOutlet private weak var label4: UILabel!
@IBOutlet private weak var label5: UILabel!

private lazy var animationLabels: [UILabel] = [label2, label3, label4]
private var duration: TimeInterval = 0

@IBAction func toggle(_ sender: Any) {
toggleButtonTapHandler?()
}

override func awakeFromNib() {
super.awakeFromNib()
selectionStyle = .none
}

override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}

func refresh(isOpen: Bool, backgroundColor: UIColor, duration: TimeInterval) {
contentView.backgroundColor = backgroundColor
self.duration = duration
refreshTitle(isOpen: isOpen)
animationLabels.forEach { $0.isHidden = isOpen }
}

private func refreshTitle(isOpen: Bool) {
let toggleStr = isOpen ? "▼" : "▲"
let title = "Duration: \(duration) " + toggleStr
toggleButton.setTitle(title, for: .normal)
}

func toggle(isOpen: Bool) {
refreshTitle(isOpen: isOpen)

/// Ainmation
let opacity: Float = isOpen ? 0.0 : 1.0
UIView.animate(withDuration: duration) {
self.animationLabels.forEach { label in
label.isHidden = isOpen
label.layer.opacity = opacity
}
self.stackView.layoutIfNeeded()
}
}
}