Kolos65 / InstagramTransition

Replicating Instagram's gesture-driven shared-element transition using UIKit.
20 stars 0 forks source link

The project is fantastic and there are no issues. I'd like to leave you a message here #1

Closed Juhnkerg closed 7 months ago

Juhnkerg commented 7 months ago

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.

Kolos65 commented 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!

Juhnkerg commented 7 months ago

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!

Kolos65 commented 7 months ago

@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
    }
}
Juhnkerg commented 7 months ago

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!

Kolos65 commented 7 months ago

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.

Kolos65 commented 7 months ago

@Juhnkerg the issue you mentioned was actually very complex to figure out: #4

Juhnkerg commented 7 months ago

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.