Bronze Challenge: Implementing StackView in The Quiz App and keeping the Animation

Hallo fellow ranchers!
I am sitting on the porch of my little swift code farm in Germany and noticed, that no body has posted a solution to use a StackView in the Quiz app and still animate it. I have tackled the problem and thought I share my experience here and my solution as well. I have to admit, that the end result is a bit different Animation then we had before, but I think it delivers the same impression.
Now I want to share my little journey, that brought me to my conclusion or idea:

tl/dr: Spoilers ahead! StackView manages a value .isHidden for each View it well stacks. This value can be changed during runtime, and this change can be animated. Also the StackView has a property called arrangedSubviews which is an array of all the views the stack displays. So the way I chose to go was to hide the currentQuestionLabel, unhide the nextQuestionLabel, and then put the currentQuestionLabel at the 0 Position in the Array so I could repeat the identical process again

First I put the current- and nextQuestionLabel in a horizontal stack, and than this new questionStack in a vertical StackView together with the answerLabel and the showAnswerButton.

As I now tried to animate the Center X Constraints of the two labels as before, I was startled. Both labels were gone! What~@!? Even in the Debug View Hierarchy it was hard to make them out or what had happened to them. I think somehow their width went to zero, or something…
"Well, lets apply some constraints to thees bloody labels then!“ someone might say. But the text of the labels varies, so the constraint would have to be very dynamic as well. Which means one would have to calculate it and maybe store it and I thought there simply must be a more easy maybe even an elegant solution.
So I started my research into StackViews

As a starting point I used the idea, that a StackView can hide his Subviews, which was nicely hinted at in the chapter itself. Thanks! :slight_smile: After a little reading I already had learned, that this subview hiding would be animated. All managed by the bloody StackView itself. “Wonderful! I am obviously on the right track!” I exclaimed in joy.
After implementing this I had already an animated changing of the asked question. First I would hide the current question and unhide the next one. That would result in the currentQuestionLabel swishing out to the right side of the screen into the abyss and the nextQuestionLabel appearing from behind where the other would sit just a second ago. After that I would turn it around. Hide the nextQuestionLable and unhide the other one. Now the nextQuestionLabel would move swiftly to the right side of the screen and beyond, where no questionLabel had gone before and the currentQuestionLabel would appear again from behind.
This was nice but not as nice as the way we had it before. Since it also conways a different meaning. Right!? It feels more like there are two parties taking turns in asking you simple questions. Questions one could easily look up, or just answer them them self… why is my phone asking me!? Normally I ask it! Not vice versa! What has happened to Siri? Why? Is he okay?? (yes my Siri is a man, a MAN!, as should be yours by the way!

Back to the story! I had an Idea. While I typed the commands to hide the view I noticed that it uses subscript syntax, on a property called arrangedSubviews. Well, Well… could that be a list… of all the subviews that the stack a r a n g e s?
Well off course! Why would you ask such a obvious question? After reading up on this I also learned, that the StackView indeed keeps an array of all the views it displays and manages. I wondered if it would be possible to simply change the order of views in this array, so that the question that is hidding always moves out to the right side.
As you might guess this assumption was indeed right. After some testing I found the shortest route to achieve my goal. And this is the point where I want to stop this dissipated exposition and simply show you my actual code.
After that I have some final thoughts and remaining Questions.

class ViewController: UIViewController {
    
    @IBOutlet var currentQuestionLabel: UILabel!
    @IBOutlet var nextQuestionLabel: UILabel!

    @IBOutlet var answerLabel: UILabel!
    
    @IBOutlet var questionsStackView: UIStackView!

    let questions: [String] = ["From what is cognac made?",
                               "What is 7+7?",
                               "What is the capital of Vermont?",
                               "What are the first four numbers of Pi?",
                               "How is the weather?"]
    let answers: [String] = ["Grapes",
                             "14",
                             "Montepelier",
                             "3,141",
                             "It's great, of course!"]
    
    var currentQuestionIndex: Int = 0
    
    // Declaring two slots, for both question UIViews
    var currentQuestionInStack : UIView!
    var nextQuestionInStack: UIView!
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // set the label's initial alpha
        nextQuestionLabel.alpha = 0
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        currentQuestionLabel.text = questions[currentQuestionIndex]
    }
    
    
    func animateLabelTransitions() {
        // Force any outstanding layout changes to occur
        view.layoutIfNeeded()
        
        UIView.animate(withDuration: 1,
                                            delay: 0,
                                            usingSpringWithDamping: CGFloat(0.9),
                                            initialSpringVelocity: CGFloat(0),
                                            options: [.curveLinear],
                                            animations: {
                                                
                                                // change the alpha of the two Labels
                                                self.currentQuestionLabel.alpha = 0
                                                self.nextQuestionLabel.alpha = 1
                                                
                                                // Because the Stack View is hiding the Views and not the Labels we will use the two slots we declared earlier.
                                                // We tell the StackView to hide and unhide each View, and the StackView takes care of the animation.
                                                self.currentQuestionInStack = self.questionsStackView.arrangedSubviews[1]
                                                self.nextQuestionInStack = self.questionsStackView.arrangedSubviews[0]
                                                self.nextQuestionInStack.isHidden = false
                                                self.currentQuestionInStack.isHidden = true
                                                
                                                self.view.layoutIfNeeded()
            },
                                            completion: { _ in
                                                // Change the reference to the next- and current question label **and View**
                                                swap(&self.currentQuestionLabel, &self.nextQuestionLabel)
                                                swap(&self.currentQuestionInStack, &self.nextQuestionInStack)**
                                                
                                                // Put the nextQuestionLable at the first position in the questionsStackView array
                                                self.questionsStackView.insertArrangedSubview(self.nextQuestionInStack, at: 0)

        })
    }
    
    @IBAction func showNextQuestion(_ sender: AnyObject) {
        currentQuestionIndex += 1
        if currentQuestionIndex == questions.count {
            currentQuestionIndex = 0
        }
        let question: String = questions[currentQuestionIndex]
        nextQuestionLabel.text = question
        answerLabel.text = "???"
        
        animateLabelTransitions()
    }
    
    @IBAction func showAnswer(_ sender: AnyObject) {
        let answer: String = answers[currentQuestionIndex]
        answerLabel.text = answer
    }

}

