Android bits and pieces: event interception
The recently redesigned Market client introduced an interactive carousel that features promoted apps and games relevant to the current page. The carousel lives in the main navigation area and allows the user to quickly browse the promoted content and view concise information on the specific app. In portrait mode, the carousel takes roughly 40-45% of the screen height (depending on the screen resolution), while in landscape mode it takes the left half of the screen. The landscape mode is, in my opinion, a major step forward in improving usability and content placement, but we’ve introduced a usability regression for the home screen in portrait mode, especially for smaller QVGA screens.
Take another look at the screenshot – as you can see, the list of featured apps is scrolled below the carousel, while the carousel itself is a static element that is anchored to the top edge of the screen. While you can see slightly more than four full app listings on higher end devices, this resulted in poor usability on smaller screens – the scrollable area was too small, showing only about two app rows at a time.
As noticed by Al Sutton, we have addressed this issue in the last few weeks – the carousel now scrolls with the rest of the screen. If you’re not interested in flipping through the promoted content, you can quickly scroll it away and enjoy the full-height scrolling of the featured content.
In the original implementation, this screen had two child views – the “green goblin” header and the list of featured apps. The views were positioned with a custom layout that set custom padding on the list’s header view in order to provide correct (and screen-specific) overlap between the two views, while still allowing the list header to fully show when the viewport is at the top of the list. The carousel component lived outside the list and had its own handling of touch events to process selection, scrolling and flinging. And now we wanted the carousel to be scrolled with the app list.
I’ve mentioned this a few times before, and it’s worth mentioning it again – do not nest scrollable views. Doing so will lead to unpredictable dispatch of scroll events, mostly due to processing motion events that cross the boundary of the nested scrollable child. The ListView component is a scrollable view, and so wrapping the carousel and list in a ScrollView was not an acceptable solution. Instead, we decided to out the entire header (with the title, carousel and navigation buttons) as a header view of the list. This worked quite well, with vertical scrolls being correctly processed by the framework. However, processing touch events on the carousel was very flaky.
We have a custom onTouchEvent handler in the carousel view that detects horizontal motion and starts scrolling the content (and flinging it on ACTION_UP if the velocity is above the threshold). As long as the touch path was horizontal, our handler was getting the motion events and processing them. However, once the touch path sloped just a little, the handler was not notified – and instead the entire screen started scrolling vertically. This was due to the order of event processing – in this case, the parent list view has its own touch handler that detects vertical motion. All in all, we traded one usability problem (smaller scrollable area) for another (flaky processing of carousel scroll gestures). As luck would have it, our framework people (Romain Guy and Adam Powell) were on a holiday break, and we spent a few days trying to subclass the ListView and override the dispatchTouchEvent and onTouchEvent, detecting different scenarios and redispatching the touch events directly to the carousel view if necessary.
After a few iterations we got some ugly code that worked reasonably well for most cases, but we certainly didn’t feel very comfortable in putting such a hack into the production environment given the breadth of platform versions and device variety that we need to support. Once Adam came back from the vacation, he took a quick look at the code and pointed out a much simpler way – use the ViewGroup.requestDisallowInterceptTouchEvent() API. This is exactly what we needed – a way for a child component to tell its parent hierarchy to stop processing touch events. And the best thing (as documented in the method Javadocs) is that you don’t need to reset this state – it is automatically reset by the parent on the up / cancel events. So this is what we do now – a motion event that crosses the vertical threshold of the list before crossing the horizontal threshold of the carousel is still handled by the list. But, if the motion event crosses the horizontal threshold of the carousel first, we call getParent().requestDisallowInterceptTouchEvent(true). As long as the touch stays within the screen bounds (doesn’t to to the bezel), the move events are being delivered to the carousel. The vertical part of those events is being ignored by carousel, so that you can freely cross the bottom edge of the carousel into the list and still operate the carousel.
If you’re running the latest version of the Market client (2.3.3) you would notice that the carousel still displays a drop shadow along its bottom edge. This is a rather unfortunate UI omission, since the presence of drop shadow elsewhere in the Market client indicates that the rest of the content is scrolled below the matching view, while that view remains statically anchored to the screen. This will be addressed shortly to look like this: