0%

漫画阅读器(二)可缩放的LayoutManager

还是漫画阅读器,数据的加载当然用的是RecyclerView,作为一个合格的漫画阅读器,缩放是必备功能了。从头手撸LayoutManager还是太难了,那么就从继承LinearlayoutManager开始。

先看效果:

Screenrecorder-2020-04-08-19-39-40-568

onLayoutChildren太长了懒得看,但layout一定会调用measureChildWithMarginslayoutDecoratedWithMargins,hook就从这两个方法入手。

首先是measureChildWithMargins,这里会测量出需要的宽高,先调用super更新widthUsedheightUsed,再修改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检测双击,注意长按不能写在子项上不然就不能响应手势了。缩放在改变scalerequestLayout之后,还要滚动一定的距离来保持缩放中心的位置,统一在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,注意竖向的滚动和横向不一样,是通过调用LinearlayoutManagerscrollToPositionWithOffset实现的。滚动距离按下面的式子算出来:

最后,为了能够横向滚动,要让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修改子项的偏移。

下一篇在这个的基础上,将横向改成翻页模式。

完整代码传送门