(譯)android利用Canvas和幾何學繪制幾何動畫

本文翻譯自Geometric Android Animations using the Canvas,不過很多與技術無關的段落沒有翻譯,只保留了技術部分。

1 創建圓形動畫

首先需要畫一些同心圓,并添加動畫將同心圓的半徑逐漸增加,即從同心圓中心向四周擴散的動畫。

需要定義一些屬性包括:同心圓間隔、圓線顏色、圓線寬度:

<attr name="wavesViewStyle" format="reference" />

<declare-styleable name="WavesView">
    <attr name="waveColor" format="color" />
    <attr name="waveStrokeWidth" format="dimension" />
    <attr name="waveGap" format="dimension" />
</declare-styleable>

<style name="Widget.WaveView" parent="@android:style/Widget">
    <item name="waveStrokeWidth">1dp</item>
    <item name="waveColor">@color/black</item>
    <item name="waveGap">16dp</item>
</style>

其次,需要定義一個layout布局和自定義View:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.android.waves.WavesView
        style="@style/Widget.WaveView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</android.support.constraint.ConstraintLayout>
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Paint.ANTI_ALIAS_FLAG
import android.graphics.PointF
import android.util.AttributeSet
import android.view.View

class WavesView
@JvmOverloads
constructor(context: Context,
            attrs: AttributeSet? = null,
            defStyleAttr: Int = R.attr.wavesViewStyle
) : View(context, attrs, defStyleAttr) {

    private val wavePaint: Paint
    private val waveGap: Float

    private var maxRadius = 0f
    private var center = PointF(0f, 0f)
    private var initialRadius = 0f

    init {
        val attrs = context.obtainStyledAttributes(attrs, R.styleable.WavesView, defStyleAttr, 0)

        //init paint with custom attrs
        wavePaint = Paint(ANTI_ALIAS_FLAG).apply {
            color = attrs.getColor(R.styleable.WavesView_waveColor, 0)
            strokeWidth = attrs.getDimension(R.styleable.WavesView_waveStrokeWidth, 0f)
            style = Paint.Style.STROKE
        }

        waveGap = attrs.getDimension(R.styleable.WavesView_waveGap, 50f)
        attrs.recycle()
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        //set the center of all circles to be center of the view
        center.set(w / 2f, h / 2f)
        maxRadius = Math.hypot(center.x.toDouble(), center.y.toDouble()).toFloat()
        initialRadius = w / waveGap
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //draw circles separated by a space the size of waveGap
        var currentRadius = initialRadius
        while (currentRadius < maxRadius) {
            canvas.drawCircle(center.x, center.y, currentRadius, wavePaint)
            currentRadius += waveGap
        }
    }
}

在自定義的View中,我們做了:

  1. 根據自定義屬性初始化畫筆屬性
  2. 定義最小圓和最大圓半徑
  3. 定義開始畫圓的位置
  4. 從最小半徑到最大半徑畫同心圓,圓間隔是waveGap。

現在屏幕上展現的應該是一些靜態的同心圓。
    private var waveAnimator: ValueAnimator? = null
    private var waveRadiusOffset = 0f
        set(value) {
            field = value
            postInvalidateOnAnimation()
        }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        waveAnimator = ValueAnimator.ofFloat(0f, waveGap).apply {
            addUpdateListener {
                waveRadiusOffset = it.animatedValue as Float
            }
            duration = 1500L
            repeatMode = ValueAnimator.RESTART
            repeatCount = ValueAnimator.INFINITE
            interpolator = LinearInterpolator()
            start()
        }
    }

    override fun onDetachedFromWindow() {
        waveAnimator?.cancel()
        super.onDetachedFromWindow()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //draw circles separated by a space the size of waveGap
        var currentRadius = initialRadius + waveRadiusOffset
        while (currentRadius < maxRadius) {
            canvas.drawCircle(center.x, center.y, currentRadius, wavePaint)
            currentRadius += waveGap
        }
    }

上面代碼創建一個運行1.5秒的屬性動畫,在無限循環中重復來完成的。在每個動畫幀上,更新waveRadiusOffset,我們在setter中調用postInvalidateOnAnimation()來重繪我們視圖的下一幀。 ·最后,onDraw使用新的偏移量運行以重繪。

2 不僅僅是圓形

圓形其實也很美,但是有時候動畫需要特定的形狀,這里我來畫出一個十角星的形狀。

