需求
在移动应用开发中,有时我们希望实现一种特殊的布局效果,即“底部二楼”效果。这个效果类似于在列表底部拖动时出现额外的内容区域,用户可以继续向上拖动查看更多内容。这种效果可以用于展示广告、推荐内容或其他信息。
效果
实现后的效果如下:
- 当用户滑动到列表底部时,可以继续向上拖动,显示出隐藏的底部内容区域。
- 底部内容区域可以包含任意视图,如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