需求
在移动应用开发中,有时我们希望实现一种特殊的布局效果,即“底部二楼”效果。这个效果类似于在列表底部拖动时出现额外的内容区域,用户可以继续向上拖动查看更多内容。这种效果可以用于展示广告、推荐内容或其他信息。
效果
实现后的效果如下:
- 当用户滑动到列表底部时,可以继续向上拖动,显示出隐藏的底部内容区域。
- 底部内容区域可以包含任意视图,如RecyclerView等。
- 滑动到一定阈值后,可以自动回弹到初始位置或完全展示底部内容。
实现思路
为了实现这一效果,我们可以自定义一个ScrollerLayout
,并使用Scroller
类来处理滑动和回弹动画。主要思路如下:
- 创建自定义的
ScrollerLayout
继承自LinearLayout
。 - 在
ScrollerLayout
中,遍历所有子视图,找到其中的RecyclerView
,并为其添加滚动监听器。 - 在
RecyclerView
滚动到顶部时,允许整个布局继续向上滑动,展示底部内容区域。 - 使用
Scroller
类实现平滑滚动和回弹效果。
实现代码
ScrollerLayout.kt
package com.yxlh.androidxy.demo.ui.scroller import android.content.Context import android.util.AttributeSet import android.util.Log import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.widget.LinearLayout import android.widget.Scroller import androidx.recyclerview.widget.RecyclerView import com.yxlh.androidxy.R //github.com/yixiaolunhui/AndroidXY class ScrollerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : LinearLayout(context, attrs, defStyleAttr) { private val mScroller = Scroller(context) private var lastY = 0 private var downY = 0 private var contentHeight = 0 private var isRecyclerViewAtTop = false private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop init { orientation = VERTICAL post { setupRecyclerViews() } } private fun setupRecyclerViews() { for (i in 0 until childCount) { val child = getChildAt(i) if (child is RecyclerView) { child.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { isRecyclerViewAtTop = !recyclerView.canScrollVertically(-1) } }) } } } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { super.onLayout(changed, l, t, r, b) val bottomBar = getChildAt(0) contentHeight = 0 for (i in 0 until childCount) { val child = getChildAt(i) if (child is RecyclerView) { contentHeight += child.measuredHeight } } bottomBar.layout(0, measuredHeight - bottomBar.measuredHeight, measuredWidth, measuredHeight) for (i in 1 until childCount) { val child = getChildAt(i) if (child is RecyclerView) { child.layout(0, measuredHeight, measuredWidth, measuredHeight + contentHeight) } } } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val isTouchChildren = isTouchInsideChild(ev) Log.d("121212", "onInterceptTouchEvent isTouchChildren=$isTouchChildren") when (ev.action) { MotionEvent.ACTION_DOWN -> { downY = ev.y.toInt() lastY = downY } MotionEvent.ACTION_MOVE -> { val currentY = ev.y.toInt() val dy = currentY - downY if (isRecyclerViewAtTop && dy > touchSlop) { lastY = currentY return true } } } return super.onInterceptTouchEvent(ev) } override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { if (!isTouchInsideChild(event)) return false if (!mScroller.isFinished) { mScroller.abortAnimation() } lastY = event.y.toInt() return true } MotionEvent.ACTION_MOVE -> { if (!isTouchInsideChild(event)) return false val currentY = event.y.toInt() val dy = lastY - currentY val scrollY = scrollY + dy if (scrollY < 0) { scrollTo(0, 0) } else if (scrollY > contentHeight) { scrollTo(0, contentHeight) } else { scrollBy(0, dy) } lastY = currentY return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { val threshold = contentHeight / 2 if (scrollY > threshold) { showNavigation() } else { closeNavigation() } return true } } return false } private fun isTouchInsideChild(event: MotionEvent): Boolean { val x = event.rawX.toInt() val y = event.rawY.toInt() for (i in 0 until childCount) { val child = getChildAt(i) if (isViewUnder(child, x, y)) { return true } } return false } private fun isViewUnder(view: View?, x: Int, y: Int): Boolean { if (view == null) return false val location = IntArray(2) view.getLocationOnScreen(location) val viewX = location[0] val viewY = location[1] return x >= viewX && x < viewX + view.width && y >= viewY && y < viewY + view.height } fun showNavigation() { val dy = contentHeight - scrollY mScroller.startScroll(scrollX, scrollY, 0, dy, 500) invalidate() } private fun closeNavigation() { val dy = -scrollY mScroller.startScroll(scrollX, scrollY, 0, dy, 500) invalidate() } override fun computeScroll() { if (mScroller.computeScrollOffset()) { scrollTo(mScroller.currX, mScroller.currY) postInvalidateOnAnimation() } } }
ScrollerActivity.kt
package com.yxlh.androidxy.demo.ui.scroller import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.yxlh.androidxy.R import com.yxlh.androidxy.databinding.ActivityScrollerBinding import kotlin.random.Random class ScrollerActivity : AppCompatActivity() { private var binding: ActivityScrollerBinding? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityScrollerBinding.inflate(layoutInflater) setContentView(binding?.root) //内容布局 binding?.content?.layoutManager = LinearLayoutManager(this) binding?.content?.adapter = ColorAdapter(false) //底部布局 binding?.bottomContent?.layoutManager = LinearLayoutManager(this) binding?.bottomContent?.adapter = ColorAdapter(true) binding?.content?.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (!recyclerView.canScrollVertically(1) && newState == RecyclerView.SCROLL_STATE_IDLE) { binding?.scrollerLayout?.showNavigation() } } }) } } class ColorAdapter(private var isColor: Boolean) : RecyclerView.Adapter<ColorAdapter.ColorViewHolder>() { private val colors = List(100) { getRandomColor() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_color, parent, false) return ColorViewHolder(view, isColor) } override fun onBindViewHolder(holder: ColorViewHolder, position: Int) { holder.bind(colors[position], position) } override fun getItemCount(): Int = colors.size private fun getRandomColor(): Int { val random = Random.Default return Color.rgb(random.nextInt(256), random.nextInt(256), random.nextInt(256)) } class ColorViewHolder(itemView: View, private var isColor: Boolean) : RecyclerView.ViewHolder(itemView) { fun bind(color: Int, position: Int) { if (isColor) { itemView.setBackgroundColor(color) } itemView.findViewById<TextView>(R.id.color_tv).text = "$position" } } }
结束
通过上述代码,我们成功实现了底部二楼效果。在用户滑动到RecyclerView底部时,可以继续向上拖动以显示底部的内容区域。这种效果可以增强用户体验,增加更多的内容展示方式。通过自定义布局和使用Scroller类,我们可以轻松实现这种复杂的滑动效果。
详情:github.com/yixiaolunhui/AndroidXY