본문 바로가기

IT/안드로이드 관련

[안드로이드] background floating button 만들기

안녕하세요 남갯입니다


오늘은 background floating button 을 만드는 방법에 대해 포스팅 해보려고 합니다.


background floating button 이란 페이스북 메신져와 비슷하게





이처럼 앱을 끄고나서도 floating button이 움직이는 것을 말합니다.



step : manifest 


<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"></uses-permission>

Alert permission 과 잠시후 만들 액티비티와 Service를 만듭니다.


<service
android:name=".bgfloating.FloatingService"
android:enabled="true"
android:permission="android.permission.SYSTEM_ALERT_WINDOW">
</service>


<activity android:name=".bgfloating.FloatingActivity"
android:launchMode="singleTop">
</activity>



step : FloatingActivity


class FloatingActivity : AppCompatActivity() {


private val ACTION_MANAGE_OVERLAY_PERMISSION_REQUEST_CODE = 1
val TAG = "Floating"

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.e(TAG, "onCreate")
setContentView(R.layout.activity_floating)
checkPermission()
initTest()
}

fun initTest() {
helloWorld.setText("changed hello world")
}

override fun onPause() {
super.onPause()
Log.e(TAG, "onPause")
}

override fun onResume() {
super.onResume()
Log.e(TAG, "onResume")
}

override fun onStop() {
super.onStop()
Log.e(TAG, "onStop")
}
override fun onStart() {
super.onStart()
Log.e(TAG, "onStart")
}


fun checkPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // 마시멜로우 이상일 경우
if (!Settings.canDrawOverlays(this)) { // 체크
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
startActivityForResult(intent, ACTION_MANAGE_OVERLAY_PERMISSION_REQUEST_CODE)
} else {
Log.e("myLog", "startService")
startService()
}
} else {
startService()
}
}

fun startService() {
val floatingService = Intent(applicationContext, FloatingService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startService(floatingService)
}
}

fun sttopService() {
stopService(Intent(applicationContext, FloatingService::class.java))
}


@TargetApi(Build.VERSION_CODES.M)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == ACTION_MANAGE_OVERLAY_PERMISSION_REQUEST_CODE) {
if (!Settings.canDrawOverlays(this)) {
// TODO 동의를 얻지 못했을 경우의 처리
finish()
} else {
startService()
}
}
}

override fun onDestroy() {
super.onDestroy()
// sttopService()
}
}


먼져 액티비티를 하나 만들어줍니다.


여기서 저는 service를 시작하자마자 보여지도록 되어있는데,



* pause 상태에만 보여주려면?


* View에서 hide 메소드를 통해 원하는 상황에서 안보이게 해주시면 됩니다.


pause 시점에만 보여주도록 하시려면?? 

bindService를 통해 바인드합니다.

private lateinit var floatingService: FloatingService
val onServiceConnection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName?) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}

override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as FloatingService.LocalBinder
floatingService = binder.getService()
}
}


그후 service에 floatingService에 show, hide를 통해 호출해주시면 됩니다.




step : FloatingService


* 여기서 중점적으로 봐야할것은 M부터는 


오버레이 권한을 받아야합니다.


* O버젼부터는 Background로 돌아갈경우 foreground 형태로 noti를 띄워주어야하지만 floating button이 있기때문에 자동으로 foreground로 인식하게 됩니다.


class FloatingService : Service() {

lateinit var floatingHeadWindow: FloatingHeadWindow
private val mBinder = LocalBinder()


override fun onBind(intent: Intent?): IBinder? {
return mBinder
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.e("myLog", "onStartCommand")
init()
return super.onStartCommand(intent, flags, startId)
}


fun init() {
Log.e("myLog", "init")
if (!::floatingHeadWindow.isInitialized) {
Log.e("myLog", "is not initalized")
floatingHeadWindow = FloatingHeadWindow(applicationContext).apply {
create()
createLayoutParams()
show()
}
}
}


override fun onCreate() {
super.onCreate()
Log.e("myLog", "onCreate")
}


inner class LocalBinder : Binder() {
fun getService(): FloatingService {
return this@FloatingService
}
}


override fun onDestroy() {
super.onDestroy()
floatingHeadWindow.hide()
Log.e("myLog", "onDestroy")
}
}


서비스는 이렇게 돌리게 되는데 

위의 예제처럼


BindService로 돌렸을경우 서비스의 생명주기에 따라 init에 

Oncreate에 넣어주시면 됩니다. (이건 서비스 부분이니 찾아서 해보시길 바랍니다.)



step : FloatingView


그리고 저희가 표현할 FloatingView를 만듭니다.


package com.namget.customline.bgfloating

import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.widget.FrameLayout


class FloatingView @JvmOverloads constructor(
val mContext: Context,
val attributeSet: AttributeSet? = null,
val def: Int = 0
) :
FrameLayout(mContext, attributeSet, def), View.OnTouchListener {


private val CLICK_DRAG_TOLERANCE =
10f // Often, there will be a slight, unintentional, drag when the user taps the FAB, so we need to account for this.

private var downRawX: Int = 0
private var downRawY: Int = 0
private var lastX: Int = 0
private var lastY: Int = 0
private lateinit var callbacks: Callbacks
private val mGestureDetector: GestureDetector
private var mTouchSlop: Int


init {
setOnTouchListener(this)
mGestureDetector = GestureDetector(context, GestureListener())
mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop

}

fun setCallbacks(callbacks: Callbacks) {
this.callbacks = callbacks
}


override fun onTouch(view: View, motionEvent: MotionEvent): Boolean {

val layoutParams = view.layoutParams

mGestureDetector.onTouchEvent(motionEvent)

val action = motionEvent.action
val x = motionEvent.rawX.toInt()
val y = motionEvent.rawY.toInt()

if (action == MotionEvent.ACTION_DOWN) {
downRawX = x
downRawY = y
lastX = x
lastY = y

return true // Consumed

} else if (action == MotionEvent.ACTION_MOVE) {
val nx = (x - lastX)
val ny = (y - lastY)
lastX = x
lastY = y

callbacks.onDrag(nx, ny)
return true // Consumed

} else if (action == MotionEvent.ACTION_UP) {
Log.e("action", "ACTION_UP")
callbacks.onDragEnd(x, y)
return true
} else {
return super.onTouchEvent(motionEvent)
}

}

inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent?): Boolean {
Log.e("gesture", "onClick")
callbacks.onClick()
return true
}
}


interface Callbacks {
fun onDrag(dx: Int, dy: Int)
fun onDragEnd(dx: Int, dy: Int)
fun onDragStart(dx: Int, dy: Int)
fun onClick()
}


}


이렇게 말이죠

여기서 GestureDetector를 통한  Callbacks는 floatingView의 이벤트를 Window에 전달하는 역할을 하게됩니다.



step : FloatingHeadWindow


class FloatingHeadWindow(val context: Context) : FloatingView.Callbacks {

private var mWindowManager: WindowManager =
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private lateinit var mLayoutParams: WindowManager.LayoutParams
private lateinit var mView: View
private var mViewAdded = false
private lateinit var rect: Rect
private lateinit var mAnimator: ObjectAnimator


fun show() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (Settings.canDrawOverlays(context)) {
mWindowManager.addView(mView, mLayoutParams)
}
} else {
mWindowManager.addView(mView, mLayoutParams)
}
mViewAdded = true
}

fun hide() {
mWindowManager.removeView(mView)
mViewAdded = false
}


fun create() {
if (!mViewAdded) {
mView = LayoutInflater.from(context).inflate(R.layout.item_floating, null, false)
(mView as FloatingView).setCallbacks(this)
rect = Rect()
updateScreenLimit(rect)
mAnimator = ObjectAnimator.ofPropertyValuesHolder(this)
mAnimator.interpolator = DecelerateInterpolator(1.0f)
}
}

fun updateScreenLimit(rect: Rect) {
val dm = context.resources.displayMetrics
rect.left = -30
rect.right = dm.widthPixels - getWidth()
rect.top = 30
rect.bottom = dm.heightPixels - getHeight()
}

