还是漫画阅读器,数据的加载当然用的是RecyclerView
,作为一个合格的漫画阅读器,缩放是必备功能了。从头手撸LayoutManager
还是太难了,那么就从继承LinearlayoutManager
开始。
先看效果:
onLayoutChildren
太长了懒得看,但layout一定会调用measureChildWithMargins
和layoutDecoratedWithMargins
,hook就从这两个方法入手。
首先是measureChildWithMargins
,这里会测量出需要的宽高,先调用super
更新widthUsed
和heightUsed
,再修改MeasureSpec
改变子项的大小:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| var scale = 1f
override fun measureChildWithMargins(child: View, widthUsed: Int, heightUsed: Int) { super.measureChildWithMargins(child, widthUsed, heightUsed) val lp = child.layoutParams as RecyclerView.LayoutParams val widthSpec = RecyclerView.LayoutManager.getChildMeasureSpec( (width * scale).toInt(), widthMode, paddingLeft + paddingRight + lp.leftMargin + lp.rightMargin + widthUsed, lp.width, canScrollHorizontally() ) val heightSpec = RecyclerView.LayoutManager.getChildMeasureSpec( height, heightMode, paddingTop + paddingBottom + lp.topMargin + lp.bottomMargin + heightUsed, lp.height, canScrollVertically() ) child.measure(widthSpec, heightSpec) }
|
然后是layoutDecoratedWithMargins
,在这里返回子项的位置,缩放之后移动的偏移量就在这里加上:
1 2 3 4 5 6 7
| var offsetX = 0
override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) { updateContent(child, this) offsetX = Math.max(0, Math.min(right - left - width, offsetX)) super.layoutDecoratedWithMargins(child, left - offsetX, top, right - offsetX, bottom) }
|
接下来就是缩放的手势了,搞个setupWithRecyclerView
把操作合在一起,这里用了setOnTouchListener
而不是addOnItemTouchListener
,是因为不知道怎么拦截能保持子项的点击事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| var doingScale = false lateinit var recyclerView: RecyclerView
@SuppressLint("ClickableViewAccessibility") fun setupWithRecyclerView( view: RecyclerView, onTap: (Int, Int) -> Unit, onPress: (View, Int) -> Unit, onTouch: (MotionEvent) -> Unit ) { recyclerView = view view.layoutManager = this var beginScale = scale val scaleGestureDetector = ScaleGestureDetector(view.context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean { beginScale = scale currentPos = Math.round(currentPos).toFloat() doingScale = true requestLayout() return super.onScaleBegin(detector) }
override fun onScale(detector: ScaleGestureDetector): Boolean { val oldScale = scale scale = beginScale * detector.scaleFactor scrollOnScale(detector.focusX, detector.focusY, oldScale) requestLayout() return super.onScale(detector) } }) val gestureDetector = GestureDetectorCompat(view.context, object : GestureDetector.SimpleOnGestureListener() { override fun onSingleTapConfirmed(e: MotionEvent): Boolean { onTap((e.x).toInt(), (e.y).toInt()) return super.onSingleTapConfirmed(e) }
override fun onLongPress(e: MotionEvent) { view.findChildViewUnder(e.x, e.y)?.let { onPress(it, view.getChildAdapterPosition(it)) } super.onLongPress(e) }
override fun onDoubleTap(e: MotionEvent): Boolean { val oldScale = scale scale = if (scale < 2f) 2f else 1f scrollOnScale(e.x, e.y, oldScale) requestLayout() return super.onDoubleTap(e) } }) view.setOnTouchListener { v, event -> onTouch(event) scaleGestureDetector.onTouchEvent(event) gestureDetector.onTouchEvent(event) false } }
|
这里用ScaleGestureDetector
检测缩放手势,同时GestureDetector
检测双击,注意长按不能写在子项上不然就不能响应手势了。缩放在改变scale
并requestLayout
之后,还要滚动一定的距离来保持缩放中心的位置,统一在scrollOnScale
函数中处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| fun scrollOnScale(x: Float, y: Float, oldScale: Float) { val adapter = recyclerView.adapter val anchorPos = (if (adapter is ScalableAdapter) { (findFirstVisibleItemPosition()..findLastVisibleItemPosition()).firstOrNull { adapter.isItemScalable(it, this) } ?: { scale = 1f null }() } else findFirstVisibleItemPosition()) ?: return recyclerView.scrollBy(((offsetX + x) * (scale - oldScale) / oldScale).toInt(), 0) if (orientation == VERTICAL) findViewByPosition(anchorPos)?.let { scrollToPositionWithOffset(anchorPos, (y - (-getDecoratedTop(it) + y) * scale / oldScale).toInt()) } }
|
因为后来又写了小说阅读器,文字的部分不会向图片一样能缩放,就给Adapter
带上了一个接口判断对应的子项是否能缩放,如果屏幕中没有能缩放的子项,就把scale
还原为1,注意竖向的滚动和横向不一样,是通过调用LinearlayoutManager
的scrollToPositionWithOffset
实现的。滚动距离按下面的式子算出来:
最后,为了能够横向滚动,要让canScrollHorizontally
返回true
:
1
| override fun canScrollVertically(): Boolean = true
|
为了根据滚动修改offset还要重写scrollHorizontallyBy
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { val view = findViewByPosition(downPage) val ddx = Math.max( Math.min( dx, (width * scale).toInt() - width - offsetX ), -offsetX ) offsetX += ddx offsetChildrenHorizontal(-ddx) view?.translationX = 0f for (i in 0 until recyclerView.childCount) updateContent(recyclerView.getChildAt(i), this) return if (scale == 1f) dx else ddx }
|
首先根据当前的scale
计算能消耗的位移ddx
,给offset
加上,注意这里没法requestLayout
,只能用offsetChildrenHorizontal
修改子项的偏移。
下一篇在这个的基础上,将横向改成翻页模式。
完整代码传送门