introduce

In the development of RecyclerView, we usually encounter the situation that no content can be displayed in a row. The product requires that our item can be scrolled and its head is fixed. Especially in the app related to stock quotations, there are many such scenarios, so the following custom components are encapsulated.

rendering

First of all, you can see that you can scroll horizontally, the head is fixed, and support the side slide drag out the egg “Hello”, here the effect is to imitate the flush of the self-selected stock pool

How to implement

1. Architecture diagram

The outermost layer uses RecyclerView, and the Item uses LinearLayout. The left side is a fixed header. Here I use TextView, and the right side is a custom ScrollView layout.

2, custom SwipeHorizontalScrollView

Implementing onMeasure

MeasureSpec. GetSize (widthMeasureSpec) measures the viewWidth of the current control on the screen, i.e. the width of the screen minus the width of the header layout.

Next, iterate over the child Views and measure the width and height of each child view with The measureChildWithMargins, overriding generateLayoutParams(). Then add the width of the child view to get the total width of the control, contentWidth. ContentHeight needs to compare the height of the child views, which may be different, to the maximum height of the control.

override fun generateLayoutParams(attrs: AttributeSet?).: LayoutParams {
    return MarginLayoutParams(context, attrs)
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    if (viewWidth == 0)
        viewWidth = MeasureSpec.getSize(widthMeasureSpec)
    var contentWidth = 0
    var contentHeight = 0
    for (i in 0 until childCount) {
        val childView = getChildAt(i)
        if(childView.visibility ! = View.GONE) { measureChildWithMargins(childView,0.0, heightMeasureSpec, 0)
            contentWidth += childView.measuredWidth
            contentHeight = max(contentHeight, childView.measuredHeight)
        }
    }
    setMeasuredDimension(contentWidth + paddingStart + paddingEnd, contentHeight + paddingTop + paddingBottom)
}
Copy the code

Implement onLayout

Traversal the subview layout from left to right. LayoutLeft = -childViewWidth if the configuration is set to hide the left view and is the first element, and the view is offset to the left to hide.

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    var layoutLeft = 0
    for (i in 0 until childCount) {
        val childView = getChildAt(i)
        val childViewWidth = childView.measuredWidth
        val childViewHeight = childView.measuredHeight
        // Hide the first view&& on the left that is the first element
        if (isNeedHideLeftView && i == 0) {
            layoutLeft = -childViewWidth
        }
        childView.layout(layoutLeft, paddingTop, layoutLeft + childViewWidth, paddingTop + childViewHeight)
        layoutLeft += childViewWidth
    }
}
Copy the code

Listen on all scrollViews

In the custom RecyclerView to define a scrollViews used to record the visible scrollView on the screen, synchronous scrolling state. The reason is if defined in SwipeHorizontalScrollView, then each control will maintain a collection efficiency is very low. Pass custom RecyclerView through the set method. IsNeedHideLeftView controls whether the leftmost view needs to be hidden. IsNeedShowShadow Controls whether the shadow needs to be displayed.

Rewrite onAttachedToWindow and onDetachedFromWindow, update scrollView in mScrollViews. Add it to the collection when it is visible on the screen, and scroll it to the recorded recordX, the offset in the x direction. When it moves off screen, move it out of the collection.

fun setRecyclerView(recyclerView: HorizontalRecyclerView, isNeedHideLeftView: Boolean = false, isNeedShowShadow: Boolean = true) {
    this.recyclerView = recyclerView
    this.isNeedHideLeftView = isNeedHideLeftView
    this.isNeedShowShadow = isNeedShowShadow
}

private fun monitorScrollViews(a): MutableList<SwipeHorizontalScrollView> {
    returnrecyclerView? .scrollViews ? : mScrollViews }override fun onAttachedToWindow(a) {
    super.onAttachedToWindow()
    if(! monitorScrollViews().contains(this))
        monitorScrollViews().add(this)
    scrollTo(getRecordX(), 0)
    setShadow(getRecordX())
}

override fun onDetachedFromWindow(a) {
    super.onDetachedFromWindow()
    monitorScrollViews().remove(this)}Copy the code

Rewrite the dispatchTouchEvent

ACTION_DOWN event records the position of x and y. When pressed, the scroller animation needs to be stopped and the current scrollX is recorded.

ACTION_MOVEWhen the offset of x and Y is compared, when the offset of horizontal direction is greater than the offset of vertical direction, the user’s behavior is judged to be horizontal sliding. callparent.requestDisallowInterceptTouchEvent(true)Cancel external interception. callcancelLongPress()Used to cancel the long press event when the user swipes down the screen horizontally but does not lift the finger.

NeedNotify is used to notify whether the RecyclerView interface elements need to be updated. For example, the information of stock increase is updated in real time. We hope that the elements of the interface are not updated when the user drags and drags, so as to reduce frequent drawing.