So You will see, that the animation is different, because the next question is not coming in from the left, like before, but more from behind, like some gangster with a knife. Still I think the “feel” of it is quiet similar.

Regarding my first approach I am still not sure what happened to the labels, when I tried to animate their center X constraint. If anyone knows, I would be happy to hear that.
I also wonder about my usage of the .insertArrangedSubview() method. At first I used it in cooperation with the .removeArrangedSubview() method. But in practice I could not see any difference so I simply got rid of it. Am I right? No honestly! Is there something I missed? What do you think?
A last thought: I use the currentQuestionInStack and nextQuestionInStack variables to simply store a reference to the view, so I can easily switch them after the animation is done and simply reuse my code for the next transition. Do you think there is a more elegant way to do this?

Okay. Now I am really done. I will now go on do the world Trotter challenge. Probably have to reprogram that whole thing, because it was so long ago and I can not remember what I did there. We’ll see.
Best Wisches from Germany
BrutusD

BrutusD,

You did it the hard way.

As you put both labels in a horizontal stack all you need to do is just to animate isHidden property of these labels.

override func viewDidLoad() {

    super.viewDidLoad()
    currentQuestionLabel.text = questions[currentQuestionIndex]
    
    updateOffScreenLabel()                    // This sets up initial state
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    //nextQuestionLabel.alpha = 0            //- Comment or delete this line as harmful
}

func animateLabelTransitions() {
    view.layoutIfNeeded()                      //Forces any outstanding layout changes to occur
    
    UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.2, initialSpringVelocity: 0.1, options: [.curveLinear], animations: {
        
        //MARK: - Bronze Challenge Chapter 13  //Animating the isHidden property
        self.updateOffScreenLabel()
        
        self.view.layoutIfNeeded()
    }) { _ in
        swap(&self.currentQuestionLabel, &self.nextQuestionLabel)
    }
}

