preface

Always wanted to take the time to re-learn the native UI and animation of Apple products, super silky. Today, the goal is a healthy heart rate chart.

Health and Android implementation preview

  1. Apple health chart interaction:

Silky, there are two modes of sliding data bar and sliding to view data annotation; Data annotation position adaptive; Both ends are automatically rolled back when they are out of bounds.

  1. This article uses Android to reproduce the chart interaction effect:

Looking at the core implementation ideas for the moment, there is plenty of room for improvement in details (auto-rollback motion curves, fast sliding, scale line changes, etc., but they are not the focus of the Demo) 😥.

1. Page content analysis

Before we start, we might as well take a closer look at the information on this page, translate it into our business needs, and organize our thoughts in advance before we start writing.

1.1 Static Layout of charts


Let’s break up the chart. It essentially consists of the following three components:

  • Article data
    • Single data bar: represents the heart rate distribution in unit time, which is simplified to the heart rate variation range (minimum ~ maximum) in unit time.
    • Data storage: Each data bar needs to contain three pieces of information: time, minimum, and maximum, and we use an ArrayList to put them together. For the missing data, we can fill in the values by time (set to 0) to achieve white space on the chart.
  • Coordinate Axis
    • Horizontal: the horizontal axis, background line and their scale (0,50,100) are almost static, except that the scale changes, which we will ignore here for the moment.
    • Longitudinal: Longitudinal background lines are distributed at specific intervals and change as they slide. They are relatively static with the data bar. So we tried to bundle them with data bars.
  • Data is labeled IndicatorLabel
    • Default: it is fixed in the upper left corner, and the time range and heart rate variation range of the current visible data are displayed
    • Indicator form: When the user long touches/clicks the chart data bar, it will appear on top of it; There will be adaptive adjustment of position on the left and right boundaries.
    • The default and indicator forms are binary. We can set a Boolean, isShowIndicator, to control them. True shows the indicator and false is the default to simplify our subsequent processing logic.

1.2 Dynamic effect of chart

Chart slides with border effects

  • Slide change: Slide the chart left and right to adjust. In the slide process, the value of the default shape data marked at the top will change, and the longitudinal background line and scale value will move with it;
  • Automatic rollback:
    • At the end of each slide, there is a slight automatic rollback to ensure the full 24 data bars are displayed in the window.
    • After the sliding window exceeds the boundary, it automatically rolls back to the original boundary.

Annotation of data generated by touch/click

  • User click/touch will trigger the data annotation indicating the shape. After entering this state, finger press the screen and slide left and right to achieve the effect of sliding data annotation
  • Once in the above state, if you swipe quickly, you can restore the default shape of the annotations and slide the chart.

2. Page implementation

Before implementing a page using a custom View, consider our workflow in light of the above layout analysis:

  1. Draw a rough sketch of the diagram, marking out the important dimensions and making sure that these dimensions allow us to calculate the coordinates of each point;
  2. Prepare a data class to hold the data at each point in time, packaged with ArrayList, as our data source;
  3. The horizontal background line and Y-axis scale are static throughout the whole process, so it is preferred to draw them.
  4. The longitudinal background line, x axis scale and data bar are bound together to draw; Coordinates are calculated by combining the index of each item in the ArrayList, and the Y-axis position of the data bar is calculated by using the value of item.
  5. It can display the specific information of corresponding points by specifying the index of an item.
  6. By overwriting onTouchEvent to trigger the data annotation effect of click/touch, realize the slide effect of chart.

After roughly thinking over the possible difficulty of each step in my mind, I found that we are mainly faced with three problems 😥 :

  1. What layout would allow us to easily calculate coordinates through the index of the item?
  2. How to use the most concise and elegant way to make our data bar move?
  3. The same is sliding, sometimes the user needs the data bar to slide left and right, and sometimes the data bar needs to remain, data annotation move, how to distinguish this?

To ensure the reading experience, the implementation section does not list all the code and explain all the details. The code can be retrieved at the bottom by Ctrl C+V.

2.1 Chart infrastructure

We follow the proposed workflow step by step:

2.1.1 Draw a sketch of the frame of the chart.

After thinking about the diagram, we can quickly draw the following structure:For the selection of lineWidth and lineSpace, assume that the maximum number of visible data strips is N. In order to achieve a regular page, we need to ensure that the following equation holds:


( l i n e W i d t h   +   l i n e S p a c e )     n = c h a r t W i d t h \rm{(lineWidth\ +\ lineSpace)\ *\ n = chartWidth}

ChartWidth is marked in the structure diagram above — the width of chart storing data bars; The reason for this is simple: assuming that n is now 24, the chart width is 24* lineWidth +23* lineSpace + left-most whitespace + right-most whitespace; The above equation ensures that the width of the left and right sides is 0.5 * lineSpace.

2.1.2 Preparing a data class

The current requirement is for a minimum and a maximum storage time, so create a simple DataClass.

data class HeartRateChartEntry(
        
    val time: Date = Date(), val minValue:Int = 66.val maxValue:Int = 88
)
Copy the code

Then we create some random data and store it with an ArrayList.

2.1.3 Draw horizontal background line and Y-axis scale

They are static and can be directly used to calculate the starting and ending point coordinates of chart and text with the drawn structure diagram.

  • StartX = (getWidth() -chartwidth)/2. Of course, you can also define the starting point of chart by yourself. I suggest that the x coordinate of this starting point is proportional to lineWidth+lineSpace
  • endX = startX + chartWidth
  • endY = startY = totalHeight – bottomTextHeight

To draw k lines, first calculate the distance between the lines unitDistance = chartHeight/(k-1). Each drawing will make unitDistance* i-starty obtain the vertical coordinate of the current horizontal line.

(0..mHorizontalLineSliceAmount).forEach{ i ->
    // Get the current scale to write on
    currentLabel = .....
    
    // Compute the current Y
    currentY = startY - i * mVerticalUnitDistance
    
    Draw a line / /
    canvas.drawLine(startX, currentY, endX, currentY, mAxisPaint)
    / / draw the textcanvas? .drawText("${currentLabel}", endX + mTextSize/3, currentY+mTextSize/3, mTextLabePaint)
    
// Draw the leftmost border
canvas.drawLine(startX, startY, startX, startY-mChartHeight, mAxisPaint)
}
Copy the code

2.1.4 Draw data bars and longitudinal background lines

Ok, so we’ve got the problem we expected, so how do we draw the data bar so that it fits our sliding requirements?

Rejected scheme: Suppose we calculate the sliding distance of finger through onTouchEvent, and use the sliding distance to calculate the data index we need to draw; But while this works for our static pages, it doesn’t make for smooth animation, just flashing as we slide. The reason for this is that he doesn’t actually change the horizontal coordinate of the data bar when it is drawn, so we can fine-tune them based on the sliding distance of onTouchEvent? But this still does not prevent the flicker of the edge data bar.

Better: Windows

Imagine that we are sitting directly in front of a window, and we assume that the window is a viewPort. In this window, we can see the landscape switch horizontally because of the relative movement between the window and the background.

If we will be the idea for our chart and data, can interpret chart for window, data is floating on the surface of the landscape, and then we just need to move data, you can switch the view (the visual effect of sliding) data, this can guarantee there will be no split, after all, all things have been drawn, only the position adjustment.

The idea seems to be able to try, before starting, we still draw a picture to clarify the idea.

  • We need to draw the data bar from right to left to show the time format
  • The starting point is better set to the far right of the chart

  • If you want to slide right, do you just move the starting point of the drawing to the right?

We use viewStartX as the starting point, draw the data bar from right to left (for loop with data subscript to calculate the X-axis coordinates), then go to the ActionMove of onTouchEvent to calculate the sliding distance, and adjust the viewStartX dynamically.

One thing to think about is that if we redraw all the data bars every time we swipe, if the data volume is large, it will definitely cause performance problems.

But it’s easy to solve. We just have to calculateThe index of the left – and right-most data bars displayed in the current window, respectively,leftRangeIndex, rightRangeIndex, we set it toOnly the range (leftRangeIndex-3, rightRangeIndex+3) is executedThat’s it. That’s itOnly draw the data bar inside the window + the edge of the window at a time.

Finally, after drawing the data bar, we need to cut down a window and put it back into our chart, which can be achieved by pairing canvas.savelayer () with Canvas.restoreToCount ().

The following is the core code to draw the data bar, see a good idea

  1. Use saveLayer() to determine a window range
valwindowLayer = canvas? .saveLayer( left = chartLeftMargin,// Chart left edge x coordinates
    top = 0F,
    right = chartRightBorner, // Chart right boundary x coordinates
    bottom = widthBottom // The y coordinates of the boundary under chart
)
Copy the code
  1. Walk through our ArrayList of stored data, using viewStartX and index to calculate the abscissa of each data bar, and plot it out
