[譯] 如何創建 BubblePicker – Android 多彩菜單動畫

RuebenKnopf 7年前發布 | 15K 次閱讀 OpenGL Android開發 移動開發

我們已經習慣了移動應用豐富的交互方式,如滑動手勢去選擇、拖拽。但是我們沒有察覺到,統一用戶的跨平臺體驗是一個正在發生的趨勢。

早期時候,iOS 和 Android 都有其獨特的體驗,但是在近期,這兩個平臺上的應用體驗和交互在逐漸的靠攏。底部導航和分屏的特性已經成為Android Nougat版本的特性,Android 和 iOS 已經有了很多相同的地方了。

對于設計者而言,設計語言的融合意味著在一個平臺上流行的特性可以適配到另一個平臺。

最近,為了跟上跨平臺風格的步伐,我們受 Apple music 上氣泡動畫的啟發,用 Android 動畫實現了一份。我們設計了一個接口,使得初學者也可以方便的使用,而且也讓有經驗的開發者覺得有趣。

使用 BubblePicker 能讓一個應用更加的聚焦內容、原汁原味和有趣。盡管 Google 已經對它所有的產品推出了材料設計語言,但是我們依然決定在此時嘗試大膽的顏色和漸變的效果,使得圖像增加更多的深度和體積。漸變可能是界面顯示最主要的視覺效果,也可能會吸引到更多的人使用。

我們的組件是白色背景,上面包含了很多明亮的顏色和圖形。

這種高反差對豐富應用的內容很有幫助,在這里用戶不得不從一系列選項列表中做出選擇。比如,在我們的概念中,我們在旅行應用中使用氣泡來持有潛在的目的地名稱。氣泡在自由的漂浮,當用戶點擊其中一個時,那個氣泡就會變大。

此外,開發者可以通過自定義屏幕中的元素使得動畫適配任何應用。

當我們在制作這個動畫的同時,我們要面對下面五個挑戰:

1. 選擇最佳開發工具

很明顯,在 Canvas 上渲染這樣一個快速的動畫效果不夠高效,所以我們決定使用OpenGL (Open Graphics Library)。 OpenGL 是一個提供 2D 或 3D 圖形渲染的、跨平臺的應用程序接口。幸運的是,Android 支持一些 OpenGL 的版本。

我們需要讓圓更加的自然,就像是汽水中的氣泡。有很多物理引擎可用于 Android,但我們的特殊需求使得做出選擇格外困難:這個引擎必須輕量而且方便嵌入 Android 庫中。大多數引擎都是為游戲開發的,你必須使項目結構適應它們。經過一些研究,我們發現了 JBox2D (一個使用 C++ 開發的、 Java 端口的 Box2D 引擎);因為我們的動畫并不支持很多數量的 body(換句話說,它不是為了200個或更多的對象設計的),我們可以使用 Java 端口而不是原生引擎。

另外,在本文的后面我們會解釋為何選擇了 Kotlin 語言編寫,并且談到這種新語言的優點。想要了解 Java 與 Kotlin 更多的區別,請訪問之前的文章。

2. 創建著色器

在開始的時候,我們需要先理解 OpenGL 中的構建塊是三角形,因為三角形是能夠模擬成其他形狀中最簡單的形狀。你在 OpenGL 中創建出的任何形狀,都包含了一個或多個三角形。為了實現動畫,我們為每個 body 使用了兩個組合三角形,所以看起來像個正方形,我們可以在里面畫圓。

渲染一個形狀至少需要寫兩個著色器 - 一個頂點著色器和一個片段著色器。它們的名稱已經體現了各自的不同。對每個三角形的每個頂點執行一個頂點著色器,而對三角形中的每個像素大小的部分則執行片段著色器。

頂點著色器通常被用于控制形狀(如縮放、位置、旋轉),而片段著色器負責控制其顏色。

// language=GLSL
    val vertexShader = """
        uniform mat4 u_Matrix;

        attribute vec4 a_Position;
        attribute vec2 a_UV;

        varying vec2 v_UV;

        void main()
        {
            gl_Position = u_Matrix * a_Position;
            v_UV = a_UV;
        }
    """http:// language=GLSL
    val fragmentShader = """
        precision mediump float;

        uniform vec4 u_Background;
        uniform sampler2D u_Texture;

        varying vec2 v_UV;

        void main()
        {
            float distance = distance(vec2(0.5, 0.5), v_UV);
            gl_FragColor = mix(texture2D(u_Texture, v_UV), u_Background, smoothstep(0.49, 0.5, distance));
        }
    """

