Making an Interactive Card based UI using Swift (Part 2 / 2)
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:
- Implement animations from one state to another.
- Animate alpha value with user’s swipe.
- 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: )
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 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 .compresse
d 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:
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.
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:
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.
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.
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:
Now going back to the shouldRecognizeSimultaneously
,
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
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.
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.
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.