func updateOffScreenLabel() {
    
    //MARK: - Bronze Challenge Chapter 13      //Toggling the isHidden property

    if nextQuestionLabel.isHidden == true {
        currentQuestionLabel.isHidden = true
        nextQuestionLabel.isHidden = false
    } else {
        nextQuestionLabel.isHidden = true
        currentQuestionLabel.isHidden = false
    }
    
}

I did struggle with the constraints animation too. Sometimes a label was seen but in a wrong place. You need to add additional centerX constrains between each label and their parent UIStackView with clipping this UIStackView.width to the screen bounds with proper stack settings. All those efforts are buggy and time consuming making the usage of UIStackView in this case painful time waste and too complicated to be called ‘Bronze Challenge’.

Trying to get the question slide-in/slide-out to work directly in a stack view is no simple task. Everything I tried resulted in auto layout constraint conflicts. In order to get the animations to work I suggest wrapping all the objects in a Vertical Stack View. Then take the following steps:

  1. Add a standard View as the top item in the vertical stack view
  2. Set a height constraint on the new View (I went with 64 points)
  3. Drag the current and next question labels into the new nested view
  4. Create an outlet
    @IBOutlet var questionView: UIView!
  5. Connect the nested view to the outlet
  6. Add top and horizontally centered constraints to the two question labels in respect of the questionView (I went with 24 points from top here)
  7. Reconnect the centered label constraint outlets to the label center X constraints
  8. Update to compute location of view when sliding in to use the questionView.centerXAnchor

Below are the relevant bits in my ViewController based on the above changes:

@BOutlet var currentQuestionLabel: UILabel!
@IBOutlet var currentQuestionLabelCenterXConstraint: NSLayoutConstraint!
@IBOutlet var nextQuestionLabel: UILabel!
@IBOutlet var nextQuestionLabelCenterXConstraint: NSLayoutConstraint!
@IBOutlet var answerLabel: UILabel!
@IBOutlet var questionView: UIView!

var layoutGuide: UILayoutGuide! = UILayoutGuide()

override func viewDidLoad() {
    super.viewDidLoad()
    
    currentQuestionLabel.text = questions[currentQuestionIndex]
    view.addLayoutGuide(layoutGuide)
    layoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
    layoutGuide.trailingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
    updateOffScreenLabel()
}

func updateOffScreenLabel() {
    
    nextQuestionLabelCenterXConstraint.isActive = false
    nextQuestionLabelCenterXConstraint = nextQuestionLabel.centerXAnchor.constraint(equalTo: layoutGuide.centerXAnchor)
    nextQuestionLabelCenterXConstraint.isActive = true
}

func animateLabelTransitions() {
    
    // Force any outstanding layout changes to occur
    view.layoutIfNeeded()
    
    // Animate the alpha and center the X constraints
    let screenWidth = view.frame.width
    currentQuestionLabelCenterXConstraint.constant += screenWidth
    nextQuestionLabelCenterXConstraint.isActive = false
    nextQuestionLabelCenterXConstraint = nextQuestionLabel.centerXAnchor.constraint(equalTo: questionView.centerXAnchor)
    nextQuestionLabelCenterXConstraint.isActive = true
    
    view.isUserInteractionEnabled = false
    
    UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 1, options: [.curveLinear], animations: {
        self.currentQuestionLabel.alpha = 0
        self.nextQuestionLabel.alpha = 1
        
        self.view.layoutIfNeeded()
    }, completion: { _ in
        swap(&self.currentQuestionLabel, &self.nextQuestionLabel)
        swap(&self.currentQuestionLabelCenterXConstraint, &self.nextQuestionLabelCenterXConstraint)
        
        self.updateOffScreenLabel()
        self.view.isUserInteractionEnabled = true
    })
}