Host Experience on Android

By Carol Leung

One of our goals is to push the envelope of Android design. We strive to craft and build an innovative, elegant, and distinctly Android experience. The new Host Home on Android is an excellent example of this. With Host Home we want to provide our Airbnb hosts with a one-stop shop in the mobile app that has all the tools they need. We enable them to easily manage their listings, organize their calendar, receive important inquiry and reservation alerts, and communicate with their guests efficiently.

Side menu and Host Home

New Inquiry/Reservation Design

The redesigned inquiry and reservation UI organizes information into sections across three swipeable tabs: Itinerary, Profile and Messages. Each tab serves one primary function:

  • Itinerary summarizes all logistics for a reservation.
  • Profile shows the personal information of a potential host or an interested guest.
  • Messages facilitates communication between a host and a guest.

These three components together form the core of an Airbnb reservation for our guests and hosts as they manage their travels and listings.

Reservation three tab design

The three tab design presents us with an interesting technical challenge: we have one single parallax, collapsible top image with a sticky toolbar that must be preserved as the top section across the three ViewPager tabs. We also want to maintain the the ability to horizontally swipe primary content on the screen.

We encapsulated the implementation of the three ViewPager tabs inside individual Fragments. We used different UI building blocks for each tab. The Itinerary and Profile tabs are ScrollViews, so each tab can flexibly display multiple content sections of variable length. The Messages tab is a ListView, so we can take full advantage of view recycling that comes free in a standard ListView. Each individual fragment-based tab still needs to scroll with the top image and toolbar together, which poses a few challenges.

Initial approaches and problems

ScrollView within a ScrollView

Our initial thought was to put the ViewPager, with its three scrollable view components, inside an outer ScrollView, and let the system take care of scroll interaction and user touch events. Unfortunately, the system cannot determine which of the two ScrollViews should handle a touch event and sometimes fails to deliver it, resulting in nondeterministic and undesirable scroll behavior.

ListView within a ScrollView

Our Messages fragment is based on a ListView. Placing a ListView inside a ScrollView negates all the benefits of using a ListView, as you lose view recycling and the important optimizations in ListView for dealing with large lists. This forces the ListView to display all of its items and to fill up the entire ScrollView container.

Solution — RelativeLayout hosting a ViewPager

The solution we came up with is to use a RelativeLayout as our outer container, which hosts the ViewPager and consists of three scrollable fragments. This outer RelativeLayout also hosts the top image and toolbar.

Reservation UI components

Let’s dig into how this was built.

Each of the ViewPager’s fragments implements a callback that is responsible for keeping track of, and updating its scroll position and the top offset within the parent RelativeLayout. As the user scrolls up and down, or side-swipes to a different tab, the callback in the individual ViewPager fragment intelligently adjusts the contained ScrollView’s or ListView’s internal top offset and scroll position to reveal the correct amount of parallaxed image underneath, achieving the visual effect desired.

Below are some simplified code samples that illustrate our solution approach.

  1. The scrollable callback interface — to be implemented by the ScrollView and ListView
public interface TabHolderScrollingContent {

/**
* Adjust content scroll position based on sticky
* tab bar position.
*/
void adjustScroll(int tabBarTop);
}

2. The ScrollView implementation of the callback — used in Itinerary and Profile Fragment

@Override
public void adjustScroll(int tabBarTop) {
if (tabBarTop == 0 && mScrollView.getScrollY() > mTopImageHeight - mTabBarHeight) {
// ScrollView does not need to adjust scroll, as the tab bar is its sticky position
return;
}
mScrollView.scrollTo(mScrollView.getScrollX(), -tabBarTop + mTopImageHeight - mTabBarHeight);
}

3. The ListView implementation of the callback — used in Messages Fragment

@Override
public void adjustScroll(int tabBarTop) {
if (tabBarTop == 0 && mListView.getFirstVisiblePosition() >= 1) {
// ListView does not need to adjust scroll, as the top tab bar is its sticky position
return;
}
mListView.setSelectionFromTop(1, tabBarTop);
}

4. Set up ViewPager.OnPageChangeListener to invoke the callback. This gets triggered when the users navigate to a different tab either by side-swipe or tapping on the top tab bar buttons.

mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {

@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
// Update content that just became visible
int currentItem = mViewPager.getCurrentItem();
if (positionOffsetPixels > 0) {
SparseArrayCompat< TabHolderScrollingContent > tabHolderScrollingContent = mViewPagerAdapter.getTabHolderScrollingContent();

TabHolderScrollingContent fragmentContent = null;
if (position < currentItem) {
// Revealed the previous page
fragmentContent = tabHolderScrollingContent.valueAt(position);
} else {
// Revealed the next page
fragmentContent = tabHolderScrollingContent.valueAt(position + 1);
}
fragmentContent.adjustScroll(mStickyTabBar.getTop());
}
}

@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
if (mStickyTabBar != null) {
// Update the custom tab bar selected state
mStickyTabBar.selectTabButton(position);
}
// Adjust the the ViewPager's Fragments' scroll position
SparseArrayCompat< TabHolderScrollingContent > fragmentContent = mViewPagerAdapter.getTabHolderScrollingContent();
TabHolderScrollingContent content = fragmentContent.valueAt(position);
content.adjustScroll(mStickyTabBar.getTop());
}
});

5. Set up ViewPager’s parent view’s ViewTreeObserver.OnGlobalLayoutListener to invoke the callback. This gets triggered when the user scrolls vertically.

mainView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
private int mLastViewHeight;

@Override
public void onGlobalLayout() {
int newHeight = mainView.getHeight();
if (newHeight != mLastViewHeight) {
SparseArrayCompat< TabHolderScrollingContent > tabHolderScrollingContent = mViewPagerAdapter.getTabHolderScrollingContent();
TabHolderScrollingContent content = tabHolderScrollingContent.valueAt(mViewPager.getCurrentItem());
content.adjustScroll(mStickyTabBar.getTop());
mLastViewHeight = newHeight;
}
}
});

A few Other interesting techniques we used on Host Home:

Leveraged the Android RenderScript framework to dynamically blur the top listing image. Photos with blurred effect in a few key views of the app enhance our narrative.

public void blur(final Context context, final float radius, final BitmapBlurCallback callback) {
mBlurTask = new AsyncTask< Integer, Void, Bitmap >() {

@Override
protected Bitmap doInBackground(Integer... params) {
if (sRS == null) {
sRS = RenderScript.create(context);
}
Allocation srcAlloc = Allocation.createFromBitmap(sRS, mSrc);
Allocation dstAlloc = Allocation.createTyped(sRS, srcAlloc.getType());
ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(sRS, Element.U8_4(sRS));
script.setRadius(radius);
script.setInput(srcAlloc);
script.forEach(dstAlloc);

Bitmap dst = Bitmap.createBitmap(mSrc.getWidth(), mSrc.getHeight(), mSrc.getConfig());
dstAlloc.copyTo(dst);
return dst;
}

@Override
protected void onPostExecute(Bitmap result) {
callback.bitmapBlurComplete(result);
mBlurTask = null;
}
};
mBlurTask.execute();
}

Implemented our own Shader-based custom ImageView to download user profile pictures and round them with an optional, adjustable border.

private void createCircularShader(Bitmap bm) {
// Shader must be based on Bitmap width/height because parent applies scale type.
float w = bm.getWidth();
float h = bm.getHeight();
float cx = 0.5f * w;
float cy = 0.5f * h;
int[] colors = { 0xffffffff, 0xffffffff, 0x00000000 };
float[] positions = { 0.0f, 0.99f, 1.0f };

RadialGradient gradient = new RadialGradient(cx, cy, 0.5f * Math.min(w, h), colors, positions, Shader.TileMode.CLAMP);
BitmapShader bitmapShader = new BitmapShader(bm, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
mCircularShader = new ComposeShader(bitmapShader, gradient, PorterDuff.Mode.MULTIPLY);
}

@Override
protected void onDraw(Canvas canvas) {
Paint paint = ((BitmapDrawable) getDrawable()).getPaint();
Shader defaultShader = paint.getShader();
paint.setShader(mCircularShader);
super.onDraw(canvas);
paint.setShader(defaultShader);

if (mBorderPaint != null) {
int w = getWidth();
int h = getHeight();
float radius = 0.5f * Math.min(w, h) - 0.5f * mBorderPaint.getStrokeWidth();
canvas.drawCircle(0.5f * w, 0.5f * h, radius, mBorderPaint);
}
}

We believe this is but one of the examples of a culmination of great design and dedicated engineering to produce an experience that is both unique to Airbnb, yet feels right at home on the Android platform.


Check out all of our open source projects over at airbnb.io and follow us on Twitter: @AirbnbEng + @AirbnbData


Originally published at nerds.airbnb.com on November 11, 2013.