Android 實(shí)現(xiàn)懸浮窗功能
前言
我們大多數(shù)在兩種情況下可以看到懸浮窗,一個是視頻通話時的懸浮窗,另一個是360衛(wèi)士的懸浮球,實(shí)現(xiàn)此功能的方式比較多,這里以視頻通話懸浮窗中的需求為例。編碼實(shí)現(xiàn)使用Kotlin。Java版本留言郵箱即可。
業(yè)務(wù)場景
以微信視頻通話為例,在視頻通話時,我們打開其他應(yīng)用或點(diǎn)擊Home鍵退出時或點(diǎn)擊縮放圖標(biāo),懸浮窗會顯示在其他應(yīng)用之上,給人的假象是通話頁面變小了,點(diǎn)擊懸浮窗回到通過頁面,懸浮窗消失。退出通話頁面懸浮窗消失。
業(yè)務(wù)場景技術(shù)分析
在編碼之前,我們必須將流程整理好,這樣更有利于編碼的實(shí)現(xiàn)。實(shí)現(xiàn)一個功能如果需要10分鐘,思考的時間是7分鐘,編碼占用的時間只是三分鐘。
1.懸浮窗可以顯示在其他應(yīng)用或launchers之上,這個肯定需要懸浮窗權(quán)限,而懸浮窗權(quán)限屬于特殊權(quán)限,所以只能通過引導(dǎo)用戶去打開無法像危險權(quán)限那樣直接申請??梢宰龅胶笈_顯示則說明懸浮窗是一個Service。
2.通話頁面隱藏時懸浮窗顯示,通話頁面顯示時懸浮窗隱藏,可以看出懸浮窗和Activity的生命周期相關(guān)聯(lián),所以懸浮窗的Service和通話頁面的Activity是通過bind去綁定的。
3.既然Service和Activity是通過bind去綁定的,說明當(dāng)懸浮窗顯示的時候,通話Activity雖然不可見但仍在運(yùn)行。
結(jié)合上述技術(shù)問題分析,我們倒敘一一通過編碼實(shí)現(xiàn)
懸浮窗實(shí)現(xiàn)方案
實(shí)現(xiàn)效果
準(zhǔn)備工作
首先我們新建一個項(xiàng)目,項(xiàng)目中有兩個Activity,我們在第二個Activity編寫通話模擬頁面。在第二個頁面的原因我們后面會講到。
如何將acitivity置于后臺
其實(shí)很簡單,我們調(diào)用一個方法即可
moveTaskToBack(true);
這個方法的含義就是將當(dāng)前的任務(wù)戰(zhàn)置于后臺,so,為什么我要在第二個Activity中實(shí)現(xiàn)的原因之一,因?yàn)槟J(rèn)的Activity的啟動模式是標(biāo)準(zhǔn)模式,而上面方法會將任務(wù)棧置于后臺而不是一個單獨(dú)的Activity,所以我們?yōu)榱孙@示懸浮窗時不影響操作軟件的其他功能,我們要將通話頁面的Activity設(shè)置為singleInstance,這樣當(dāng)調(diào)用上面方法的時候只是將通話頁面所在的Activity棧置于后臺,如果你還不了解啟動模式可以移步至上一篇文章:Activity的啟動模式。
我們現(xiàn)在在右上方的點(diǎn)擊事件中添加上述代碼,可以看到通話頁面的Activity的已經(jīng)在后臺運(yùn)行了。
判斷是否有懸浮窗權(quán)限
點(diǎn)擊左上角圖標(biāo)時,我們要先判斷當(dāng)前app是否有懸浮窗權(quán)限,首先我們在配置文件中添加,懸浮窗的權(quán)限。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
(很多文章標(biāo)題都是懸浮窗如何繞過權(quán)限,什么設(shè)置類型為TOAST或者PHONE,我想說不可能的事,TOAST類型的雖然部分機(jī)型可以顯示但是就是一個普通的TOSAT會自動消失)
那么我們?nèi)绾闻袛嗍欠裼袘腋〈皺?quán)限呢,這一塊不同廠商處理方案可能不一樣,這里我們用一種通用的處理方案,測試表明除了(vivo部分)無效,其他多數(shù)機(jī)型都o(jì)k。并且vivo部分機(jī)型微信通話也不會彈出提示(這我就放心了~)
fun zoom(v: View) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!Settings.canDrawOverlays(this)) { Toast.makeText(this, "當(dāng)前無權(quán)限,請授權(quán)", Toast.LENGTH_SHORT) GlobalDialogSingle(this, "", "當(dāng)前未獲取懸浮窗權(quán)限", "去開啟", DialogInterface.OnClickListener { dialog, which -> dialog.dismiss() startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0) }).show() } else { moveTaskToBack(true) val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java) hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE) } } }
我們通過Settings.canDrawOverlays(this)
來判斷當(dāng)前應(yīng)用是否有懸浮窗權(quán)限,如果沒有,我們彈窗提示,通過
startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
跳轉(zhuǎn)到開啟懸浮窗權(quán)限頁面。如果懸浮窗權(quán)限已開啟,直接將當(dāng)前任務(wù)棧置于后臺,開啟服務(wù)即可。
其實(shí)回調(diào)方法,并沒有直接告訴我們是否授權(quán)成功,所以我們需要在回調(diào)中再次判斷
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { if (requestCode == 0) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!Settings.canDrawOverlays(this)) { Toast.makeText(this, "授權(quán)失敗", Toast.LENGTH_SHORT).show() } else { Handler().postDelayed({ val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java) intent.putExtra("rangeTime", rangeTime) hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE) moveTaskToBack(true) }, 1000) } } } }
這里我們可以看到回調(diào)中延遲了1秒,因?yàn)闇y試發(fā)現(xiàn)某些機(jī)型反應(yīng)“過快”,收到回調(diào)的時候還以為沒有授權(quán)成功,其實(shí)已經(jīng)成功了。
綁定Service我們需要一個ServiceConnection對象
internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { // 獲取服務(wù)的操作對象 val binder = service as FloatWinfowServices.MyBinder binder.service } override fun onServiceDisconnected(name: ComponentName) {} }
Main2Activity的完整代碼如下所示:
/** * @author Huanglinqing */ class Main2Activity : AppCompatActivity() { private val chronometer: Chronometer? = null private var hasBind = false private val rangeTime: Long = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main2) } fun zoom(v: View) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!Settings.canDrawOverlays(this)) { Toast.makeText(this, "當(dāng)前無權(quán)限,請授權(quán)", Toast.LENGTH_SHORT) GlobalDialogSingle(this, "", "當(dāng)前未獲取懸浮窗權(quán)限", "去開啟", DialogInterface.OnClickListener { dialog, which -> dialog.dismiss() startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0) }).show() } else { moveTaskToBack(true) val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java) hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE) } } } internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { // 獲取服務(wù)的操作對象 val binder = service as FloatWinfowServices.MyBinder binder.service } override fun onServiceDisconnected(name: ComponentName) {} } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { if (requestCode == 0) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!Settings.canDrawOverlays(this)) { Toast.makeText(this, "授權(quán)失敗", Toast.LENGTH_SHORT).show() } else { Handler().postDelayed({ val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java) intent.putExtra("rangeTime", rangeTime) hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE) moveTaskToBack(true) }, 1000) } } } } override fun onRestart() { super.onRestart() Log.d("RemoteView", "重新顯示了") //不顯示懸浮框 if (hasBind) { unbindService(mVideoServiceConnection) hasBind = false } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) } override fun onDestroy() { super.onDestroy() } }
新建懸浮窗Service
新建懸浮窗Service FloatWinfowServices,因?yàn)槲覀兪褂玫腂indService,我們在onBind方法中初始化service中的布局
override fun onBind(intent: Intent): IBinder? { initWindow() //懸浮框點(diǎn)擊事件的處理 initFloating() return MyBinder() }
service中我們通過WindowManager來添加一個布局顯示。
/** * 初始化窗口 */ private fun initWindow() { winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager //設(shè)置好懸浮窗的參數(shù) wmParams = params // 懸浮窗默認(rèn)顯示以左上角為起始坐標(biāo) wmParams!!.gravity = Gravity.LEFT or Gravity.TOP //懸浮窗的開始位置,因?yàn)樵O(shè)置的是從左上角開始,所以屏幕左上角是x=0;y=0 wmParams!!.x = winManager!!.defaultDisplay.width wmParams!!.y = 210 //得到容器,通過這個inflater來獲得懸浮窗控件 inflater = LayoutInflater.from(applicationContext) // 獲取浮動窗口視圖所在布局 mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null) // 添加懸浮窗的視圖 winManager!!.addView(mFloatingLayout, wmParams) }
懸浮窗的參數(shù)主要設(shè)置懸浮窗的類型為
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
8.0 以下可設(shè)置為:
wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
代碼如下所示:
private //設(shè)置window type 下面變量2002是在屏幕區(qū)域顯示,2003則可以顯示在狀態(tài)欄之上 //設(shè)置可以顯示在狀態(tài)欄上 //設(shè)置懸浮窗口長寬數(shù)據(jù) val params: WindowManager.LayoutParams get() { wmParams = WindowManager.LayoutParams() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE } wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT return wmParams }
當(dāng)點(diǎn)擊懸浮窗的時候回到Activity2頁面,并且懸浮窗消失,所以我們只需要給懸浮窗添加點(diǎn)擊事件
linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }
當(dāng)Service走到onDestory的時候?qū)iew移除,對于Activity2頁面來說 當(dāng)onResume的時候 解綁Service,當(dāng)onstop的時候 綁定Service。
從效果圖中我們可以看到懸浮窗可以拖拽的,所以還要設(shè)置觸摸事件,當(dāng)移動距離超過某個值的時候讓onTouch消費(fèi)事件,這樣就不會觸發(fā)點(diǎn)擊事件了。這個算是view比較基礎(chǔ)的知識,相信大家都明白了。
//開始觸控的坐標(biāo),移動時的坐標(biāo)(相對于屏幕左上角的坐標(biāo)) private var mTouchStartX: Int = 0 private var mTouchStartY: Int = 0 private var mTouchCurrentX: Int = 0 private var mTouchCurrentY: Int = 0 //開始時的坐標(biāo)和結(jié)束時的坐標(biāo)(相對于自身控件的坐標(biāo)) private var mStartX: Int = 0 private var mStartY: Int = 0 private var mStopX: Int = 0 private var mStopY: Int = 0 //判斷懸浮窗口是否移動,這里做個標(biāo)記,防止移動后松手觸發(fā)了點(diǎn)擊事件 private var isMove: Boolean = false private inner class FloatingListener : View.OnTouchListener { override fun onTouch(v: View, event: MotionEvent): Boolean { val action = event.action when (action) { MotionEvent.ACTION_DOWN -> { isMove = false mTouchStartX = event.rawX.toInt() mTouchStartY = event.rawY.toInt() mStartX = event.x.toInt() mStartY = event.y.toInt() } MotionEvent.ACTION_MOVE -> { mTouchCurrentX = event.rawX.toInt() mTouchCurrentY = event.rawY.toInt() wmParams!!.x += mTouchCurrentX - mTouchStartX wmParams!!.y += mTouchCurrentY - mTouchStartY winManager!!.updateViewLayout(mFloatingLayout, wmParams) mTouchStartX = mTouchCurrentX mTouchStartY = mTouchCurrentY } MotionEvent.ACTION_UP -> { mStopX = event.x.toInt() mStopY = event.y.toInt() if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) { isMove = true } } else -> { } } //如果是移動事件不觸發(fā)OnClick事件,防止移動的時候一放手形成點(diǎn)擊事件 return isMove } }
FloatWinfowServices所有代碼如下所示:
class FloatWinfowServices : Service() { private var winManager: WindowManager? = null private var wmParams: WindowManager.LayoutParams? = null private var inflater: LayoutInflater? = null //浮動布局 private var mFloatingLayout: View? = null private var linearLayout: LinearLayout? = null private var chronometer: Chronometer? = null override fun onBind(intent: Intent): IBinder? { initWindow() //懸浮框點(diǎn)擊事件的處理 initFloating() return MyBinder() } inner class MyBinder : Binder() { val service: FloatWinfowServices get() = this@FloatWinfowServices } override fun onCreate() { super.onCreate() } /** * 懸浮窗點(diǎn)擊事件 */ private fun initFloating() { linearLayout = mFloatingLayout!!.findViewById<LinearLayout>(R.id.line1) linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) } //懸浮框觸摸事件,設(shè)置懸浮框可拖動 linearLayout!!.setOnTouchListener(FloatingListener()) } //開始觸控的坐標(biāo),移動時的坐標(biāo)(相對于屏幕左上角的坐標(biāo)) private var mTouchStartX: Int = 0 private var mTouchStartY: Int = 0 private var mTouchCurrentX: Int = 0 private var mTouchCurrentY: Int = 0 //開始時的坐標(biāo)和結(jié)束時的坐標(biāo)(相對于自身控件的坐標(biāo)) private var mStartX: Int = 0 private var mStartY: Int = 0 private var mStopX: Int = 0 private var mStopY: Int = 0 //判斷懸浮窗口是否移動,這里做個標(biāo)記,防止移動后松手觸發(fā)了點(diǎn)擊事件 private var isMove: Boolean = false private inner class FloatingListener : View.OnTouchListener { override fun onTouch(v: View, event: MotionEvent): Boolean { val action = event.action when (action) { MotionEvent.ACTION_DOWN -> { isMove = false mTouchStartX = event.rawX.toInt() mTouchStartY = event.rawY.toInt() mStartX = event.x.toInt() mStartY = event.y.toInt() } MotionEvent.ACTION_MOVE -> { mTouchCurrentX = event.rawX.toInt() mTouchCurrentY = event.rawY.toInt() wmParams!!.x += mTouchCurrentX - mTouchStartX wmParams!!.y += mTouchCurrentY - mTouchStartY winManager!!.updateViewLayout(mFloatingLayout, wmParams) mTouchStartX = mTouchCurrentX mTouchStartY = mTouchCurrentY } MotionEvent.ACTION_UP -> { mStopX = event.x.toInt() mStopY = event.y.toInt() if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) { isMove = true } } else -> { } } //如果是移動事件不觸發(fā)OnClick事件,防止移動的時候一放手形成點(diǎn)擊事件 return isMove } } /** * 初始化窗口 */ private fun initWindow() { winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager //設(shè)置好懸浮窗的參數(shù) wmParams = params // 懸浮窗默認(rèn)顯示以左上角為起始坐標(biāo) wmParams!!.gravity = Gravity.LEFT or Gravity.TOP //懸浮窗的開始位置,因?yàn)樵O(shè)置的是從左上角開始,所以屏幕左上角是x=0;y=0 wmParams!!.x = winManager!!.defaultDisplay.width wmParams!!.y = 210 //得到容器,通過這個inflater來獲得懸浮窗控件 inflater = LayoutInflater.from(applicationContext) // 獲取浮動窗口視圖所在布局 mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null) chronometer = mFloatingLayout!!.findViewById<Chronometer>(R.id.chronometer) chronometer!!.start() // 添加懸浮窗的視圖 winManager!!.addView(mFloatingLayout, wmParams) } private //設(shè)置window type 下面變量2002是在屏幕區(qū)域顯示,2003則可以顯示在狀態(tài)欄之上 //設(shè)置可以顯示在狀態(tài)欄上 //設(shè)置懸浮窗口長寬數(shù)據(jù) val params: WindowManager.LayoutParams get() { wmParams = WindowManager.LayoutParams() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE } wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT return wmParams } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { super.onDestroy() winManager!!.removeView(mFloatingLayout) } }
實(shí)際應(yīng)用中需要考慮的一些其他問題
在使用使用的過程中,我們肯定會遇到其他問題:
1.用戶使用過程中,可能會直接按Home鍵,這個時候如何提示呢?
產(chǎn)生問題原因:因?yàn)橛脩舭碒ome鍵之后,開發(fā)者無法重寫Home鍵邏輯,此時應(yīng)用不在前臺運(yùn)行,無法彈窗提醒,此時用戶點(diǎn)擊APP圖標(biāo)進(jìn)入的是第一個棧,這個時候用戶就沒有進(jìn)入通話頁面的入口了。
解決方案:
第一種解決方案 我們可以仿照微信那樣去做,就是在整個通話過程中開啟一個前臺通知,用戶點(diǎn)擊通知時進(jìn)入通話頁面。
第二種解決方案 就是檢測應(yīng)用是否在前臺,當(dāng)通話頁面在運(yùn)行的時候,并且應(yīng)用重新回到前臺,我們廣播到其他頁面,提示權(quán)限引導(dǎo)即可。
2.用戶在通話頁面(singleInstance模式),點(diǎn)擊Home鍵
應(yīng)用在后臺運(yùn)行的時候,通話結(jié)束,Activity被finish,此時從任務(wù)程序中切回應(yīng)用你會發(fā)現(xiàn)打開的竟然是通話頁面!
這個問題簡單的說就是,如果你在通話頁面呼叫某人,通話過程中按Home鍵,然后電話掛斷,此時你從任務(wù)程序中切回應(yīng)用,會再次呼叫這個人,也就是這種狀態(tài)下重新回到了onCreate方法。
問題產(chǎn)生原因:
1.因?yàn)橥ㄔ掜撁媸莝ingleInstance模式,此時有兩個任務(wù)棧,按Home鍵后再從任務(wù)程序中切回,此時應(yīng)用只保留了第二個任務(wù)棧,已經(jīng)失去了和第一個任務(wù)棧的關(guān)系,finish之后無法在回到第一個任務(wù)棧。
解決方案:
1.(不推薦)通話頁面不使用singleInstance模式,這種情況下,在通話過程中無法操作軟件的其他功能,一般都不采取。
2.(我目前的解決方案)設(shè)置一個標(biāo)記位,標(biāo)記當(dāng)前是否在通話,在onCreate中如果通話已經(jīng)結(jié)束了,跳轉(zhuǎn)到一個過渡頁面(標(biāo)準(zhǔn)模式),過渡頁面中finish,就可以了,添加過渡頁面的原因是我們不知道上一個頁面是哪里,因?yàn)槲覀兪盏絹黼娍赡苁侨我忭撁妫覀兾覀冊谶^渡頁面finsh之后,就再次回到了第一個任務(wù)棧。
總結(jié)
以上所述是小編給大家介紹的Android 實(shí)現(xiàn)懸浮窗功能,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復(fù)大家的。在此也非常感謝大家對腳本之家網(wǎng)站的支持!
如果你覺得本文對你有幫助,歡迎轉(zhuǎn)載,煩請注明出處,謝謝!
相關(guān)文章
發(fā)布?Android?library?到?Maven?解析
這篇文章主要介紹了發(fā)布?Android?library到Maven解析,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-09-09Android編程之內(nèi)存溢出解決方案(OOM)實(shí)例總結(jié)
這篇文章主要介紹了Android編程之內(nèi)存溢出解決方案(OOM),結(jié)合實(shí)例實(shí)例總結(jié)分析了Android編程過程中常見的內(nèi)存溢出情況與對應(yīng)的解決方法,具有一定參考借鑒價值,需要的朋友可以參考下2015-11-11Android Studio 3.0 原生支持kotlin 例子詳解
這篇文章主要介紹了 Android Studio 3.0 原生支持kotlin 例子詳解,非常具有實(shí)用價值,需要的朋友可以參考下2017-05-05Android自定義EditText實(shí)現(xiàn)淘寶登錄功能
這篇文章主要為大家詳細(xì)介紹了Android自定義EditText實(shí)現(xiàn)淘寶登錄功能,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-12-12android?studio組件通信:Intend啟動Activity接收返回結(jié)果
這篇文章主要介紹了android?studio組件通信:Intend啟動Activity接收返回結(jié)果,設(shè)計一個主Activity和一個子Activity(Sub-Activity),使用主Activity上的按鈕啟動子Activity,并將子Activity的一些信息返回給主Activity,并顯示在主Activity上,需要的朋友可以參考一下2021-12-12Flutter Reusable Lottie Animations技巧
這篇文章主要為大家介紹了Flutter Reusable Lottie Animations技巧,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12Android View教程之自定義驗(yàn)證碼輸入框效果
這篇文章主要給大家介紹了關(guān)于Android View教程之自定義驗(yàn)證碼輸入框效果的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對各位Android開發(fā)者們具有一定的參考學(xué)習(xí)價值,需要的朋友們下面來一起學(xué)習(xí)學(xué)習(xí)吧2019-05-05