Android仿同花顺自选股列表控件

4,450 阅读5分钟

介绍

RecyclerView的开发中,我们通常会遇到一行显示不下内容的情况,产品会要求我们的item是可以滚动的,并且头部是固定的。特别在股票行情类相关的app上,这样的场景是非常多的,所以封装了如下的自定义组件。

效果图

首先上效果图,可以看到可以横向滚动,头部固定不动,并且支持侧滑拖出彩蛋“hello”,这里的实现效果是模仿的同花顺的自选股池

e3yl8-bj85x.gifuntitled.giffixPosition.gif

如何实现

1、架构图

最外层使用RecyclerView,item使用LinearLayout布局,左边是一个固定的头部,我这里使用的是TextView,右边是一个自定义的ScrollView布局。 image.png

2、自定义SwipeHorizontalScrollView

实现onMeasure

首先MeasureSpec.getSize(widthMeasureSpec)用来测量当前控件在屏幕内的可显示宽度viewWidth,即屏幕宽度减去头布局的宽度。

接下来遍历子view,通过measureChildWithMargins测量出每个子view的宽高,这里需要重写generateLayoutParams()。然后累加子view的宽度,得到整个控件的总宽度contentWidthcontentHeight需要比较子view的高度,因为每个子view的高度可能不一样,进行比较取得子view最大高度为控件的高度。

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)
}

实现onLayout

遍历子view从左往右布局。这里如果设置了开启隐藏左边view的配置并且是第一个元素的时候,layoutLeft = -childViewWidth,向左偏移view进行隐藏。

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)
        if (childView.visibility != View.GONE) {
            val childViewWidth = childView.measuredWidth
            val childViewHeight = childView.measuredHeight
            // 需要隐藏左边第一个view&&是第一个元素的时候
            if (isNeedHideLeftView && i == 0) {
                layoutLeft = -childViewWidth
            }
            childView.layout(layoutLeft, paddingTop, layoutLeft + childViewWidth, paddingTop + childViewHeight)
            layoutLeft += childViewWidth
        }
    }
}

监听所有的scrollview

在自定义的RecyclerView中定义一个scrollViews用来记录屏幕内可见的scrollView,同步滚动状态。原因是如果在SwipeHorizontalScrollView中定义的话,那么每个控件都维护一个集合效率会很低。通过set方法将自定义的RecyclerView传进来。isNeedHideLeftView用来控制是否需要隐藏最左边的view。isNeedShowShadow用来控制是否需要展示阴影。

重写onAttachedToWindowonDetachedFromWindow,更新mScrollViews中的scrollView。当其在屏幕内可见的时候添加进集合。当其移出屏幕外时,将它移出集合。

fun setRecyclerView(
    recyclerView: HorizontalRecyclerView,
    isNeedHideLeftView: Boolean = false, 
    isNeedShowShadow: Boolean = true,
    isNeedVibrate: Boolean = true,
    extendThreshold: Float? = null,
    foldThreshold: Float? = null,
    defaultShowLeft: Boolean = false
) {
    this.recyclerView = recyclerView
    this.isNeedHideLeftView = isNeedHideLeftView
    this.isNeedShowShadow = isNeedShowShadow
    this.isNeedVibrate = isNeedVibrate
    this.extendThreshold = extendThreshold
    this.foldThreshold = foldThreshold
    this.defaultShowLeft = defaultShowLeft
}

private fun monitorScrollViews(): MutableList<SwipeHorizontalScrollView> {
    return recyclerView?.scrollViews ?: mScrollViews
}

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    if (!monitorScrollViews().contains(this))
        monitorScrollViews().add(this)
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    monitorScrollViews().remove(this)
}

重写dispatchTouchEvent

ACTION_DOWN事件记录x,y的位置,按下的时候需要把scroller的动画停止掉,并且记录当前的scrollX。

ACTION_MOVE比较x与y的偏移量,当水平方向的偏移大于垂直方向的偏移量时,判定用户的行为是水平滑动。调用parent.requestDisallowInterceptTouchEvent(true)取消外部拦截。调用cancelLongPress()用来取消用户点下屏幕水平滑动但是手指未抬起时的长按事件。 untitled.gif

needNotify用来通知RecyclerView的界面元素是否需要更新,例如股票的涨幅的信息是实时更新的,我们希望当用户拖拽的时候不更新界面的元素,减少频繁绘制。

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)
}

重写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)
}

重写onTouchEvent

首先我们需要熟悉Scroller及滑动机制

(tag as? View)?.onTouchEvent(event)将touch事件传递给设置的tag,tag我们设置的是RecyclerView的itemView,方便的设置itemView的点击以及长按事件。

