跪拜 Guibai
← Back to the summary

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.

Screen_recording_20260721_134311.gif

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:

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 NestedScrollingParentHelper that 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.
Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

getglory 1 likes

I've already handed all the more complex nested scrolling over to AI [facepalm]

雨白

That works