作者:晴晴
Jetpack Compose 的正式版已经发布一个多月了,相信很多读者都进行了一番尝试。下面我们通过自定义股票 K 线图控件来学习下 Compose 绘制和手势处理相关知识。【注:下文如无特殊说明,Compose 均指代 Jetpack Compose】
在进入正文之前,我们还是要介绍一下什么是 Compose,以及它出现的意义。这一切都可从官方给出的Compose 定义中找到答案:
Jetpack Compose 是用于构建原生 Android 界面的新工具包。它使用更少的代码、强大的工具和直观的 Kotlin API,可以帮助您简化并加快 Android 界面开发,打造生动而精彩的应用。它可让您更快速、更轻松地构建 Android 界面。
从这段定义中可以得出,Compose 是用来写 Android 界面的,相比于传统 XML+View 绘制界面工具,它完全摒弃了 XML 中罗列布局,Java/Kotlin 中 findViewById 那一套机制,而是引入声明式 UI 与可组合函数的概念,使其具有着更直观,代码量更少,UI 实时预览的优势。
除此之外,Compose 的渲染机制与传统相比也有很大差异,采用组合而不是继承的设计思想,使用起来会更加灵活,扩展性更强。并且手势处理结合协程的使用,也很好的避免复杂手势影响主线程性能的问题。
好了,Compose 的概念和优势就介绍到这,要想深刻体会到 Compose 的优势,还需要多用,多练,下面进入 demo 实战部分~
进入正题
先通过下面的 Gif 图,大家可以更直观感受下,要实现的是描述股票两个比较常用的功能:分时图,日K线图,以及拖动,长按,缩放手势处理。
图中:分时图是由个股每分钟的最后一笔成交价的连线而得到,反应的是个股实时走势;日 K 线图又称阴阳烛、蜡烛图,最初是日本米商用来表示米价涨跌状况的工具,后来引入股市,表示当天个股的涨跌情况。
一、采用 Android View 如何实现呢?
大家可以思考下,在 Android View 中实现一个股票K线图控件,需要哪些步骤呢?有过自定义 View 经验的同学相信很快会得出,如下几步:
1)继承 View, 重写 onDraw 等方法;
2)绘制边框;
3)绘制矩形蜡烛及上阴线,下阴线;
4)绘制坐标轴数值;
5)拖动,缩放,长按手势处理;
所以,绘制部分的代码基本如下:
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
initCandleData();
// 1.绘制边框,固定不变
drawFrame(canvas);
// 2.绘制蜡烛
drawCandles(canvas);
// 3.在蜡烛图后绘制y轴坐标,避免被蜡烛图遮挡
drawYValue(canvas);
if (isShowCross) {
// 4.显示十字交叉线
drawCross(canvas);
}
}
即如下四个步骤:
手势处理的代码如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
gestureDetector.onTouchEvent(event);
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
break;
case MotionEvent.ACTION_MOVE:
if (isShowCross) {
// 需要显示十字光标,更新光标坐标
updateCrossValue(event);
} else if (event.getPointerCount() == 1) {
// 单指滑动时,拖动k线
handleDragKLine(event);
} else if (event.getPointerCount() >= 2) {
// 多指时,处理缩放
if (handleScaleKLine(event)) break;
}
invalidate();
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
二、采用 Compose 实现
清楚了如何在传统 View 去实现,对于如何采用 Compose 实现起到了事半功倍的效果。剩下的就是熟悉 Compose 相关函数了。
准备工作
1、自定义控件,按照传统方式是继承 View 重写 onDraw 方法,在 onDraw 方法中得到画布 Canvas 对象,并通过 Canvas 去绘制想要的样式。在 Compose 中,前面已经提到,摒弃了继承 View 的这一套体系,它是直接在可组合函数中使用 Canvas 组件,这里的 Canvas 组件类似传统 View 中的一个独立的View,我们来看一下 Compose 中的 Canvas 使用方式:
官方提供了两个重载函数:
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw))
@ExperimentalFoundationApi
@Composable
fun Canvas(modifier: Modifier, contentDescription: String, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw).semantics { this.contentDescription = contentDescription })
modifier 是必须要传入的,它用于指定画布的大小,外观样式,以及交互手势(这个后面会讲到)。
onDraw 是一个 receiver 为 DrawScope 类型的 lambda 函数,我们在使用时可以调用任意 DrawScope 提供的 API。此函数在绘制时调用,需要注意,它没有被 @Composable 修饰,所以在这个 lambda 中是不能调用 Composable 函数的。
contentDescription 参数是内容描述,一般我们自定义的 view 仅用来展示,所以几乎不会用到这个参数。
使用方式:
Canvas 提供的绘制方法,基本与传统 View 中的 Canvas 功能一致:
细心的同学可能会发现,怎么没有 drawText 方法,的确是没有的,现在官方还提供,如果要绘制文字的话,需要先获取到原生的 canvas,再通过原生的 canvas 调用 drawText 方法,如下:
清楚了 Compose 中的 canvas 是怎么使用的,下面就可以进行绘制了。
开始绘制:
1)首先需要对数据进行处理,根据画布的宽高,求出等分线间隔,初始化蜡烛小矩形的宽度,间隙,蜡烛数量,开始下标,结束下标,以及当前屏幕中的最高股价及最低股价等信息,具体如下代码:
width = drawContext.size.width
height = drawContext.size.height
// y轴等分高度
yInterval = height / DIVIDER_NUM
// 蜡烛宽度
candleWidth = CANDLE_DEFAULT_WIDTH.toPx() * scale
// 蜡烛间隙
candleSpace = CANDLE_DEFAULT_SPACE_WIDTH.toPx() * scale
// 当前画布能够放置蜡烛的数量
count = (width / (candleSpace + candleWidth)).toInt()
indexStart = indexEnd - count
if (indexStart < 0) {
// 边界值处理
indexStart = 0
indexEnd = count
}
// 计算当前画布中的最高股价和最低股价
maxValue = dataList[indexStart].mMaxPrice
minValue = dataList[indexStart].mMinPrice
for (i in indexStart until indexEnd) {
// 找出一屏幕内,股价的最大值和最小值
if (dataList[i].mMaxPrice > maxValue) {
maxValue = dataList[i].mMaxPrice
}
if (dataList[i].mMinPrice < minValue) {
minValue = dataList[i].mMinPrice
}
}
// y轴最大坐标,最小坐标 与最大价格/最小价格流出间距,用于给最大值和最小值流出绘制空间
yMaxValue = maxValue + getOffset(maxValue)
yMinValue = minValue - getOffset(minValue)
// 股价等分间隔
yValueInterval = (yMaxValue - yMinValue) / DIVIDER_NUM
2)第一步,绘制边框,代码如下:
drawIntoCanvas {
// 第1步.绘制边框即y轴等分线
it.drawRect(0f, 0f, width, height, framePaint)
it.drawLine(Offset(0f, yInterval), Offset(width, yInterval), framePaint)
it.drawLine(Offset(0f, yInterval * 2), Offset(width, yInterval * 2), framePaint)
it.drawLine(Offset(0f, yInterval * 3), Offset(width, yInterval * 3), framePaint)
}
绘制结果如下:
第二步,我们进行绘制蜡烛图:
...
// 第2步.绘制柱状图及上下阴线
var startX = 0f
for (i in indexStart until indexEnd) {
if (dataList[i].mClosePrice > dataList[i].mOpenPrice) {
candlePaint.color = Red_F54346
candlePaint.style = PaintingStyle.Stroke
} else {
candlePaint.color = Green_14BB71
candlePaint.style = PaintingStyle.Fill
}
// 绘制矩形
var offset = 0f
if (dataList[i].mClosePrice == dataList[i].mOpenPrice) offset = 0.1f // 开盘价等收盘价,绘制一个0.1px的实线
it.drawRect(startX, priceToY(dataList[i].mClosePrice + offset, yMaxValue, yMinValue, height),
startX + candleWidth,
priceToY(dataList[i].mOpenPrice, yMaxValue, yMinValue, height), candlePaint)
// 绘制上阴线
it.drawLine(
Offset(startX + candleWidth / 2,
priceToY(Math.max(dataList[i].mOpenPrice, dataList[i].mClosePrice), yMaxValue, yMinValue, height)),
Offset((startX + candleWidth / 2),
priceToY(dataList[i].mMaxPrice, yMaxValue, yMinValue, height)),
candlePaint)
// 绘制下阴线
it.drawLine(
Offset(startX + candleWidth / 2,
priceToY(Math.min(dataList[i].mOpenPrice, dataList[i].mClosePrice), yMaxValue, yMinValue, height)),
Offset((startX + candleWidth / 2),
priceToY(dataList[i].mMinPrice, yMaxValue, yMinValue, height)),
candlePaint)
// 标示最大值和最小值
if (dataList[i].mMaxPrice == maxValue) {
candlePaint.color = Color.Black
it.drawLine(
Offset(startX + (candleWidth / 2),
priceToY(dataList[i].mMaxPrice, yMaxValue, yMinValue, height)),
Offset(startX + (candleWidth / 2) + lineWidth,
priceToY(dataList[i].mMaxPrice, yMaxValue, yMinValue, height)), candlePaint);
it.nativeCanvas.drawText(maxValue.toString(), startX + (candleWidth / 2) + lineWidth,
priceToY(dataList[i].mMaxPrice, yMaxValue, yMinValue, height), yValuePaint)
} else if (dataList[i].mMinPrice == minValue) {
candlePaint.color = Color.Black
it.drawLine(
Offset(startX + (candleWidth / 2), priceToY(dataList[i].mMinPrice, yMaxValue, yMinValue, height)),
Offset(startX + (candleWidth / 2) + lineWidth, priceToY(dataList[i].mMinPrice,yMaxValue, yMinValue, height)),
candlePaint)
it.nativeCanvas.drawText(minValue.toString(), startX + (candleWidth / 2) + lineWidth,
priceToY(dataList[i].mMinPrice, yMaxValue, yMinValue, height), yValuePaint)
}
startX += candleWidth + candleSpace
}
...
结果如下图所示:
第三步,添加y轴坐标:
...
// 第3步.绘制y轴坐标
yValuePaint.color = Color.Black.toArgb()
it.nativeCanvas.drawText(yMaxValue.toString(), 0f, yValuePaint.textSize, yValuePaint)
it.nativeCanvas.drawText((yMaxValue - yValueInterval).toString(), 0f, yInterval + yValuePaint.textSize, yValuePaint)
it.nativeCanvas.drawText((yMaxValue - yValueInterval * 2).toString(), 0f, yInterval * 2 + yValuePaint.textSize, yValuePaint)
it.nativeCanvas.drawText((yMaxValue - yValueInterval * 3).toString(), 0f, yInterval * 3 + yValuePaint.textSize, yValuePaint)
it.nativeCanvas.drawText((yMinValue).toString(), 0f, height, yValuePaint)
...
绘制结果如下:
3)使用过股票控件的同学应该会注意到长按时在 K 线图上层还会显示十字光标,那这个在上层显示的十字光标,我们可以放置到 Modifier 中的 drawWithContent 方法中,这个 API 是提供给开发者来控制绘制层级的。
fun Modifier.drawWithContent(
onDraw: ContentDrawScope.() -> Unit
): Modifier = this.then(
...
)
interface ContentDrawScope : DrawScope {
/**
* Causes child drawing operations to run during the `onPaint` lambda.
*/
fun drawContent()
}
通过注释可以看出,我们可以将制定内容放在 drawContent 的前面或者后面,这与 onDraw 方法中在super.onDraw 前面或者绘制内容一样,所以绘制十字光标如下:
.drawWithContent {
drawContent()
if (isShowCross) {
drawIntoCanvas {
val priceStr = yToPrice(crossY, yMaxValue, yMinValue, height).toString()
val textWidth = yValuePaint.measureText(priceStr)
// 绘制十字光标
it.drawLine(Offset(0f, crossY), Offset(width - textWidth, crossY), crossPaint)
it.drawLine(Offset(crossX, 0f), Offset(crossX, height), crossPaint)
// 绘制交叉线上的价格
yValuePaint.color = Color.Blue.toArgb()
it.nativeCanvas.drawText(priceStr, width - textWidth, crossY, yValuePaint)
}
}
}
绘制结果如下:
手势处理:
前面介绍了,传统 View 的手势处理是复写 View 类中的 onTouch 方法,而 Compose UI 框架没有了 View 类这一概念,所有手势操作的处理都需要封装在这个 Modifier 中的 pointerInput 方法中,我们知道 Modifier 是用来修饰 UI 组件的,所以将手势操作的处理封装在 Modifier 符合开发者设计直觉,这同时也做到了手势处理逻辑与 UI 视图的解耦,从而提高复用性。
fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = composed(
...
) {
...
remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
LaunchedEffect(this, key1) {
block()
}
}
}
interface PointerInputScope : Density {
val size: IntSize
val viewConfiguration: ViewConfiguration
suspend fun <R> awaitPointerEventScope(
block: suspend AwaitPointerEventScope.() -> R
): R
}
通过 pointerInput 定义可以看出,我们所定义的自定义手势处理流程均发生在 PointerInputScope
中,suspend 关键字也告知我们自定义手势处理流程是发生在协程中,根据协程优势,可知:使用 Compose 框架进行手势处理对主线程是没有任何影响的,它的手势处理性能也会相对更好些。
在编写手势处理代码之前,我们先看几个重要的方法:
1)forEachGesture
在该方法的作用域中可以接收每一次的手势事件,就像传统 View 中的每一次手势操作都会执行onTouchEvent 方法(不考虑拦截情况),可以把它当作接收手势事件的入口。注意如果,在手势处理操作没有放置在该函数作用域中,那么我们只能接收到第一次的手势事件。
2)手势事件作用域 awaitPointerEventScope
在上面的 PointerInputScope
中有一个名为 awaitPointerEventScope
的方法,它也是一个协程方法,接收参数是一个 lambda 表达式,返回结果是 lambda 的返回值。并且该方法中提供了一些底层手势处理的方法,这也为自定义手势处理提供了必要条件。
API名称 | 作用 |
---|---|
awaitPointerEvent | 手势事件 |
awaitFirstDown | 第一根手指的按下事件 |
drag | 拖动事件 |
horizontalDrag | 水平拖动事件 |
verticalDrag | 垂直拖动事件 |
awaitDragOrCancellation | 单次拖动事件 |
awaitHorizontalDragOrCancellation | 单次水平拖动事件 |
awaitVerticalDragOrCancellation | 单次垂直拖动事件 |
awaitTouchSlopOrCancellation | 有效拖动事件 |
awaitHorizontalTouchSlopOrCancellation | 有效水平拖动事件 |
awaitVerticalTouchSlopOrCancellation | 有效垂直拖动事件 |
3) 万物之源 awaitPointerEvent
suspend fun awaitPointerEvent(
pass: PointerEventPass = PointerEventPass.Main
): PointerEvent
通过阅读源码可以发现 awaitPointerEvent 函数返回值是一个 PointerEvent 类型的对象,PointerEvent 中封装了 AndroidView 中的 MotionEvent,但是作为一个内部对象的形式进行了封装,所以我们也不能直接去调用,不过,可以通过 changes 对象来得知当前手势的处理情况,changes 对象是 PointerInputChange 类型的集合,集合大小代表屏幕上的手指个数,PointerInputChange 里面封装了当前手指的 id,按压状态,在屏幕中的位置等信息。讲到这,我们终于找到了与传统 MotionEvent 相类似的 PointerInputChange,用它来获取手指移动位置,按压状态就可以了。
下面上代码:
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
while (true) {
val event: PointerEvent = awaitPointerEvent(PointerEventPass.Final)
if (event.changes.size == 1) {
// 1.单指操作
val pointer = event.changes[0]
if (!pointer.pressed) {
// 手指抬起,结束
break
} else {
if (pointer.previousPressed
&& abs(pointer.previousUptimeMillis - pointer.uptimeMillis)
> viewConfiguration.longPressTimeoutMillis) {
// 1.1长按
isShowCross = true
crossX = pointer.position.x
crossY = pointer.position.y
} else if (isShowCross && pointer.previousPressed) {
// 坐标显示并且上一次的手指是按压状态,可判断为长按后开始拖动的状态
crossX = pointer.position.x
crossY = pointer.position.y
} else if (pointer.previousPressed) {
// 1.2没有进行长按的普通拖动
val dx = pointer.position.x - downX
count = (-dx / (candleWidth + candleSpace * 2)).toInt()
if (abs(count) >= 1) {
indexStart += count
indexEnd += count
downX = pointer.position.x
if (indexStart < 0) {
indexEnd += abs(indexStart)
indexStart = 0
}
if (indexEnd > dataList.size - 1) {
indexStart += indexEnd - dataList.size
indexEnd = dataList.size - 1
}
}
} else if (!pointer.previousPressed) {
// 上一次手指没有按压,可以判断为单次点击事件
downX = pointer.position.x
if (isShowCross) {
isShowCross = false
}
}
}
} else if (event.changes.size > 1) {
// 2.多指操作
if (!event.changes[0].pressed || !event.changes[1].pressed) {
// 多指操作时,前两个主要手指抬起,可以判断为手势抬起
break
}
// 缩放处理
val dis = distance(event)
val minDis: Double = (width / 50.0).coerceAtLeast(4.dp.toPx().toDouble())
if (dis > minDis) {
if (dis > twoPointsDis) {
twoPointsDis = dis
// 放大
scale += SCALE_STEP
} else if (dis < twoPointsDis) {
twoPointsDis = dis
// 缩小
scale -= SCALE_STEP
}
if (scale > SCALE_MAX) {
twoPointsDis = dis
scale = SCALE_MAX
}
if (scale < SCALE_MIN) {
twoPointsDis = dis
scale = SCALE_MIN
}
}
}
}
}
}
}
乍一看,很长一段手势处理代码,不过,里面的逻辑还是比较清晰的,主要分为两部分,单指操作和多指操作:
1)单指操作中包含:长按,长按后拖动以及非长按的普通拖动
2)多指操作中包含:缩放处理
除上面手势相关函数外,pointerInput 中还提供了detectDragGestures(拖拽),detectTapGestures(点击),detectTransformGestures(变换)等相关 API,大家可以结合自己需求灵活使用,本文之所以选择通过awaitPointerEventScope
作用域下去自定义来完成长按,拖拽,缩放的手势处理,而不是在上述三个 API(detectDragGestures,detectTapGestures,detectTransformGestures)去实现,有两个原因:一是长按后不离开屏幕继续拖拽,显示十字光标的移动,直接使用 API 不能达到这个效果,需要长按后先离开屏幕,再去拖拽才可以;二是本着从更加底层的态度去熟悉 Compose 的手势处理,因为官方提供的其他手势处理API也是基于awaitPointerEventScope
来实现的。
如何更新 UI 呢?
通过上面手势处理的介绍,我们现在已经拿到了手势移动后的坐标,那么怎么通知界面使用新坐标进行重绘呢,在传统 View 中是通过调用 invalidate() 方法,触发 onDraw 方法重新调用来完成重绘,那在 Compose 中呢?我们先看一下可组合函数的生命周期,如下图:
由此可知,在 Composable 函数中,当某一部分发生改变( state 发生改变)时,受 state 影响的这一部分会进行重组,也就是触发界面重绘,
所以我们只需要把手势影响的对象声明为 state,当他们跟随手势发生变化后,自动就触发了重组。如下:
// 在屏幕中的第一个蜡烛图对应集合的起始下标
var indexStart by remember{ mutableStateOf(0)}
// 在屏幕中的第一个蜡烛图对应集合的结束下标
var indexEnd by remember{ mutableStateOf(dataList.size - 1)}
// 是否显示十字光标
var isShowCross by remember{ mutableStateOf(false)}
// 十字光标x轴坐标
var crossX by remember { mutableStateOf(0f) }
// 十字光标y轴坐标
var crossY by remember { mutableStateOf(0f) }
// 放大缩小比例
var scale by remember{ mutableStateOf(SCALE_DEFAULT)}
这样一个带有手势处理的股票K线图控件基本就完成啦,分时图的处理逻辑可以参考K线图来实现。
为感兴趣的同学奉上源码地址:github.com/jingqingqin…
注:【文中的代码compose版本:1.0.0-beta09,Kotlin版本:1.5.10,Android Studio版本:2021.1.1 Canary 2】
总结
本文通过自定义股票K线图控件,学习了一下 Compose 的绘制和手势处理这两部分的知识,在日后使用 Compose 改造项目或者开发项目,绘制和手势处理也是必须要掌握的重要知识。此外开发 demo 过程中最大的感受就是 Compose 的实时预览功能,和控件单独运行调试的功能实在是太强大了,这很好的提高了开发效率。而且声明式编写代码的方式也避免了手动操纵视图带来的高出错率。感兴趣的同学可以尝试下啦~
参考
1.docs.compose.net.cn/design/draw…
2.docs.compose.net.cn/design/gest…
3.developer.android.com/jetpack/com…
还有一件事
雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。
热招岗位:Android/iOS/FE 技术专家、推荐算法工程师、Java 开发工程师。