In the first part of this tutorial we set up the view controller and created a Glide class where we wrote code regarding segmentation, calculated the segmentation heights and implemented the pan gesture recognizer.

In this part we will do the following things:

  1. Implement animations from one state to another.
  2. Animate alpha value with user’s swipe.
  3. Figure out a way to detect scrollViews inside the card view controller and handle the scroll view’s pan gesture and the card’s panGesture which we have implemented previously.

Animations

Let’s start with the animations. We will be animating cards from one state to the other depending upon the direction of the pan gesture recognizer. Here is how:

Let’s write a method and call it showCard(state: )

guard let card = self.card else { return }
        guard let container = self.containerView else { return }  
        self.window.layoutIfNeeded()

        if (configuration.segmented) {

            switch state {
            case .compressed:
                guard let compressedSegmentHeight = calculatedSegmentHeightsDictionary\[.compressed\] else {
                    print("No Compressed Segment Height in Configuration File")
                    return }
                cardTopAnchorConstraint!.constant = compressedSegmentHeight


            let showCard = UIViewPropertyAnimator(duration: 0.8, dampingRatio: 1, animations: {  self.window.layoutIfNeeded() })
                showCard.addAnimations {
                    card.view.layer.cornerRadius = 15
                    self.blackView.alpha = 0.4
                 }

                showCard.addCompletion { position in
                    switch position {
                    case .end:
                        self.currentState = .compressed
                        print(self.currentState)
                    default:
                        ()
                    }
                }

                showCard.startAnimation()

                if showCard.isRunning {
                    if let detectedScrollView = detectScrollView() {
                        detectedScrollView.panGestureRecognizer.isEnabled = false
                    }
                }
                break

In this method we are calling a Switch case statement on all three states. I have implemented only one case, but the remaining two will do exactly the same thing for respective dimensions/constraints constant. First we check if the segmentation is enabled or not. If enabled then we use the Switch case to go through the compressed state. We use the calculatedSegentHeightsDictionary[.compressed] to grab the constraint constant which we will apply to the cardTopAnchorConstraint.

Then we will use the UIViewPropertyAnimator.  We create a property animator calling it showCard and providing it with duration of 0.8, and damping ratio of 1 and then simply pass the self.window.layoutIfNeeded() that will refresh the layout and animate the card change from .open to .compressed. Then we will add some more animations to the property view animator. Firstly being the cornerRadius of the card and then blackView’s alpha value to 0.4. We can add completion code as well so as soon as the animation completes the currentState gets updated to .compressed

Then simply do showCard.startAnimation(). The code after this line till break is the code that I added later after I realized that there is a slight bug when the user swipes down the card from .open state to .compressed. Without the above code, the scrollView is scrollable that cause issues with the scrolling logic which we will implement later on. Basically we don’t want any scrolling to the place when the card is in closed position or compressed position. In fact the gesture of scrolling should expand the card from the closed state to compressed state and finally to open state. Once the open state has been reached only then we can scroll through the scrollView.

In order to fix this, I implement this logic:

  if showCard.isRunning {
      if let detectedScrollView = detectScrollView() {
          detectedScrollView.panGestureRecognizer.isEnabled = false
       }
   }

If the animation is running, then I am using a detectScrollView() method, more on this later to grab the scrollView if it is available inside the card view controller and disabling it’s panGestureRecognizer.

In doing so, the user is not able to scroll while the card is in transit from .open to .compressed state.

The code for the remaining two states are pretty similar and just require few changes, for example .alpha value should be 0 for the closed state and the respective calculated segment heights must be assigned to the constraints for respective states.

Animating Alpha Values:

When the user animates the card from closed state to the compressed state notice how the alpha value goes to it’s max set value with card’s position in the screen. This is achieved in the following method. Let’s take a look at it:

    private func dimAlphaWithCardTopConstraint(value: CGFloat) -> CGFloat {
      let fullDimAlpha : CGFloat = 0.4

      guard let safeAreaHeight = UIApplication.shared.keyWindow?.safeAreaLayoutGuide.layoutFrame.size.height,
        let bottomPadding = UIApplication.shared.keyWindow?.safeAreaInsets.bottom else {
        return fullDimAlpha
      }

      let fullDimPosition = (safeAreaHeight + bottomPadding) / 2.0

      let noDimPosition = (safeAreaHeight + bottomPadding - (headerHeight ?? 0))

      if value < fullDimPosition { return fullDimAlpha }
      if value > noDimPosition {
        return 0.0
      }

      return fullDimAlpha - fullDimAlpha \* ((value - fullDimPosition) / fullDimPosition)
    }

First I have created a fullDimAlpha property that is equal to the max alpha value you require when the card is in the open state. Then we grab the safeAreaHeight and bottom inset heights. Next property is fullDimPosition - the position at which the alpha should be equal to the fullDimAlpha. This should be equal to the height of the open state or compressed state. However, I have made it simple for this version. The alpha value of the blackView should reach 0.4 (fullDimAlpha) when the card reaches the height that is half of the parent view controller. If segmentation is present, then from compressed state to open state the alpha value remains the same. However from compressed to closed state the alpha value changes.

I might make it more dynamic - so that it works precisely with the open or compressed state dimensions - something for the next version!

Now the actual method takes in a parameter called value as well.

The next property we define is noDimPosition. In this we have the safeAreaHeight + bottomPadding minus any headerHeight. So now we have two extreme values. Between these two variables we will be monitoring the progress of the swipe/card position and return the alpha value.

If the card position is above the fullDimPosition then simply return the fullDimAlpha value or if the position of the card is below the noDimPosition then return 0. If the card is between the two variables then do this:

fullDimAlpha - fullDimAlpha \* ((value - fullDimPosition) / fullDimPosition)

Whatever value we are getting from the method argument minus the fullDimPosition divided by fullDimPosition. What we are doing here is basically calculating how much the card value has moved between the noDimPosition and fullDimPosition and how much that equates to the fullDimAlpha value. So if the card has covered 50% of the distance between the noDimPosition and fullDimPosition, then the alpha value should be 50% of the max value as well.

We are using this method in the panGesture handle method.

case .changed:
       let translationY = recognizer.translation(in: card.view).y
       if self.startingConstant + translationY > 0 {
         self.cardTopAnchorConstraint!.constant = self.startingConstant + translationY
         blackView.alpha = dimAlphaWithCardTopConstraint(value: cardTopAnchorConstraint!.constant)
       }

Inside the .changed case, we are setting the topAnchor constraint constant and then also passing this value to the dim alpha method.

Handling Scroll Views:

The next thing we will talk about is handling any scrollView that the card view controller may contain. We need to come up with a way to switch between the panGesture recognizer and the scrollView at the right state and time. So let’s start with detecting the scrollView.

This is a method that I have created that returns an optional UIScrollView:

    func detectScrollView() -> UIScrollView? {
        guard let cardViewController = self.card else { return nil}
        var detectedScrollView : UIScrollView? = UIScrollView()
        for subview in cardViewController.view.subviews {
               if let view = subview as? UIScrollView {
                   detectedScrollView = view
               }
           }
        return detectedScrollView
    }

I am using a simple for loop that iterates through all the subviews of the card View controller and checks if the subview is of type UIScrollView or not. Even if you have a tableView implemented or a collectionView - all these classes inherit the UIScrollView class. The detected view is then returned and can be used in the next method.

NOTE: This is a very basic implementation of detecting scroll views method. A more advanced version could be if there are more than one scrollView in a particular view controller and to be able to correctly identify which scrollView should be tracked. 

func gestureRecognizer(\_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let panGestureRecognzier = gestureRecognizer as? UIPanGestureRecognizer else { return true }
        guard let cardViewController = self.card else { return false}

        guard let detectedScrollView = detectScrollView() else { return false}


        let velocity = panGestureRecognzier.velocity(in: cardViewController.view)
        detectedScrollView.panGestureRecognizer.isEnabled = true

         detectedScrollView.delegate = self

        if otherGestureRecognizer == detectedScrollView.panGestureRecognizer {
            switch currentState {
            case .compressed:
                detectedScrollView.panGestureRecognizer.isEnabled = false
                return false
            case .closed:
                detectedScrollView.panGestureRecognizer.isEnabled = false
                return false
            case .open:
                if velocity.y > 0.0 {
                    if detectedScrollView.contentOffset.y > 0.0 {
                        return true
                    }
                    shouldHandleGesture = true
                    detectedScrollView.panGestureRecognizer.isEnabled = false
                    return false
                }else {
                    shouldHandleGesture = false
                    return true
                }
            default:
                ()
            }
        }

        return false
    }

The shouldRecognizeSimultaneously method is a UIGestureRecognizerDelegate method and for this to work you need to conform to the UIGestureRecognizerDelegate and make sure to assign panGesture’s delegate to self.

 class Glide : NSObject, UIGestureRecognizerDelegate, UIScrollViewDelegate
 gestureRecognizer.delegate = self // Make sure to assign the delegate of panGestureRecognizer to self

Now lets talk about what is happening in the shouldRecognizeSimultaneously  method. In this method we get two gesture recognizer and we will apply few conditions as to when both the detectedScrollView’s scrollView can work and when the pan gesture recognizer should work and when both these recognizers should work. The otherRecognizer is scrollView’s pan gesture recognizer whereas panGestureRecognizer is the main card’s gesture recognizer we have implemented.

Let’s take a look at another bool property called shouldHandleGesture. This boolean property is like a flag here. If this is true then all the code inside handlePanRecognizer will work and if it is false then the pan gesture code won’t work. We have implemented this in handlePanRecognizer method here:

@objc func handlePanRecognizer(recognizer: UIPanGestureRecognizer) {
            guard let safeAreaLayout = UIApplication.shared.keyWindow?.safeAreaLayoutGuide.layoutFrame.height else { return }
            guard let bottomAreaInset = UIApplication.shared.keyWindow?.safeAreaInsets.bottom else { return }
         **guard shouldHandleGesture else { return } // This should stop any bottom code from running if false, else gesture code should work fine.**
            guard let container = self.containerView else { return }
            guard let card = self.card else { return }

             switch recognizer.state {

             case .began:
              //
             case .ended:
              //

Now going back to the shouldRecognizeSimultaneously,

switch currentState {
            case .compressed:
                detectedScrollView.panGestureRecognizer.isEnabled = false
                return false
            case .closed:
                detectedScrollView.panGestureRecognizer.isEnabled = false
                return false
            case .open:
                if velocity.y > 0.0 {
                    if detectedScrollView.contentOffset.y > 0.0 {
                        return true
                    }
                    shouldHandleGesture = true
                    detectedScrollView.panGestureRecognizer.isEnabled = false
                    return false
                }else {
                    shouldHandleGesture = false
                    return true
                }

In the switch statement we are checking the current state of the card and if it is .compressed  then that means both gestures shouldn’t work simultaneously so we will first disable the detectedScrollView’s panGestureRecognizer and return false. Similarly in the .closed position. In the .open position, we check if the user is scrolling upwards . Then we check if the scrollView’s contentOffset is greater than 0 or not which means if there is still some content to go and we have not reached the top of the scrollView. During this we are returning true.

Since we are simultaneously recognizing both gestures, as soon as the top of the scrollview is reached, we assign the bool value of shouldHandleGesture  to true and disable the scrollView’s pan gesture recognizer and return false which causes the card to move down to the compressed state.

If the user is scrolling the card downwards, keep recognizing both gestures but make sure that the pan gesture doesn’t run the code. (shouldHandleGesture = false). 

Then we will use a UIScrollViewDelegate function called scrollViewDidScroll

    func scrollViewDidScroll(\_ scrollView: UIScrollView) {
           let contentOffset = scrollView.contentOffset.y
           if contentOffset <= 0.0 &&
               currentState == .open &&
               gestureRecognizer.velocity(in: gestureRecognizer.view).y != 0.0 {
               shouldHandleGesture = true
               scrollView.isScrollEnabled = false
               scrollView.isScrollEnabled = true
           }
    }

Which is pretty much doing the same thing - if the top of the scroll view is reached and the user wants to scroll upwards and the current state is .open then make the shouldHandleGesture boolean true and the card will then move down to the compressed state etc.

glideUI.gif

That is about it really! We have a fully functional Interactive card based UI ready. The UI is designed in such that it could be reused easily.Most of the logic takes place inside the Glide class. To configure the card and set its dimensions we have a GlideConfiguration file. To make any view controller work as a card, you just need to conform it to Glideable protocol. Most important feature is that it has a simple set up - Just 3 to 4 lines of code to get the UI working.

let glideConfig = Configuration() // Glide Configuration File
let cardController = CardViewController() // Card View Controller

/// One line manager initializer
glideManager = Glide(parentViewController: self, configuration: glideConfig, card: cardController)

More features will be added to this project in the near future. Things like being able to receive notifications inside the parent view controller when the card is dismissed or is in the compressed state and more.

The full project is available on my GitHub.