override fun dispatchTouchEvent(ev: MotionEvent?).: Boolean {
    when(ev? .action) { MotionEvent.ACTION_DOWN -> { downPoint.set(ev.x, ev.y)
            moveX = ev.x
            monitorScrollViews().forEach {
                if(! it.mScroller.isFinished) { it.mScroller.abortAnimation() } } setRecordX(scrollX) recyclerView? .needNotify =false
        }
        MotionEvent.ACTION_MOVE -> {
            if (abs(downPoint.x - ev.x) > abs(downPoint.y - ev.y)) {
                parent.requestDisallowInterceptTouchEvent(true)}if (abs(downPoint.x - ev.x) >= touchSlop || abs(downPoint.y - ev.y) >= touchSlop) {
                (tag as? View)? .cancelLongPress() } } MotionEvent.ACTION_UP -> { recyclerView? .needNotify =true
        }
        MotionEvent.ACTION_CANCEL -> {
            (tag as? View)? .cancelLongPress() } }return super.dispatchTouchEvent(ev)
}
Copy the code

Rewrite onInterceptTouchEvent

override fun onInterceptTouchEvent(ev: MotionEvent?).: Boolean {
    if(ev? .action == MotionEvent.ACTION_MOVE && abs(downPoint.x - ev.x) > abs(downPoint.y - ev.y)) {return true
    }
    return super.onInterceptTouchEvent(ev)
}
Copy the code

Rewrite the onTouchEvent

First, we need to familiarize ourselves with the Scroller and sliding mechanics

(tag as? View)? OnTouchEvent (event) will pass the touch event to the set tag, tag we set is RecyclerView itemView, convenient to set the click and long press event of itemView.

scenario describe
Need to hide the view on the left: first get the width of the hidden view, judgeafterScrollX >= -firstViewWidth && afterScrollX <= measuredWidth - viewWidth - firstViewWidth(The distance after scrolling is within the range within which the control can be rolled). When the scroll is dragged within the firstViewWidth or when the firstView is hidden and dragged to the right,deltaX / 2Simulate the viscous effect.
You don’t need to hide the view on the left and call it when it’s scrollablescrollBy(deltaX, 0)Can be
if (isShowLeft) {fixScrollX()}When the hidden view is expanded, you don’t want to scroll fast horizontally, you just want to hide firstView and scroll to scrollX=0.
Called when the fistView is hidden and scrollX is 0 and slides rightfixScrollX(). Otherwise, the Fling event is passed to each scrollView.MinX = -(firstViewWidth * 0.2).toint ()When you hide firstView and scrollX is not 0, a quick scroll to the right will not fully expand the firstView, showing at most 20% of the firstView
MotionEvent.ACTION_MOVE -> {
    (tag as? View)? .onTouchEvent(event)val deltaX = (moveX - event.x).toInt()
    mDirection = if (deltaX > 0) {
        Direction.DIRECTION_LEFT // Swipe your finger from right to left to scroll to the left
    } else {
        Direction.DIRECTION_RIGHT
    }
    val afterScrollX = scrollX + deltaX

    if (isNeedHideLeftView) {
        val firstViewWidth = getChildAt(0).measuredWidth
        if (afterScrollX >= -firstViewWidth && afterScrollX <= measuredWidth - viewWidth - firstViewWidth) {
            if ((afterScrollX >= -firstViewWidth && afterScrollX < 0) || afterScrollX == 0 && deltaX < 0) {
                scrollBy(deltaX / 2.0)}else {
                scrollBy(deltaX, 0)}}}else {
        if (afterScrollX >= 0 && afterScrollX <= measuredWidth - viewWidth) {
            scrollBy(deltaX, 0)
        }
    }
}

MotionEvent.ACTION_UP -> {
    if (abs(downPoint.x - event.x) < touchSlop && abs(downPoint.y - event.y) < touchSlop) {
        (tag as? View)? .onTouchEvent(event) }/ / releasevelocityTracker? .run { computeCurrentVelocity(1000)
        val firstViewWidth = getChildAt(0).measuredWidth
        if (abs(xVelocity) > mMinimumVelocity) {
            needFix = true
            if (isShowLeft) {
                fixScrollX()
            } else {
                if (mDirection == Direction.DIRECTION_RIGHT && scrollX < 0) {
                    fixScrollX()
                } else {
                    val maxX = if (measuredWidth < viewWidth) 0 else measuredWidth - viewWidth
                    if (isNeedHideLeftView) {
                        monitorScrollViews().forEach {
                            it.mScroller.fling(scrollX, 0, (-xVelocity.toInt() * 1.5).toInt(), 0, -(firstViewWidth * 0.2).toInt(), maxX - firstViewWidth, 0.0)}}else {
                        monitorScrollViews().forEach {
                            it.mScroller.fling(scrollX, 0, (-xVelocity.toInt() * 1.5).toInt(), 0.0, maxX, 0.0)}}}}}else {
            if (isNeedHideLeftView) {
                fixScrollX()
            }
        }
        postInvalidate()
        recycle()
        velocityTracker = null}}Copy the code

Expanded and folded states

The scrollX will be in the range of -FirstViewWidth ~ -FirstViewWidth +threshold, that is, when the display of firstView exceeds 70%, the firstView will be expanded. Fold the firstView when scrollX is greater than -FirstViewWidth + threshold. Expand and collapse call the startScroll() method of Scroller

