Closed Juhnkerg closed 7 months ago
Hi @Juhnkerg, thanks for your kind words!
I am not planning to replicate the transition you mentioned, but if you have any specific questions I am happy to help if I can!
Thank you very much for your patient reply! I know you have rich development experience, so I would like to ask you about your idea of implementing the transition of UITableViewCell to expand the cell after tapping, such as Things3, without special code. I really hope you can share your idea, thank you!
@Juhnkerg I implemented a small example you can start off with. It uses some custom utils like register(_ type:)
but you can find all of them inside this repo. This is a really simple example of table view cell expansion but you can build on this to get closer to the Things3 effect. I hope this helps, UIKit can be a real pain in the ass sometimes especially when it comes to animating table and collection layouts so don't give up easily 😃
import UIKit
class TableScreen: UIViewController {
private let tableView = UITableView()
private var data = (0..<50)
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
}
func setupTableView() {
view.fillWith(tableView)
tableView.dataSource = self
tableView.delegate = self
tableView.allowsMultipleSelection = true
tableView.register(ExpandableCell.self)
/// This is the default value but it is important for self-sizing cells.
/// UITableView.automaticDimension will tell UIKit that we want to use auto-layout
/// to specify our cells' height (instead of `heightForRowAt(_:)`).
tableView.rowHeight = UITableView.automaticDimension
/// This is also the default value since iOS 16.
/// This is what makes our tableview re-layout itself when we invalidate the intrinsicContentSize
/// of our table view cell.
tableView.selfSizingInvalidation = .enabled
}
}
extension TableScreen: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeuCellOfType(ExpandableCell.self)
let cellData = data[indexPath.row]
cell.setup(with: cellData)
return cell
}
}
extension TableScreen: UITableViewDelegate {
/// We alter the default behavior of the table view by deselecting already selected cells on second tap.
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
guard let selected = tableView.indexPathsForSelectedRows,
selected.contains(indexPath) else { return indexPath }
/// Calling delegates manually is not a good idea, but `deselectRow(at:)` will not call the
/// delegates for us so we need to make those calls to keep consistent states.
if let indexPath = tableView.delegate?.tableView?(tableView, willDeselectRowAt: indexPath) {
tableView.deselectRow(at: indexPath, animated: true)
tableView.delegate?.tableView?(tableView, didDeselectRowAt: indexPath)
}
return nil
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
updateCellHeight(at: indexPath, selected: true)
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
updateCellHeight(at: indexPath, selected: false)
}
private func updateCellHeight(at indexPath: IndexPath, selected: Bool) {
guard let cell = tableView.cellForRow(at: indexPath) as? ExpandableCell else { return }
/// We initiate a custom animation for our selection change.
UIView.animate(
withDuration: 0.8,
delay: 0,
usingSpringWithDamping: 0.5,
initialSpringVelocity: 0) {
/// We need to wrap our updates inside a `beginUpdates()` and `endUpdates()` block
/// so the tableView itself knows about our layout changes. (It needs to know about it since
/// a single cell's height change effects every other cell's location)
self.tableView.beginUpdates()
/// We call our update method that will alter our cell's state.
cell.setExpanded(selected)
/// We call layoutIfNeeded() so the layout happens within our animation block.
cell.layoutIfNeeded()
/// We end our table view update block.
self.tableView.endUpdates()
}
}
}
class ExpandableCell: UITableViewCell {
private let stackView = UIStackView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private var subtitleHeightConstraint: NSLayoutConstraint?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupView() {
selectionStyle = .none
setupStackView()
setupTitleLabel()
setupSubtitleLabel()
}
private func setupStackView() {
let insets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
contentView.fillWith(stackView, insets: insets)
stackView.axis = .vertical
}
private func setupTitleLabel() {
stackView.addArrangedSubview(titleLabel)
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
}
private func setupSubtitleLabel() {
stackView.addArrangedSubview(subtitleLabel)
subtitleLabel.font = .systemFont(ofSize: 12, weight: .light)
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.numberOfLines = 0
/// We set our subtitle's initial height to 0 so it is not visible.
subtitleHeightConstraint = subtitleLabel.heightAnchor.constraint(equalToConstant: 0)
subtitleHeightConstraint?.isActive = true
}
func setup(with data: Int) {
titleLabel.text = "Item \(data)"
subtitleLabel.text = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent vel mi dapibus nisi sollicitudin feugiat. Fusce malesuada mollis quam, at ultrices nunc pulvinar in.
"""
}
func setExpanded(_ isExpanded: Bool) {
/// We disable the height constraint on our subtitile so it appears when selected.
subtitleHeightConstraint?.isActive = !isExpanded
subtitleLabel.layer.opacity = isExpanded ? 1 : 0
}
}
Thank you so much for your response, it's a great example, and I think Things3 could be the same way.
I'm confused though, whether it expands and shrinks the cell with animations alone or requires a custom transition to an ExpandedViewController
, After all, it works much like a transition with a source view and a destination view.
(Also: I found that in the InstagramTransiton project's DetailScreen, the slide-back gesture had a chance of causing the Nav gesture and the back button to not work. This may be caused by a conflict between Nav's sideslip back gesture and DetailScreen's handlePan gesture. But these are small details, and InstagramTransiton is still a great project.
I used navigationController?.interactivePopGestureRecognizer?.isEnabled = false
in viewDidLoad() of DetailScreen to resolve conflicts. But I'm sure it has several other ways.)
Thanks again!
Thanks for the QA @Juhnkerg nice catch!
As for the animation, I don't think this effect needs to be a transition, it just makes a single cell bigger. Making this a VC transition would complicate things even further.
@Juhnkerg the issue you mentioned was actually very complex to figure out: #4
Nice work! I happened to find that the gestures were not working, and I thought it was a conflict of gestures. It turns out that the root problem is the tansition call. It's actually really hard to locate.
I read your article and thought it was great, so I left a comment there, I thought you might see it on GitHub, so I used issue to leave a comment. https://medium.com/supercharges-mobile-product-guide/replicating-instagrams-shared-transition-on-ios-uikit-part-i-144a26c31353
It perfectly presents the transition animation of Instagram, great work! I'm not sure if you've heard of the app called Things3 - the transition when you click a cell to expand it, including the seamless transition of text, and the ability to scroll after expanding the cell. I have been trying to replicate its transition, but unfortunately I haven't succeeded. I wonder if you would be interested in replicating it.