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实战部分~
进入正题
先通过下面的视频,大家可以更直观感受下,要实现的是描述股票两个比较常用的功能:分时数据图,日K数据图,以及拖动,长按,缩放手势处理。
一、采用Android View如何实现呢?
大家可以思考下,在Android View中实现一个股票K线图控件,需要哪些步骤呢?有过自定义View经验的同学相信很快会得出,如下几步:
1)继承View, 重写onDraw等方法;
2)绘制边框;
3)绘制坐标轴数值;
4)绘制矩形蜡烛及上阴线,下阴线;
5)拖动,缩放,长按手势处理;
所以,绘制部分的代码基本如下:
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
initCandleData();
// 绘制边框,固定不变
drawFrame(canvas);
// 绘制y轴坐标
drawYValue(canvas);
// 绘制蜡烛
drawCandles(canvas);
if (isShowCross) {
// 显示十字交叉线
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是按照继承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 {
// 绘制边框
it.drawRect(0f, 0f, width, height, framePaint)
// 绘制y轴等分线
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)
// 绘制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)
// 绘制柱状图及上下阴线
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
}
}
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,用它来获取手指移动位置,按压状态就可以了。
下面上代码:
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改造项目或者开发项目,绘制和手势处理也是必须要掌握的重要知识。感兴趣的同学可以学起来啦~
参考:
1.docs.compose.net.cn/design/draw…