著色器是使用 GLSL (OpenGL Shading Language) 編寫的,必須在運行時編譯。如果你用的是 Java 代碼,最方便的方法是將你的著色器寫到一個單獨的文件中,然后使用輸入流取回。如你所見,Kotlin 開發人員通過將任何多行代碼放到三重引號(""")中,更方便的在類中創建著色器。

GLSL 有幾種不同類型的變量:

  • 統一變量對所有頂點和片段持有相同的值

  • 屬性變量對每個頂點都不同

  • 變化中變量將數據從頂點著色器傳遞到片段著色器,對于每個片段都是用線性內插法賦值

u_Move 變量包含了 x 和 y 兩個值,用于表示頂點當前位置的移動增量。很明顯,他們的值應該與一個形狀中的所有頂點的該變量的值相同,類型也應該是相同的,雖然這些頂點各自的位置不同。a_Position 變量是屬性變量,a_UV 變量用于以下兩個目的:

  1. 得到當前片段與正方形中心的距離;根據這個距離,我們能夠改變片段的顏色來畫圓。

  2. 將紋理(照片和國家名稱)放在圖形的中心。

a_UV 變量包含了 x 和 y 兩個變量,這兩個值對每個頂點都不同但都在 0 和 1 之間。在頂點著色器中,我們將值從 a_UV 變量傳遞給 v_UV 變量,這樣每個片段都會被插入 v_UV 變量。結果,形狀中心片段的 v_UV 變量的值就是 [0.5, 0.5]。我們使用 distance() 方法來計算一個選中的片段到中心的距離。這個方法使用兩點作為參數。

3. 使用 smoothstep 方法畫抗鋸齒圓

起初,我的片段著色器看起來有些不一樣:

gl_FragColor = distance < 0.5 ? texture2D(u_Text, v_UV) : u_BgColor;

我根據到中心的距離改變了片段顏色,沒有使用抗鋸齒。結果并不理想,圓的邊緣被切開了。

smoothstep 方法可以解決這個問題。在紋理和背景間平滑插入由起點和終點決定的值,取值范圍在 0 到 1 之間。。紋理的透明度在 0 到 0.49 之間值設為1,0.5 以上的為0,并且0.49 到 0.5 之間會被插入,所以圓的邊緣會被抗鋸齒。

4. 使用紋理在 OpenGL 中顯示圖片和文本

動畫中的每個圓都有兩個狀態 - 正常狀態和選中狀態。在正常狀態中,圓中的紋理包含了文字和顏色;在選中的狀態,紋理則還會包含了一個圖片。所以,對每個圓我們都應該創建兩個不同的紋理。

為了創建紋理,我們使用一個 Bitmap 的實例,在實例里我們畫出所有的元素并綁定紋理:

fun bindTextures(textureIds: IntArray, index: Int){
            texture = bindTexture(textureIds, index * 2, false)
            imageTexture = bindTexture(textureIds, index * 2 + 1, true)
        }

        private fun bindTexture(textureIds: IntArray, index: Int, withImage: Boolean): Int {
            glGenTextures(1, textureIds, index)
            createBitmap(withImage).toTexture(textureIds[index])
            return textureIds[index]
        }

        private fun createBitmap(withImage: Boolean): Bitmap {
            var bitmap = Bitmap.createBitmap(bitmapSize.toInt(), bitmapSize.toInt(), Bitmap.Config.ARGB_4444)
            val bitmapConfig: Bitmap.Config = bitmap.config ?: Bitmap.Config.ARGB_8888
            bitmap = bitmap.copy(bitmapConfig, true)

            val canvas = Canvas(bitmap)

            if (withImage) drawImage(canvas)
            drawBackground(canvas, withImage)
            drawText(canvas)

            return bitmap
        }

        private fun drawBackground(canvas: Canvas, withImage: Boolean){
            ...
        }

        private fun drawText(canvas: Canvas){
            ...
        }

        private fun drawImage(canvas: Canvas){
            ...
        }

做完這些之后,我們將這個紋理傳遞給 u_Text 變量。我們通過 texture2D() 方法來獲取一個片段的真實顏色,我們還能獲得紋理單元和片段相對于其頂點的位置。

5. 使用 JBox2D 讓氣泡移動

從物理的角度,這個動畫非常簡單。主對象是一個 World 實例,所有的 body 都需要在這個 World 里創建:

classCircleBody(world: World, varposition: Vec2, varradius: Float, varincreasedRadius: Float) {

        val decreasedRadius: Float = radius
        val increasedDensity = 0.035f
        val decreasedDensity = 0.045f
        var isIncreasing = false
        var isDecreasing = false
        var physicalBody: Body
        var increased = falseprivate val shape: CircleShape
            get()= CircleShape().apply {
                m_radius = radius + 0.01f
                m_p.set(Vec2(0f, 0f))
            }

        private val fixture: FixtureDef
            get()= FixtureDef().apply {
                this.shape = this@CircleBody.shape
                density = if (radius > decreasedRadius) decreasedDensity else increasedDensity
            }

        private val bodyDef: BodyDef
            get()= BodyDef().apply {
                type = BodyType.DYNAMIC
                this.position = this@CircleBody.position
            }

        init {
            physicalBody = world.createBody(bodyDef)
            physicalBody.createFixture(fixture)
        }

    }

正如我們所見,body 容易創建:我們需要簡單的制定 body 類型(如:dynamic, static, kinematic),position,radius,shape,density 和 fixture 屬性。

當這個面被畫出來,我們需要調用 World 的 step() 方法來移動所有的 body。然后,我們就可以在新的位置畫出所有的形狀了。

我們遇到一個問題,JBox2D 不能支持軌道重力。這樣,我們就不能將圓移動到屏幕中間了。所以我們只能自己實現這個特性:

private val currentGravity: Float
            get()= if (touch) increasedGravity else gravity

    private fun move(body: CircleBody){
            body.physicalBody.apply {
                val direction = gravityCenter.sub(position)
                val distance = direction.length()
                val gravity = if (body.increased) 1.3f * currentGravity else currentGravity
                if(distance > step * 200){
                    applyForce(direction.mul(gravity / distance.sqr()), position)
                }
            }
    }

每當 World 移動時,我們計算一個合適的力度作用于每個 body,使得看起來像是受到了重力的影響。

6. 在 GlSurfaceView 中檢測用戶觸摸事件

GLSurfaceView 和其他的 Android view 一樣可以對用戶觸碰反應:

override fun onTouchEvent(event: MotionEvent): Boolean {
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    startX = event.x
                    startY = event.y
                    previousX = event.x
                    previousY = event.y
                }
                MotionEvent.ACTION_UP -> {
                    if (isClick(event)) renderer.resize(event.x, event.y)
                    renderer.release()
                }
                MotionEvent.ACTION_MOVE -> {
                    if (isSwipe(event)) {
                        renderer.swipe(event.x, event.y)
                        previousX = event.x
                        previousY = event.y
                    } else {
                        release()
                    }
                }
                else -> release()
            }

            returntrue
    }

    private fun release()= postDelayed({ renderer.release() }, 1000)

    private fun isClick(event: MotionEvent)= Math.abs(event.x - startX) < 20 && Math.abs(event.y - startY) < 20private fun isSwipe(event: MotionEvent)= Math.abs(event.x - previousX) > 20 && Math.abs(event.y - previousY) > 20

GLSurfaceView 攔截所有的觸摸事件,渲染器處理它們:

//Rendererfun swipe(x: Float, y: Float)= Engine.swipe(x.convert(glView.width, scaleX),
                y.convert(glView.height, scaleY))

    fun release()= Engine.release()

    fun Float.convert(size: Int, scale: Float) = (2f * (this / size.toFloat()) - 1f) / scale

    //Enginefun swipe(x: Float, y: Float){
            gravityCenter.set(x * 2, -y * 2)
            touch = true
    }

    fun release(){
            gravityCenter.setZero()
            touch = false
    }

當用戶滑動屏幕,我們增加重力并改變中心,在用戶看來就像是控制了氣泡的移動。當用戶停止了滑動,我們將氣泡恢復到初始狀態。

7. 通過用戶觸碰的坐標找到氣泡

當用戶點擊了一個圓,我們通過 onTouchEvent() 方法接收到了觸碰點在屏幕上的坐標。但是,我們還需要找到被點擊的圓在 OpenGL 坐標體系中的位置。默認情況下,GLSerfaceView 中心的坐標是 [0, 0],x 和 y 變量在 -1 到 1 之間。所以,我們還需要考慮到屏幕的比例:

private fun getItem(position: Vec2)= position.let {
            val x = it.x.convert(glView.width, scaleX)
            val y = it.y.convert(glView.height, scaleY)
            circles.find { Math.sqrt(((x - it.x).sqr() + (y - it.y).sqr()).toDouble()) <= it.radius }
    }

當我們找到了選中的圓就改變它的半徑、密度和紋理。

這是我們第一版 Bubble Picker,而且還將進一步完善。其他開發者可以自定義泡泡的物理行為,并指定 url 將圖片添加到動畫中。而且我們還將添加一些新的特性,比如移除泡泡。

請將你們的實驗發給我們,讓我們看到你是如何使用 Bubble Picker 的。如果對動畫有任何問題或建議,請告訴我們。

 

來自:https://juejin.im/post/591e734d2f301e006bea5243

 

 本文由用戶 RuebenKnopf 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!