This site looks healthier in portrait mode.

Background

At Zocdoc, one of our goals is making it as easy as possible for patients to find and book doctors appointments. To that end, we want patients to know that being healthy isn’t just about seeing a doctor when something is wrong, but also includes regular preventative care as recommended by top health organizations like the American Medical Association. Our user research showed that patients were often unaware of what types of preventive care they could book on Zocdoc, and of the types of preventative care that are recommended for healthy adults. Our app didn’t have any prompting, onboarding, or messaging to advertise these types of appointments. So, we set out to test whether simply prompting patients with a preventative care suggestion could lead to them decide to book.

We decided that right after booking one preventive care appointment was a potentially key moment to serve users the prompt for another one, because they’re already in the mindset of scheduling visits and being aware of their health. Ideally, we were aiming to keep that momentum going and seamlessly allow a user to book all of their preventative care appointments in one session.

Our audience

We decided to start with a conservative approach, limiting the prompting to a very specific audience — only users who had just booked an appointment in one of our four existing preventative care “Well Guide” categories. The prompt would show up if we didn’t have any recent record for that patient of an appointment in the other categories: Annual Physical, Dental Cleaning, Annual Skin Screening, and Eye Exam.

We were also aware that some patients may have recently received this care without booking their appointments through Zocdoc. So, we decided to also test a prompt giving users the option declare that they’d already completed the check-up in question, and provide the date they completed it in case they’d like to be reminded when to schedule their next one. By making this record of past appointments, Zocdoc can nudge the patient later for their next check up.

We strive to provide our users with the best possible user experience, using supportive graphics, vibrant colors, and meaningful motion. Here’s how we implemented these techniques across platforms to create a delightful user experience.

The implementation – Android app

As seen in the below animation, we designed a new variant of our booking confirmation page to refocus it around booking preventative care. We included sequential animations and straightforward, lighthearted language, in contrast to the clinical, cold connotations often associated with preventative care.

Android animation

At first glance, the above animation may seem complex, but if we manage to break it down into smaller pieces for individual analysis, we will realize it is quite manageable. As is the case with a lot of animations, they all break down into fundamental building blocks – translation, rotation, scaling, and fading. The above animation is broken down into two sets: animating the doctor in, followed by the rest of the text & buttons.

Doctor Animation
If we focus in slow motion how the doctor moves into the screen, one thing we notice is the image moving from right to left (translation). While it is moving, we also notice a counter clockwise rotation followed by a clockwise rotation. Let’s explore into how this was done.

<?xml version="1.0" encoding="utf-8"?>
<set
    xmlns:android="http://schemas.android.com/apk/res/android" android:fillAfter="true">
	<set android:duration="@android:integer/config_mediumAnimTime">
    	<rotate
                           	android:fromDegrees="0"
                           	android:toDegrees="-40"
                           	android:pivotX="50%"
                           	android:pivotY="50%"
           	/>
    	<translate
                           	android:fromXDelta="0"
                           	android:toXDelta="-50%"
           	/>
	</set>
	
    	<!--It's a lot easier to follow when these sets are broken down like this-->
	<set
           	android:startOffset="@android:integer/config_mediumAnimTime"
           	android:duration="@android:integer/config_longAnimTime">
    	
           	<!--fromDegrees 0 means to continue where it was left off. -40 from above is now its "0" -->
    	<rotate
                           	android:fromDegrees="0"
                           	android:toDegrees="10"
                           	android:pivotX="50%"
                           	android:pivotY="50%"
           	/>
    	<!--0 here is already translated -50% from above -->
 	   <translate
                           	android:fromXDelta="0"
                           	android:toXDelta="-15%"
           	/>
	</set>
</set>

Create a doctor_animation.xml file and put in the res/anim directory.

This is where we will define the animation that produces the graphics above. Within is two additional sets, each set providing distinct rules on how the animation should behave. The first instructs the image to move from 0 to -50% in the X direction. 0 indicates the original position, 50% refers to the percentage of the image’s width, and negative indicates the left direction. The rotation instructs the image to go from 0 degrees (the original position) to -40, using the image’s X and Y midpoint as the pivot of rotation. Both the rotate and translate are within the same set, so they happen in parallel.

The second set is similar in that it is just another combination of a translate & rotate. This time we rotate in the positive direction (clockwise). Note that fromDegrees = 0 refers to the position after the first animation set has run, which is -40°. The duration of the first set config_mediumAnimTime is a native Android resource defined to be 400ms. This is the time it will take for all animations within the set to finish. Therefore the second set’s startOffset is config_mediumAnimTime, so that the two can appear to be continuous.