我們利用三角函數(還記得sohcahtoa嗎?)來繪制十角星。需要溫習高中知識的話可以看下這個文獻:https://en.wikipedia.org/wiki/Trigonometry#Mnemonics

繪制星星的邊的動圖:

首先要計算出每個點的位置,需要每個點的弧度(或者角度)和到圓形的長度這兩個變量才能計算出其位置。由于圓的整個弧度是2\pi,那么每個點的弧度就是當前點的索引除以2\pi

    private val wavePath = Path()
    
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //draw circles separated by a space the size of waveGap
        val path = createStarPath(width/2f, wavePath)
        canvas.drawPath(path, wavePaint)
    }

    private fun createStarPath(
            radius: Float,
            path: Path = Path(),
            points: Int = 20
    ): Path {
        path.reset()
        val pointDelta = 0.7f // difference between the "far" and "close" points from the center
        val angleInRadians = 2.0 * Math.PI / points // essentially 360/20 or 18 degrees, angle each line should be drawn
        val startAngleInRadians = 0.0 //starting to draw star at 0 degrees

        //move pointer to 0 degrees relative to the center of the screen
        path.moveTo(
                center.x + (radius * pointDelta * Math.cos(startAngleInRadians)).toFloat(),
                center.y + (radius * pointDelta * Math.sin(startAngleInRadians)).toFloat()
        )

        //create a line between all the points in the star
        for (i in 1 until points) {
            val hypotenuse = if (i % 2 == 0) {
                //by reducing the distance from the circle every other points, we create the "dip" in the star
                pointDelta * radius
            } else {
                radius
            }

            val nextPointX = center.x + (hypotenuse * Math.cos(startAngleInRadians - angleInRadians * i)).toFloat()
            val nextPointY = center.y + (hypotenuse * Math.sin(startAngleInRadians - angleInRadians * i)).toFloat()
            path.lineTo(nextPointX, nextPointY)
        }

        path.close()
        return path
    }

onDraw方法也要更改下:


override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    //draw circles separated by a space the size of waveGap
    var currentRadius = initialRadius + waveRadiusOffset
    while (currentRadius < maxRadius) {
        val path = createStarPath(currentRadius, wavePath)
        canvas.drawPath(path, wavePaint)
        currentRadius += waveGap
    }
}

上面的代碼實現的效果如圖:

3 漸變色

加點漸變色總是很酷的,我們的漸變色是通過繪制畫筆加到波紋上的而不是加到背景上的。為了加強落到漸變色上的波紋,我們使用PorterDuff.Mode.SRC_IN模式,關于更多模式可以參考Android矢量圖(二)--VectorDrawable所有屬性全解析,這篇文章對PorterDuff的十八種模式進行了比較詳細的分析解釋。下面的代碼定義一個漸變色的畫筆:

import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode

private val gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    // Highlight only the areas already touched on the canvas
    xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

我們用一個alpha數組作為畫筆的shader的參數來實現漸變功能:

import android.graphics.Color
import android.graphics.RadialGradient
import android.graphics.Shader

// gradient colors
private val green = Color.GREEN
// solid green in the center, transparent green at the edges
private val gradientColors = 
    intArrayOf(green, modifyAlpha(green, 0.10f), 
                      modifyAlpha(green, 0.05f))
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    ...
    
    //Create gradient after getting sizing information
    gradientPaint.shader = RadialGradient(
            center.x, center.y, maxRadius, 
            gradientColors, null, Shader.TileMode.CLAMP
    )
}
override fun onDraw(canvas: Canvas) {
    ...
    canvas.drawPaint(gradientPaint)
}

最后我們需要給自定義的View加上layerType的屬性,默認的layerType是0,需要改成1(software)或者2(hardware),否則漸變色不會有效果:

    <com.example.android.waves.WavesView
        style="@style/Widget.WaveView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layerType="software" />

效果:

4 移動

我們可以通過移動畫筆的shader的局部矩陣來實現漸變色的移動。

private val gradientMatrix = Matrix()

private fun updateGradient(x: Float, y: Float) {
    gradientMatrix.setTranslate(x - center.x, y - center.y)
    gradientPaint.shader.setLocalMatrix(gradientMatrix)
    postInvalidateOnAnimation()
}

現在我們利用加速度和磁感應器來獲取手機的方向,以決定移動畫筆shader矩陣的具體的值。關于感應器的更多的知識:sensors_positionWaveTiltSensor這個類用來獲取手機設備的方向角度并做相應的邏輯處理。

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager

interface TiltListener {

