咱們已經習慣了移動應用豐富的交互方式,如滑動手勢去選擇、拖拽。可是咱們沒有察覺到,統一用戶的跨平臺體驗是一個正在發生的趨勢。javascript
早期時候,iOS 和 Android 都有其獨特的體驗,可是在近期,這兩個平臺上的應用體驗和交互在逐漸的靠攏。底部導航和分屏的特性已經成爲Android Nougat版本的特性,Android 和 iOS 已經有了不少相同的地方了。html
對於設計者而言,設計語言的融合意味着在一個平臺上流行的特性能夠適配到另外一個平臺。前端
最近,爲了跟上跨平臺風格的步伐,咱們受 Apple music 上氣泡動畫的啓發,用 Android 動畫實現了一份。咱們設計了一個接口,使得初學者也能夠方便的使用,並且也讓有經驗的開發者以爲有趣。java
使用 BubblePicker 能讓一個應用更加的聚焦內容、原汁原味和有趣。儘管 Google 已經對它全部的產品推出了材料設計語言,可是咱們依然決定在此時嘗試大膽的顏色和漸變的效果,使得圖像增長更多的深度和體積。漸變多是界面顯示最主要的視覺效果,也可能會吸引到更多的人使用。react
咱們的組件是白色背景,上面包含了不少明亮的顏色和圖形。android
這種高反差對豐富應用的內容頗有幫助,在這裏用戶不得不從一系列選項列表中作出選擇。好比,在咱們的概念中,咱們在旅行應用中使用氣泡來持有潛在的目的地名稱。氣泡在自由的漂浮,當用戶點擊其中一個時,那個氣泡就會變大。ios
此外,開發者能夠經過自定義屏幕中的元素使得動畫適配任何應用。git
當咱們在製做這個動畫的同時,咱們要面對下面五個挑戰:github
很明顯,在 Canvas 上渲染這樣一個快速的動畫效果不夠高效,因此咱們決定使用OpenGL (Open Graphics Library)。 OpenGL 是一個提供 2D 或 3D 圖形渲染的、跨平臺的應用程序接口。幸運的是,Android 支持一些 OpenGL 的版本。canvas
咱們須要讓圓更加的天然,就像是汽水中的氣泡。有不少物理引擎可用於 Android,但咱們的特殊需求使得作出選擇格外困難:這個引擎必須輕量並且方便嵌入 Android 庫中。大多數引擎都是爲遊戲開發的,你必須使項目結構適應它們。通過一些研究,咱們發現了 JBox2D (一個使用 C++ 開發的、 Java 端口的 Box2D 引擎);由於咱們的動畫並不支持不少數量的 body(換句話說,它不是爲了200個或更多的對象設計的),咱們可使用 Java 端口而不是原生引擎。
另外,在本文的後面咱們會解釋爲什麼選擇了 Kotlin 語言編寫,而且談到這種新語言的優勢。想要了解 Java 與 Kotlin 更多的區別,請訪問以前的文章。
在開始的時候,咱們須要先理解 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() 方法來計算一個選中的片斷到中心的距離。這個方法使用兩點做爲參數。
起初,個人片斷着色器看起來有些不同:
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 之間會被插入,因此圓的邊緣會被抗鋸齒。
動畫中的每一個圓都有兩個狀態 - 正常狀態和選中狀態。在正常狀態中,圓中的紋理包含了文字和顏色;在選中的狀態,紋理則還會包含了一個圖片。因此,對每一個圓咱們都應該建立兩個不一樣的紋理。
爲了建立紋理,咱們使用一個 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() 方法來獲取一個片斷的真實顏色,咱們還能得到紋理單元和片斷相對於其頂點的位置。
從物理的角度,這個動畫很是簡單。主對象是一個 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,使得看起來像是受到了重力的影響。
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
}複製代碼
當用戶滑動屏幕,咱們增長重力並改變中心,在用戶看來就像是控制了氣泡的移動。當用戶中止了滑動,咱們將氣泡恢復到初始狀態。
當用戶點擊了一個圓,咱們經過 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、前端、後端、產品、設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃。