홈 위젯을 사용하기 위해서 필요한 클래스와 xml 정의에 대해 간략하게 알아보고, 코틀린에서는 어떻게 사용하면 좋을지 예제와 함께 정리해보았다.
<receiver android:name=".presentation.widget.AppWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/covid_appwidget"/>
</receiver>
ACTION_APPWIDGET_ENABLED
ACTION_APP_WIDGET_DISABLED
ACTION_APPWIDGET_UPDATE
ACTION_APPWIDGET_OPTIONS_CHANGED
ACTION_APPWIDGET_DELETED
ACTION_APPWIDGET_CONFIGURE
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minHeight="72dp"
android:minResizeWidth="72dp"
android:minWidth="146dp"
android:updatePeriodMillis="86400000"
android:initialLayout="@layout/layout_covid_appwidget"
android:previewImage="@drawable/abc_vector_test"
android:configure="com.jshme.covidwidget.presentation.MainActivity"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
minHeight, minWidth
: 위젯을 설정할 수 있는 최소 너비, 높이minResizeWidth, minResizeHeight
: 홈에 설정한 위젯을 사용자가 크기를 변경하려고 했을 때, 최소한으로 줄일 수 있는 너비와 높이updatePeriodMillis
: 홈 위젯의 업데이트 주기. 최소 30분에 한번씩 업데이트 설정이 가능하지만, 1시간에 1번을 권장한다.previewImage
: 홈 위젯을 설정할 때 보이는 프리뷰 이미지configure
: 사용자가 홈 위젯을 추가하고나서 띄울 액티비티resizeMode
: 위젯의 크기를 조절할 수 있는 규칙widgetCategory
: 앱 위젯을 홈 화면(home_screen), 잠금 화면(keyguard) 또는 둘 다에 표시할 수 있는지 여부👉 width, height 크기는 보통 cell 단위로 잡는 것을 권장한다. cell이란 홈스크린을 일정한 비율로 나눈 영역
이며 cell 크기는 계산은 (셀 개수 * 74) - 2 (단위 : dp)
로 잡는다. (1cell: 72dp, 2cell: 146dp ...)
우리는 모든 View와 ViewGroup으로 화면을 구성하지만, 앱 위젯
은 사용할 수 있는 View와 ViewGroup이 제한적이다. 앱 위젯은 RemoteView
객체를 중심으로 구현되는데, 이 RemoveView 라는 녀석은 한정된 클래스
만을 지원해주기 때문이다. (@RemoteView Annotation이 붙은 뷰만 지원해준다.)
✅ RemoteView 가 지원하는 View List
FrameLayout
LinearLayout
RelativeLayout
GridLayout
AnalogClock
Button
Chronometer
ImageButton
ImageView
ProgressBar
TextView
ViewFlipper
ListView
GridView
StackView
위 클래스를 제외하고는 다른 뷰는 만들 수 없다. 하지만 홈화면에 Checkbox와 같이 RemoteView를 지원하지 않는 뷰를 홈 위젯으로 만들고 싶다면, RemoteView 가 지원하는 뷰를 이용해 커스텀하는 것은 어떨까 👀 고된 길이 될 수 있겠지만 꽤 흥미로울 것 같다.
RemoteView 는 View가 아닌 최상위 객체인 object를 상속받고있는 클래스인데, 자세한 설명은 아래와 같다.
A class that describes a view hierarchy that can be displayed in another process. The hierarchy is inflated from a layout resource file, and this class provides some basic operations for modifying the content of the inflated hierarchy.
(다른 프로세스에서... View의 Hierarchy 를 만들 수 있는 클래스?🤔 hmmmm....what... what..)
AppWidget 은 실제 어플리케이션과 다른 프로세스에서 동작하게 되어 직접 홈 화면에 위젯을 바로 그릴 수 없다. 이 때 자신이 원하는 형태의 화면을 그릴 수 있도록 RemoteView
라는 뷰의 설계도를 들고 AppWidgetHost
에게 부탁하는 과정이 필요한데, 이를 위처럼 설명했다고 보면 된다.
하지만, 우리는 이미 appwidget-provider
tag 를 이용해 android:initialLayout="@layout/layout_covid_appwidget"
를 설정해주었는데, 왜 "뷰의 설계도"라는 것이 필요한 것일까?
initialLayout
속성은 앱 위젯을 맨 처음에 만들게 될 때 그려지게 된다. 하지만 그려지고난 후, 화면을 새롭게 업데이트를 해야하는 상황, 혹은 특정 뷰에 이벤트를 넣어야하는 상황이 필요할 때 RemoteView 를 사용하게 된다. 앱 위젯은 하나의 BroadcastReceiver에 불과하기 때문에, onReceive() 가 종료되는 순간에 소멸되어 오랫동안 뷰를 간직할 수 없다는 한계를 가지고 있다.
그래서, 앱 위젯이 자신이 필요한 뷰를 직접 만드는 것보다 뷰에 필요한 설계도(RemoteView) 만 만들고, 실제 화면 갱신과 업데이트는 AppWidgetHost 에게 위임하게 된다. 이러한 과정은 앱 위젯이 화면을 갱신할 때마다 반복하게 된다. 즉, 매번 새롭게 RemoteView를 만들고 통째로 넘겨주는 방식이다. 하지만 설계도만 넘겨줄 뿐이라 새로운 View가 매번 생성되는 것은 아니다.
AppWidgetProvider 는 BroadcastReceiver 클래스를 상속받은 형태이며, BroadcastReceiver가 발생되었을 때 관련된 콜백메소드를 호출하게된다.
1. onUpdate() : AppWidgetProviderInfo 에 정의된 업데이트 주기에 맞추어 호출되거나, 앱 위젯이 처음 홈 스크린에 추가될 때 호출된다.
2. onDeleted() : 홈 위젯이 제거될 때 호출된다.
3. onEnabled() : 홈 위젯이 처음 생성될 때 호출된다. 예를들어, 유저가 앱 위젯 2개를 차례로 추가하게 된다면, 맨 처음 추가시에만 호출될 것이다.
4. onDeisable() : 존재하는 마지막 홈 위젯이 제거될 때 호출된다.
홈 위젯 설정 시 위젯설정 화면
으로 이동하고, 설정한 값들이 홈 화면의 위젯
으로 보이도록 만들 예정이다.
홈 화면 | 위젯설정 화면 (AddWidgetActivity) |
---|---|
<application
....
<activity android:name=".presentation.ui.add.AddWidgetActivity">
<intent-filter>
// 이 속성이 있어야 앱 위젯이 추가될 때 "AddWidgetActivity" 화면으로 이동한다.
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver android:name=".presentation.widget.AppWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/covid_appwidget" />
</receiver>
</application>
AppWidget
관련 코드를 한 곳에서 관리하기 위해 Builder Pattern을 사용해 Helper class를 생성한다.package com.jshme.covidwidget.presentation.ui.helper
import android.app.Activity
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.widget.RemoteViews
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import com.jshme.covidwidget.R
class AppWidgetHelper(
private val context: Context
) {
private var title: String? = null
private var description: String? = null
private var pendingIntent: (() -> PendingIntent)? = null
@DrawableRes
private var iconRes: Int? = null
private var widgetId: Int = 0
private lateinit var appWidgetManager: AppWidgetManager
fun onInitialize(title: String? = null, description: String? = null): AppWidgetHelper = apply {
appWidgetManager = AppWidgetManager.getInstance(context)
this.title = title
this.description = description
}
fun setWidgetId(id: Int?): AppWidgetHelper = apply {
this.widgetId = id ?: AppWidgetManager.INVALID_APPWIDGET_ID
}
fun setTitleText(title: String?): AppWidgetHelper = apply {
this.title = title
}
fun setIcon(@DrawableRes iconRes: Int?): AppWidgetHelper = apply {
this.iconRes = iconRes
}
fun setDescriptionText(description: String?): AppWidgetHelper = apply {
this.description = description
}
fun setOnClickListener(pendingIntent: () -> PendingIntent): AppWidgetHelper = apply {
this.pendingIntent = pendingIntent
}
fun build() {
// layout_covid_appwidget.xml 이 홈 위젯에 추가
RemoteViews(context.packageName, R.layout.layout_covid_appwidget).apply {
// 사용자가 입력한 텍스트와 이미지가 위젯에 보여지도록 설정
setTextViewText(R.id.title, title ?: "")
setTextViewText(R.id.description, description ?: "")
iconRes?.let { icon -> setImageViewResource(R.id.icon, icon) }
// layout에서 rootLayout을 클릭하면, 정의해주었던 PendingIntent가 작동
setOnClickPendingIntent(R.id.root, pendingIntent?.invoke())
}.also { views ->
// 위젯 업데이트
appWidgetManager.updateAppWidget(widgetId, views)
}
val resultValue = Intent().apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
}
(context as AppCompatActivity).apply {
setResult(Activity.RESULT_OK, resultValue)
finish()
}
}
}
class AddWidgetActivity : AppCompatActivity(), OnSelectIconDismissListener {
...
private var appWidgetId: Int? = null
@DrawableRes private var iconResId: Int? = null
private val appWidgetHelper: AppWidgetHelper by lazy {
AppWidgetHelper(this)
}
...
private fun initData() {
// appWidgetId 를 가져온다.
appWidgetId = intent.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
dataBinding.saveButton.setOnClickListener {
createAppWidget()
}
}
// helper class 를 이용해 app widget 을 생성한다.
private fun createAppWidget() {
appWidgetHelper.onInitialize()
.setWidgetId(appWidgetId)
.setTitleText(viewModel.titleText.value)
.setDescriptionText(viewModel.descriptionText.value)
.setIcon(iconResId)
.setOnClickListener {
Intent(this, MainActivity::class.java)
.let { intent ->
PendingIntent.getActivity(this, 0, intent, 0)
}
}
.build()
}
PrivateConst클래스가 빠져있어, 빌드가 안되네요. commit 가능하실까요?