DevAndArtist
DevAndArtist

Reputation: 5159

Cannot fix Auto Layout animation while rotation event

Don't be afraid of the huge code that will follow here. You can copy and paste the code snippet into a new single view application to see how it behaves. The problem sits somewhere inside the completion block of the animation executed alongside the rotation animation.

import UIKit

let sizeConstant: CGFloat = 60

class ViewController: UIViewController {

    let topView = UIView()
    let backgroundView = UIView()
    let stackView = UIStackView()
    let lLayoutGuide = UILayoutGuide()
    let bLayoutGuide = UILayoutGuide()
    var bottomConstraints = [NSLayoutConstraint]()
    var leftConstraints = [NSLayoutConstraint]()

    var bLayoutHeightConstraint: NSLayoutConstraint!
    var lLayoutWidthConstraint: NSLayoutConstraint!

    override func viewDidLoad() {

        super.viewDidLoad()

        print(UIScreen.main.bounds)

        //        self.view.layer.masksToBounds = true

        let views = [
            UIButton(type: .infoDark),
            UIButton(type: .contactAdd),
            UIButton(type: .detailDisclosure)
        ]
        views.forEach(self.stackView.addArrangedSubview)

        self.backgroundView.backgroundColor = UIColor.red
        self.backgroundView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(self.backgroundView)

        self.topView.backgroundColor = UIColor.green
        self.topView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(self.topView)

        self.stackView.axis = isPortrait() ? .horizontal : .vertical
        self.stackView.distribution = .fillEqually
        self.stackView.translatesAutoresizingMaskIntoConstraints = false
        self.backgroundView.addSubview(self.stackView)

        self.topView.topAnchor.constraint(equalTo: self.topLayoutGuide.bottomAnchor).isActive = true
        self.topView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
        self.topView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
        self.topView.heightAnchor.constraint(equalToConstant: 46).isActive = true

        self.view.addLayoutGuide(self.lLayoutGuide)
        self.view.addLayoutGuide(self.bLayoutGuide)

        self.bLayoutGuide.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
        self.bLayoutGuide.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
        self.bLayoutGuide.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
        self.bLayoutHeightConstraint = self.bLayoutGuide.heightAnchor.constraint(equalToConstant: isPortrait() ? sizeConstant : 0)
        self.bLayoutHeightConstraint.isActive = true

        self.lLayoutGuide.topAnchor.constraint(equalTo: self.topView.bottomAnchor).isActive = true
        self.lLayoutGuide.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
        self.lLayoutGuide.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
        self.lLayoutWidthConstraint = self.lLayoutGuide.widthAnchor.constraint(equalToConstant: isPortrait() ? 0 : sizeConstant)
        self.lLayoutWidthConstraint.isActive = true

        self.stackView.topAnchor.constraint(equalTo: self.backgroundView.topAnchor).isActive = true
        self.stackView.bottomAnchor.constraint(equalTo: self.backgroundView.bottomAnchor).isActive = true
        self.stackView.leadingAnchor.constraint(equalTo: self.backgroundView.leadingAnchor).isActive = true
        self.stackView.trailingAnchor.constraint(equalTo: self.backgroundView.trailingAnchor).isActive = true

        self.bottomConstraints = [
            self.backgroundView.topAnchor.constraint(equalTo: self.bLayoutGuide.topAnchor),
            self.backgroundView.leadingAnchor.constraint(equalTo: self.bLayoutGuide.leadingAnchor),
            self.backgroundView.trailingAnchor.constraint(equalTo: self.bLayoutGuide.trailingAnchor),
            self.backgroundView.heightAnchor.constraint(equalToConstant: sizeConstant)
        ]

        self.leftConstraints = [
            self.backgroundView.topAnchor.constraint(equalTo: self.lLayoutGuide.topAnchor),
            self.backgroundView.bottomAnchor.constraint(equalTo: self.lLayoutGuide.bottomAnchor),
            self.backgroundView.trailingAnchor.constraint(equalTo: self.lLayoutGuide.trailingAnchor),
            self.backgroundView.widthAnchor.constraint(equalToConstant: sizeConstant)
        ]

        if isPortrait() {

            NSLayoutConstraint.activate(self.bottomConstraints)

        } else {

            NSLayoutConstraint.activate(self.leftConstraints)
        }
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

        let willBePortrait = size.width < size.height

        coordinator.animate(alongsideTransition: {

            context in

            let halfDuration = context.transitionDuration / 2.0

            UIView.animate(withDuration: halfDuration, delay: 0, options: .overrideInheritedDuration, animations: {

                self.bLayoutHeightConstraint.constant = 0
                self.lLayoutWidthConstraint.constant = 0
                self.view.layoutIfNeeded()

            }, completion: {

                _ in

                // HERE IS THE ISSUE!

                // Putting this inside `performWithoutAnimation` did not helped
                if willBePortrait {

                    self.stackView.axis = .horizontal
                    NSLayoutConstraint.deactivate(self.leftConstraints)
                    NSLayoutConstraint.activate(self.bottomConstraints)

                } else {

                    self.stackView.axis = .vertical
                    NSLayoutConstraint.deactivate(self.bottomConstraints)
                    NSLayoutConstraint.activate(self.leftConstraints)
                }
                self.view.layoutIfNeeded()

                UIView.animate(withDuration: halfDuration) {

                    if willBePortrait {

                        self.bLayoutHeightConstraint.constant = sizeConstant

                    } else {

                        self.lLayoutWidthConstraint.constant = sizeConstant
                    }
                    self.view.layoutIfNeeded()
                }
            })
        })

        super.viewWillTransition(to: size, with: coordinator)
    }

    func isPortrait() -> Bool {

        let size = UIScreen.main.bounds.size
        return size.width < size.height
    }
}

Here are a few screenshots of the issue I'm unable to solve. Look closely at the corners:

enter image description here enter image description here enter image description here enter image description here

I'd assume that after reactivating different constraint array and force recalculation, the view would immediately snap to the layout guide, but as shown, it doesn't. Furthermore I don't understand why the red view is not in sync with the stack view, even if the stackview should always follow it's superview, which here is the red view.

PS: The best way to test it is the iPhone X Plus simulator.

Upvotes: 16

Views: 1528

Answers (2)

SwiftArchitect
SwiftArchitect

Reputation: 48542

Use Size Classes

An entirely different approach to smoothly animate the toolbar animation is to leverage on the autoLayout size classes, specifically hR (height Regular) and hC (height Compact), and create different constraints for each.

Horizontal to vertical
↻ replay animation

  • A further improvement is to actually use two distinct toolbars, one for the vertical display, and one for the horizontal one. This is not by any mean a requirement, but it solves the resizing of the toolbar itself (†).

  • A final refinement is to implement these changes in Interface Builder, yielding exactly 0 lines of code, which of course is not mandatory either.

Vertical to Horizontal
↻ replay animation


Zero Lines of Code

None of the proposed solutions tinker with UIViewControllerTransitionCoordinator which not only greatly simplify the source code development and maintenance, it also doesn't need to rely on hardcoded values or supporting utilities. You also get a preview in Interface Builder. And once finalized in IB, you can still convert the logic to runtime programming if it is an absolute requirement.

  • Notice that the UIStackView is embedded in the toolbar, and thus follows the animation. You can control the amount of swing of the toolbars out of sight by a constant ; I picked 1024 so that they move quickly out of the screen, and only reappear at the end of the transition.

    Smooth

  • (†) Further leveraging on Interface Builder and size classes, you may still use a single toolbar, but if you do so it will resize during the transition. Again, the UIStackView is embedded, and its orientation is, too, size classes dependent, and the OS handles all the animation without the need to create a coordinator:

    Size Classes

    Smooth too


► Find this solution on GitHub and additional details on Swift Recipes.

Upvotes: 6

Kevin Aleman
Kevin Aleman

Reputation: 393

I'd assume that after reactivating different constraint array and force recalculation, the view would immediately snap to the layout guide, but as shown, it doesn't.

This isn't happening because you are activating/deactivating your constraints inside of the coordinator.animate(alongsideTransition:completion:) method. When you use this method, everything inside of the animation block will be animated along with your view controller's transition, so there will be no immediate snaps to the layout guide. If you wanted the red view and green view to immediately snap to their new positions before the rotation animation, then animate to fill their desired positions as the view controller is animating, then you could do something like this:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

    let willBePortrait = size.width < size.height

    self.bLayoutHeightConstraint.constant = 0
    self.lLayoutWidthConstraint.constant = 0

    if willBePortrait {
        self.stackView.axis = .horizontal
        NSLayoutConstraint.deactivate(self.leftConstraints)
        NSLayoutConstraint.activate(self.bottomConstraints)
    } else {
        self.stackView.axis = .vertical
        NSLayoutConstraint.deactivate(self.bottomConstraints)
        NSLayoutConstraint.activate(self.leftConstraints)
    }
    self.view.layoutIfNeeded()

    coordinator.animate(alongsideTransition: { context in
        let halfDuration = context.transitionDuration / 2
        UIView.animate(withDuration: halfDuration, delay: halfDuration, animations: {
            if willBePortrait {
                self.bLayoutHeightConstraint.constant = sizeConstant
            } else {
                self.lLayoutWidthConstraint.constant = sizeConstant
            }
            self.view.layoutIfNeeded()
        })
    })

    super.viewWillTransition(to: size, with: coordinator)
}

EDIT: If you want to animate the red view out while transitioning, and then animate the red view back in at the end, then that's a good time to use the coordinator, as the animation out can happen in the animation block and you can split the out- and in- blocks inside of it:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

    let willBePortrait = size.width < size.height

    if willBePortrait {
        self.stackView.axis = .horizontal
        NSLayoutConstraint.deactivate(self.leftConstraints)
        NSLayoutConstraint.activate(self.bottomConstraints)
    } else {
        self.stackView.axis = .vertical
        NSLayoutConstraint.deactivate(self.bottomConstraints)
        NSLayoutConstraint.activate(self.leftConstraints)
    }
    self.view.layoutIfNeeded()

    coordinator.animate(alongsideTransition: { context in
        let halfDuration = context.transitionDuration / 2

        UIView.animate(withDuration: halfDuration, animations: {
            if willBePortrait {
                self.lLayoutWidthConstraint.constant = 0
            } else {
                self.bLayoutHeightConstraint.constant = 0
            }
        })
        UIView.animate(withDuration: halfDuration, delay: halfDuration, animations: {
            if willBePortrait {
                self.bLayoutHeightConstraint.constant = sizeConstant
            } else {
                self.lLayoutWidthConstraint.constant = sizeConstant
            }
            self.view.layoutIfNeeded()
        })

    })
    super.viewWillTransition(to: size, with: coordinator)
}

Upvotes: 1

Related Questions