手把手带你实现Compose版股票K线图

1,909 阅读11分钟

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数据图,以及拖动,长按,缩放手势处理。 compose-stock-demo-3.gif

一、采用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仅用来展示,所以几乎不会用到这个参数。

使用方式:
截屏2021-09-12 下午8.30.41.png Canvas提供的绘制方法,基本与传统View中的Canvas功能一致: 截屏2021-09-12 下午8.29.21.png
细心的同学可能会发现,怎么没有drawText方法,的确是没有的,现在官方还提供,如果要绘制文字的话,需要先获取到原生的canvas,再通过原生的canvas调用drawText方法,如下: nativecanvas.png 清楚了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中呢?我们先看一下可组合函数的生命周期,如下图:

recompose.png 由此可知,在 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…

2.docs.compose.net.cn/design/gest…

3.developer.android.com/jetpack/com…