Edited at

FacebookのMessengerみたいなアプリを作る


はじめに

この記事はロボP Advent Calendar 2018の2日目の記事です.


FBのMessengerって?

メッセージが来ると横にプヨって出てくるやつ

ChatHeadって言うらしい。

chathead.jpg


作る


画面に残るViewを作る

WindowManagerをServiceで動かして実装します。

通常のアプリケーションより高いレイヤーに描画するイメージ。


Permission

画面を重ねて描画するためのSYSTEM_ALERT_WINDOW


AndroidManifest.xml

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

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


MainActivity

無くてもいけますが、テストしやすいため今回は使います。


MainActivity.kt

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

//API23から権限が必要
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
checkPermission()
}

findViewById<TextView>(R.id.text).setOnClickListener {
val intent = Intent(application, OverlayService::class.java)
// Serviceの開始
// API26以上はNotificationが入るため処理が別
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}

@RequiresApi(Build.VERSION_CODES.M)
fun checkPermission() {
if (!Settings.canDrawOverlays(this)) {
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName"))
startActivityForResult(intent, 1000)
}
}
}


Android8.0(Oreo|API26)からは画面上に重ねて描画すると

Notificationが出るようになったため、startForegroundService()でServiceを開始します。


activity_main.xml

<?xml version="1.0" encoding="utf-8"?>

<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</android.support.constraint.ConstraintLayout>


レイアウトは特に変えてません。


OverlayService

こいつが今回の肝です。


OverlayService.kt

class OverlayService : Service() {

private var view: View? = null
private var windowManager: WindowManager? = null
private var dpScale: Int = 0

override fun onCreate() {
super.onCreate()

dpScale = resources.displayMetrics.density.toInt()
}

override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {

val context = applicationContext
val channelId = "default"
val title = context.getString(R.string.app_name)

val pendingIntent = PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT)

//8.0(Oreo)以上用のNotification設定
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
channelId, title, NotificationManager.IMPORTANCE_DEFAULT)

if (notificationManager != null) {
notificationManager.createNotificationChannel(channel)

val notification = Notification.Builder(context, channelId)
.setContentTitle(title)
.setSmallIcon(android.R.drawable.btn_star)
.setContentText("APPLICATION_OVERLAY")
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setWhen(System.currentTimeMillis())
.build()

startForeground(1, notification)
}
}

// inflaterの生成
val layoutInflater = LayoutInflater.from(this)

//8.0(Oreo)以上のみレイヤータイプを分ける
val typeLayer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT

windowManager = applicationContext
.getSystemService(Context.WINDOW_SERVICE) as WindowManager

val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
typeLayer,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT)

val nullParent: ViewGroup? = null
view = layoutInflater.inflate(R.layout.layout_overlay, nullParent)
//クリックで解除できるように
view?.findViewById<Button>(R.id.btn_overlay)
?.setOnClickListener {
// Viewを削除
windowManager!!.removeView(view)
}

// ViewにTouchListenerを設定する
view!!.setOnTouchListener { v, event ->
Log.d("debug", "onTouch")
if (event.action == MotionEvent.ACTION_DOWN) {
Log.d("debug", "ACTION_DOWN")
}
if (event.action == MotionEvent.ACTION_UP) {
Log.d("debug", "ACTION_UP")

view!!.performClick()

stopSelf()
}
false
}

// Viewを画面上に追加
windowManager!!.addView(view, params)

return super.onStartCommand(intent, flags, startId)
}

override fun onDestroy() {
super.onDestroy()
Log.d("debug", "onDestroy")
// Viewを削除
windowManager!!.removeView(view)
}

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


レイヤータイプが8.0以上とそれ以外で異なるので、注意してください。


//8.0(Oreo)以上のみレイヤータイプを分ける
val typeLayer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT

ここで描画するViewはここでinflateしてます。

view = layoutInflater.inflate(R.layout.layout_overlay, nullParent)


layout_overlay.xml

<?xml version="1.0" encoding="utf-8"?>

<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">

<Button
android:id="@+id/btn_overlay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ボタン"/>

</android.support.constraint.ConstraintLayout>



実行!

どこに行ってもボタンが出るようになったかと思います。


UIをそれっぽくする

今回はMessengerの代わりにWebを出すようにします。


Permission


AndroidManifest.xml

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


webViewを使うのでINTERNETを追加します。


OverlayService


OverlayService.kt

class OverlayService : Service() {

private var view: View? = null
private var windowManager: WindowManager? = null
private var dpScale: Int = 0
private var typeLayer: Int = 0

private var isBigView: Boolean = false

var overlayImgV: ImageView? = null
var webV: WebView? = null

override fun onCreate() {
super.onCreate()

dpScale = resources.displayMetrics.density.toInt()
}

override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {

val context = applicationContext
val channelId = "default"
val title = context.getString(R.string.app_name)

val pendingIntent = PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT)

//8.0(Oreo)以上用のNotification設定
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
channelId, title, NotificationManager.IMPORTANCE_DEFAULT)

if (notificationManager != null) {
notificationManager.createNotificationChannel(channel)

val notification = Notification.Builder(context, channelId)
.setContentTitle(title)
.setSmallIcon(android.R.drawable.btn_star)
.setContentText("APPLICATION_OVERLAY")
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setWhen(System.currentTimeMillis())
.build()

startForeground(1, notification)
}
}

// inflaterの生成
val layoutInflater = LayoutInflater.from(this)

//8.0(Oreo)以上のみレイヤータイプを分ける
typeLayer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_SYSTEM_ALERT

windowManager = applicationContext
.getSystemService(Context.WINDOW_SERVICE) as WindowManager

val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
typeLayer,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT)

params.gravity = Gravity.TOP or Gravity.LEFT

val nullParent: ViewGroup? = null
view = layoutInflater.inflate(R.layout.layout_overlay, nullParent)
//クリックで解除できるように
view?.findViewById<Button>(R.id.btn_overlay)
?.setOnClickListener {
// Viewを削除
windowManager!!.removeView(view)
}
overlayImgV = view?.findViewById<ImageView>(R.id.ic_overlay)
overlayImgV?.setOnClickListener {
if (!isBigView) {
openBigView()
} else {
closeBigView()
}
}
webV=view?.findViewById<WebView>(R.id.web_overlay)
webV?.visibility=View.GONE
webV?.webViewClient = WebViewClient()
webV?.loadUrl("https://www.google.com/")

// ViewにTouchListenerを設定する
view!!.setOnTouchListener { v, event ->
Log.d("debug", "onTouch")
if (event.action == MotionEvent.ACTION_DOWN) {
Log.d("debug", "ACTION_DOWN")
}
if (event.action == MotionEvent.ACTION_UP) {
Log.d("debug", "ACTION_UP")

view!!.performClick()

stopSelf()
}
false
}

// Viewを画面上に追加
windowManager!!.addView(view, params)

return super.onStartCommand(intent, flags, startId)
}

override fun onDestroy() {
super.onDestroy()
Log.d("debug", "onDestroy")
// Viewを削除
windowManager!!.removeView(view)
}

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

private fun openBigView() {
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
typeLayer,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT)
params.gravity = Gravity.TOP or Gravity.LEFT
windowManager?.updateViewLayout(view, params)

val cx = overlayImgV?.left!! + overlayImgV?.width!! / 2
val cy = overlayImgV?.top!! + overlayImgV?.height!! / 2
val radius = Math.max(webV?.width!!, webV?.height!!)

val anim = android.view.ViewAnimationUtils.createCircularReveal(webV, cx, cy, 0f, radius.toFloat())

webV?.visibility = View.VISIBLE
TransitionManager.beginDelayedTransition(webV?.rootView as ViewGroup)
anim.start()

isBigView = true
}

private fun closeBigView() {

val cx = overlayImgV?.left!! + overlayImgV?.width!! / 2
val cy = overlayImgV?.top!! + overlayImgV?.height!! / 2
val radius = Math.max(webV?.width!!, webV?.height!!)

val anim = android.view.ViewAnimationUtils.createCircularReveal(webV, cx, cy, radius.toFloat(), 0f)
anim.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
webV?.visibility = View.GONE
TransitionManager.beginDelayedTransition(webV?.rootView as ViewGroup)

val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
typeLayer,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT)
params.gravity = Gravity.TOP or Gravity.LEFT
windowManager?.updateViewLayout(view, params)
}
})
anim.start()

isBigView = false
}
}


アニメーションを追加しました。


layout_overlay.xml

<?xml version="1.0" encoding="utf-8"?>

<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content">

<WebView
android:id="@+id/web_overlay"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="WebViewLayout" />

<ImageView
android:id="@+id/ic_overlay"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@mipmap/ic_launcher"
app:layout_constraintStart_toStartOf="parent" />

<Button
android:id="@+id/btn_overlay"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_oval"
android:text="×"
android:textColor="#ffffff"
android:textSize="20dp"
app:layout_constraintEnd_toEndOf="@id/ic_overlay"
app:layout_constraintTop_toTopOf="@id/ic_overlay" />

</android.support.constraint.ConstraintLayout>


ButtonをImageViewに買えて×ボタンとWebViewを追加しました。


実行!


参考

nyanのアプリ開発 | WindowManagerを使ってServiceから画像を表示させ続ける

AndroidでViewを他のアプリの上に表示する (APIバージョン別 対応方法まとめ)

めっちゃ参考にしました。ありがとうございます。