Nested ScrollView Inner-First Scrolling Without the Fight
Foreword
Recently I had a small requirement: two nested scrolling containers need to implement inner-first scrolling — once the inner container reaches the top or bottom, the event should then be handed to the outer container.
Although I roughly knew the approach, I had never actually implemented it. So, let's do it hands-on.
If you need to understand the principles of touch feedback and event dispatch, you can read my blog.
First, we need to decide whether to replace the outer container or the inner one. It should actually be the inner one.
Because only the inner container can determine whether it has currently scrolled to the top or bottom. At the same time, the inner container can also request the outer container not to intercept events via parent?.requestDisallowInterceptTouchEvent().
Handling Strategy
Let's first outline the handling strategy:
- On finger down (
ACTION_DOWN), disallow the parent layout from intercepting; otherwise, subsequent events will all be handled by the parent layout and won't reach us. - On finger up, restore the parent layout's interception capability so it can handle subsequent gestures.
- The key part is during finger move: if scrolling is still possible, continue disallowing the parent from intercepting. When scrolling to the top and dragging further down, or scrolling to the bottom and dragging further up, release the parent's interception and let the outer container take over.
If you are nesting
NestedScrollView, it's not this complicated — the default behavior is already inner-first. It comes with the Nested Scrolling cooperation mechanism built in.
Code Implementation
First, define the basic skeleton code. To be absolutely safe, we place the event interception strategy uniformly in dispatchTouchEvent.
/**
* Inner-log-priority scrolling container
*/
class NestedLogScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (ev == null) return super.dispatchTouchEvent(ev)
return super.dispatchTouchEvent(ev)
}
}
On finger down, disallow the parent layout from intercepting events. Then on finger up, restore the parent layout's interception:
class NestedLogScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
// Record the previous absolute Y coordinate of the finger
private var lastRawY = 0f
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (ev == null) return super.dispatchTouchEvent(ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// On down, record the absolute coordinate
lastRawY = ev.rawY
// And tell the parent not to intercept events; otherwise, once it starts intercepting, we won't receive subsequent events
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
...
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
// Gesture ended (up or cancelled), restore parent interception
parent?.requestDisallowInterceptTouchEvent(false)
}
}
return super.dispatchTouchEvent(ev)
}
}
Now we've done most of the work. Only the finger move part remains.
// Get the system-standard touch slop distance to filter out tiny jitters on finger down, preventing the inner and outer layers from fighting over the gesture
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
MotionEvent.ACTION_MOVE -> {
val currentRawY = ev.rawY
val dy = currentRawY - lastRawY
// If the displacement is too small, treat it as jitter and don't change the interception strategy
if (abs(dy) > touchSlop) {
// dy > 0 means the gesture is scrolling down (trying to see content hidden above)
val isScrollingDown = dy > 0
val canScrollFurther = if (isScrollingDown) {
canScrollVertically(-1) // Check if there is still content above to scroll to
} else {
canScrollVertically(1) // Check if there is still content below to scroll to
}
if (!canScrollFurther) {
// Already scrolled to the boundary, release interception and hand the event back to the outer layer
parent?.requestDisallowInterceptTouchEvent(false)
} else {
// Can still scroll further, hold onto the event
parent?.requestDisallowInterceptTouchEvent(true)
}
// Update the coordinate
lastRawY = currentRawY
}
}
Now this requirement is complete. Isn't it simple?
There's one detail here: the coordinate we get is ev.rawY (the absolute screen coordinate), not ev.y (the coordinate relative to the top of the container). Because when the container scrolls, this relative position keeps changing. Using it could cause coordinate jumps, making the inner and outer layers fight over the gesture and causing the page to jitter up and down.
Additionally, the key to determining whether scrolling is possible lies in the canScrollVertically() method. Passing a negative number checks whether there is still content at the top; passing a positive number checks the bottom.
Its internal logic is also very clear: first, get the current scroll offset value, which starts at 0 and increases as you scroll down. It represents the distance the content has already been scrolled up and out of view.
Then get the total height of the complete content and the height of the container's visible area to derive the maximum scrollable distance range. The breakdown is in the code below.
public boolean canScrollVertically(int direction) {
final int offset = computeVerticalScrollOffset();
final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
// If the scrollable distance is 0, it means the content exactly fills the container's visible area. Directly return unable to scroll.
if (range == 0) return false;
if (direction < 0) { // Check the top direction
// offset > 0 means we have already scrolled down some distance, and there is content hidden at the top, so we can still scroll back up.
return offset > 0;
} else { // Check the bottom direction
// The offset hasn't reached the maximum scroll value yet, meaning there is still content at the bottom.
return offset < range - 1; // -1 is for boundary tolerance
}
}
Also, the value of range can be negative, which indicates insufficient content. If direction > 0, it will enter the else branch and return false, meaning not scrollable. The semantics are still correct.
For convenience, here is the complete code for NestedLogScrollView:
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.widget.ScrollView
import kotlin.math.abs
class NestedLogScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
private var lastRawY = 0f
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (ev == null) return super.dispatchTouchEvent(ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastRawY = ev.rawY
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
val currentRawY = ev.rawY
val dy = currentRawY - lastRawY
if (abs(dy) > touchSlop) {
val isScrollingDown = dy > 0
val canScrollFurther = if (isScrollingDown) {
canScrollVertically(-1)
} else {
canScrollVertically(1)
}
if (!canScrollFurther) {
parent?.requestDisallowInterceptTouchEvent(false)
} else {
parent?.requestDisallowInterceptTouchEvent(true)
}
lastRawY = currentRawY
}
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
}
}
return super.dispatchTouchEvent(ev)
}
}
Pitfall Avoidance Guide
Why put all the logic in dispatchTouchEvent? Shouldn't we consume events in onTouchEvent?
First, the idea that "if a child View consumes the event, the parent ViewGroup cannot consume it" is correct in ordinary containers, but wrong in ScrollView.
When you press and hold a Button inside a ScrollView and start sliding, initially ACTION_DOWN is consumed by the Button. But as soon as the finger's sliding distance exceeds the threshold (TouchSlop), ScrollView's internal interception mechanism (onInterceptTouchEvent) will automatically send an ACTION_CANCEL event to the Button, forcibly taking over the subsequent gesture and starting its own scrolling.
If we put the inner-first logic in onTouchEvent, let's see what happens.
If a child View consumes the event, our onTouchEvent won't even receive ACTION_DOWN, let alone receive subsequent MOVE events by returning true. The event will simply be ruthlessly snatched away by the outer container, and our inner-first logic will fail.
Therefore, we must move the logic up to dispatchTouchEvent — this is to be able to take over events at any time.
Example Code
Example layout:
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/outerScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvTopContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="14sp" />
<Button
android:id="@+id/btnAppendLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Append Logs" />
<Button
android:id="@+id/btnClearLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Clear Logs" />
<com.example.demoview.widget.NestedLogScrollView
android:id="@+id/logScrollView"
android:layout_width="match_parent"
android:layout_height="180dp"
android:layout_marginTop="12dp"
android:background="#EEEEEE"
android:fillViewport="true"
android:scrollbars="vertical">
<TextView
android:id="@+id/logText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:hint="Log Area"
android:padding="12dp"
android:textSize="13sp" />
</com.example.demoview.widget.NestedLogScrollView>
<TextView
android:id="@+id/tvBottomContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="24dp"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
Example code:
class NestedLogScrollDemoActivity : AppCompatActivity() {
private lateinit var logScrollView: NestedLogScrollView
private lateinit var logText: TextView
private var logIndex = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_nested_log_scroll_demo)
logScrollView = findViewById(R.id.logScrollView)
logText = findViewById(R.id.logText)
findViewById<TextView>(R.id.tvTopContent).text = getLines(count = 15)
findViewById<TextView>(R.id.tvBottomContent).text = getLines(count = 30)
findViewById<Button>(R.id.btnAppendLogs).setOnClickListener { appendLogs(count = 5) }
findViewById<Button>(R.id.btnClearLogs).setOnClickListener {
logIndex = 0
logText.text = ""
}
appendLogs(count = 12)
}
private fun getLines(count: Int): String {
val sb = StringBuilder()
repeat(times = count) { line ->
sb.append("Line ${line + 1}\n")
}
return sb.toString()
}
private fun appendLogs(count: Int) {
val sb = StringBuilder()
repeat(times = count) {
logIndex++
sb.append("log #$logIndex\n")
}
logText.append(sb.toString())
logScrollView.post { logScrollView.fullScroll(ScrollView.FOCUS_DOWN) }
}
}
Running effect: