Search code examples
iosiphoneuiviewcore-animationuiviewpropertyanimator

UIViewPropertyAnimator issue with Autolayout


Here is the code of what I tried to repeat according to Apple WWDC but with autolayout:

    extension AugmentedReallityViewController {
    @objc func handlePan(recognizer: UIPanGestureRecognizer) {
//        // hide languages and units anyway
//        moveUnitView(show: false)
//        moveLanguageView(show: false)
//
//        let isNowExpanded = settingsPanelState == SettingsPanelState.expanded
//        let newState = isNowExpanded ? SettingsPanelState.collapsed : SettingsPanelState.expanded
//
//        switch recognizer.state {
//        case .began:
//            startInteractiveTransition(state: newState, duration: 1)
//            isLastPanelUpdateToReachTheNewState = true // just in case, but we should change this property later
//        case .changed:
//            let translation = recognizer.translation(in: viewSettings)
//            let fractionComplete = translation.y / viewSettings.frame.size.height
//
//            // we will use this property when interaction ends
//            if fractionComplete != 0 { // if it's == 0 , we need to use prev data
//                isLastPanelUpdateToReachTheNewState = (newState == SettingsPanelState.expanded && fractionComplete < 0) || (newState == SettingsPanelState.collapsed && fractionComplete > 0)
//            }
//
//            updateInteractiveTransition(fractionComplete: fractionComplete)
//        case .ended:
//            continueInteractiveTransition(cancel: !isLastPanelUpdateToReachTheNewState)
//        default:
//            break
//        }
    }

    @objc func handleSettingsTap() {
        // hide languages and units anyway
        moveUnitView(show: false)
        moveLanguageView(show: false)

        let isNowExpanded = settingsPanelState == SettingsPanelState.expanded
        let newState = isNowExpanded ? SettingsPanelState.collapsed : SettingsPanelState.expanded

        animateOrReverseRunningTransition(state: newState, duration: 10)
    }

    // perform all animations with animators if not already running
    private func animateTransitionIfNeeded(state: SettingsPanelState, duration: TimeInterval) {

        if runningAnimators.isEmpty {

//            // define constraint for frame animation
//            // update constraints
//            switch state {
//            case .expanded:
//                constraint_settingsView_bottom.constant = 0
//            case .collapsed:
//                constraint_settingsView_bottom.constant = -constraint_height_settingViewWhitePart.constant
//            }
            // animate that
            let frameAnimator = UIViewPropertyAnimator(duration: duration, curve: .linear, animations: { [weak self] in
                if let strongSelf = self {
                    // define constraint for frame animation
                    // update constraints
                    switch state {
                    case .expanded:
                        strongSelf.constraint_settingsView_bottom.constant = 0
                    case .collapsed:
                        strongSelf.constraint_settingsView_bottom.constant = -(strongSelf.constraint_height_settingViewWhitePart.constant)
                    }
                }

                self?.view.layoutIfNeeded()
            })
            frameAnimator.startAnimation()
            runningAnimators.append(frameAnimator)
            frameAnimator.addCompletion({ [weak self] (position) in
                if position == UIViewAnimatingPosition.end { // need to remove this animator from array
                    if let index = self?.runningAnimators.index(of: frameAnimator) {
                        print("removed animator because of completion")
                        self?.runningAnimators.remove(at: index)
                        // we can change state to a new one
                        self?.settingsPanelState = state
                    }
                    else {
                        print("animator completion with state = \(position)")
                    }
                }
            })
        }
    }

    // starts transition if neccessary or reverses it on tap
    private func animateOrReverseRunningTransition(state: SettingsPanelState, duration: TimeInterval) {
        if runningAnimators.isEmpty { // start transition from start to end
            animateTransitionIfNeeded(state: state, duration: duration)
        }
        else { // reverse all animators
            for animator in runningAnimators {
                animator.stopAnimation(true)
                animator.isReversed = !animator.isReversed
                // test
                print("tried to reverse")
            }
        }
    }

    // called only on pan .begin
    // starts transition if neccessary and pauses (on pan .begin)
    private func startInteractiveTransition(state: SettingsPanelState, duration: TimeInterval) {
        animateTransitionIfNeeded(state: state, duration: duration)
        for animator in runningAnimators {
            animator.pauseAnimation()

            // save progress of any item
            progressWhenInterrupted = animator.fractionComplete
        }
    }

    // scrubs transition on pan .changed
    private func updateInteractiveTransition(fractionComplete: CGFloat) {
        for animator in runningAnimators {
            animator.fractionComplete = fractionComplete + progressWhenInterrupted
        }
    }

    // continue or reverse transition on pan .ended
    private func continueInteractiveTransition(cancel: Bool) {
        for animator in runningAnimators {
            // need to continue or reverse
            if !cancel {
                let timing = UICubicTimingParameters(animationCurve: .easeOut)
                animator.continueAnimation(withTimingParameters: timing, durationFactor: 0)
            }
            else {
                animator.isReversed = true
            }
        }
    }

    private func addPanGustureRecognizerToSettings() {
        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(AugmentedReallityViewController.handlePan(recognizer:)))
//        panGestureRecognizer.cancelsTouchesInView = false
        viewSettings.addGestureRecognizer(panGestureRecognizer)
    }

    private func addTapGestureRecognizerToSettings() {
        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AugmentedReallityViewController.handleSettingsTap))
        tapGestureRecognizer.cancelsTouchesInView = false
        viewSettingsTopTriangle.addGestureRecognizer(tapGestureRecognizer)
    }
}

