Android puls view: Canvas, Path and vectors
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 😀