    fun onTilt(pitchRollRad: Pair<Double, Double>)
}

interface TiltSensor {

    fun addListener(tiltListener: TiltListener)

    fun register()

    fun unregister()
}

class WaveTiltSensor(context: Context) : SensorEventListener, TiltSensor {

    private val sensorManager: SensorManager
    private val accSensor: Sensor
    private val magneticSensor: Sensor
    private var listeners = mutableListOf<TiltListener>()

    init {
        sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
        accSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        magneticSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
    }


    override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
        // Do nothing here
    }

    override fun onSensorChanged(event: SensorEvent) {
        // TODO: Get orientation angles and notify listeners
    }

    override fun addListener(tiltListener: TiltListener) {
        listeners.add(tiltListener)
    }

    override fun register() {
        sensorManager.registerListener(this, accSensor, SensorManager.SENSOR_DELAY_UI)
        sensorManager.registerListener(this, magneticSensor, SensorManager.SENSOR_DELAY_UI)
    }

    override fun unregister() {
        listeners.clear()
        sensorManager.unregisterListener(this, accSensor)
        sensorManager.unregisterListener(this, magneticSensor)
    }
}

主要的邏輯是放在onSensorChanged方法里:

class WaveTiltSensor(context: Context) : SensorEventListener, TiltSensor {
 
    private val rotationMatrix = FloatArray(9)
    private val accelerometerValues = FloatArray(3)
    private val magneticValues = FloatArray(3)
    private val orientationAngles = FloatArray(3)

    override fun onSensorChanged(event: SensorEvent) {
        if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
            System.arraycopy(event.values, 0, accelerometerValues, 0, accelerometerValues.size)
        } else if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
            System.arraycopy(event.values, 0, magneticValues, 0, magneticValues.size)
        }

        SensorManager.getRotationMatrix(rotationMatrix, null, accelerometerValues, magneticValues)
        SensorManager.getOrientation(rotationMatrix, orientationAngles)

        val pitchInRad = orientationAngles[1].toDouble()
        val rollInRad = orientationAngles[2].toDouble()
      
        val pair = Pair(pitchInRad, rollInRad)
        listeners.forEach {
            it.onTilt(pair)
        }
    }
}

現在已經有了pitch(x軸的角度)和roll(y軸的角度)的弧度,只是2D的移動的話,不需要關心azimuth (z軸的角度)。
image.png

當設備的pitch變化,漸變色會上下移動,當設備的roll變化,漸變色會左右移動,但是漸變色的移動范圍不能超過屏幕的邊界。
接著實現TiltListener 接口:
   val tiltSensor = WaveTiltSensor(context)

   override fun onTilt(pitchRollRad: Pair<Double, Double>) {
        val pitchRad = pitchRollRad.first
        val rollRad = pitchRollRad.second
        
        // Use half view height/width to calculate offset instead of full view/device measurement
        val maxYOffset = center.y.toDouble()
        val maxXOffset = center.x.toDouble()

        val yOffset = (Math.sin(pitchRad) * maxYOffset)
        val xOffset = (Math.sin(rollRad) * maxXOffset)

        updateGradient(xOffset.toFloat() + center.x, yOffset.toFloat() + center.y)
    }
    
    override fun onAttachedToWindow() {
        tiltSensor.addListener(this)
        tiltSensor.register()
    }
    
    override fun onDetachedFromWindow() {
        tiltSensor.unregister()
    }

5 總結

下面是可以運行的demo源碼的git地址,希望本篇文章能讓你覺得使用三角函數和陀螺儀制作動畫更簡單!

alexio/Android-AnimatedWaveView

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,401評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,011評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,263評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,543評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,323評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,874評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,968評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,095評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,605評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,551評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,720評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,242評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,961評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,358評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,612評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,330評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,690評論 2 370

推薦閱讀更多精彩內容

  • 【Android 動畫】 動畫分類補間動畫(Tween動畫)幀動畫(Frame 動畫)屬性動畫(Property ...
    Rtia閱讀 6,195評論 1 38
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,142評論 4 61
  • 昨天是感恩節,心里特別感恩修理工大叔,而我卻困的睡著了 。 馬桶不上水有一段時間了,接著衛生間水管又漏水。本來忙亂...
    要發狂的母貓閱讀 378評論 0 0
  • 2018年4月9日 星期一 晴 潞潞的寫字書前幾天就察覺到丟了,家里所有的角落都找遍了也沒有,心想估計是他忘到...
    大潞小淇閱讀 161評論 2 1