Right now I'm just testing tap gestures. And there are 2 main issues:

1) Tap recognizer doesn't work properly during animation. But in apple WWDC they changed frames (not constraints like in my case) and tap recognizers worked perfectly

2) If I change reverse property it changes constraints really very bad. I have extra strips and so on

3) I tried both ways to put changing constraint before animation block and inside. It doesn't really matter, works the same

Any help how to do that with autolayout? Or at least how to do it with frames but my view controller is based on autolayout, so anyway I will have constraints to this bottom view.


Solution

  • When you are using autolayout for animations, you do it as follows:

    1. Make sure autolayout is done:

      self.view.layoutIfNeeded()
      
    2. Then you change the constraints BEFORE the animation block. So for example:

      someConstraint.constant = 0
      
    3. Then after changing the constraint, you tell the autolayout that constraints have been changed:

      self.view.setNeedsLayout()
      
    4. And then you add an animation block with simply calling layoutIfNeeded():

      UIView.animate(withDuration: 1, animations: {
          self.view.layoutIfNeeded()
      })
      

    The same applies when you use UIViewPropertyAnimator - change the constraints in the animation block. E.g.:

    self.view.layoutIfNeeded()
    someConstraint.constant = 0
    self.view.setNeedsLayout()
    let animator = UIViewPropertyAnimator(duration: 1, curve: .easeInOut) {            
        self.view.layoutIfNeeded()
    }
    animator.startAnimation()
    

    This happens because layoutIfNeeded() does the actual layout - it calculates the frames of the affected views. So if you are setting frames directly, you set them in the animation block. However, Autolayout sets the frames for you - therefore what you need is to tell the autolayout to set them in the animation block (as you would do, if you would set them directly). The layoutIfNeeded() call does exactly that - it tells the autolayout engine to calculate and set the new frames.

    About reversal:

    While I don't have enough experience to be 100% sure, I would expect that simply setting the animator to reverse would not suffice. Since you apply the constraints before starting the animation, and then you just tell the autolayout to update frames according to the constraints - I would assume that when you reverse the animator, you would also need to reverse also the constraints that are driving the animation.

    Animator just animates views into new frames. However, reversed or not, the new constraints still hold regardless of whether you reversed the animator or not. Therefore after the animator finishes, if later autolayout again lays out views, I would expect the views to go into places set by currently active constraints. Simply said: The animator animates frame changes, but not constraints themselves. That means reversing animator reverses frames, but it does not reverse constraints - as soon as autolayout does another layout cycle, they will be again applied.