精华内容
下载资源
问答
  • Android 实现分组标题吸顶效果,支持上下左右padding
    千次阅读
    2022-02-28 11:05:15

    先上gif效果图:

    技术方案:RecycleView + ItemDecoration

    具体实现:

    第一步:先实现相关业务代码,让数据加载出来

    Activity:

    /**
     * 实现吸顶效果 演示
     */
    class RecyclerViewActivity : Activity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_recyclerview)
            val data: MutableList<DataBean> = getData()
            val adapter = RVAdapter(data)
            act_recyclerview_rv.adapter = adapter
            act_recyclerview_rv.layoutManager = LinearLayoutManager(this)
            val divider = DividerItemDecoration(this,DividerItemDecoration.VERTICAL)
            divider.setDrawable(getDrawable(R.drawable.shape_divider)!!)
    //        act_recyclerview_rv.addItemDecoration(divider)
            //自定义itemDecoration 实现吸顶效果
            act_recyclerview_rv.addItemDecoration(MyItemDecoration())
    
        }
    
        private fun getData(): MutableList<DataBean> {
            val data: MutableList<DataBean> = mutableListOf()
            for (i in 0..2) {
                for (j in 0..9) {
                    if (i == 0) {
                        data.add(DataBean("曹操$i$j","曹操分组"))
                    } else if (i == 1) {
                        data.add(DataBean("刘备$i$j","刘备分组"))
                    } else if (i == 2) {
                        data.add(DataBean("孙权$i$j","孙权分组"))
                    }
                }
            }
            return data
        }
    }
    R.layout.activity_recyclerview
    <?xml version="1.0" encoding="utf-8"?>
    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <androidx.recyclerview.widget.RecyclerView
            android:layout_margin="10dp"
            android:padding="20dp"
            android:id="@+id/act_recyclerview_rv"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    
    </FrameLayout>
    RVAdapter
    class RVAdapter : RecyclerView.Adapter<RVViewHolder> {
        var data: MutableList<DataBean> = mutableListOf()
    
        constructor(data: MutableList<DataBean>) {
            this.data = data
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RVViewHolder {
            val view =
                LayoutInflater.from(parent.context).inflate(R.layout.item_act_rec_rv, parent, false)
            view.setOnClickListener {
                ToastUtil.showShortToast("you click ${view.findViewById<TextView>(R.id.act_rec_rv_item_tv).text}")
            }
            return RVViewHolder(view)
        }
    
        override fun onBindViewHolder(holder: RVViewHolder, position: Int) {
            holder.name!!.text = data[position].name
        }
    
        override fun getItemCount(): Int {
            return data.size
        }
    
        /**
         *  判断此位置是否是每一组的第一个view
         */
        fun isFirstGroupView(childLayoutPos: Int): Boolean {
            if (childLayoutPos == 0) {
                return true
            }
            if (data[childLayoutPos].groupName != data[childLayoutPos - 1].groupName) {
                return true
            }
            return false
        }
    
        fun getGroupName(childLayoutPosition: Int): String {
            return data[childLayoutPosition].groupName
        }
    
    }
    RVViewHolder
    class RVViewHolder : RecyclerView.ViewHolder {
        var name: TextView? = null
    
        constructor(view: View) : super(view) {
            name = view.findViewById(R.id.act_rec_rv_item_tv)
        }
    }
    DataBean
    data class DataBean(
        var name: String,
        var groupName: String
    )
    ZSConstants
    object ZSConstants {
        val TITLE_TEXT_SIZE: Int = 18
        val DIVIDER_HEIGHT: Int = 10
        //此变量和布局文件中设置的高度保持一致
        val ITEM_HEIGHT: Int = 60
        val GROUP_HEIGHT: Int = 40
        val GROUP_NAME_MARGIN: Int = 10
    }

    第二步:利用自定义ItemDecoration来实现吸顶效果,并处理RecycleView的各种padding

    相关说明都写在了注释里面,代码如下:

    /**
     * 自定义分割线实现分类标题自动吸顶效果
     * 如果 需求是分组标题支持点击的话 当前是不满足的,就得切换实现思路了,思路如下:
     * (1)group标题直接使用item实现并且实现点击事件,这种情况在getItemOffsets里面就没有必要在预留那么大的空间了,因为不需要onDraw来绘制分组信息了
     * (2)吸顶时还是要通过onDrawOver来绘制悬浮到顶部,此时的点击事件比较麻烦,需要通过RecycleView的onTouch事件来根据点击位置来处理
     * 点击时的顶部的这个区域就是当前的吸顶布局了,然后做处理就可以了,(要记录下现在哪个分组在顶部)
     */
    class MyItemDecoration : RecyclerView.ItemDecoration {
    
        private val headPaint = Paint()
        private val headPaint2 = Paint()
        private val textPaint = Paint()
        private val groupHeight: Float = DensityUtil.dp2px(ZSConstants.GROUP_HEIGHT).toFloat()
        private val dividerHeight: Float = DensityUtil.dp2px(ZSConstants.DIVIDER_HEIGHT).toFloat()
        private val groupNameMargin: Float = DensityUtil.dp2px(ZSConstants.GROUP_NAME_MARGIN).toFloat()
    
        constructor() {
            headPaint.color = Color.parseColor("#ff0000")
            headPaint.style = Paint.Style.FILL
            headPaint2.color = Color.parseColor("#00ff00")
            headPaint2.style = Paint.Style.FILL
            textPaint.color = Color.BLACK
            textPaint.isDither = true
            textPaint.isAntiAlias = true
            textPaint.textSize = DensityUtil.dp2px(ZSConstants.TITLE_TEXT_SIZE).toFloat()
        }
    
        /**
         * 此方法绘制的内容在RecyclerView item下面,因此可能会被item挡住
         */
        override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
            super.onDraw(c, parent, state)
            //在预留出的空间中 绘制分组标题
            val adapter = parent.adapter
            val left: Float = parent.paddingLeft.toFloat()
            val right: Float = parent.width.toFloat() - parent.paddingRight
            if (adapter is RVAdapter) {
                //获取可见view的个数
                val childCount = parent.childCount
                //循环遍历去绘制
                for (i in 0 until childCount) {
                    c.save()
                    //得到屏幕上显示的view
                    val view = parent.getChildAt(i)
                    //得到该view在整个列表布局中的位置
                    val childLayoutPosition = parent.getChildLayoutPosition(view)
                    //判断该位置是否是每组view的第一个
                    val isFirstGroupView = adapter.isFirstGroupView(childLayoutPosition)
                    if (isFirstGroupView &&
                        //头部屏蔽没有必要的绘制
                        view.top - groupHeight - parent.paddingTop >= 0 &&
                        //底部屏蔽没有必要的绘制
                        view.top <= parent.measuredHeight - parent.paddingBottom + groupHeight
                    ) {
                        // 最底部的分割线需要c.clip一下
                        if (view.top.toFloat() > parent.measuredHeight - parent.paddingBottom) {
                            val rect = Rect(
                                left.toInt(),
                                view.top - groupHeight.toInt(),
                                right.toInt(),
                                parent.measuredHeight - parent.paddingBottom
                            )
                            c.clipRect(rect)
                        }
                        //绘制分组矩形背景
                        c.drawRect(
                            left,
                            view.top - groupHeight,
                            right,
                            view.top.toFloat(),
                            headPaint
                        )
                        //绘制标题文本
                        val text: String = adapter.getGroupName(childLayoutPosition)
                        c.drawText(
                            text,
                            left + groupNameMargin,
                            view.top - groupHeight / 2 + abs(textPaint.fontMetrics.ascent) / 2 - textPaint.fontMetrics.descent / 2,
                            textPaint
                        )
                    } else if (
                    //头部屏蔽没有必要的绘制
                        view.top - groupHeight - parent.paddingTop >= 0 &&
                        //底部屏蔽没有必要的绘制
                        view.top <= parent.measuredHeight - parent.paddingBottom + dividerHeight
                    ) {
                        //绘制分割线
                        if (i == childCount - 1) {
                            log("parent height - parent.paddingBottom = ${parent.measuredHeight - parent.paddingBottom} view.top=${view.top}")
                        }
                        //最底部的分割线需要c.clip一下
                        if (view.top.toFloat() > parent.measuredHeight - parent.paddingBottom) {
                            val rect = Rect(
                                left.toInt(),
                                view.top - dividerHeight.toInt(),
                                right.toInt(),
                                parent.measuredHeight - parent.paddingBottom
                            )
                            c.clipRect(rect)
                        }
                        c.drawRect(
                            left,
                            view.top.toFloat() - dividerHeight.toInt(),
                            right,
                            view.top.toFloat(),
                            headPaint
                        )
                    }
                    c.restore()
                }
            }
        }
    
        /**
         * 此方法绘制的内容在RecyclerView item上面,因此会在最外层显示,可以挡住item
         */
        override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
            super.onDrawOver(c, parent, state)
            val adapter = parent.adapter
            val left: Float = parent.paddingLeft.toFloat()
            val top: Float = parent.paddingTop.toFloat()
            val right: Float = parent.width.toFloat() - parent.paddingRight
            if (adapter is RVAdapter) {
                //拿到第一个可见的view
                val firstVisiblePos =
                    (parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
                val viewHolder = parent.findViewHolderForLayoutPosition(firstVisiblePos)
                val itemView = viewHolder!!.itemView
                //日志打印
    //            val textView = itemView.findViewById<TextView>(R.id.act_rec_rv_item_tv)
    //            log("${textView.text} firstVisiblePos = $firstVisiblePos")
                //判断当前位置的下一个是否是分组的第一个view
                //为甚是下一个,因为当前的那个被onDrawOver位置的常驻标题挡住了
                //所以如果下一个是分组第一个的话,刚好开始执行推动的效果
                val isFirstGroupView = adapter.isFirstGroupView(firstVisiblePos + 1)
                if (isFirstGroupView) {
                    //慢慢往上推动
    //                log("${itemView.top} itemView.bottom = ${itemView.bottom}")
    //                log("top-$top itemView.top=${itemView.top} itemView.bottom = ${itemView.bottom}")
                    val bottom = min(groupHeight, itemView.bottom.toFloat() - top) + top
                    c.drawRect(left, top, right, bottom, headPaint2)
                    val y =
                        bottom - groupHeight / 2 + abs(textPaint.fontMetrics.ascent) / 2 - textPaint.fontMetrics.descent / 2
                    val rect = Rect(0, top.toInt(), right.toInt(), bottom.toInt())
                    c.clipRect(rect)
                    val text: String = adapter.getGroupName(firstVisiblePos)
                    c.drawText(
                        text,
                        left + groupNameMargin,
                        y,
                        textPaint
                    )
                } else {
                    //标题常驻在顶部
                    c.drawRect(left, top, right, top + groupHeight, headPaint2)
                    val y =
                        top + groupHeight - groupHeight / 2 + abs(textPaint.fontMetrics.ascent) / 2 - textPaint.fontMetrics.descent / 2
                    val text: String = adapter.getGroupName(firstVisiblePos)
                    c.drawText(
                        text,
                        left + groupNameMargin,
                        y,
                        textPaint
                    )
                }
            }
    
        }
    
        /**
         * 通过此方法来设置item的预留区间,进而给ItemDecoration留出位置
         * 只绘制可见部分,滚动到屏幕内的则进行绘制
         */
        override fun getItemOffsets(
            outRect: Rect,
            view: View,
            parent: RecyclerView,
            state: RecyclerView.State
        ) {
            //拿到对应的adapter
            val adapter = parent.adapter
            if (adapter is RVAdapter) {
                //拿到当前view所在的位置
                val childLayoutPos = parent.getChildLayoutPosition(view)
                //判断此view是否是每一组的第一个view
                if (adapter.isFirstGroupView(childLayoutPos)) {
                    outRect.set(0, groupHeight.toInt(), 0, 0)
                } else {
                    outRect.set(0, dividerHeight.toInt(), 0, 0)
                }
    
                //日志打印
                val textView = view.findViewById<TextView>(R.id.act_rec_rv_item_tv)
    //            log("${textView.text} childLayoutPos = $childLayoutPos")
            }
    
        }
    }

    注意:涉及到具体的尺寸计算,特别是bottom、top之类的要十分细心小心,可以自己画画图来理解,也可以把工程跑起来,根据效果一点一点去理解。

    难点就在于两个标题靠在一起时上面的标题慢慢被顶上去,这里的实现思路是在onDrawOver方法里面不断绘制上面的标题空间,让bottom不断减小(减小就是往上走),标题文字的绘制也要跟着往上走,然后还要通过canvas的clipRect方法去裁剪绘制区域,要不然会绘制到RecycleView paddingTop区域。

    更多相关内容
  • js实现导航吸顶效果

    2020-10-20 08:16:13
    本文主要分享了js实现导航吸顶效果的示例代码,具有很好的参考价值,下面跟着小编一起来看下吧
  • 主要介绍了Vue开发实现吸顶效果的示例代码,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
  • 本文主要介绍了Android 实现当下最流行的吸顶效果的示例代码。具有很好的参考价值,下面跟着小编一起来看下吧
  • 主要为大家详细介绍了RecyclerVIew实现悬浮吸顶效果,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
  • 主要为大家详细介绍了微信小程序实现吸顶效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
  • 主要为大家详细介绍了js实现移动端吸顶效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
  • MultiType-Adapter打造悬浮吸顶效果 注:当前版本只适合配合RecyclerView快速打造一款 展示UI 悬浮吸顶效果,如 通讯录效果,由于实现机制的原因,暂时不支持触摸事件。 MultiType-Adapter介绍地址:MultiType-...
  • 主要为大家详细介绍了js实现多个标题吸顶效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
  • 微信小程序sticky吸顶效果.zip
  • 在.js文件中使用page的onPageScroll事件和scroll-view的scroll事件中产生卡顿 (setData 渲染会阻塞其它脚本执行,导致了整个用户交互的动画过程会有延迟), 所以使用wxs响应事件来实现吸顶效果。wxs响应事件基础库 ...
  • 下面小编就为大家带来一篇浅谈react.js中实现tab吸顶效果的问题。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
  • 吸顶结合tabLayout+viewpage+fragment
  • 最近公司开发方向偏向移动端,于是就被调去做RN(react-native),体验还不错,当前有个需求是首页中间吸顶效果,虽然已经很久没写样式了,不过这种常见样式应该是so-easy,没成想翻车了,网上搜索换了几个方案都...
  • RecyclerView多布局和实现吸顶效果
  • 原生js实现吸顶效果

    2020-10-20 05:19:54
    本文主要介绍了原生js实现吸顶效果的示例。具有很好的参考价值。下面跟着小编一起来看下吧
  • 滑动listview标题置顶,listview吸顶效果,安卓系统状态栏透明,有图有源码
  • 淘宝店铺详情页吸顶效果,MVVM ,纯约束布局。
  • Android吸顶效果,并有着ViewPager左右切换,见我的博文有着具体使用
  • 非常不错的吸顶效果

    2017-11-18 19:52:00
    网页吸顶,下托网页悬停网页顶部 我修改处理后可用的吸顶特效代码
  • 吸顶效果解决方案

    2021-06-05 04:21:49
    页面向下滚动超过吸顶元素初始位置时,把吸顶元素固定在顶部要求吸顶的元素一般是二级导航栏、搜索框、文章标题栏(h1)、表头(thead)、tab条等等,共同特点是在内容或功能上比较重要,但又不是最重要的元素(最重...

    一.场景

    “吸顶”是一种比较老的交互方式,在PC页面已经用了很多年了,如图:

    a78732959b269a47eac9cd3c56078180.png

    sticky

    吸顶元素的初始位置一般靠近页面顶部,但与顶部有一定距离,这块区域放的是最醒目的元素,比如Banner图。页面向下滚动超过吸顶元素初始位置时,把吸顶元素固定在顶部

    要求吸顶的元素一般是二级导航栏、搜索框、文章标题栏(h1)、表头(thead)、tab条等等,共同特点是在内容或功能上比较重要,但又不是最重要的元素(最重要的元素通常固定在页面顶部,navbar-fixed-top)

    二.PC解决方案

    页面滚动到一定位置时,做一些事情

    “回到顶部”按钮也是这样的,页面向下滚动超过150px时,显示该按钮,否则隐藏

    所以实现思路是监听scroll事件:

    var stickyEl = document.querySelector('.sticky');

    var stickyT = stickyEl.offsetTop;

    window.onscroll = function(e) {

    var scrollT = document.body.scrollTop;

    // console.log(scrollT, stickyT);

    if (scrollT > stickyT) {

    stickyEl.classList.add('fixed-top');

    }

    else {

    stickyEl.classList.remove('fixed-top');

    }

    };

    和“回到顶部”的实现方式一模一样,效果好像还不错,但很快会发现滚动到临界位置stickyT的时候,页面抖了一下,向上缩了一截。因为stickyEl此时fixed出去了,下面的元素上来,抢占sticky元素老家,所以页面抖了一下

    我们希望平滑,不要抖动,所以还需要一个占位符,守住stickyEl老家:

    var stickyEl = document.querySelector('.sticky');

    // 守家占位符

    var stickyHolder = document.createElement('div');

    var rect = stickyEl.getBoundingClientRect();

    // console.log(rect);

    stickyEl.parentNode.replaceChild(stickyHolder, stickyEl);

    stickyHolder.appendChild(stickyEl);

    stickyHolder.style.height = rect.height + 'px';

    var stickyT = stickyEl.offsetTop;

    window.onscroll = function(e) {

    var scrollT = document.body.scrollTop;

    // console.log(scrollT, stickyT);

    if (scrollT > stickyT) {

    stickyEl.classList.add('fixed-top');

    }

    else {

    stickyEl.classList.remove('fixed-top');

    }

    };

    把吸顶元素用相同高度的占位符包起来,临界位置stickyEl被fixed出去,空间由stickyHolder撑起来,下面元素挤不上来,页面不抖了

    这样做还有一些问题,吸顶元素上方的各个元素加载很慢的话,拿到的stickyT比实际的小,甚至为0(如果上方是一张很大的Banner图的话)。所以需要配合默认图片占位符(base64)使用,或者偷懒先用min-height顶着,上方图片onload时再修正stickyT

    三.移动端解决方案

    从原理上看,直接搬过来是可以的。在Android 4.0+确实可以,但IOS几乎全家都行不通

    Android scroll

    Android 4.0的scroll事件不那么实时(自带节流的感觉),但Android 4.1之后scroll事件和PC几乎没什么区别

    The Android browser in Ice Cream Sandwich fires the event but doesn’t feel very responsive and only sporadically re-paints the DOM to move the blue box. Luckily, Jelly Bean’s Android browser handles this example perfectly; everything is updated and rendered smoothly as the user scrolls.

    (引自参考资料1)

    只要页面还在滚动,scroll事件就疯狂触发,需要手动节流,这正是我们需要的效果。如果scroll本身自带节流,就很容易错过临界点判断,导致吸顶元素“跳一下”,体验不平滑

    IOS scroll

    IOS 8-的Safari,包括UIWebView,对scroll事件做了很大限制:

    手指划动屏幕 -> 滚动 -> 手指抬起 -> 惯性滚动 -> 停止滚动

    整个过程,直到停止滚动时才会触发1次scroll事件,也就是说,IOS8以下的scroll变成了scrollend。监听滚动判断位置的方法完全失效,平滑吸顶效果变成了滚过临界位置直到停止滚动时,吸顶元素跳到目标位置,体验非常差,不可忍受

    scroll不能用,但还可以有一些奇怪的思路,比如定时器读scrollTop,touchmove,iscroll等等

    有前辈做了详细测试,见参考资料1

    定时器在手指没有离开屏幕时不会执行,touchmove触发频率足够,也能拿到scrollTop,但touchend后,惯性滚动期间,没有任何事件可用,拿不到这段的scrollTop,很难预测这段惯性滚动距离(减速运动),甚至不确定各IOS版本这段距离的计算方式是否相同

    iscroll这种假滚动,自然可以实时获取滚动位置,iscroll有一个专用版本来做这个事情:

    iscroll-probe.js, probing the current scroll position is a demanding task, that’s why I decided to build a dedicated version for it. If you need to know the scrolling position at any given time, this is the iScroll for you. (I’m making some more tests, this might end up in the regular iscroll.js script, so keep an eye on it).

    IOS 8+的Safari和WKWebView能够疯狂触发scroll,无论手指在不在屏幕上,无论是不是惯性滚动期间。但IOS 8+的UIWebView,scroll限制还在

    如果要支持IOS 8-设备以及任意IOS版本的UIWebView,此路不通,忘掉scroll

    sticky

    虽然scroll方案行不通,但IOS提供了另一种方式:position: sticky,自IOS 6.1就支持了,最近Chrome56才支持

    这个CSS规则专门负责吸顶,一般用法:

    .sticky {

    // 滚过初始位置时自动吸顶

    position: -webkit-sticky;

    position: sticky;

    // 吸顶时的定位

    top: 0;

    left: 0;

    // z比下方所有z高

    z-index: 9999;

    }

    没有滚过初始位置时,和position: relative表现类似(占据空间,!static能为后代元素提供定位参照),但top和left无效

    滚过初始位置时,和position: fixed表现类似,top和left生效,固定在屏幕可见区域,但页面不会抖动,原本占据的空间还在(自带守家占位符的感觉)

    吸顶效果非常平滑,比Android scroll方案体验更平滑,但限制很明显,无法实时获知吸顶状态,于此相关的各种效果都受限制,比如吸顶tab列表:

    83f73273af63b3d251e5adca5a7a9101.png

    sticky-tab

    非吸顶状态时可以划动列表部分,让页面滚动,转到吸顶状态,多个tab列表无缝切换,浏览状态互不影响

    吸顶状态时划动当前tab列表,到头,让页面滚动,转到非吸顶状态

    也就是说,非吸顶状态时,让tab列表不能滚动(overflow-y: hidden);吸顶状态时,让tab列表可以滚动(overflow-y: auto)

    但是IOS sticky不由我们控制,且无法实时获知吸顶状态,想要获知吸顶状态的话,又回到了最初的问题,页面滚动过程中,怎样实时获知滚动条位置?CSS sticky并不能解决这个问题

    笔者还没有找到合适的解决方案,目前方案是牺牲tab浏览状态独立性,多tab共用body的滚动条,切换tab时滚回之前的位置。这样做避免了判断吸顶状态,但牺牲了tab列表无缝切换的完美体验

    如果有新思路、好点子,或者成熟方案,麻烦告知,感激不尽

    四.在线Demo

    五.总结

    一般元素吸顶:Android用scroll方案,在效果可接受范围内手动节流,提升性能;IOS用CSS sticky,如果不需要兼容IOS 8-以及任意版本UIWebView的话,也可以采用scroll方案

    吸顶tab列表:没有好的解决方案,暂用牺牲无缝切换的方案

    整页iScroll是一个冒险方案,页面复杂的话,不要轻易尝试,即便页面不复杂,也难保以后不会变得复杂

    参考资料

    展开全文

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 3,656
精华内容 1,462
关键字:

吸顶效果

友情链接: AIP-TOME-ADMIN.zip