场景描述
需要隐藏左边的view:首先获取隐藏view的宽度,判定afterScrollX >= -firstViewWidth && afterScrollX <= measuredWidth - viewWidth - firstViewWidth(滚动后的距离在该控件可滚动的范围区间内)。当滚动区间在firstViewWidth的区间内拖拽或者当firstView处于隐藏状态并且向右拖拽时,deltaX / 2模拟粘性效果。image.png
不需要隐藏左边的view,处于可滚动范围内直接调用scrollBy(deltaX, 0)即可
if (isShowLeft) {fixScrollX()},当隐藏的view被展开的时候,快速水平方向滑动的时候不希望飞快的滚动,只希望隐藏firstView并且滚动到scrollX=0的位置上。untitled.gif
fistView被隐藏时,scrollX处于0时并且向右滑动时,调用fixScrollX()。否则将fling事件传递给每个scrollView。minX = -(firstViewWidth * 0.2).toInt()处理隐藏firstView并且scrollX不为0的情况下,向右快速滚动不会完整的展开firstView,最多只会展示firstView的20%image.png
MotionEvent.ACTION_MOVE -> {
    (tag as? View)?.onTouchEvent(event)
    val deltaX = (moveX - event.x).toInt()
    mDirection = if (deltaX > 0) {
        Direction.DIRECTION_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)
    }
    // 释放
    velocityTracker?.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
    }
}

展开与折叠状态

滚动后的scrollX在-firstViewWidth~-firstViewWidth+threshold的区间内,即firstView的显示超出70%的时候,展开firstView;scrollX大于-firstViewWidth + threshold,即firstView显示小于30%的时候,折叠firstView。展开与折叠调用Scroller的startScroll()方法

/**
 * 修正x位置
 */
private fun fixScrollX() {
    needFix = false
    if (isNeedHideLeftView) {
        val firstViewWidth = getChildAt(0).measuredWidth
        val threshold = firstViewWidth * 0.3 // [-firstViewWidth  -firstViewWidth+threshold    -threshold  0]
        if (isShowLeft) { // 展开状态
            if (scrollX >= -firstViewWidth && scrollX <= -firstViewWidth + threshold) {
                extend()
            } else if (scrollX > -firstViewWidth + threshold) {
                fold()
            }
        } else { // 收起状态
            if (scrollX <= -threshold) {
                extend()
            } else if (scrollX > -threshold && scrollX <= 0) {
                fold()
            }
        }
    }
}

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

/**
 * 折叠view
 */
private fun fold() {
    monitorScrollViews().forEach {
        it.mScroller.startScroll(scrollX, 0, -scrollX, 0, 300)
    }
    isShowLeft = false
}

3、自定义HorizontalRecyclerView

重写addView

ids.xml中配置滚动view与阴影view的全局id,从child中找到滚动view,调用setRecyclerView()将HorizontalRecyclerView的引用传递给SwipeHorizontalScrollView,调用decorateScrollView()装饰SwipeHorizontalScrollView,给其添加阴影。

<resources>
    <item name="swipeHorizontalView" type="id" />
    <item name="swipeHorizontalShadowView" type="id" />
</resources>
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
    val rightScroll = 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()
    val parent = scrollView?.parent as? ViewGroup?
    parent?.removeView(scrollView)
    scrollView?.let {
        frameLayout.addView(it)
    }
    frameLayout.addView(shadowView)
    parent?.addView(frameLayout)
    return frameLayout
}

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

将recyclerview与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)
}

如何使用

1、编写xml布局

SwipeHorizontalScrollView添加id@+id/swipeHorizontalView app:needHideLeft="true" app:needShadow="true"左边可隐藏并且需要展示阴影。 如若不需要隐藏第一个view或者不需要阴影可以设置为false

<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">

        <头部view />

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

            <隐藏的view />

            <可滚动的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>

2、创建Adapter

item.xml使用如上的布局

<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>

3、将头布局与recyclerview绑定

mBinding.rvStock.bindHeadScrollView(mBinding.swipeHorizontalView)

项目地址

最后贴上项目的地址:SwipeHorizontalScrollView

如果觉得对您有帮助就点个👍吧~

新增功能

  • app:needVibrate="true"设置是否需要在折叠or展开触发震动效果
  • app:extendThreshold="70dp"设置展开的阈值
  • app:foldThreshold="60dp"设置折叠的阈值
  • 增加展开&折叠监听
    interface OnHorizontalRecyclerViewStateListener {
        fun extend()
        fun fold()
    }
    
  • app:needFixItemPosition="true|false"设置是否需要自动修正item的位置
  • HorizontalRecyclerView.recordX=-折叠view的width设置默认进入页面的时候折叠的部分展开
  • HorizontalRecyclerView.dingColumn=columnIndex|null设置钉住某一列,null为取消钉住(仿写懂车帝车型PK对比的钉住功能),记得重新调用一次bindHeadScrollView同步状态给头部