/** * fix the x position */
private fun fixScrollX(a) {
    needFix = false
    if (isNeedHideLeftView) {
        val firstViewWidth = getChildAt(0).measuredWidth
        val threshold = firstViewWidth * 0.3 // [-firstViewWidth -firstViewWidth+threshold -threshold 0]
        if (isShowLeft) { // Expand state
            if (scrollX >= -firstViewWidth && scrollX <= -firstViewWidth + threshold) {
                extend()
            } else if (scrollX > -firstViewWidth + threshold) {
                fold()
            }
        } else { // Unpacked state
            if (scrollX <= -threshold) {
                extend()
            } else if (scrollX > -threshold && scrollX <= 0) {
                fold()
            }
        }
    }
}

/** * expand view */
private fun extend(a) {
    val left = getChildAt(0).measuredWidth
    monitorScrollViews().forEach {
        it.mScroller.startScroll(scrollX, 0, -left - scrollX, 0.300)
    }
    isShowLeft = true
}

/** * collapse view */
private fun fold(a) {
    monitorScrollViews().forEach {
        it.mScroller.startScroll(scrollX, 0, -scrollX, 0.300)
    }
    isShowLeft = false
}
Copy the code

3. Customize HorizontalRecyclerView

Rewrite the addView

Shadows in ids. Scroll the view in the XML configuration and the view of global id, found in the child scroll view, called setRecyclerView () pass HorizontalRecyclerView reference to SwipeHorizontalScrollView, Call decorateScrollView adornment SwipeHorizontalScrollView (), to add the shadow.

<resources>
    <item name="swipeHorizontalView" type="id" />
    <item name="swipeHorizontalShadowView" type="id" />
</resources>
Copy the code
override fun addView(child: View? , index:Int, params: ViewGroup.LayoutParams?). {
    valrightScroll = child? .findViewById<SwipeHorizontalScrollView>(R.id.swipeHorizontalView) rightScroll? .setRecyclerView(this, isNeedHideLeftView = needHideLeft, isNeedShowShadow = needShadow) rightScroll? .tag = child decorateScrollView(rightScroll)super.addView(child, index, params) rightScroll? .scrollTo(recordX,0)}private fun decorateScrollView(scrollView: View?).: FrameLayout {
    val frameLayout = FrameLayout(context).apply {
        layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
    }
    val shadowView = getShadowView()
    valparent = scrollView? .parentas? ViewGroup? parent? .removeView(scrollView) scrollView? .let { frameLayout.addView(it) } frameLayout.addView(shadowView) parent? .addView(frameLayout)return frameLayout
}

private fun getShadowView(a): View {
    return View(context).apply {
        id = R.id.swipeHorizontalShadowView
        setBackgroundResource(R.drawable.view_shadow)
        layoutParams = MarginLayoutParams(36, ViewGroup.LayoutParams.MATCH_PARENT)
        visibility = GONE
    }
}
Copy the code

Bind RecyclerView and headScrollView

fun bindHeadScrollView(view: View) {
    val rightScroll = view.findViewById<SwipeHorizontalScrollView>(R.id.swipeHorizontalView)
    rightScroll.setRecyclerView(this, isNeedHideLeftView = needHideLeft, isNeedShowShadow = needShadow) rightScroll? .tag = decorateScrollView(rightScroll)if (scrollViews.contains(rightScroll)) scrollViews.remove(rightScroll)
    scrollViews.add(rightScroll)
}
Copy the code

How to use

1. Write XML layouts

Add id SwipeHorizontalScrollView @ + id/swipeHorizontalView app: needHideLeft = “true” app: needShadow = “true” can be hidden and need to show the shadow on the left. Set to false if you do not need to hide the first view or do not need shadows

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:gravity="center_vertical"
        android:orientation="horizontal">< header view /><com.loren.component.view.widget.SwipeHorizontalScrollView
            android:id="@+id/swipeHorizontalView" 
            android:layout_width="match_parent"
            android:layout_height="match_parent">< hidden view /> < scrollable view /></com.loren.component.view.widget.SwipeHorizontalScrollView>

    </LinearLayout>

    <com.loren.component.view.widget.HorizontalRecyclerView
        android:id="@+id/rvStock"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:needHideLeft="true"
        app:needShadow="true"
        tools:listitem="@layout/item_stock" />

</LinearLayout>
Copy the code

2. Create Adapter

Item.xml uses the layout shown above

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="60dp"
    android:gravity="center_vertical"
    android:orientation="horizontal">

    <TextView
        android:id="@+id/tvName"
        android:layout_width="100dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:padding="8dp"
        android:textColor="@color/black"
        android:textSize="18sp" />

    <com.loren.component.view.widget.SwipeHorizontalScrollView
        android:id="@+id/swipeHorizontalView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">.</com.loren.component.view.widget.SwipeHorizontalScrollView>

</LinearLayout>
Copy the code

3, the head layout and recyclerView binding

mBinding.rvStock.bindHeadScrollView(mBinding.swipeHorizontalView)
Copy the code

The project address

The last post project address: SwipeHorizontalScrollView

If you feel helpful to you click 👍 ~