Custom Nested Scrolling Layout Implementation for Lofter Personal Homepage on Android
This article details the design and implementation of a custom nested scrolling layout for the Lofter 7.1.0 personal homepage, covering the selection of Android coordinator components, the underlying NestedScrolling mechanism, step‑by‑step integration, code analysis, inertia handling, and animation management.
The Lofter 7.1.0 personal homepage requires a nested scrolling effect where the parent layout scrolls first, then the child layout takes over, supports sticky behavior, and can fully slide out of the screen.
Technical solution selection : Although CoordinatorLayout + AppBarLayout + ViewPager can achieve basic nested scrolling, they cannot easily handle pull‑down, rebound, and extensibility requirements. Therefore a custom nested scrolling control based on the core NestedScrolling principles was created.
Basic principles of nested scrolling :
External interception (onInterceptTouchEvent) decides whether the parent consumes the event.
Internal interception (requestDisallowInterceptTouchEvent) lets the child inform the parent whether to intercept.
Android 5.0 introduced the NestedScrolling mechanism, allowing both parent and child to cooperate during a single scroll.
The mechanism relies on NestedScrollingChild and NestedScrollingParent interfaces. Their key methods are shown below:
//========================= NestedScrollingChild ======================================
public interface NestedScrollingChild {
void setNestedScrollingEnabled(boolean enabled);
boolean isNestedScrollingEnabled();
boolean startNestedScroll(@ScrollAxis int axes);
void stopNestedScroll();
boolean hasNestedScrollingParent();
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
// ========================= NestedScrollingParent =====================================
public interface NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
void onStopNestedScroll(@NonNull View target);
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
int getNestedScrollAxes();
}Implementation steps for the personal homepage :
Initial upward swipe moves the outer card while the inner content stays static.
When the personal‑info area fully covers the screen, the inner card starts following the gesture.
During a downward swipe, the inner card scrolls first; once it reaches the top, the outer card follows, and the layout collapses completely at a defined threshold.
Key layout parameters such as topHeight , topBarHeight , contentTransY , downFlingCutOff , topBarAlphaOffset , and downEndY are used to calculate translation, alpha, and rebound behavior.
Code analysis – NestedScrollingParent2 implementation
// onStartNestedScroll()
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
var handleChildView = true
if (target is SubChildNestScrollVIew && !target.isSlideToBottom()) {
handleChildView = mLlContent?.translationY != topBarHeight
}
if (target is SubChildNestScrollVIew && !target.isSlideToTop()) {
handleChildView = false
}
if (target is SubChildNestScrollVIew && mLlContent?.translationY == topBarHeight &&
target.isSlideToTop() && !target.upScroll()) {
handleChildView = true
}
return enableScroll && axes == ViewCompat.SCROLL_AXIS_VERTICAL && handleChildView
}
// onNestedPreScroll()
override fun onNestedPreScroll(
@NonNull target: View,
dx: Int,
dy: Int,
@NonNull consumed: IntArray,
type: Int
) {
// omitted for brevity – handles upward, downward, and fling scenarios, updates translation, calls progress callbacks, and controls scrolling enable flag
}
// onStopNestedScroll()
override fun onStopNestedScroll(target: View, type: Int) {
mParentHelper.onStopNestedScroll(target, type)
val tran = mLlContent!!.translationY
if (tran == contentTransY || reboundedAnim?.isStarted == true ||
restoreOrExpandAnimator?.isStarted == true) return
if (tran >= contentTransY) {
if (tran > contentTransY + downFlingCutOffY) expand(0) else restore(0)
}
}Inertia handling is performed either by consuming the fling in onNestedPreFling/onNestedFling or by letting the pre‑scroll logic absorb the remaining velocity. Four scenarios are considered, ranging from fast upward flings to rebound‑driven downward flings.
Resource release :
/**
* Release resources
*/
fun releaseFromWindow() {
if (restoreOrExpandAnimator?.isStarted == true) {
restoreOrExpandAnimator!!.cancel()
restoreOrExpandAnimator!!.removeAllUpdateListeners()
restoreOrExpandAnimator = null
}
if (reboundedAnim!!.isStarted) {
reboundedAnim!!.cancel()
reboundedAnim!!.removeAllUpdateListeners()
reboundedAnim = null
}
mProgressUpdateListener = null
}Rebound animation uses a ValueAnimator with a cubic interpolator, updating the content translation and invoking callbacks on animation start/end.
Restore/expand animation also uses a ValueAnimator (300 ms) to animate the content back to its middle or collapsed state, handling vibration effects and alpha updates.
NestedScrollingChild2 implementation is provided by extending NestedScrollView (class SubChildNestScrollVIew ) to unify child types, recalculate RecyclerView height, and support tab‑header follow‑scroll behavior.
class SubChildNestScrollVIew : NestedScrollView {
// constructors omitted for brevity
override fun onFinishInflate() { /* set up RecyclerView listeners */ }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { /* adjust child heights */ }
override fun canScrollVertically(direction: Int): Boolean = recyclerView?.canScrollVertically(direction) == true
fun isSlideToBottom(): Boolean = recyclerView?.canScrollVertically(1) == false
fun isSlideToTop(): Boolean = recyclerView?.canScrollVertically(-1) == false
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
return if (scrollY != 0) false else super.onInterceptTouchEvent(ev)
}
}Overall, the article presents a complete, production‑ready solution for complex nested scrolling interactions on Android, including detailed API usage, custom view logic, animation handling, and callback interfaces.
LOFTER Tech Team
Technical sharing and discussion from NetEase LOFTER Tech Team
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.