(0 until mValueArray.size).forEach { it ->
    // If it is not in the range we expect, then yo-yo, no drawing
    if (it > drawRangeRight || it < drawRangeLeft) {
        return@forEach
    }
    // Calculate coordinates x, the starting and ending points of the y axis of the data bar
    currentX = mViewStartX - (it) * (mLineWidth + mLineSpace) - chartRightMargin
    startY = baseY - mChartHeight / mYAxisRange.second * mValueArray[it].maxValue
    endY = baseY - mChartHeight / mYAxisRange.second * mValueArray[it].minValue

    if(mValueArray[it].maxValue ! =0) { canvas? .drawLine(currentX, startY, currentX, endY, mLinePaint) }Copy the code
  1. Draw a vertical background line and scale at a specific point in time that we have defined.

  2. Finally, we’ll store this window in our View

cavas? .restoreToCount(windowLayer!!)Copy the code

2.1.5 Drawing function of data annotation

As mentioned above, there are two types of data annotation in our chart, one is the default form and the other is the indicator form. They are either-or. We just need to set a Boolean variable, isShowIndicator, and then dynamically set this variable in onTouchEvent to switch them.

At the same time, we maintain a variable indexOnClicked in onTouchEvent, which represents the index of the currently clicked data bar and draws data annotations indicating shape.

There is no need to describe the drawing process here.

2.2 Chart touch events

Again, clear your head before writing code. We hope that:

  • Charts can determine the user’s long-touch, fast-swipe behavior

    • Our chart needs to be able to judge the following two state values
      • Scrolling status – isScrolling: indicates that the user isScrolling quickly to switch between columns (changing the coordinates of viewStartX)
      • LongTouch -isLongTouch: The user keeps his finger on our screen because he wants to see the data label. The switch in this state does not switch the data bar, but the subscript of the data label.
  • The chart calculates the distance of each slide, dynamically adjusting the viewStartX and the left and right boundaries of the array to be drawn

OnTouchEvent chain of events

To do this, we need to look at onTouchEvent(Event: MotionEvent?

For touch events, we handle the following callbacks:

  • ACTION_DOWN
    • Finger down: Whether clicking or swiping, ACTION_DOWN is their initial action
  • ACTION_MOVE
    • Finger slide: After ACTION_DOWN is triggered, if the finger slides, the MOVE is triggered several times to indicate the finger’s slide on the chart
  • ACTION_UP
    • Finger lift: must be the end step of the click event, possibly the end step of the slide event (possibly ACTION_CANCEL)
  • ACTION_CANCEL
    • Gesture abandonment: May be the end step of the sliding event (may also be ACTION_UP)

Let’s first deal with how to make the graph judge to be fast sliding:

  1. We maintain a currentTime currentTime
  2. Every time the ACTION_DOWN finger is pressed, we record the time at that moment;
  3. When we encounter ACTION_MOVE, we first get the currentTime and subtract currentTime from the record to get the time interval
  4. If this interval is less than some time threshold TIMEDURATION, we count it as a quick slip
  5. However, we add a restriction condition that the distance of this move must be greater than a certain threshold, otherwise it will be considered as a slight move(hand slide generated, not the user’s mind).
  6. For subsequent sliding events (ACTION_MOVE N in the figure above), they may have exceeded the threshold, but they also need to perform the sliding task; In ACTION_MOVE 1, we set isScrolling to true. In the following sliding event, as long as isScrolling==true is in sliding state, It can then boldly start performing the sliding event.

According to this, we have the following code:

override fun onTouchEvent(event:MotionEvent?).:Boolean{
// Get the abscissa of the current touch pointmCurrentX = event!! .xwhen (event.action) {
    MotionEvent.ACTION_DOWN -> {
        // Record the touch point to record the sliding distance
        mLastX = mCurrentX
        // Record the current time, used to judge the fast slide
        currentMS = System.currentTimeMillis()

    }
    MotionEvent.ACTION_MOVE -> {
        // Get the sliding distance
        mMoveX = mLastX - mCurrentX
        // Record the touch points
        mLastX = mCurrentX

        // If move time 
      
        Xpx, this is fast sliding
      
        if (((System.currentTimeMillis() - currentMS) < TOUCHMOVEDURATION && (abs(mMoveX) > mLineWidth)) || isScrolling) {
            isScrolling = true
            
            // Update the setters of mViewStartX with invalidate()
            mViewStartX -= mMoveX
            
            // Update the left and right boundaries
            updateCurrentDrawRange()
        }
    }
}
Copy the code

Next, let’s deal with how to make the graph judge to be long touch -isLongTouch:

  • What kind of event stream is long touch?
    • Long touch, when the user’s hand is placed on it, does not lift it up, but only slides slightly
    • We set this threshold to TIMEDURATION as the time threshold for determining fast slippage
    • If we don’t trigger any ACTION events other than a slight swipe during a TIMEDURATION after ACTION_DOWN, that’s a long touch
  • Use code to implement:
    • We start a child thread after every ACTION_DOWN and then set isLongTouch to true after TIMEDURATION if it is not canceled
    • In this way, we turn on the long touch mode, which can add judgment in ACTION_MOVE and display our data annotation switch with isLongTouch.
    • Similarly, we cancel the child thread in the event that ACTION_UP and ACTION_MOVE move significantly.

Here, I’m using the Kotlin coroutine to implement the child thread that determines the long touch

Functions that turn on coroutines:

fun startIndicatorTimer(a) {
    showIndicatorJob = mScope.launch(Dispatchers.Default) {
        // Use hasTimer to check whether a child thread is running
        hasTimer = true
        // The task is delayed
        delay(TOUCHMOVEDURATION + 10.toLong())
        withContext(Dispatchers.Main) {
            // Long touch, then sliding state must be false
            isScrolling = false
            // Long touch: It's my turn
            isLongTouch = true
            // Find the index of the current data bar being touched
            setCurrentIndexOnClicked()
            // Display the data label indicating the shape
            isShowIndicator = true
            // When the child thread finishes running, set the flag to false
            hasTimer = false}}}Copy the code

Function to close coroutines:

fun turnOffIndicatorTimer(a) {
    if (hasTimer) {
        showIndicatorJob.cancel()
        hasTimer = false}}Copy the code

The core code in the touch event

/ / excerpt
when(event.action){
    MotionEvent.ACTION_DOWN->{
        // Record coordinates, record time
        mLastX = mCurrentX
        currentMS = System.currentTimeMillis()
        
        // Start a task for the child thread
        startIndicatorTimer()
    }
    MotionEvent.ACTION_MOVE->{
        mMoveX = mLastX - mCurrentX
        mLastX = mCurrentX
    if(is a fast slide){// Close the long touch judgment thread
        turnOffIndicatorTimer()
    }
    // Long touch state, so we activate isShowIndicator
    else if(isLongTouch){
        isShowIndicator = true
    }
    else if(not a slight slide){// Turn off the long touch judgment event
        turnOffIndicatorTimer()
    }
    }
}
Copy the code

An automatic rollback

  1. We need to judge at the end of each slide, so that the window displays N completed data bars
    • Based on our structure, this is easy to do, just change the coordinates of our viewStartX(the initial drawing point) to an integer (lineWidth+lineSpace)
mViewStartX - (mViewStartX - mInitialStartX).mod(mLineSpace+mLineWidth)
Copy the code
  1. We want the window to automatically roll back to the boundary value after sliding out of bounds
    • And that’s the same implementation, we’re just going to say viewStartX is out of bounds, and we’re just going to let viewStartX go back to the bounds that we set

Instead of assigning viewStartX directly, ObjectAnimator provides a smooth switch. We write this logic in drawBackToBorder() and add it to the ACTION_CANCEL and ACTION_UP callbacks. Because only the two of them could be the end of the stream of touch events.

Don’t add invalidate() to Setter methods of viewStartX or the animation won’t fire. 😈


fun drawBackToBorder(){
    var endValue:Float = 0F

    endValue =
        //out of right borderline
    if(mViewStartX < mInitialStartX){
        mInitialStartX
        //out of left borderline
    } else if(mViewStartX > mInitialStartX + (mValueArray.size-24)*(mLineWidth+mLineSpace)){
        mInitialStartX + (mValueArray.size-24)*(mLineWidth+mLineSpace)
        //does not reach the bound, need reposition to exact place.
    } else {
        mViewStartX - (mViewStartX - mInitialStartX).mod(mLineSpace+mLineWidth)
    }

    val anim = ObjectAnimator.ofFloat(mViewStartX, endValue)
    anim.interpolator = DecelerateInterpolator()
    anim.addUpdateListener {
        mViewStartX = it.animatedValue as Float
    }
    anim.start()
}
Copy the code

Write in the last

The core of writing a blog is to exercise my ability to speak clearly while replicating. Compared with Posting codes, drawing pictures and explaining words is more what I like to do.

Thanks for reading here, if you have any questions, please leave a comment and contact me. 😋

3. Attach – code

The code covers two files:

  1. HeartRateEntry. Kt data classes
  2. Isensechart. kt Custom view file without adding the external parameter StyleValue

YunmaoLeo/AppleHealthChart (github.com)