Improving the TripAdvisor Flights experience on Android

Cheng Han posted December 4, 2015

Our goal is to create a first class user experience for Android. In an effort to improve this on native apps, TripAdvisor Flights team did a great amount of work about year ago. For our flights app, because most of important flights itinerary information needs to be shown on the search header, it takes a considerable amount of screen real estate, which leaves less space for showing the search result itself. In order to alleviate this problem, the first improvement is to show more flight search results by hiding the search header when user scrolls up.

However, when I was implementing sticky header, the Design Support Library had not yet been released by Google. As a result, most of the following implementation was started from scratch, and I made every effort to fix any bugs I found during my implementations (and I will talk about these bugs and how they were fixed). Fortunately, after Google I/O in 2015, new APIs were announced that can make this implementation much easier by using CoordinatorLayout, Toolbar and RecyclerView.

In the second section of this blog, I will explore the proposed way to use these new libraries in a sample app that mimics current UX. I will also talk about how easy they are to use.

Flights Experience v0.1

First, lets start with the flight search poll. When we poll for the results, we use the progress bar (see below) to indicate that we are getting the flight search results. After it finishes, that progress bar disappears and the search header is updated. When user taps an outbound itinerary and it goes to return search view, the search header now contains the outbound itinerary summary and the return header detail view, the search header also should be updated. The following pictures illustrates how flights app used to work before my changes (with non-sticky header).

ifa_non-sticky-header-blog-gif

Part I : New Design

As I mentioned earlier, we wanted to make the header scroll when user scrolls the search results. This required the redesign of the layout that hosted the search results. In the new layout, we use one ViewPager as a host for each outbound and return, and ViewPager contains outbound/return header view and four Fragments, and each fragment hosts sorted flight search result with different criteria. The four Fragments have four different sorts based on price, flights duration, departure time and arrival time.

Picture below illustrates this new design.

ifa_Flights Return Header

Initial approaches and problems

My initial thought was based on the old design that the ViewPager only contains fragments. When I tried to move the header based on the vertical scroll offset of search result ListView, the header that is above the ViewPager did not move. The reason is that the header and ViewPager are the separate components, and they did not know each other until they are somehow added in one common component. The solutions that I came up with is to simply move the search header into ViewPager.

As a result, each list view will now have a header, this was done by calling ListView’s addHeaderView() method. Then we can decide the position of tab bar by getting the vertical offset of ListView. Let me explain how it is implemented in the code.

The following simplified sample code is to illustrate how to make the header move.

  1. Callback interface of ListView – when performing scrolling operation

  1. Set up the onScrollListener of ListView to invoke the callback – calculate the ListView’s vertical offset

  1. The implementation of callback – implement the delegate callbacks

Tada! Flights experience v1.0

ifa_sticky-header-blog-gif

Other interesting techniques we are using in this design

The above code gives us the basic UX, however there are lot of cases we need to consider. I spent lots of time to make it perfect. In this section, I will explain the problems I found during implementation and how I tackled those issues.

a. Update header height

In order to update search header, we need to know when the header is updated and need to send feedback of this information to ListView so that it can update its own header. Based on the Flights Experience 101, we know that the header will be updated when polling response is done or user goes to return view. The code below illustrates this approach.

The method updateListViewHeaderHeight in fragment should update the header of ListView by doing the two things:

  1. Since this method is called many times, we should not simply add header to ListView. It should first check if header was added to the ListView before. If it was, then remove the old header and add the new header. I this case I used header’s height to check whether it’s the new header or old.
  2. After header is updated, we need to make sure that the position of ListView is also updated.

b. Detect if user is flinging on search results

You might notice that there is one parameter isFlingingEnded in method onScrollChangedof ListViewOnScrollCallbacks. This is needed because most of time, we show over hundreds of search results, and user might want to find some specific search results or in some cases that user is just flinging the search results and not very happy with current set of sorted results and might change to another sort type. For example, user might not care much about the lowest price and want to change to quickest flights sorts. Handling the fling operation properly is very important from UX perspective. This is very easy to do in onScrollListener, lets check it out.

We use this boolean variable mIsFlingEnded to check whether current result list is flinging or not. When user is switching flights search result sorts or outbound to return, we can just simply stop ListView from flinging by callingsmoothScrollBy(0, 0) method of ListView. This will stop ListView from scrolling and it will not affect the vertical scroll offset when we change the Fragment.

c. One thing about the Fragment

We use one ViewPager that hosts four Fragments, each fragment holds a list of search results with different sort types. The search header is same for four fragments, but the position of header for each fragment is based on the ListView’s vertical offset inside of fragment.

I previously had an issue when switching to second or third fragment and the header of new fragment is affected by the first fragment, which I assume the problem is that the first fragment is the primary fragment and its subsequent fragment is not set to primary fragment (searched all over the internet to find out whether other people have same problem, but couldn’t find any). One of solution I came up with is to record the correct vertical offset value of ListView before switching to the new fragment and then restore and replace the wrong offset value with the right one after switching to the new fragment.

d. One problem when using Espresso 2.0 or 2.1

