flutter 自定义绘制
I love our new designs! Recently I’ve been working on user interactions. One of them is presented on the GIF above. I wanted to create a custom Drawable, so I can set a background on any view I want without wrapping it with some custom ViewGroup. An extra layer means extra calculations.
我喜欢我们的新设计! 最近,我一直在进行用户交互。 其中一个在上面的GIF上介绍。 我想创建一个自定义Drawable,因此我可以在所需的任何视图上设置背景,而无需将其与某些自定义ViewGroup进行包装。 额外的层意味着额外的计算。
I couldn’t find any guide nor article that would help me create a Drawable object that I would be able to set as a background via XML. Like a Ripple Drawable. Unfortunately if someone counts that in this article I solved that mistery I’ll heads up a little — I didn’t. I looked though how RippleDrawable is defined and I prepared my Drawable classes based on what I saw.
我找不到任何指南和文章来帮助我创建一个Drawable对象,该对象可以通过XML设置为背景。 就像涟漪可绘制。 不幸的是,如果有人认为我在这篇文章中解决了我的困惑,我会抬起头,但我没有。 我查看了RippleDrawable的定义方式,并根据所见内容准备了Drawable类。
分而治之 (Divide and Counquer)
All advanced animations look complicated until we find a way to disassemble them. What animations are present on this GIF?
所有高级动画看上去都很复杂,直到我们找到一种将其分解的方法为止。 此GIF上有哪些动画?
There are three:
有三种:
- Rounded Rectangle Bounce 圆角矩形弹跳
- Alpha Mask Appearance 阿尔法面膜外观
- Long-Click Filling Color 长按填充色
How a Ripple knows when animate? That was my first question. When I think about a View it comes to my mind onClickListener or onTouchEvent. Drawable doesn’t have such methods. It has a method named onStateChanged like this:
涟漪如何知道何时设置动画? 那是我的第一个问题。 当我想到视图时,我想到的是onClickListener或onTouchEvent。 Drawable没有这种方法。 它具有一个名为onStateChanged的方法,如下所示:
…
@Override
protected boolean onStateChange(int[] stateSet) {
final boolean changed = super.onStateChange(stateSet);
boolean enabled = false;
boolean pressed = false;
boolean focused = false;
boolean hovered = false;
for (int state : stateSet) {
if (state == R.attr.state_enabled) {
enabled = true;
} else if (state == R.attr.state_focused) {
focused = true;
} else if (state == R.attr.state_pressed) {
pressed = true;
} else if (state == R.attr.state_hovered) {
hovered = true;
}
}
setRippleActive(enabled && pressed);
setBackgroundActive(hovered, focused, pressed);
return changed;
}
…
圆角矩形弹跳 (Rounded Rectangle Bounce)
That was a starting point. I reflected this method in my class, removing the irrelevant states:
那是一个起点。 我在课堂上反映了此方法,删除了不相关的状态:
class ClickDrawable(layers: Array<out Drawable> = arrayOf()) : LayerDrawable(layers) {
private var wasPressed = false
private var mActive = false
private var canStart = true
private var mBounds = bounds
override fun isStateful(): Boolean {
return true
}
override fun onStateChange(state: IntArray?): Boolean {
val stateSet = state?.toSet() ?: emptySet()
var enabled = false
var pressed = false
for (s in stateSet) {
when (s) {
android.R.attr.state_enabled -> enabled = true
android.R.attr.state_pressed -> pressed = true
}
}
setClickActive(enabled && wasPressed && !pressed)
wasPressed = pressed
return super.onStateChange(state)
}
private fun setClickActive(active: Boolean) {
if (mActive != active) {
mActive = active
if (active && canStart) {
canStart = false
mBounds = copyBounds()
startAnimation()
}
}
}
private fun setBounds(value: Float) {
if (value == 1f) {
canStart = true
}
val x = mBounds.width()
val y = mBounds.height()
val factorX = (x * value).roundToInt()
val factorY = (y * value).roundToInt()
bounds = mBounds.run {
Rect(
right - factorX,
bottom - factorY,
factorX,
factorY
)
}
invalidateSelf()
}
private fun startAnimation() {
val anim = ObjectAnimator.ofFloat(this, "bounds", 0.99f, 0.95f, 1f)
anim.duration = 225
anim.interpolator = LinearInterpolator()
anim.start()
}
}
So first step is to override a method isStateful. Without that, the code from onStateChanged will never be triggered.Next, I wanted to start the animation on finger released. Otherwise long click would start with a bouncing Drawable. The condition to start was: is enabled and was pressed but is not pressed anymore.
因此,第一步是重写isStateful方法。 否则,onStateChanged的代码将永远不会被触发。接下来,我想松开手指开始动画。 否则,长按会从反弹的Drawable开始。 开始的条件是:已启用并被按下,但不再被按下。
setClickActive(enabled && wasPressed && !pressed)
To start an animation I used an ObjectAnimator and took advantage of float type by starting a value from 0.99 and finishing on 1. This cheats an eye enough to see a fluent movement and I get an end-value different from start-value which helps me differentiate if the anim is in run without extra flags.
要开始动画,我使用了ObjectAnimator并利用了float类型,即从0.99开始并在1上结束。这足以使眼睛看到流畅的运动,并且得到的最终值不同于开始值,这对我有帮助区分动画是否在运行中没有额外的标志。
阿尔法面膜外观 (Alpha Mask Appearance)
So the rectangle shrinks, but the animation also has a transparent mask showing on top of. The mask must be fully transparent at first and fade-in and out within the same time as previous animation.
因此,矩形会缩小,但动画的顶部也会显示一个透明蒙版。 蒙版首先必须完全透明,并且在与上一个动画相同的时间内淡入和淡出。
class ClickDrawable(layers: Array<out Drawable> = arrayOf()) : LayerDrawable(layers) {
…
private val mask: Drawable =
ColorDrawable(0xFF00FFFF.toInt()).apply { alpha = 0 }
init {
addLayer(mask)
}
…
@Keep
private fun setMaskAlpha(alpha: Int) {
mask.alpha = alpha
invalidateSelf()
}
private fun colorMask(fromAlpha: Int, toAlpha: Int) {
val anim = ObjectAnimator.ofInt(this, "maskAlpha", fromAlpha, toAlpha, fromAlpha)
anim.duration = DURATION
anim.interpolator = LinearInterpolator()
anim.start()
}
private fun setClickActive(active: Boolean) {
if (mActive != active) {
mActive = active
if (active && canStart) {
…
colorMask(0, 64)
}
}
}
@Keep
private fun setBounds(value: Float) { … }
…
private companion object {
const val DURATION = 225L
}
}
This code is not a final touch but the POC that I went out of. Anyone may feel free to do the same thing.Also, keep in mind that an ObjectAnimator uses reflection to trigger set methods, so always add a @Keep annotation, to prevent ProGuard from minifying them.
这段代码不是最终的决定,而是我淘汰的POC。 任何人都可以随意做同样的事情。此外,请记住,ObjectAnimator使用反射来触发设置方法,因此请始终添加@Keep注释,以防止ProGuard缩小它们。
使用自定义可绘制 (Using Custom Drawable)
As I mentioned in the beginning of the article I wasn’t able to add a background drawable in a layout resource file, so I had to do it programmatically like this:
正如我在文章开头提到的那样,我无法在布局资源文件中添加背景可绘制对象,因此我必须以编程方式进行如下操作:
Cascade usage of drawables was possible only because of deriving from LayerDrawable class which is a well known <layer-list> node in drawable XMLs.
级联使用可绘制对象仅是因为它是从LayerDrawable类派生的,该类是可绘制XML中众所周知的<layer-list>节点。
长按填充色 (Long-Click Filling Color)
I have a nice bouncing animation now that I can add to any of my Views. There’s only one more animation left to be done. Filling a color on long click.
现在,我有一个不错的弹跳动画,可以将其添加到任何“视图”中。 只剩下一个动画需要完成。 长按即可填充颜色。
I know how to react to states of the Drawable. The only difference is the start of the animation. I wouldn’t like the color to start filling right after I press a View, because I would loose that experience that, these are in fact, two different actions. There’s an easy way though. Object animator has a property startDelay that I experimented with to get that natural distinction between a simple click and a long click. To me 100ms is enough:
我知道如何对Drawable的状态做出React。 唯一的区别是动画的开始。 我不希望在按下View后立即开始填充颜色,因为我会失去那种实际上是两个不同动作的体验。 有一个简单的方法。 对象动画师具有一个属性startDelay,我尝试过该属性以实现简单单击和长按之间的自然区别。 对我来说100ms就足够了:
class FillDrawable(private val color: Int, layers: Array<out Drawable>) : LayerDrawable(layers) {
private val radius: Float = 20f
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = this@FillDrawable.color
}
private var r = copyBounds()
private var activeState = false
override fun isStateful(): Boolean = true
override fun onBoundsChange(bounds: Rect?) {
super.onBoundsChange(bounds)
val b = requireNotNull(bounds)
r = Rect(b.left, b.top, b.left + (b.right * 0.1).toInt(), b.bottom)
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
canvas.drawRoundRect(r.toRectF(), radius, radius, paint)
}
override fun onStateChange(state: IntArray?): Boolean {
val stateSet = state?.toSet() ?: emptySet()
var enabled = false
var pressed = false
for (s in stateSet) {
when (s) {
android.R.attr.state_enabled -> enabled = true
android.R.attr.state_pressed -> pressed = true
}
}
setActive(enabled && pressed)
return super.onStateChange(state)
}
private fun setActive(active: Boolean) {
if (activeState != active) {
activeState = active
if (active) {
extendsWidth()
} else {
anim.cancel()
}
}
}
private fun setWidth(factor: Float) {
r = Rect(
bounds.left,
bounds.top,
(bounds.right * factor).toInt(),
bounds.bottom
)
invalidateSelf()
}
private val anim = ObjectAnimator.ofFloat(this, "width", 0.1f, 1f)
private fun extendsWidth() {
anim.startDelay = 100
anim.duration = 300
anim.interpolator = LinearInterpolator()
anim.start()
}
}
结论 (Conclusion)
It’s possible and not that hard to create a custom Drawable. But is it worth it? Is it better than creating a custom ViewGroup? Every solution in programming has its pros and cons. It’s always our own decision which way we choose.
创建自定义Drawable可能并没有那么困难。 但是这值得吗? 比创建自定义ViewGroup好吗? 编程中的每个解决方案都有其优缺点。 选择哪种方式始终是我们自己的决定。
A custom Drawable:
自定义Drawable:
- Saves calculations of positioning 节省定位计算
- Doesn’t require to change all XMLs by adding a surrounding ViewGroup 不需要通过添加周围的ViewGroup来更改所有XML
- Supports only limited number of states, that are already recognized by a View and passed to a Drawable 仅支持有限数量的状态,这些状态已被视图识别并传递给Drawable
A custom ViewGroup:
自定义ViewGroup:
- Custom ViewGroup on the other hand can be used in an XML and a background Drawable can be added there rather than from the code 另一方面,可以在XML中使用自定义ViewGroup,并且可以在其中添加背景Drawable,而不是从代码中添加
- With custom ViewGroup you have more control by overriding onTouchEvent whereas custom Drawable only reacts to states 使用自定义ViewGroup,您可以通过覆盖onTouchEvent来获得更多控制权,而自定义Drawable仅对状态做出React
There must be a reason why Google doesn’t let us create a custom Drawable easily. There are no tutorials, no option to create a custom node like <ripple> has. Maybe it’s because it’s not the best choice? Or maybe soon Google give us a tool to do just that?
Google不允许我们轻易创建自定义Drawable一定是有原因的。 没有教程,也没有创建像<ripple>这样的自定义节点的选项。 也许是因为这不是最佳选择? 也许不久之后Google会给我们提供一种工具来做到这一点?
flutter 自定义绘制