It’s been over a month since the official release of Jetpack Compose, and I’m sure many readers have given it a try. Let’s learn about Compose drawing and gesture handling by customizing the stock k-graph control. [Note: Compose refers to Jetpack Compose unless otherwise specified below]

Compose before we get into the main body, let’s talk about what Compose is and what it means. The answer can be found in the official definition of Compose:

Jetpack Compose is a new toolkit for building native Android interfaces. With less code, powerful tools, and an intuitive Kotlin API, it can help you simplify and speed up Android interface development, creating lively and exciting applications. It makes building Android interfaces faster and easier.

Compose is composed for Android, which is composed for Android. For example, Compose is composed for Android, which is composed for Android. Compose is composed for Android, which is composed for Android, which is composed for Android. In addition, the concept of declarative UI and composable functions is introduced to make it more intuitive, less code, and the advantages of real-time PREVIEW of UI.

In addition, Compose’s rendering mechanism is quite different from traditional ones, with composition rather than inheritance making it more flexible and scalable to use. And gesture processing combined with the use of coroutines, also very good to avoid complex gestures affect the performance of the main thread.

For example, “Compose” is one of the many things that Compose users. For example, for example, “Compose” is one of the many things that Compose users

Get into the business

First through the following video, we can feel more intuitive, to achieve the description of the stock two more commonly used functions: time-sharing data graph, daily K data graph, and drag, long press, zoom gesture processing.

First, how to implement Android View?

You can think about, in Android View to achieve a stock K line control, what steps are needed? For those of you who have experience with customizing a View, you will get the following steps:

1) Inherit View, rewrite onDraw and other methods; 2) Draw the border; 3) Draw coordinate axis values; 4) Draw the rectangular candle and the upper and lower shade lines; 5) Drag, zoom, long press gesture processing;

So, the code for the drawing part is basically as follows:

protected void onDraw(Canvas canvas) { super.onDraw(canvas); initCandleData(); // Draw the border with a fixed drawFrame(canvas); DrawYValue (canvas); // hold the candle in canvas; If (isShowCross) {// drawCross(canvas); }}Copy the code

The code for gesture processing is as follows:

@Override public boolean onTouchEvent(MotionEvent event) { gestureDetector.onTouchEvent(event); switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: downX = event.getX(); break; ACTION_MOVE: if (isShowCross) {// If (isShowCross) {// If (isShowCross) {// If (isShowCross); } else if (event.getPointerCount() == 1) {// Drag handleDragKLine(event); } else if (event.getPointerCount() >= 2) {if (handleScaleKLine(event)) break; } invalidate(); break; case MotionEvent.ACTION_UP: break; } return true; }Copy the code

2, using Compose implementation

It is clear how to implement in the traditional View and how to use Compose to achieve more results with less effort. All that remains is to become familiar with the Compose related function.

The preparatory work

1, custom control, according to the traditional View is in accordance with the inheritance of View to rewrite onDraw method, in the onDraw method to get the Canvas object, and through Canvas to draw the style you want. In Compose, it uses a Canvas component directly in a composable function. The Canvas component is like a separate View in a traditional View. Let’s take a look at how the Canvas component is used in Compose: Two overloaded functions are officially provided:

@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 })
Copy the code

Modifier is mandatory and is used to specify the canvas size, style, and interaction gestures (more on this later).

OnDraw is a lambda function whose receiver is of type DrawScope. We can call the API provided by any DrawScope when using onDraw. This function is called when drawing. Note that it is not decorated with @composable, so the Composable function cannot be called in this lambda.

The contentDescription parameter is a description of the content, and is rarely used because our custom view is used for presentation only.

Usage:

The drawing method provided by Canvas is basically the same as the Canvas function in traditional View:

If you are careful, you may notice that there is no drawText method. Indeed, there is no drawText method. Now the official system also provides that if you want to drawText, you need to obtain the native canvas first and then call the drawText method through the native canvas, as follows:Now that you know how the canvas in Compose is used, it’s time to draw.

Start drawing:

1) First, we need to process the data. According to the width and height of the canvas, we need to calculate the interval of bisector lines, initialize the width of the small rectangle of candles, the gap, the number of candles, the start subscript, the end subscript, and the information of the highest and lowest stock prices in the current screen. The specific codes are as follows:

