Building a Zhihu-Style Collapsing Header with Android's NestedScrolling Protocol
Foreword
Now, the effect we want to achieve is a collapsible title bar. It appears in many everyday scenarios, such as Zhihu's personal homepage: when you swipe up, the title bar collapses first, and once it reaches the top, the content starts scrolling.
In fact, the official CoordinatorLayout has long provided a mature solution for this collapsing effect (for details, see my blog post: Collapsible Title Bar), but to deeply understand the underlying nesting mechanism, let's build a custom container from scratch.
Why Traditional Event Dispatch Doesn't Work?
This involves the NestedScrolling protocol. Before understanding it, let's first consider a question:
What would happen if we implemented this effect using the traditional event dispatch mechanism (dispatchTouchEvent, onInterceptTouchEvent)?
When a finger swipes up, the parent container intercepts and consumes the event, causing the homepage area (header) to collapse first. After the homepage area is fully collapsed, the event is handed over to the child View for consumption, and the RecyclerView list starts scrolling.
But you'll find a fatal problem: the scrolling is not continuous. To hand the event over to the child View, the finger must first leave the screen and then swipe up again. This is because once the parent container intercepts the event, subsequent events in this gesture cycle will not be passed to the child View. Similarly, if the child View consumes the event, the parent container cannot intervene. Event handling is one-way and exclusive.
The traditional mechanism simply cannot achieve a seamless change of the consumer within the same gesture. Hence, the NestedScrolling protocol was born, whose core is driven by the child View to coordinate scrolling.
Nested Scrolling Workflow and Protocol Mechanism
This mechanism has two core interfaces, NestedScrollingChild3 and NestedScrollingParent3, through which parent and child views communicate during scrolling.
NestedScrollingChild3: A scrollable child control (like RecyclerView, NestedScrollView) that receives touch events and proactively queries the parent View according to the protocol.
NestedScrollingParent3: The outer parent container (like CoordinatorLayout, NestedScrollView, and the custom View we are about to write) that receives the child View's requests and decides whether to consume the scroll distance.
Specific interface breakdowns will be shown in the code; here, let's first clarify the process. We'll explain the working mechanism of nested scrolling through a complete gesture cycle:
ACTION_DOWNWhen a finger presses down, the child View receives the
ACTION_DOWNevent and then callsstartNestedScroll()to search up the View tree for a parent container willing to cooperate with scrolling. If found, it returnstrue, indicating a successful connection.ACTION_MOVE(Before Scrolling)When the finger moves, the child View calculates the distance moved this time (e.g., swiping up 50px). Before scrolling itself, it first calls
dispatchNestedPreScroll()to ask the parent container: I want to scroll 50px, do you want to consume some of it?The parent container might consume all of it, or only a part, such as consuming 30px to collapse the header.
Child View's Own Consumption
The child View gets the distance consumed by the parent container (30px) and the remaining distance (20px), then uses this remaining 20px for its own list scrolling.
ACTION_MOVE(After Scrolling)If the child View scrolls to the top/bottom but there is still scroll distance left (e.g., 10px remaining), it calls
dispatchNestedScroll()to ask the parent container again: I have 10px left after scrolling, do you want to use it? (For example, the parent container could use it to trigger a pull-to-refresh or expand the header).ACTION_UP / CANCELWhen the finger lifts or the event is canceled, the child View calls
stopNestedScroll()to notify the parent container that this scroll session has ended and it can clean up its state.
Hands-on: Building MyCollapsingLayout
Next, we'll implement a custom intercepting parent container MyCollapsingLayout, mimicking Zhihu's homepage to achieve a collapsing homepage area, a sticky detail header, and list scrolling.
First, following the official Material design approach, we provide the Demo layout.
There's a small detail here: to prevent the translucent overscroll glow effect at the scroll edge from disrupting the visual continuity of nested scrolling, we add android:overScrollMode="never" to the RecyclerView.
If the default glow effect is kept, a stretch animation is triggered when the list scrolls to the top, which hinders the immediate return of events to the parent container, causing nested scrolling to stutter.
<?xml version="1.0" encoding="utf-8"?>
<!--
Structure
MyCollapsingLayout
├─ contentList RecyclerView
├─ profileSection Homepage area (collapses as a whole on swipe up)
├─ stickyHeader Detail sticky header (Answers / Updates / Articles)
└─ toolbar Fixed title bar (declared last, overlaid on top)
-->
<com.example.demoview.widget.MyCollapsingLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/collapsingRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="32dp"
android:background="#F6F6F6">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contentList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never" />
<!-- Collapsible homepage area -->
<LinearLayout
android:id="@+id/profileSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:layout_width="64dp"
android:layout_height="64dp"
android:background="#FF6D00" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="雨白"
android:textColor="#121212"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Learning Nested Scrolling in Android"
android:textColor="#8590A6"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Homepage Bio: Swipe up to collapse this section first. After it's collapsed, the 'Answers / Updates / Articles' header sticks to the top, then the list scrolls."
android:textColor="#444444"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="128\nFollowing"
android:textColor="#121212"
android:textSize="13sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="3\nFollowers"
android:textColor="#121212"
android:textSize="13sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="56\nAgrees"
android:textColor="#121212"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- Detail sticky header: sticks below the title bar after the homepage collapses -->
<LinearLayout
android:id="@+id/stickyHeader"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="#FFFFFF"
android:elevation="2dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="16dp"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Answers"
android:textColor="#121212"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Updates"
android:textColor="#8590A6"
android:textSize="15sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Articles"
android:textColor="#8590A6"
android:textSize="15sp" />
</LinearLayout>
<!-- Fixed title bar: always pinned to the top; shows username when collapse progress is high -->
<FrameLayout
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#FFFFFF"
android:elevation="4dp"
android:paddingVertical="16dp"
android:paddingTop="8dp">
<TextView
android:id="@+id/toolbarTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:alpha="0"
android:text="雨白"
android:textColor="#121212"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="←"
android:textColor="#121212"
android:textSize="20sp" />
</FrameLayout>
</com.example.demoview.widget.MyCollapsingLayout>
Next, let's implement this custom parent container step by step.
Skeleton Setup: Implementing the Interface
First, implement the NestedScrollingParent3 interface
class MyCollapsingLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
NestedScrollingParent3 { // Implement NestedScrollingParent3 interface
init {
// Clip child Views that exceed the top of the parent container to prevent drawing outside the status bar
clipChildren = true
clipToPadding = true
}
// Subsequent methods to be filled in gradually...
}
Measurement Logic: Determining the List's Actual Height
Then comes the measurement code. There's a crucial detail here: the list's height must be the remaining screen space after collapsing, otherwise, after the header collapses, there will be a blank area at the bottom of the list.
This also means that our MyCollapsingLayout outermost layer should be a container with a definite height. It should not be wrapped in a container with an uncertain height (UNSPECIFIED), like ScrollView.
private lateinit var toolbar: View // Top fixed title bar
private lateinit var profile: View // Homepage info area
private lateinit var sticky: View // Sticky header
private lateinit var scrollChild: View // Bottom scrolling list
// Record the heights of each area
private var toolbarHeight = 0
private var profileHeight = 0
private var stickyHeight = 0
// Maximum collapsible distance
private var maxCollapse = 0
override fun onFinishInflate() {
super.onFinishInflate()
// After the layout is loaded, find the corresponding child controls
toolbar = findViewById(R.id.toolbar)
?: error("can't find @id/toolbar")
profile = findViewById(R.id.profileSection)
?: error("can't find @id/profileSection")
sticky = findViewById(R.id.stickyHeader)
?: error("can't find @id/stickyHeader")
scrollChild = findViewById(R.id.contentList)
?: error("can't find @id/contentList")
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Get the exact space given by the parent container
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
// Save own dimensions
setMeasuredDimension(width, height)
// Measurement specs: width must fill parent (EXACTLY), height determined by children (unrestricted)
val exactWidth = MeasureSpec
.makeMeasureSpec(width, MeasureSpec.EXACTLY)
val wrapHeight = MeasureSpec
.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
// Measure child Views
toolbar.measure(exactWidth, wrapHeight)
profile.measure(exactWidth, wrapHeight)
sticky.measure(exactWidth, wrapHeight)
// Get the measured heights of each component
toolbarHeight = toolbar.measuredHeight
profileHeight = profile.measuredHeight
stickyHeight = sticky.measuredHeight
// The maximum collapse distance is the height of the homepage area
maxCollapse = profileHeight
// Measure the bottom scrolling list
// List height = Total height - (Always visible Toolbar) - (Always visible sticky header)
val listHeight = (height - toolbarHeight - stickyHeight).coerceAtLeast(0)
scrollChild.measure(
exactWidth,
MeasureSpec.makeMeasureSpec(listHeight, MeasureSpec.EXACTLY)
)
}
Layout Placement and Scroll Displacement
Then comes layout placement. We need to place views according to their fully expanded coordinates. This is because our collapsing is achieved through translationY offset, not by changing the height.
This way, frequent re-measurement and re-layout (
requestLayout) during scrolling won't cause page jitter or list height fluctuations.
// Current collapsed distance
private var collapseOffset = 0
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
val w = right - left
// Place according to fully expanded coordinates
toolbar.layout(0, 0, w, toolbarHeight)
profile.layout(0, toolbarHeight, w, toolbarHeight + profileHeight)
sticky.layout(
0,
toolbarHeight + profileHeight,
w,
toolbarHeight + profileHeight + stickyHeight
)
val listTop = toolbarHeight + profileHeight + stickyHeight
scrollChild.layout(0, listTop, w, listTop + scrollChild.measuredHeight)
// Modify drawing order (Z-axis)
scrollChild.elevation = 0f // List is at the bottom layer
profile.elevation = 1f
sticky.elevation = 2f
toolbar.elevation = 3f // Toolbar is at the top layer
// Apply the current collapse offset initially, also applicable for scenarios like screen rotation
applyCollapseOffset(collapseOffset, notify = false)
}
// Collapse progress, 0.0 ~ 1.0, convenient for external adjustment of title bar transparency
var onCollapseProgressChanged: ((Float) -> Unit)? = null
/**
* Apply collapse offset
* @param offset Collapse offset distance
* @param notify Whether to notify the callback
*/
private fun applyCollapseOffset(offset: Int, notify: Boolean) {
// 0 means fully expanded, maxCollapse means fully collapsed
collapseOffset = offset
// Pushing upwards, so it's a negative value
val ty = -offset.toFloat()
// All three move up synchronously: homepage retracts, sticky header sticks below the title bar, list follows
profile.translationY = ty
sticky.translationY = ty
scrollChild.translationY = ty
// Calculate progress callback, 0f is fully expanded, 1f is fully collapsed
if (notify) {
val progress = if (maxCollapse == 0) 0f else offset.toFloat() / maxCollapse
onCollapseProgressChanged?.invoke(progress)
}
}
Core Logic: Intercepting and Consuming Scroll Events
Next, let's look at the most core callbacks in nested scrolling, starting with the validation and establishment before participating in scrolling.
The official API provides a helper class
NestedScrollingParentHelperthat allows us to quickly implement some fixed, tedious logic.
// -------------------------------------------------------------------------
// NestedScrollingParent3
// -------------------------------------------------------------------------
private val parentHelper = NestedScrollingParentHelper(this)
/**
* Scroll starts, decide whether to participate in this nested scroll (establish connection)
* @param child The child View that triggers the scroll
* @param target The child View that actually generates the scroll, generally equal to child
* @param axes Scroll direction, horizontal or vertical, bitmask, could be both
* @param type Scroll type, touch screen or fling
*/
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
// Only respond to vertical nested scrolling
return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}
/**
* Participate in this nested scroll, complete initialization
*/
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
// Save scroll direction state
parentHelper.onNestedScrollAccepted(child, target, axes, type)
}
Then come onNestedPreScroll() and onNestedScroll().
Note: When assigning a value to
consumed[1], it must equal the actual consumed pixel value, otherwise the child View might receive an incorrect remaining distance, leading to scroll misalignment.
/**
* Before the child View scrolls, the parent container intercepts first
* @param target The child View that actually generates the scroll
* @param dx The horizontal offset the child View wants to scroll this time, >0 means finger moves right
* @param dy The vertical offset the child View wants to scroll this time, >0 means finger moves up
* @param consumed The offset consumed by the parent in horizontal and vertical directions respectively
* @param type Scroll type, touch screen or fling
*/
override fun onNestedPreScroll(
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
// If the finger swipes up and the homepage hasn't fully collapsed yet
if (dy > 0 && collapseOffset < maxCollapse) {
// Consume a distance to collapse the header
// Record in consumed[1] to clearly tell the child View how many vertical pixels we consumed
consumed[1] = applyCollapseDelta(dy)
}
}
/**
* The child View has scrolled to the top, handing the remaining distance to the parent container
* @param target The child View that actually generates the scroll
* @param dxConsumed Horizontal offset consumed by the child View
* @param dyConsumed Vertical offset consumed by the child View
* @param dxUnconsumed Remaining horizontal offset from the child View
* @param dyUnconsumed Remaining vertical offset from the child View
* @param type Scroll type, touch screen or fling
* @param consumed The offset consumed by the parent in horizontal and vertical directions respectively
*/
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
// If the finger swipes down, the list has scrolled to the top (dyUnconsumed < 0), and the homepage hasn't fully expanded yet
if (dyUnconsumed < 0 && collapseOffset > 0) {
// Consume a distance to expand the homepage
consumed[1] += applyCollapseDelta(dyUnconsumed)
}
}
/**
* Receive the scroll delta, apply the offset, and return the actual consumed pixel value
* @param delta >0 continues collapsing; <0 expands
* @return The actual effective delta
*/
private fun applyCollapseDelta(delta: Int): Int {
if (maxCollapse == 0 || delta == 0) return 0
val old = collapseOffset
val newOffset = (collapseOffset + delta).coerceIn(0, maxCollapse)
val actual = newOffset - old // Calculate the actual effective pixel difference
if (actual == 0) return 0
applyCollapseOffset(newOffset, notify = true)
return actual // Return the actual consumption to the upper-level consumed array
}
Smooth Fling Transition and Miscellaneous Completion
Finally, complete the remaining legacy API compatibility code and the Fling method.
Our Fling (inertial scrolling) method directly returns false. This is because in the V2/V3 protocol, the RecyclerView's inertial scrolling is converted by the underlying layer into TYPE_NON_TOUCH Scroll events that are continuously dispatched.
We don't need to implement it; using the onNestedPreScroll written above directly achieves a perfect, seamless connection. This is also the core manifestation of how the V2 version solved the pain points of the V1 version.
/**
* Get the scroll direction
*/
override fun getNestedScrollAxes(): Int = parentHelper.nestedScrollAxes
/**
* Scroll ended
*/
override fun onStopNestedScroll(target: View, type: Int) {
parentHelper.onStopNestedScroll(target, type)
}
// --- Below is all legacy API compatibility code, directly calling V3 methods with TYPE_TOUCH ---
override fun onStartNestedScroll(child: View, target: View, axes: Int): Boolean =
onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH)
override fun onNestedScrollAccepted(child: View, target: View, axes: Int) =
onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH)
override fun onStopNestedScroll(target: View) =
onStopNestedScroll(target, ViewCompat.TYPE_TOUCH)
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) =
onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH)
override fun onNestedScroll(
target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int
) = onNestedScroll(
target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
ViewCompat.TYPE_TOUCH, IntArray(2)
)
override fun onNestedScroll(
target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
dyUnconsumed: Int, type: Int
) = onNestedScroll(
target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, IntArray(2)
)
// Ignore manual interception of Fling, rely on the V2/V3 underlying TYPE_NON_TOUCH dispatch mechanism for smooth transition
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float) = false
override fun onNestedFling(
target: View,
velocityX: Float,
velocityY: Float,
consumed: Boolean
) = false
/**
* Generate default LayoutParams
*/
override fun generateDefaultLayoutParams(): LayoutParams {
return LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
Finally, the test code to run in the Activity:
class MyCollapsingDemoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my_collapsing_demo)
supportActionBar?.hide()
title = "Imitating Zhihu Collapsing Homepage"
val toolbarTitle = findViewById<TextView>(R.id.toolbarTitle)
val root = findViewById<MyCollapsingLayout>(R.id.collapsingRoot)
root.onCollapseProgressChanged = { progress ->
// The more the homepage collapses, the more visible the title bar username becomes
toolbarTitle.alpha = (progress * 1.2f).coerceIn(0f, 1f)
}
val list = findViewById<RecyclerView>(R.id.contentList)
list.layoutManager = LinearLayoutManager(this)
list.adapter = FeedAdapter(buildFakeFeed())
}
private fun buildFakeFeed(): List<FeedItem> {
return (1..40).map { index ->
FeedItem(
title = "Answer #$index: In nested scrolling, who gets dy first?",
body = "Parent receives the homepage first in PreScroll; after the homepage is collapsed, Child scrolls the list." +
"The remaining downward swipe after reaching the top then expands the homepage in NestedScroll."
)
}
}
private data class FeedItem(val title: String, val body: String)
private class FeedAdapter(
private val items: List<FeedItem>
) : RecyclerView.Adapter<FeedAdapter.Holder>() {
class Holder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.tvItemTitle)
val body: TextView = view.findViewById(R.id.tvItemBody)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_zhihu_feed, parent, false)
return Holder(view)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val item = items[position]
holder.title.text = item.title
holder.body.text = item.body
}
override fun getItemCount(): Int = items.size
}
}
Supplement: Protocol Evolution History
You see the number 3 in NestedScrollingParent3 in the code. This is actually the third version of the official evolution. We also mentioned this when explaining Fling earlier. Finally, let's summarize the evolution of these three versions with a table:
| Version | Core Pain Point & Improvement | Change Details |
|---|---|---|
| V1 | Pain Point: Inertial scrolling (Fling) is not continuous. | Fling is thrown to either the parent or child in one go, unable to be distributed pixel by pixel like normal Scroll. |
| V2 | Improvement: Converts Fling into normal scrolling. | All interface methods added a type parameter (TYPE_TOUCH for finger scrolling, TYPE_NON_TOUCH for inertial scrolling), allowing Fling to also link perfectly. |
| V3 | Improvement: Precise post-scroll consumption feedback. | dispatchNestedScroll added a consumed array parameter, so after the parent container handles the remaining scroll, it can tell the child View exactly how many pixels it consumed. |
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
I've already handed all the more complex nested scrolling over to AI [facepalm]
That works