fun magHorizontal(x: Int): Int {
var x = x
val dm = context.resources.displayMetrics
if (x + 78 < 0) {
x = rect.left
} else if (x + getWidth() - 78 > dm.widthPixels) {
x = rect.right
}
return x
}

fun magVertical(y: Int): Int {
var y = y
if (y < rect.top) {
y = rect.top
} else if (y > rect.bottom) {
y = rect.bottom
}
return y
}

fun getX(): Int = mLayoutParams.x
fun getY(): Int = mLayoutParams.y


fun settle() {
Log.e("settle", "before X ${getX()}")
Log.e("settle", "before Y ${getY()}")
val x = magHorizontal(getX())
val y = magVertical(getY())
Log.e("settle", "" + x)
Log.e("settle", "" + y)
animateTo(x, y)
}

fun animateTo(x: Int, y: Int) {
val xHolder = PropertyValuesHolder.ofInt("x", x)
val yHolder = PropertyValuesHolder.ofInt("y", y)
Log.e("animateTo", "x holder $xHolder")
Log.e("animateTo", "y holder $yHolder")
mAnimator.setValues(xHolder, yHolder)
mAnimator.duration = 200
mAnimator.start()
mAnimator.addListener(object : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {
}

override fun onAnimationEnd(animation: Animator?) {
Log.e("AnimatorListener", "onAnimationEnd")
}

override fun onAnimationCancel(animation: Animator?) {
Log.e("AnimatorListener", "onAnimationCancel")
}

override fun onAnimationStart(animation: Animator?) {
Log.e("AnimatorListener", "onAnimationStart")
}
})
}


fun createLayoutParams() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mLayoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR,
PixelFormat.TRANSLUCENT
)
} else {
mLayoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT,
(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR),
PixelFormat.TRANSLUCENT
)
}
mLayoutParams.gravity = Gravity.TOP or Gravity.LEFT
mLayoutParams.x = -mView.width
mLayoutParams.y = -mView.height
}

fun moveTo(pos: Point) {
moveTo(pos.x, pos.y)
}

fun moveTo(x: Int, y: Int) {
if ((mView != null) and (mViewAdded)) {
Log.e("test Int", "x : $x y : $y")
mLayoutParams.x = x
mLayoutParams.y = y
mWindowManager.updateViewLayout(mView, mLayoutParams)
}
}

fun moveBy(dx: Int, dy: Int) {
if (mView != null && mViewAdded) {
Log.e("test Int", "dx : $dx dy : $dy")

mLayoutParams.x += dx
mLayoutParams.y += dy
mWindowManager.updateViewLayout(mView, mLayoutParams)
}
}


fun getWidth(): Int = mView.measuredWidth


fun getHeight(): Int = mView.measuredHeight

override fun onDrag(dx: Int, dy: Int) {
Log.e("test Float", "dx : $dx dy : $dy")
moveBy(dx, dy)
}

override fun onDragEnd(dx: Int, dy: Int) {
settle()
}

override fun onDragStart(dx: Int, dy: Int) {
}

override fun onClick() {
startActivity()
}

private fun startActivity() {
try {
// 알림 팝업용 펜딩 인덴트
val contentIntent = PendingIntent.getActivity(
context,
9999,
Intent(context, FloatingActivity::class.java)
.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_SINGLE_TOP
or Intent.FLAG_ACTIVITY_NO_USER_ACTION
or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
),
//.putExtras(pushIntent),
PendingIntent.FLAG_ONE_SHOT
)

contentIntent.send()
} catch (e: Exception) {
e.printStackTrace()
}
}
}


- create 메소드를 통해 이전에 생성했던 FloatingView를 만들고  moveTo 메소드를 통해 플로팅을 이동시키는것입니다.


startActivity는 플로팅버튼을 클릭했을때 어떤 액티비티를 실행시킬지에 대한 내용입니다.


show와 hide를 상황에 맞게 쓰면 됩니다.