如何建立 BubblePicker – 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;
}
"""// 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 變數用於以下兩個目的:
-
得到當前片段與正方形中心的距離;根據這個距離,我們能夠改變片段的顏色來畫圓。
-
將紋理(照片和國家名稱)放在圖形的中心。
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 的。如果對動畫有任何問題或建議,請告訴我們。
我們會盡快釋出更多幹貨。 敬請關注!
戳這裡進一步檢視 BubblePicker animation on GitHub 和 BubblePicker on Dribbble。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。