Android puls view: Canvas, Path and vectors

Oryna Starkina
3 min readAug 14, 2019

When starting a new Android project you could face challenging UI 😰 and struggle with it for a long period of time. But after a few minutes of staying and thinking you accept the challenge and start to develop the best solution ever!

This post about such kind of situation and a little bit about coding puls view 🙃 This one:

So in some project was needed to draw lines that will link elements and visualize a data direction. And the challenge began! How to deal with it? What to use?

Basically, we need a custom View. Called it a PulsView, as a parent class chooses View because we do not need special functions only ability to draw.

class PulsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : View(context, attrs, defStyle)

Animated line drawing consists of two parts

draw line that links views (centers of it), background line:

canvas?.drawPath(path, linePaint)

draw puls with somehow dynamically changed interval, which causes an animation effect:

pulsPaint?.let {
canvas?.drawPath(path, it)
}

Let's go step by step.

Points start/end calculation:

private fun calcPointsFromTo(): Pair<Point, Point> {    val fromView = (context as Activity).findViewById<View>(viewFromId)
val toView = (context as Activity).findViewById<View>(viewToId)

// find from and to views bounds
val fromViewBounds = Rect()
val toViewBounds = Rect()

fromView.getDrawingRect(fromViewBounds)
toView.getDrawingRect(toViewBounds)
// important that both Views (line and destination points) must
// belong to the same parent
with(
this.parent as ViewGroup) {
offsetDescendantRectToMyCoords(
fromView,
fromViewBounds
)

offsetDescendantRectToMyCoords(
toView,
toViewBounds
)
}


return Pair(calcPointFrom(fromViewBounds), calcPointTo(toViewBounds))
}

Path forming:

private fun calcPath() {    if (path.isEmpty) {        val (pointFrom, pointTo) = calcPointsFromTo()        if (bypass != 0f && pointFrom.y == pointTo.y) {
path.moveTo(pointFrom.x.toFloat(), pointFrom.y.toFloat())
path.lineTo(pointFrom.x.toFloat(), pointFrom.y.toFloat() + bypass)
path.lineTo(pointTo.x.toFloat(), pointTo.y.toFloat() + bypass)
path.lineTo(pointTo.x.toFloat(), pointTo.y.toFloat())
} else {
if (isMirrored) {
path.moveTo(pointFrom.x.toFloat(), pointFrom.y.toFloat())
path.lineTo(pointTo.x.toFloat(), pointFrom.y.toFloat())
path.lineTo(pointTo.x.toFloat(), pointTo.y.toFloat())
}
else {
path.moveTo(pointFrom.x.toFloat(), pointFrom.y.toFloat())
path.lineTo(pointFrom.x.toFloat(), pointTo.y.toFloat())
path.lineTo(pointTo.x.toFloat(), pointTo.y.toFloat())
}
}
}
}

With background line everything is pretty easy: Path from one point to another, but how to do the puls? Path again!

Basic idea: draw one Path over another. Background Path (same one actually)— single line, foreground — dashed line with a single dash, and dynamic phase

In the future, we want to have a flexible way to change puls form. Vector seems to fit perfectly, but now I do not implement extracting string vector path from drawable resources and use hardcoded one.

private val dashPath: Path =
PathParser.createPathFromPathData(
// puls
"M8.8978,5C9.3863,3 8.079,2 7,2C5.921,2 4.6527,2.9883 5.0926,5C5.9672,9 6,13 6,13H8C8,13 7.9209,9 8.8978,5Z"

)
.apply {
close()
transform(Matrix().apply {
setScale(lineWidth / 2f, lineWidth / 2f)
})
// rotate vector because it has wrong direction
val bounds = RectF() // todo: edit drawable to remove this
computeBounds(bounds, true)
transform(Matrix().apply {
postRotate(270f, bounds.centerX(), bounds.centerY())
}) // end remove
// here is manually calculated magic number,
// it needed to handle case when puls cropped by background path
offset(0f, lineWidth * -3.75f)
}

And the puls paint

pulsPaint = Paint().apply {
style = Paint.Style.STROKE
isAntiAlias = false
color = lineColor
strokeWidth = lineWidth
}

For changing phase Animator fits perfectly

ValueAnimator().apply {

setValues(
PropertyValuesHolder.ofFloat(
PROPERTY_X, 0f, PathMeasure()
.apply { setPath(path, false) }.length
)
)
this.duration = duration.toLong()
interpolator
= AccelerateDecelerateInterpolator()

addUpdateListener { valueAnimator ->

// apply new path effect to puls path - animation makes here
pulsPaint?.apply {
this.pathEffect = ComposePathEffect(
PathDashPathEffect(
dashPath, PathMeasure().apply { setPath(path, false) }.length,
valueAnimator.animatedValue as Float, // phase of dash
PathDashPathEffect.Style.MORPH
), CornerPathEffect(corners)
)
}

invalidate()
}

this.repeatCount = repeatCount
}

Bad thing is that we need to recreate PathEffects objects each time animation updates. Maybe in the future, I will handle it :)

Put all together:

<com.orynastark.pulsview.PulsView

android:id="@+id/pulsView"

android:layout_width="match_parent"
android:layout_height="match_parent"

app:viewFrom="@id/thirdItem"
app:viewTo="@id/firstItem"

app:layout_constraintTop_toBottomOf="@id/thirdItem"
app:layout_constraintEnd_toStartOf="@id/firstItem"
/>
pulsView.animateArrows(1000, Animation.INFINITE)

It's alive! Alive! Thanks for reading!

View full code on GitHub 😀

--

--