Meet the Green Goblin, part 4
Last Friday we announced a significant update to the Android Market client. A whole slew of features went into this update (and many more are to come), and this week the pixel geek in me will be talking about the new visual design of the application. Here’s a quick recap of the first three posts:
- Part 1 – overlapping layouts and non-rectangular views.
- Part 2 – optimizing user experience for the landscape orientation.
- Part 3 – custom canvas drawing.
Today i’m going to cover a few miscellaneous bits and pieces. As they say, the devil is in details, and every single bit mentioned in this post is a small step towards improving the user experience and overall aesthetics of the application. I should note that these bits aren’t strictly necessary from the purely functional perspective – and are quite time-consuming to get right. But if you want your app to be less ass and more class, read on.
First, let’s take another look at the home screen of the new Market client in the portrait mode:
There’s a whole bunch of visual information that we put in the header section – market-bag icon, market title, search button, carousel, title / rating / price for the fronted app and the navigation buttons. This content can be roughly divided into three sections – the title bar, the carousel and the navigation footer. Following an established UX practice, we want to add some visual separation between the three sections. The separation between the title bar and the carousel is achieved by scaling down and fading thumbnails that are close to the edges. This creates extra vertical space directly below the market-bag icon and the search button. What about the separation between the carousel and the navigation footer?
Here we cannot “rely” on the vertical white space below the outermost thumbnails – that space is negated by a rather long row of views that display the title, rating and price for the fronted app. Instead, the design pushes the middle navigation button down towards the curved header edge. This effectively arranges the navigation buttons along the bottom edge of the container and adds extra vertical space above the middle button. Let’s take a closer look at the actual bounds of these three buttons:
How was this achieved? Here, we extend a LinearLayout with horizontal orientation, giving each button zero width and equal weight. This ensures that the buttons are perfectly placed along the X axis. We don’t override the onMeasure and rely on the parent’s implementation to compute the correct measured width and height. However, we do override the doLayout:
- Call the super implementation so that it can compute the final left and right bounds. We’re not going to change those.
- Compute the height of the curvature area. As mentioned before, this value depends on the pixel size of the screen and is only known at runtime.
- For each visible child button, take two reference points. One is at 25% of its horizontal span, and another is at 75%. For each reference point, compute the matching Y position on the curvature arc. The min of these two Y values will define the bottom bound of the button.
Why choose 25% and 75%? Take a look at the “Apps” and “My apps” buttons in the screenshot above. Each one extends all the way to the container edges, and computing the Y offset based on those would result in unnecessarily high bottom bound. Here you can say – what if the button text is very long and spans the entire button width? There are a couple of other settings at play here – left / right padding and scaling down the text size (will be detailed below) that keep the texts from overlapping the curved arc.
Next up – text scaling. As mentioned in the previous entries, Android devices come in a wide variety of screen sizes and densities. In addition, if you target international markets, you should properly localize your strings. So, unless you want to see your fine-crafted UI looking (say it with me) like ass on a lower end device running under one of the more expressive European languages, you’ll need to make sure that the texts comfortably fit within their intended bounds.
Marking the view with marquee ellipsizing and flipping the selected bit to true is one option. That would enforce auto-scrolling texts that do not fully fit in the padded bounds. This is simple, but quite distracting. If evolution is to be believed, even before our ancestors were hunters, they were prey to large clawed animals. If you detected a suspicious movement in the nearby bushes, you were supposed to run away like a little girl. Or stand there and be eaten like a real caveman. Anyways. Getting back to writing pleasing UIs. User’s eye will be instinctively drawn to scrolling content – which is one of the reasons why we set a relatively large auto-advance interval on the carousel. Now imagine us scrolling the texts on all three navigation buttons at the same time. That’s not really good. They are navigation buttons, not the main content. Unless you’re absolutely sure that this is what you want, you should not marquee anything on the screen. So another option is to scale down the text size based on the available space.
Here is one example:
This is the header section of the new app details screen. We have a nice drop shadow for the thumbnail, the white texts and extra nice vertical alignment of the thumbnail and the control column to its right (both top edge and right edge). If you look closer at the two buttons, you will see that the regular / bold style is not the only difference. The second button also uses a slightly smaller text size to fit the longer string and prevent some of the characters from overflowing into the padding. This is fairly easy to achieve:
- Extend the Button class. In the constructor, call getTextSize() – that would be the default text size, and divide it by the screen density. Store that value.
- In addition, override the onLayout method. The following bullets are relevant to the context of that method.
- Get the measured width and height (for restoring those values later on).
- Starting from the saved text size, call setTextSize() and measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED). Assuming that the button is defined to be singleLine, you can now call getMeasuredWidth() to know how many pixels does the button need (including the padding) for the specific text size.
- As long as the measured width is less than available width – use the left and right parameters passed to onLayout, decrease the text size and repeat.
- Once the you found the max text size that makes the text fit in, call setMeasuredDimension to restore the “original” measured width and height.
And here’s another example:
Here, we have rather long Korean strings for the tab titles. The algorithm above can be applied here as well to scale these strings to completely fit on one line, but the end result will be quite unreadable. Instead, the tabs allow two lines of text, and the algorithm is slightly different:
- Call setMaxLines(2). Call setLayoutParams with a LayoutParams set to WRAP_CONTENT on both axes.
- Now you want to compute the two-line height of the view given the specific width limitation and see whether it fits in the available height. This will also cover the case where the text can completely fit on one line with the starting text size. Call measure passing the MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.EXACTLY) for width and MeasureSpec.UNSPECIFIED for Y. Now call getMeasuredHeight() and compare to the available height. If it fits, you’re good to go. Otherwise, proceed.
- Now call setMaxLines(10). This is an arbitrary setting, but it’s important that the value is larger than 2.
- Starting from the initial text size, call measure with the same values as above. Check two conditions – that the measured height is less than the available height and that getLineCount() is at most 2. The second condition will prevent displaying more than two lines of text – even if three (or more) will fit in. Here, we prefer to show two lines at a smaller size than three lines at a larger size – to maintain visual balance across all three tabs that may have radically different text lengths for their titles.
Now let’s talk about padding. In landscape orientation we display content side by side – the carousel / overview go on the left, and the vertical listing goes on the right. Here is an example of such layout:
We want to prevent the curved right edge of the info section from partially hiding some of the content in the vertical column to its right. This can be achieved by setting the left padding based on the horizontal span of the overlapping area. As mentioned before, we want the section headers (fading vertical gray gradient) to fully extend below the arc – effectively preventing us from setting the left padding on the entire column. This means that we set the left padding on each child view. However, some of the child views may have their own left padding defined in their layout XMLs – for purposes of balance, alignment or hierarchy.
Our first attempt was to call getPaddingLeft(), add our extra padding and then call setPadding(). Surprisingly simple, but not quite working. The layout pipeline can make several passes at measuring and laying out the views, and if you add extra padding on each such pass, you will end up with too much padding. There’s a simple workaround:
- Create a res/ids.xml file and add an item with type=”id” and some name and recompile your project. You should have the new R.id.yourName constant.
- Every time you need to tweak the padding, call getTag(R.id.yourName) and check if it’s null. If it is, get the current left padding and store it with setTag. If it is not null, then the current tag value is the original left padding.
As with everything else, adding animations should be done with restraint. It’s very easy to go over the top and make everything pop, scroll, slide, fly, cross fade and in the process cause great deal of misorientation, distraction and frustration (which, unless you’re selling shady prescription-only drugs or cheesy wedding videos is not a good thing). In the new Market client you should see only two types of non-user-initiated animations: auto-advance on the carousel and marquee scrolling of long app titles. The auto-advance is scheduled to run every 20 seconds, and is bumped to 40 seconds once the user starts interacting with the carousel. It goes down to 20 in decrements of 2 as long as there is no user interaction. This allows us to show that there is more than is currently visible without overloading the screen too much. Long app titles should be scrolled in order to see the full application title without going to the details page.
However, some aspects of the new UI deserve an extra dynamic facet to guide the user through the complete flow.
This is how the new application details page looks in landscape mode. Note the “More” button in the description section. Application descriptions can be quite long (especially with the newly added “recent changes”). The long descriptions effectively push the screenshot section down and result in a large amount of text displayed on the screen by default. Our interaction designers felt that the screenshot section deserves to be at least partially visible above the fold, with the description section collapsed by default to three lines. Note the nice fade-to-white transition on the last line of the description section, hinting that there is more content, and ending in the “More” button. Tapping the button expands the description to show the full contents – in a nice and smooth transition. We felt that this short (around 300ms) transition helps to guide the eye between the two views without being too annoying (==long).
The implementation is quite simple and works well even on older devices going all the way back to 1.6 (looking at you in disapproval, Streak). The vertical column on the right is a LinearLayout with vertical orientation. When the “More” button is tapped, we initiate an animation. On every animation callback, we call getLayoutParams() of the description view and change the height attribute based on the current animation position. This is followed by calling requestLayout(). The framework takes care of the rest – detecting that the child view layout params has been modified and going to the parent (our LinearLayout) to re-layout all its children.
The same transition sequence happens when you click the price button in the info section. The content on the right is replaced with the permission list which slides from the top edge, guiding the user’s eye to connect the tapping of the button and the content change on the screen.
There’s a whole lot more going under the hood and this has been just a small glimpse into the redesign of the Android market client which has been a truly collaborative effort across our designers, developers, testers, dev rel, product management and the guy sitting in the basement with his red stapler. If you’re interested in joining us and seeing your app user base growing by 300,000 people every day, let us know.