Width = drawContext.size.width height = drawcontext.size. height // Y dividing height yInterval = height/DIVIDER_NUM // Candle width CandleWidth = CANDLE_default_width.topx () * scale // candleSpace = CANDLE_default_space_width.topx () * scale // Count = (width/(candleSpace + candleWidth)).toint () indexStart = indexEnd - count if (indexStart < 0) { 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].mmaxprice > maxValue) {maxValue = dataList[I].mmaxprice} if (dataList[I]. MinValue) {minValue = dataList[I].mminprice}} YMaxValue = maxValue + getOffset(maxValue) yMinValue = minValue - getOffset(minValue) // Share price equal partition interval yValueInterval = (yMaxValue - yMinValue) / DIVIDER_NUMCopy the code

2) Draw the border, coordinate and candle diagram as follows:

DrawIntoCanvas {drawRect(0f, 0f, width, height, framePaint) 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), Color = color.black.toarGB () it.nativecanvas. drawText(ymaxValue.tostring (), 0f, drawText(ymaxValue.tostring (), 0f, 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, 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].mopenprice == dataList[I].mopenprice) offset = 0.1f // DrawRect (startX, priceToY(dataList[I]. MClosePrice + offset, yMaxValue, yMinValue, height) startX + candleWidth, priceToY(dataList[i].mOpenPrice, yMaxValue, yMinValue, height), DrawLine (Offset(startX + candleWidth / 2, priceToY(math.max (dataList[I]. MOpenPrice, priceToY(math.max (dataList[I]. dataList[i].mClosePrice), yMaxValue, yMinValue, height)), Offset((startX + candleWidth / 2), priceToY(dataList[i].mMaxPrice, yMaxValue, yMinValue, height)), DrawLine (startX + candleWidth / 2, priceToY(math.min (dataList[I]. MOpenPrice, priceToY(math.min (dataList[I]. dataList[i].mClosePrice), yMaxValue, yMinValue, height)), Offset((startX + candleWidth / 2), priceToY(dataList[i].mMinPrice, yMaxValue, yMinValue, height)), If (dataList[I].mmaxprice == maxValue) {candlePaint. Color = color.black it. DrawLine (dataList[I].mmaxprice == maxValue) 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 } }Copy the code

3) Those of you who have used the stock control will have noticed that there is a cross cursor displayed at the top of the k-graph for a long time. The cross cursor displayed at the top of the k-graph can be placed in the drawWithContent method of the Modifier. This API is provided to developers to control the drawing level.

fun Modifier.drawWithContent(
    onDraw: ContentDrawScope.() -> Unit
): Modifier = this.then(
    ...
)
interface ContentDrawScope : DrawScope {
    /**
     * Causes child drawing operations to run during the `onPaint` lambda.
     */
    fun drawContent()
}
Copy the code

As you can see from the comments, we can place the drawContent before or after the drawContent, just as we did in the onDraw method before or after super.onDraw, so draw the cross cursor as follows:

.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)
        }
    }
}
Copy the code

Gesture processing:

The Compose UI framework does not contain the view class, so all gesture handling needs to be wrapped in the Modifier pointerInput method. We know that Modifier is used to modify UI components, so encapsulating the gesture handling in Modifier is intuitive to the developer, and it also decouifies the gesture processing logic from the UI view, thus improving reusability.

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
}
Copy the code

From the definition of pointerInput, it can be seen that all the custom gesture processing flows defined by us take place in PointerInputScope. The suspend keyword also tells us that the custom gesture processing flows take place in coroutines. According to the advantages of coroutines, it can be seen that: Using the Compose framework for gesture processing has no impact on the main thread and its performance is relatively better.

Before we start writing our code for gestures, let’s look at a few important methods:

1) forEachGesture

Each gesture event can be received in the scope of this method, just as the onTouchEvent method is executed for each gesture operation in a traditional View (regardless of interception), which can be used as an entry point to receive gesture events. Note that if the gesture action is not placed in this function scope, then we will only receive the first gesture event.

2) Gesture event scope awaitPointerEventScope

In the PointerInputScope above, there is a method called awaitPointerEventScope, which is also a coroutine method that takes a lambda expression and returns the return value of the lambda. And the method provides some low-level gesture processing methods, which also provides the necessary conditions for custom gesture processing.

The name of the API role
awaitPointerEvent Gesture events
awaitFirstDown The first finger press event
drag Drag events
horizontalDrag Horizontal drag event
verticalDrag Vertical drag event
awaitDragOrCancellation Single drag event
awaitHorizontalDragOrCancellation Single horizontal drag event
awaitVerticalDragOrCancellation Single vertical drag event
awaitTouchSlopOrCancellation Valid drag event
awaitHorizontalTouchSlopOrCancellation Effective horizontal drag event
awaitVerticalTouchSlopOrCancellation Valid vertical drag event

3) The source of all things awaitPointerEvent

suspend fun awaitPointerEvent(
    pass: PointerEventPass = PointerEventPass.Main
): PointerEvent
Copy the code

By reading the source code, it can be found that the return value of the awaitPointerEvent function is an object of type PointerEvent. The PointerEvent encapsulates the MotionEvent in AndroidView, but is encapsulated as an internal object. So we can’t call it directly, but we can tell what’s going on with the gesture by looking at the Changes object, which is a set of pointerinputChanges, which represents the number of fingers on the screen, PointerInputChange encapsulates the current finger ID, press status, position in the screen and other information. At this point, we finally found a PointerInputChange similar to the traditional MotionEvent, which can be used to obtain the finger movement position and press the state.

Here’s the code:

forEachGesture { awaitPointerEventScope { while (true) { val event: PointerEvent = awaitPointerEvent(PointerEventPass.Final) if (event.changes.size == 1) { // 1. Val pointer = event.changes[0] if (! Pointer. Pressed) {/ / finger lift, end break} else {the if (pointer) previousPressed && abs (pointer. PreviousUptimeMillis - Pointer. UptimeMillis) > viewConfiguration. LongPressTimeoutMillis) {/ / 1.1 long press isShowCross = true crossX = pointer.position.x crossY = pointer.position.y } else if (isShowCross && pointer.previousPressed) { // Coordinates are displayed and the last finger was pressed, CrossX = pointer.position.x crossY = pointer.position.y} else if (pointer.previousPressed) {// 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 (! previousPressed) {// The last time the finger did not press, DownX = pointer.position.x if (isShowCross) {isShowCross = false}}}} else if (event.changes.size > 1) {downX = pointer.position.x if (isShowCross) {isShowCross = false}}}} else if (event.changes.size > 1) { / / 2. If (! event.changes[0].pressed || ! < span style = "box-width: border-box; color: RGB (50, 50, 50); line-height: 1.5px; font-size: 14px! Important; word-break: break-all; Double = (width / 50.0).coerceatleast (4.dp.topx ().todouble ()) if (dis > twoPointsDis) {twoPointsDis If (dis < twoPointsDis) {if (dis < twoPointsDis) = dis -= SCALE_STEP} if (dis < twoPointsDis (scale > SCALE_MAX) { twoPointsDis = dis scale = SCALE_MAX } if (scale < SCALE_MIN) { twoPointsDis = dis scale = SCALE_MIN } } } } } } }Copy the code

At first glance, a long section of gesture processing code, however, the logic is relatively clear, mainly divided into two parts, single finger operation and multiple finger operation:

1) Single-finger operation includes: long press, drag after long press and normal drag without long press. 2) Multi-finger operation includes: zooming

In addition to the gesture function above, pointerInput also provides apis like detectDragGestures, detectTapGestures, and detectTransformGestures. You can use it flexibly according to your own needs. In this paper, we choose to customize it in the scope of awaitPointerEventScope to complete the gesture processing of long pressing, dragging and zooming, rather than the three apis mentioned above (detectDragGestures, DetectTapGestures, detectTransformGestures) for two reasons: one is to keep dragging without leaving the screen, to show the movement of the cross cursor, which cannot be achieved by using API directly, you need to leave the screen after holding down, and then drag. Second, familiarize yourself with Compose’s gesture processing at a lower level, since the other API is also based on awaitPointerEventScope.

How do I update the UI?

For example, in Compose, you can call the invalidate() method and trigger the onDraw method to redraw the object. For example, in Compose, you can call the invalidate() method to redraw the object. Let’s first look at the life cycle of a composable function, as shown below:

In the Composable function, when a part is changed (state changes), the part affected by state will be reorganized, that is, the interface will be redrawn.

So all we need to do is declare the objects affected by the first gesture as state, and when they change with the gesture, the recombination is triggered automatically. As follows:

Var indexStart by remember{mutableStateOf(0)} var indexEnd by remember{ Var isShowCross by remember{mutableStateOf(false)} var isShowCross by remember{mutableStateOf(false) CrossX by remember {mutableStateOf(0f)} var crossY by remember {mutableStateOf(0f)} var scale by remember{ mutableStateOf(SCALE_DEFAULT)}Copy the code

Such a stock K – chart control with gesture processing is basically completed, time-sharing diagram processing logic can refer to the K – chart to achieve.

Source code address: github.com/jingqingqin…

Note: Compose version 1.0.0-beta09, Kotlin version 1.5.10, Android Studio version 2021.1.1 Canary 2

conclusion

In this paper, I learned the knowledge of Compose drawing and gesture processing by customizing the stock K-chart control. In the future, drawing and gesture processing are also important knowledge that must be mastered when using Compose to transform or develop projects. Those of you who are interested can learn

Reference:

1. docs.compose.net.cn/design/draw…

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

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