Android 如何實現氣泡選擇動畫

  • 2019 年 10 月 4 日
  • 筆記

跨平台用戶體驗統一正處於增長趨勢:早些時候 iOS 和Android有著不同的體驗,但是最近在應用設計以及交互方面變得越來越接近。從Android Nougat 的底部導航到分屏特性,兩個平台間有了許多相同之處。對設計師而言,我們可以將主流功能設計成兩個平台一致(過去需要單獨設計)。對開發者而言,這是一個提高、改進開發技巧的好機會。所以我們決定開發一個Android氣泡選擇的組件庫 —— 靈感來自於蘋果音樂的氣泡選擇。

先說設計

我們的氣泡選擇動畫是一個好的範例,它對不同的用戶群體有著同樣的吸引力。氣泡以方便的 UI 元素匯總資訊,通俗易懂並且視覺一致。它讓介面對新手足夠簡單的同時還能吸引老司機的興趣。

這種動畫類型對豐富應用的內容由很大幫助,主要使用場景是:用戶要從一系列選項中進行選擇時的頁面。例如,我們使用氣泡來選擇旅遊應用中潛在目的地名字。氣泡自由的浮動,當用戶點擊一個氣泡時,選中的氣泡會變大。這給用戶很深刻的回饋並增強操作的直觀感受。

組件使用白色主題,明亮的顏色和圖片貫穿始終。此外,我決定試驗漸變來增加深度和體積。漸變可能是主要的顯示特徵,會吸引新用戶的注意。

氣泡選擇的漸變

我們允許開發者自定義所有的 UI 元素,所以我們的組件適合任意的應用。

再來看看開發者的挑戰

當我決定實現這個動畫時,我面臨的第一個問題就是使用什麼工具開發。我清楚知道繪製如此快速的動畫在 Canvas 上繪製的效率是不夠的,所以決定使用 OpenGL (Open Graphics Library)。OpenGL 是一個跨平台的 2D 和 3D 圖形繪製應用開發介面。幸運地是,Android 支援部分版本的 OpenGL。

我需要圓自然地運動,就像碳酸飲料中的氣泡那樣。對 Android 來說有許多可用的物理引擎,同時我又有一些特定需要,使得選擇變得更加困難。我的需求是:引擎要輕量級並且方便嵌入 Android 庫。多數的引擎是為遊戲開發的,並且它們需要調整工程結構來適應它們。功夫不負有心人,我最終找到了 JBox2D(C++ 引擎 Box2D 的 Java 版),因為我們的動畫不需要支援大量的物理實體(例如 200+),使用非原版的 Java 版引擎已經足夠了。

此外,本文後面我會解釋我為什麼選擇 Kotlin 語言開發,以及這樣做的好處。需要了解 Java 和 Kotlin 更多不同之處可以閱讀我之前的文章。

如何創建著色器?

首先,我們需要理解 OpenGL 中的基礎構件三角形,因為它是和其它形狀類似且最簡單的形狀。所以你繪製的任意圖形都是由一個或多個三角形組成。在動畫實現中,我使用兩個關聯的三角形代表一個實體,所以我畫圓的地方像一個正方形。

繪製一個形狀至少需要兩個著色器 —— 頂點著色器和片段著色器。通過名字就可以區分他們的用途。頂點著色器負責繪製每個三角形的頂點,片段著色器負責繪製三角形中每個像素。

三角形的片段和頂點

頂點著色器負責控制圖形的變化(例如:大小、位置、旋轉),片段著色器負責形狀的顏色。

// 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;      }  """
頂點著色器
// 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 著色語言) 編寫,需要運行時編譯。如果項目使用的是 Java,那麼最方便的方式是在另一個文件編寫你的著色器,然後使用輸入流讀取。如上述示例程式碼所示,Kotlin 可以簡單地在類中創建著色器。你可以在 """ 中間添加任意的 GLSL 程式碼。

GLSL 中有許多類型的變數:

  • 頂點和片段的 uniform 變數的值是相同的
  • 每個頂點的 attribute 變數是不同的
  • varying 變數負責從頂點著色器向片段著色器傳遞數據,它的值由片段線性地插入。

u_Matrix 變數包含由圓初始化位置的x 和 y 構成的變化矩陣,顯然它的值對圖形的所有頂點拉說都是相同的,類型為 uniform,然而頂點的位置是不同的,所以 a_Position 變數是 attribute 類型。a_UV 變數有兩個用途:

  • 確定當前片段和正方形中心位置的距離。根據這個距離,我可以調整片段的顏色而實現畫圓。
  • 正確地將 texture(照片和國家的名字)置於圖形的中心位置。
image
圓的中心

a_UV 包含 x 和 y,它們的值每個頂點都不同,取值範圍是 0 ~ 1。我只給頂點著色器 a_UV 和 v_UV 兩個入參,因此每個片段都可以插入 v_UV。並且對於片段中心點的 v_UV 值為 [0.5, 0.5]。我使用 distance() 方法計算兩個點的距離。

使用 smoothstep 繪製平滑的圓

起初片段著色器看上去不太一樣:

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

我根據點到中心的距離調整片段的顏色,沒有採取抗鋸齒手段。當然結果差強人意 —— 圓的邊是凹凸不平的。

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">有鋸齒的圓</figcaption>

解決方案是 smoothstep。它根據到 texture 與背景的變換起始點的距離平滑的從0到1變化。因此距離 0 到 0.49 時 texture 的透明度為 1,大於等於 0.5 時為 0,0.49 和 0.5 之間時平滑變化,如此圓的邊就平滑了。

image

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">無鋸齒圓</figcaption>

OpenGL 中如何使用 texture 顯示影像和文本?在動畫中圓有兩種狀態 —— 普通和選中。在普通狀態下圓的 texture包含文字和顏色,在選中狀態下同時包含影像。因此我需要為每個圓創建兩個不同的 texture。

我使用 Bitmap 實例來創建 texture,繪製所有元素。

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) {          ...      }

之後我將 texture 單元賦值給 u_Text 變數。我使用 texture2() 方法獲取片段的真實顏色,texture2() 接收 texture 單元和片段頂點的位置兩個參數。

使用 JBox2D 讓氣泡動起來

關於動畫的物理特性十分的簡單。主要的對象是 World 實例,所有的實體創建都需要它。

class CircleBody(world: World, var position: Vec2, var radius: Float, var increasedRadius: Float) {        val decreasedRadius: Float = radius      val increasedDensity = 0.035f      val decreasedDensity = 0.045f      var isIncreasing = false      var isDecreasing = false      var physicalBody: Body      var increased = false        private 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 = [email protected]              density = if (radius > decreasedRadius) decreasedDensity else increasedDensity          }        private val bodyDef: BodyDef          get() = BodyDef().apply {              type = BodyType.DYNAMIC              this.position = [email protected]          }        init {          physicalBody = world.createBody(bodyDef)          physicalBody.createFixture(fixture)      }    }

如你所見創建實體很簡單:需要指定實體的類型(例如:動態、靜態、運動學)、位置、半徑、形狀、密度以及運動。

每次畫面繪製,都需要調用 World 的 step() 方法移動所有的實體。之後你可以在圖形的新位置進行繪製。

我遇到的問題是 World 的重力只能是一個方向,而不能是一個點。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)              }          }  }

引力挑戰

每次發生移動時,我計算出力的大小並作用於每個實體,看上去就像圓受引力作用在移動。

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()          }            return true  }    private fun release() = postDelayed({ renderer.release() }, 1000)    private fun isClick(event: MotionEvent) = Math.abs(event.x - startX) < 20 && Math.abs(event.y - startY) < 20    private fun isSwipe(event: MotionEvent) = Math.abs(event.x - previousX) > 20 && Math.abs(event.y - previousY) > 20

GLSurfaceView 攔截所有的點擊,並用渲染器進行處理。

渲染器:

fun 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

引擎:

fun swipe(x: Float, y: Float) {          gravityCenter.set(x * 2, -y * 2)          touch = true  }    fun release() {          gravityCenter.setZero()          touch = false  }

用戶點擊螢幕時,我將重力中心設為用戶點擊點,這樣看起來就像用戶在控制氣泡的移動。用戶停止移動後我會將氣泡恢復到初始位置。

根據用戶點擊坐標查找氣泡

當用戶點擊圓時,我從 onTouchEvent() 方法獲取螢幕點擊點。但是我也需要找到 OpenGL 坐標系中點擊的圓。GLSurfaceView的默認中心位置坐標為[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 }  }

當找到選擇的圓後,我會修改它的半徑和 texture。

你可以隨機的使用本組件!

我們的組件可以讓應用更聚焦內容、原始以及充滿樂趣。以下途徑可以獲取 Bubble Picker :GitHub:https://github.com/igalata/Bubble-Picker

這只是組件的第一個版本,但我們肯定會有後續的迭代。我們將支援自定義氣泡的物理特性和通過 url 添加動畫的影像。此外,我們還計劃添加一些新特性(例如:移除氣泡)。

Tags: