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_MOVE
When 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 / 2 Simulate 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 👍 ~