//loads the animation
doctorAnimation = AnimationUtils.loadAnimation(this, R.anim.doctor_animation)
 
//doctor_image is the view to be animated.
doctor_image.startAnimation(doctorAnimation)

Finally, if we execute the code above, it will load the animation we’ve defined, and execute on the view we wanted to. Keep in mind the doctor’s image is initially offscreen. In our case, our parent layout is a ConstraintLayout, and we constrained the image’s left edge to the parent’s right edge.

<android.support.constraint.ConstraintLayout 
...
    	
    	<ImageView
    	android:id="@+id/doctor_image"
           	...
    	app:layout_constraintLeft_toRightOf="parent"
    	/>
</android.support.constraint.ConstraintLayout>

Slide Up Animation

In analyzing the animation of text and buttons, we noticed everything moves up in unison. Moreover, all components are stacked one below another, in a typical linear layout format. For this animation, we simply translate the LinearLayout from offscreen to its final position. Since this is a simple animation, we don’t need to define a complicated set, we can use the TranslateAnimation that comes with Android.

/**
* Animate buttons from off screen on the bottom to the natural positions
*/
private fun animateTextAndButtons() {
    	val fromXDelta = 0f
    	val toXDelta = 0f
    	val fromYDelta = parent_container.height.toFloat()
    	val toYDelta = 0f
	val translation = TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta)
	translation.duration = longDuration
	translation.interpolator = OvershootInterpolator(.5f)
	button_container.visibility = View.VISIBLE
	button_container.startAnimation(translation)
}

As with the doctor animation, this slide up animation also starts off screen. However, this time we’ll use a different technique, one that is more suitable to the situation on hand. We want to make sure the animation is done in such a way that the text & buttons end up in the precise location we want it to be. The left, bottom, right padding needs to be 16dp from the edge. To do this, we position the LinearLayout in the spot where we want it to be, and “hide” it from the user by setting the view initially to INVISIBLE. The animation block above instructs the view to start its animation from the bottom edge (parent_container.height), and animate it to the 0 (original) position.

 
<android.support.constraint.ConstraintLayout
    	android:id="@+id/parent_container"
    	android:padding="16dp"
 
...
    	
    	<LinearLayout
    		android:id="@+id/button_container"
    		android:visibility="invisible"
           	...
	>
</android.support.constraint.ConstraintLayout>

Lastly, to spice things up a little, we’ve incorporated a OvershootInterpolator to give the very quick effect that it slides up, overshoots its destination by a little and bounces back down.

Putting It Together

Tying the two animations together is perhaps the simplest part, and most rewarding part of this exercise. Where the fruits of your labor can be seen when seemingly unrelated animations can be pieced together to form cohesive, meaningful motion, and bring material design to life. We can attach a AnimationListener to the doctorAnimation, and start the text blob animation when the first one completes.

doctorAnimation.setAnimationListener(object : AnimationListenerAdapter() {
	override fun onAnimationEnd(animation: Animation?) {
    	animateTextAndButtons()
	}
})
doctor_image.startAnimation(doctorAnimation)

There you have it! Even the most complex of animations generally breaks down to its building blocks – Rotation, Translation, Scaling and Alpha. The key is the ability to disassemble the motion into individual parts, and then glue it back together using the tools we have.

The implementation – iOS app

On iOS, the animation follows the same design as on Android:

iOS animation

  • First, confirmation text for the booked appointment fades in on the top
  • Next, the avatar animates in from right
  • Then, the suggestion text is appearing from left-to-right
  • After that, the action buttons scroll up from bottom
  • And finally, “X” button on top right corner fades in

Pretty simple, right? It’s just a sequence of animations presented in a specific order. Each animation can be achieved by methods provided by UIKit. Before jumping into details on how these animations are implemented and linked together, let’s first see how ui elements are set up:

As shown above, the header text, middle description, and bottom buttons are grouped together as a custom view to make it easier to animate and also avoid Massive View Controller problem. One note here is all of these views are enclosed in a UIScrollViewwith a content view, but more on this later.

Initially, all views are located at their right spot, except AvatarImageView and BottomView which both slide in. In order to reduce the pain of working with auto layout, we use SnapKit, a DSL to make Auto Layout easy on both iOS and OS X.

Let’s start looking at how the animations are chained together now. If you’re not familiar with UIKit animations, you can check the Apple documentation before continuing forward. In general, the way the animations are structured is as a chain of animation blocks each chained together through their completion blocks. We won’t go into details of each animation here. We created this sample app show all animations together and you can download and look at code and play around. In the rest of this part, we’re going look closer into some of the animations.