Google released Espresso 2.0 in Dec 2014, with lots of new features and improvements. However, one thing we should pay attention to is that for new Espresso 2.0, they added some logic to automatically select first item of ListView, which makes some of tests fail after we upgrade to Espresso 2.0. I spend lots of time to try to find out whyand it was difficult since the source code for Espresso 2.0 was not open sourced when I was working on it. Finally I found this webpage. According to the reply, Google added adapter.setSelection(dataPosition) inisDataRenderedWithinAdapterView() in Espresso 2.0, which makes the selected ListView item move to top when using onData().atPosition(dataPosition) method. Because of this, part of ListView item view is underneath the tab bar (holds four sorts), this caused some of tests to fail because it couldn’t check view’s visibility.

According to release note from Google, it has not been fixed yet.

Part II: All About UI

As I mentioned earlier, Design Support Library brings us some new APIs as per Google’s material design guideline. In this section, I will explore how sticky header can be implemented by using new libraries. Please note that this is just demo app that I am showing, it still needs a lot of work from UX perspective. You can find the source code of this demo app over this Github page.

ifa_flights_app_new_libraryifa_flights_app_new_library_fling (2)

For picture on the left, in order to mimic flight polling response, a loading indicator is dismissed after 3 seconds in ResultsActivity. When I scroll the list up, the search header is hidden based on vertical offset of scrolled list. Then I open the filter screen to change the number of results, and you can see that when we are back on results screen, the header and the position of result are showing correctly. This is where most of bugs I found and had a hard time to fix when I was implementing sticky header in a painful way described in Part I. From picture on the right, you can see that it works very well when I fling the search results and change tabs or change number of data set. Can’t wait to see how it is implemented? Lets check it out.

Using New Libraries’ Layout

For Layout, I use CoordinatorLayout as the root view and it contains AppBarLayout, CollapsingToolbarLayout, Toolbar and TabLayout.

CoordinatorLayout is a super-power FrameLayout – as per android API doc. It basically can coordinate or specify its children’s behavior by simply adding attributes likelayout_anchor, layout_behavior, etc.

The key to coordinate the scroll function between result list and search header is to add the layout_behavior attribute in ViewPager and layout_scrollFlags in CollapsingToolbarLayout, which we can directly use from library. For more information about these two attributes and the scrolling effects, you can refer to this article. In order to stick the TabLayout to the top when scrolling up, you need to add android:layout_gravity=”bottom” in your TabLayout, in this case, the TabLayout will always in the bottom of AppBarLayout, so when AppBarLayout is hidden, TabLayout will stick on the top.

From xml code above, you can see that instead of calculating vertical offset of ListView, you can just simply add your own customized view in CollapsingToolbarLayout. The android API will handle rest of scrolling options. Moreover, if you want to define your own scrolling behavior considering the transition of  the Toolbar, you might want to go to this github link.

RecyclerView

RecyclerView is very important and very easy to use. It has its own ViewHolder pattern and has default animations for removing and adding items. It is a more advanced and more flexible version of ListView. First let me talk about the difference between ListView and RecyclerView.

Compare with ListView

  1. Required ViewHolder in Adapters
    ListView adapters don’t require to use ViewHolder pattern. However, using ViewHolder pattern will have a great improvement on performance. Compared to ListView, ViewHolder for RecyclerView is very easy to use. I will explain its usage later.
  2. Item Animations
    ListView has no default animations for addition or deletion the item in the list. However, for RecyclerView, it has default animations, we don’t need to implement the animations except you need customized animations.
  3. Item Decoration
    ListView has android:divider attribute to draw the divider between items. For RecyclerView, if you want to have your own customized divider, you can useRecyclerView.ItemDecoration to setup divider decorations. You can refer to this open source library if you want to have more beautiful and more flexible divider decorations.

ifa_recyclerview_save_search

The picture above illustrates the animations on deleting the saved flight search and adding it back (modified version).

Now lets turn to Layout. Note that, in order to keep things simple, I only use TextView and ImageView for item row layout.

RecyclerView: Adapter

The adapter is the bridge that connects the data model we want to show in our UI and the UI component that renders this information. Like adapter in ListView, if we want to show data in our UI, we need to tell adapter how to do this. And for RecyclerView, life is much easier, all we need to do is to extend RecyclerView.Adapter<> and implement methodsonCreateViewHolder and onBindViewHolder. Lets check it out.

  1. Implements onCreateViewHolder method and onBindViewHolder

  1. Add Views into ViewHolder

  1. If we want to delete item from results or undo deleted items, we can create two functions like this

Note that after we remove item in our list of results, we must call notifyItemRemoved() and notifyItemRangeChanged(), thus it will notify the observers in the RecyclerView that data has changed, and it will cause the change of RecyclerView layout, as a result, the default animations will be shown, as per this source code; same scenario as adding action.

In our custom Activity that holds RecyclerView, we can do something like this:

Flights experience v2.0

Since the sticky header changes we have made few more usability improvements and tried to make it adhere more to material standards. Below is the latest experience. More information about the material changes can be found here: Going Material

ifa_new design_git_2

Conclusion

Hopefully, this article will give you a glimpse of using new libraries and our current implementation. Compared with current design, which took me several weeks to implement and fix bugs, using new libraries only took me about 2 days. More importantly, it requires less code, which means fewer bugs and easier implementation. It is a great learning experience for me. We hope to continue shipping high quality product with best user experience.

2 responses to “Improving the TripAdvisor Flights experience on Android”

  1. Llanos says:

    Nice job!
    It’s great to see my designs not lose its integrity while improving on the experience!

    -Llanos

  2. iqrabatool says:

    great article thanks for sharing this.

Leave a Reply

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