One is the avatar animation block which is done by taking advantage of animateKeyframesWithDuration:delay:options:animations:completion:method on UIView. If you look into it closely, this animation is a combination of transitions and rotations at the same time. 30 is a final rotation degree that the animation finishes with. It starts with rotating to 35 while sliding into screen. Then, there’s another transition happening to position the image in the right spot and meanwhile, several rotations with 29.5, 30.5, and finally 30 degrees during this time. That back and forth between 29.5 and 30.5 are for giving a bit of spring animation style which would be like the avatar is waving at the user. At the end, in the completion block, we update the constraints of the ImageViewto position into this new location.

let firstTransition: CGFloat = -52
let secondTransition: CGFloat = -40
UIView.animateKeyframes(withDuration: 0.8,
                    	delay: 0.7,
                    	options: UIViewKeyframeAnimationOptions(rawValue: 0),
                    	animations: {
                        	UIView.addKeyframe(withRelativeStartTime: 0,
                                               relativeDuration: 0.2,
                                            	animations: {
                            	self.avatarImageView.setTransform(rotationInDegrees: -35)
                        	})
                            UIView.addKeyframe(withRelativeStartTime: 0,
                                            	relativeDuration: 0.3,
                                            	animations: {
                            	self.avatarImageView.center.x += firstTransition
                        	})
                        	UIView.addKeyframe(withRelativeStartTime: 0.2,
                                            	relativeDuration: 0.2,
                                            	animations: {
                            	self.avatarImageView.center.x += secondTransition
                        	})
                        	UIView.addKeyframe(withRelativeStartTime: 0.4,
                                            	relativeDuration: 0.2,
                                            	animations: {
                            	self.avatarImageView.setTransform(rotationInDegrees: -29.5)
                        	})
                        	UIView.addKeyframe(withRelativeStartTime: 0.6,
                                            	relativeDuration: 0.1,
                                            	animations: {
                            	self.avatarImageView.setTransform(rotationInDegrees: -30.5)
         	               })
                        	UIView.addKeyframe(withRelativeStartTime: 0.7,
                                            	relativeDuration: 0.1,
                                            	animations: {
                          	  self.avatarImageView.setTransform(rotationInDegrees: -30)
                        	})
                        	
}, completion: { _ in
	self.avatarImageView.snp.updateConstraints { (make) in
    	make.right.equalToSuperview().offset(self.avatarImageViewWidth + firstTransition + secondTransition)
	}
	...
}

The next piece that is worth looking into is when we’re running it on iPhone SE:

As you might have noticed, now the action buttons are out of the user’s view. This becomes worse when the middle text is lengthier:

To solve this issue, we decided that if any of the buttons ended up to be outside the screen, when the animation finishes we scroll down to the end of the screen so the user can see all available options.

let intersectionRect = self.contentScrollView.bounds.intersection(self.bottomView.frame)
let bottomViewHeight = self.bottomView.frame.height
if intersectionRect.height < bottomViewHeight {
	let bottomOffset = CGPoint(x: 0, y: self.contentScrollView.contentSize.height - self.contentScrollView.bounds.size.height)
	self.contentScrollView.setContentOffset(bottomOffset, animated: true)
}

Results

This experiment was a success — we saw a substantial increase in same-session rebookings. We also rolled out the prompt to show after all booking types. While some visit reasons were correlated with higher conversion than others, the overall effectiveness of the prompt remained.

Learnings

One way we’ve further refined the experiment is by improving the prompting logic to provide a better user experience. Initially, each time a prompt was shown, it was randomly chosen, so by chance some users who dismissed a prompt after one booking were shown the same prompt after their next one. We now won’t show you a prompt you’ve dismissed until six months have passed. We’re thinking about ways for patients to customize reminders by letting them disable a category, customize the timing, or add their own.

Next steps

We are continuing to conduct user research and collect data that helps us hone our preventative care messaging strategy. We want to lessen the burden of remembering to schedule doctor appointments for patients, and support more personalized prompts and reminders.

About the Authors

Nick Abbate is the senior product manager for the Zocdoc Mobile team since 2017.

Mani Ramezan is a senior mobile engineer at Zocdoc working on the iOS application. He enjoys traveling and going to live music performances especially the ones in Brooklyn venues. He recently talked at the iOSoho meetup.

Jia Tse is a senior mobile engineer at Zocdoc working on the Android application. He recently became a first time dad, and instead of Android articles he is now reading bedtime stories.

No comments yet, be the first?

Close comments

Leave a Reply

Your email address will not be published. Required fields are marked *

You might also like