たくさんの自由帳
Androidのお話
投稿日 : | 0 日前
文字数(だいたい) : 36533
どうもこんばんわ。
スタディ§ステディ2 攻略しました、えちえちでした

E-mote 搭載だから立ち絵がめっちゃ動く、
本編関係ないけど UI もアニメーション頑張っててすごいと思った(こなみかん、大変そう


やえちゃん!!
前作ヒロイン?とのやりとりがあったんですけどやえちゃんルートのが好み
なまりかわいい

由乃ちゃんかわいいのでぜひ!!!


この目すき


イベントCG貼るわけには行かないけどヒロインも背景もめっちゃきれいでした、すごい
過去の話とかちょろっと出てくるので由乃ちゃんルートは最後が良いかも?
NewRadioSupporter にウィジェットを追加しました!お待たせしちゃいました
ホーム画面からすぐ確認できます!!!

大きいサイズも作りました、
後述しますがJetpack Glanceが面倒なことを全部肩代りしてくれたので大きいサイズも難しくないです。

(小さいアプリみたいで結構気に入ってる)
どうでもいいけどドコモのn28見つけた、わーい
高いキャリア版買ってよかった(まぁミリ波アンテナほしかったし...)
で、今回はこのNewRadioSupporterでウィジェットを作るために使ったJetpack Glanceのお話です。
https://developer.android.com/jetpack/compose/glance
Jetpack Composeな文法で、Androidのホーム画面ウィジェットが作れる。
うーんxml + RemoteViewsでウィジェットを作ったことがある身からすると現実味が無いんだけど、確かに動いているんですよね。
class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
Text("Hello World")
}
}
}
なんで動いてるか分からんくて本当に魔法みたい
不思議でたまらないけど、、、もしこれなら難しすぎて地獄のxml + RemoteViewsから開放されるってことでいい?
レビューに来てたし私も欲しかったんですけど、、、
あ、この部分は読んでも読まなくてもどっちでもいいです。
しばらく放置されていたイメージ、、あと後方互換のために下手に手を出せなかった可能性。
古いから情報があるのかと思うとあんまり...。
実際公式ドキュメントの日本語版はいつのスクリーンショットだよって言いたくなる、、はぁ
(日本語版なんて見るなという話ではある
決まったViewしか使えない!
https://developer.android.com/reference/android/widget/RemoteViews
ConstraintLayoutも使えません。RelativeLayoutとか今更やるくらいならLinearLayoutとかFrameLayout使ったほうが良さそう。
あと、xmlがベースなので、ちょっと凝った事するとめんどいです。角を丸くしたい場合はカスタムDrawableを作らないと行けない、Jetpack Composeだと一発なのにつらいね。
また、findViewByIdは使えない。じゃあどうやってテキストや画像をセットするんだって話ですが、
RemoteViewsにテキスト設定メソッド、画像設定メソッドがあるので、xmlのandroid:idと値をそれぞれセットしていく形になります。
https://developer.android.com/reference/android/widget/RemoteViews#setTextViewText(int,%20java.lang.CharSequence)
val views = RemoteViews(context.packageName, R.layout.music_control_widget).apply {
setTextViewText(R.id.widget_title, "title")
setTextViewText(R.id.widget_album, "album")
setTextViewText(R.id.widget_artist, "artist")
}
Visibility変更や、Drawableセットなんかの基本的なやつはありますが、あんまり凝ったメソッドまではないので出来ない事もある。
一応、リフレクションじみた事ができますが、、、これも万能ではないらしく、LinearLayoutのsetOrientationしようとしたら落ちた
https://developer.android.com/develop/ui/views/appwidgets/enhance#use-runtime-mod-of-remoteviews
あとつらいのがリスト系。GmailとかGoogle Keepとかのリストでアイテム増やしたりできるやつ。あれが難しい
最近はドキュメントが充実してて良い感じなのですが、リストの各アイテムを押せるようにするためには、あらかじめメソッドを呼び出す必要があるとか、
リストへデータを渡すためのクラス、なんかよくわからないやつを継承するんだけど、実装しないといけないメソッドが多く圧倒される。
レイアウトを変更させると?、ウィジェットは利用できませんみたいな表示になって、置き直さないといけない
アプリアイコン長押し→ウィジェットを押すとすぐ再設置できます(時短テク
https://support.google.com/android/answer/9450271?hl=ja#zippy=%2Cウィジェットを追加するサイズ変更する
Logcat追ってったらクラッシュするようなコード書いてたうわーみたいな。
ワナが多すぎる
前述の通りfindViewByIdが使えないので、setOnClickListener { }なんてものは使えない。
なので、あらかじめPendinIntentという押した時に発行するIntentを設定しておきます。(すぐ使うわけじゃないのでPendingなIntent)
アプリの画面を開くPendingIntent.getActivity、サービスを起動するPendingIntent.getService、任意のコードを呼ぶためのコールバックを受け取るPendinIntent.getBroadcastなど。
任意のコードを呼ぶためのコールバックを受け取るやつはAppWidgetProvider#onReceiveに書く。すぐブロードキャストレシーバーが使える状態にはなってる。
PendingIntentを作る際は、このクラス(以下の例だとExampleWidget)に向けたPendingIntentを作ってセットすれば良い。
contextがもらえるけど、Activityのcontextではないのでダイアログは出せない。
Kotlin Coroutines の suspend 関数を呼びだければgoAsync()メソッドを見てみてください。
class ExampleWidget : AppWidgetProvider() {
/** ブロードキャスト受け取り */
override fun onReceive(context: Context?, intent: Intent?) {
// Intent に PendinIntent でセットした Intent があるはず
// 押したら Toast を出すなど
// ここでは Context#startActivity は使えないので、PendingIntent.getActivity を使う
}
}
ぱっと見何も難しいことはないように見えるが、、、なんかたまによく反応しない時がたまによくあるんですよね。
PendingIntentの引数に渡したrequestCodeが重複していると押してもBroadcastReceiverが反応しない・・・から重複しないようにすれば直るとか...
ウィジェットを再設置するとボタンが反応するようになる時がある...とか。
難しすぎる
書き出してみるとそんなに無いな...確かにしんどかった記憶はあるんだけど。
これらの問題が結構解決します!!
xmlで書く必要がないのでとても見やすい。やっぱレイアウトと行き来するのつらいよな。
使えるコンポーネントはandroidx.glanceパッケージ傘下にあるComposable 関数に限定される。最終的にはRemoteViewsに変換するため致し方ない。
(なので、よってJetpack ComposeとUIコンポーネントを相互利用できるわけではない)
ただ、UI関係ないComposable 関数は使えちゃいます!
Flow#collectAsStateとかLaunchedEffect { }とか。なんで動くのか本当に不思議
そして何より嬉しいのが、リスト系がめちゃめちゃ簡単に作れるようになった
LazyColumnがGlanceでも動く。本当に感動。こんな楽していいのか?
どういうことかというと、Jetpack GlanceはJetpack Composeの文法で書かれたUIをRemoteViewsに変換するために、Composable 関数を監視しておく必要があるわけで、
その監視のための時間があるわけです。その間は動的なので、remember { mutableStateOf() }なんかも使えちゃうわけです。
class GlanceCountWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) = provideContent {
// カウンター
// TODO 再起動とかで値をロストするので、永続化が必要
var counter by remember { mutableStateOf(0) }
// テーマの設定
GlanceTheme(colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors else colors) {
// 横並び
Row(
modifier = GlanceModifier
.fillMaxSize()
.padding(5.dp)
.background(GlanceTheme.colors.secondaryContainer)
.cornerRadius(16.dp),
horizontalAlignment = Alignment.Horizontal.CenterHorizontally,
verticalAlignment = Alignment.Vertical.CenterVertically
) {
Button(
modifier = GlanceModifier.size(50.dp),
text = "-1",
onClick = { counter-- }
)
Text(
modifier = GlanceModifier.defaultWeight(),
text = counter.toString(),
style = TextStyle(
fontSize = 20.sp,
textAlign = TextAlign.Center
)
)
Button(
modifier = GlanceModifier.size(50.dp),
text = "+1",
onClick = { counter++ }
)
}
}
}
companion object {
/** ウィジェットの色 */
val colors = ColorProviders(
light = LightColorScheme,
dark = DarkColorScheme
)
}
}
完全なコード→ https://github.com/takusan23/GlanceCountWidget
最新の Android Studio でビルドできるはずです。

Jetpack GlanceでFlow#collectAsStateやLaunchedEffect { }が動くのは、この動的な間が存在するからなんですね。
ちなみに、数十秒間の間しか動かないので、もし状態を保持しておきたい(上の例だとカウントを引き継ぎたい)場合はSharedPreferencesやDataStore等の保存するためのシステムを用意する必要があります。
Jetpack ComposeからRemoteViewsに変換する部分でWorkManager?が使われていたり、非同期処理にKotlin Coroutinesが使われています。神!!!
Jetpack Glance専用のGlanceModifierが用意されています。
角を丸くする処理がJetpack Composeと同じく一行で書けます。
もうカスタムDrawable作らずに済みます・・・!
これもめっちゃ簡略化されていて、なんとGlanceModifier.clickable { }を使うだけです。
内部ではBroadcastReceiverが動いているため、Context#startActivityが呼び出せない等の成約があるものの、BroadcastReceiver#onReceiveにクリックイベントを書くより何倍も分かりやすい!!!
もちろんActivityを開く方法もちゃんとあります。
今回は写真を一覧表示するウィジェットでも作ってみましょう。
ただ、それだと既にあると思うので、写真を押したらウィジェット内で表示するように(一覧表示から1つだけ)してみます。
本当にミニアプリを目指していきます。
いや~~~これRemoteViewsでやろうとするとめっちゃだるいやろなぁ
こうしき https://developer.android.com/jetpack/compose/glance
| なまえ | あたい |
|---|---|
| Android Studio | Android Studio Giraffe 2022.3.1 Patch 1 |
| 端末 | Xperia 1 V / Google Pixel 6 Pro |
| targetSdk | 34 (Android 14) |
Jetpack Composeが入っているプロジェクトである必要があります。新規に作るならEmpty Compose Project?
Jetpack Composeない場合はまず入れるところからですね
Empty Compose Projectで、後は適当に、
Android最低バージョンは6が良いらしい(Jetpack Glanceが5をサポートしてないとかなんとか)

ktsを使うかはおまかせします、新規で作るならktsで良いかも、今あるプロジェクトを書き換えてまではメリットなさそう。
appの中のbuild.gradle.kts(もしくはbuild.gradle)で、以下を足す
glanceと、画像読み込みライブラリのGlideです。
dependencies {
// これら
// For AppWidgets support
implementation ("androidx.glance:glance-appwidget:1.0.0")
// For interop APIs with Material 3
implementation ("androidx.glance:glance-material3:1.0.0")
// 画像読み込みライブラリ
implementation("com.github.bumptech.glide:glide:4.16.0")
// 以下省略...
あとは、targetSdkを34にします。
android {
namespace = "io.github.takusan23.touchphotowidget"
compileSdk = 34 // ここ
defaultConfig {
applicationId = "io.github.takusan23.touchphotowidget"
minSdk = 23
targetSdk = 34 // ここ
versionCode = 1
versionName = "1.0"
// 以下省略...
ウィジェットのメタデータ(最小幅とか、ウィジェット設定用 Activity の指定)を書く。
resの中にxmlがあるはずなので、そこに適当にtouch_photo_widget_info.xmlみたいなのを置く

後は適当にコピペします。
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:minWidth="40dp"
android:minHeight="40dp"
android:previewImage="@drawable/ic_launcher_foreground"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen" />
これらのメタデータの詳細は以下。
https://developer.android.com/develop/ui/views/appwidgets#other-attributes
descriptionとかpreviewImageはちゃんとしておいたほうが良い
2つ用意します。
まずは GlanceAppWidgetを継承したクラスを作ります。
class TouchPhotoWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
// TODO この後すぐ!
}
}
次に、GlanceAppWidgetReceiverを継承したクラスも作ります。
glanceAppWidgetは、↑で作ったGlanceAppWidgetを継承したクラスのインスタンスを渡せば大丈夫
class TouchPhotoWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = TouchPhotoWidget()
}
多分こうなってるはず。

<receiver>を書きます。
android:name=".TouchPhotoWidgetReceiver"と、android:resource="@xml/touch_photo_widget_info"は各自違う値になるはず!
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TouchPhotoWidget"
tools:targetApi="31">
<!-- 詳細は... -->
<!-- <application> の中に↓を書く -->
<receiver
android:name=".TouchPhotoWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/touch_photo_widget_info" />
</receiver>
</application>
</manifest>
TouchPhotoWidget(GlanceAppWidgetを継承したクラス)を開き、provideGlanceの中で、provideContent { }を呼び出します。
provideContentのブロック内はComposableなので、あとはここにレイアウトを書いていくだけです!
まじで魔法みたいに動く。
class TouchPhotoWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// Composable
}
}
}
適当に、Hello Worldと、あと押したらアプリを起動するようにするにはこんな感じで。
前述の通り、Glance用のコンポーネントを呼び出す必要があるので、importの際は注意してください
(GlanceModifierが引数に入っているか、パッケージ名がandroidx.glanceで始まっているか)

class TouchPhotoWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
Box(
modifier = GlanceModifier
// match_parent
.fillMaxSize()
// 背景色
.background(Color.White)
// 押したら Activity 起動
.clickable(actionStartActivity<MainActivity>()),
contentAlignment = Alignment.Center
) {
Text(
text = "Hello World",
style = TextStyle(fontSize = 24.sp)
)
}
}
}
}
あとは実行して、実際にウィジェットを置いてみてください、
どうでしょう、ちゃんとHello Worldが出て、押して起動しますか?
RemoteViews時代よりずっっっっっっっと簡単になりましたね、感動
が、その前に写真の取得の話をしないとなんですよね。
Android 13以上をターゲットにする場合(今回は14なのでもちろんこちらの対象)、READ_EXTERNAL_STORAGEではなくREAD_MEDIA_IMAGESの権限を宣言してリクエストする必要があるそう。
ただし、この権限は13からなので、それ以前のAndroidをサポートする場合は引き続きREAD_EXTERNAL_STORAGEを宣言してリクエストする必要がある。
(ちなみにドキュメントに書いてあるかわかんないですが、自分が作った写真(自分のアプリからContentResolver#insert)の場合は↑の権限無しで取得できたはず。。。)
パスを渡す方法は取れません(Android 10からのScoped Storageのせい)。つまり、以下のような方法は取れません
val file = File("sdcard/DCIM/Example.jpg")
じゃあどうするんだって話ですが、Androidでは画像等のメディアはデータベースみたいなやつに問い合わせると取得できるようになります。
MediaStoreとかContentResolverとか言われてるやつです。
例です、こんな感じだと思う。
SQLっぽいやつで取得したいメディアを取り出して、cursorで上から舐めていきます。
もちろん、これを実行する前に権限があるかのチェックが必要です。
(0 until 10).map { }とかで指定した数の配列を作れます。書いてて楽しいKotlin
val uriList: List<Uri> = context.contentResolver.query(
// 保存先、SDカードとかもあったはず
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
// 今回はメディアの ID を取得。他にもタイトルとかあります
// 実際のデータの取得には、まず ID を取得する必要があります
arrayOf(MediaStore.MediaColumns._ID),
// SQL の WHERE。ユーザー入力が伴う場合はプレースホルダーを使いましょう
null,
// SQL の WHERE のプレースホルダーの値
null,
// SQL の ORDER BY
null
)?.use { cursor ->
// 一応最初に移動しておく
cursor.moveToFirst()
// 配列を返す
(0 until cursor.count)
.map {
// ID 取得
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
// Uri の取得
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
// 次のレコードに移動
cursor.moveToNext()
// 返す
uri
}
} ?: emptyList()
AndroidManifest.xmlに書き足します。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Android 12 まではこちらの権限が必要 -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Android 13 からはこちら -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 以下省略 -->
MainActivityに書きます。
権限がなければ要求するボタンを出します。簡単になりましたね。
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val context = LocalContext.current
val isGranted = remember {
// 初期値は権限があるか
mutableStateOf(ContextCompat.checkSelfPermission(context, REQUEST_PERMISSION) == PackageManager.PERMISSION_GRANTED)
}
// 権限コールバック
val requestPermission = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {
isGranted.value = it
}
TouchPhotoWidgetTheme {
Scaffold(
topBar = { TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) }) }
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGranted.value) {
Text(text = "ホーム画面を長押しして、ウィジェットを追加してください")
} else {
Text(text = "写真を取得する権限が必要です")
Button(onClick = {
requestPermission.launch(REQUEST_PERMISSION)
}) { Text(text = "権限をリクエストする") }
}
}
}
}
}
}
companion object {
/** 必要な権限 */
val REQUEST_PERMISSION = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
}
}
画像を読み込むユーティリティクラスを用意します!!!
IDをとって、Uriにして、Bitmapを取得する感じです。
、、、と思ってたんですけど、メモリ使い過ぎで怒られたため、GlideというライブラリでBitmapを読み込むようにしました。
object PhotoTool {
/**
* 写真を取得する
*
* @param context [Context]
* @param limit 上限
* @param size [Bitmap] の大きさ
* @return [PhotoData] の配列
*/
suspend fun getLatestPhotoBitmap(
context: Context,
limit: Int = 20,
size: Int = 200
): List<PhotoData> = withContext(Dispatchers.IO) {
val contentResolver = context.contentResolver
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection = arrayOf(MediaStore.MediaColumns._ID)
val sortOrder = "${MediaStore.MediaColumns.DATE_ADDED} DESC"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// LIMIT が使える
contentResolver.query(
uri,
selection,
bundleOf(
ContentResolver.QUERY_ARG_LIMIT to limit,
ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder
),
null
)
} else {
// 使えないので、取り出す際にやる
contentResolver.query(
uri,
selection,
null,
null,
sortOrder
)
}?.use { cursor ->
cursor.moveToFirst()
// 返す
(0 until min(cursor.count, limit))
.map {
// コンテンツの ID を取得
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
cursor.moveToNext()
id
}
.map { id ->
// ID から Uri を取得
ContentUris.withAppendedId(uri, id)
}
.map { uri ->
// Uri から Bitmap を返す
// Glide で小さくしてから Bitmap を取得する
PhotoData(getBitmap(context, uri, size), uri)
}
} ?: emptyList()
}
/**
* 画像をロードする
* Glide を使うので小さくして Bitmap を返せます
*
* @param context [Context]
* @param uri [Uri]
* @param size サイズ
* @return [Bitmap]
*/
suspend fun getBitmap(
context: Context,
uri: Uri,
size: Int,
): Bitmap = withContext(Dispatchers.IO) {
Glide.with(context)
.asBitmap()
.load(uri)
.submit(size, size)
.get()
}
/**
* [Bitmap] と [Uri] のデータクラス
*/
data class PhotoData(
val bitmap: Bitmap,
val uri: Uri
)
}
あとはウィジェット上で画像を表示するだけ!
Jetpack Composeみたいな感じで書いていけば良いはず。LocalContextはなさそうなので、引数にあるContextをバケツリレーすると良さそう
class TouchPhotoWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// 押した画像、選択していない場合は null
val selectPhoto = remember { mutableStateOf<PhotoTool.PhotoData?>(null) }
// 画像一覧
val bitmapList = remember { mutableStateOf(emptyList<PhotoTool.PhotoData>()) }
// 画像をロード
LaunchedEffect(key1 = Unit) {
bitmapList.value = PhotoTool.getLatestPhotoBitmap(context)
}
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color.White)
) {
if (selectPhoto.value != null) {
// 選択した画像がある
PhotoDetail(
photoData = selectPhoto.value!!,
onBack = { selectPhoto.value = null }
)
} else {
// 一覧表示
PhotoGridList(
photoDataList = bitmapList.value,
onClick = { bitmap -> selectPhoto.value = bitmap }
)
}
}
}
}
/**
* グリッド表示で写真を表示する
*
* @param context [Context]
* @param onClick 写真を押したら呼ばれる
*/
@Composable
fun PhotoGridList(
photoDataList: List<PhotoTool.PhotoData>,
onClick: (PhotoTool.PhotoData) -> Unit
) {
LazyVerticalGrid(
modifier = GlanceModifier.fillMaxSize(),
gridCells = GridCells.Fixed(4)
) {
items(photoDataList) { photoData ->
Image(
modifier = GlanceModifier
.fillMaxWidth()
.height(100.dp)
.clickable { onClick(photoData) },
provider = ImageProvider(photoData.bitmap),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
}
}
/**
* 写真の詳細画面
*
* @param photoData [PhotoTool.PhotoData]
* @param onBack 戻る押した時
*/
@Composable
fun PhotoDetail(
photoData: PhotoTool.PhotoData,
onBack: () -> Unit
) {
// 画像アプリで開くための Intent
// data に Uri を渡すことで対応しているアプリをあぶり出す
val intent = remember { Intent(Intent.ACTION_VIEW, photoData.uri) }
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(5.dp)
) {
Button(
modifier = GlanceModifier.padding(10.dp),
text = "戻る",
onClick = onBack
)
Spacer(modifier = GlanceModifier.defaultWeight())
Button(
modifier = GlanceModifier.padding(10.dp),
text = "開く",
onClick = actionStartActivity(intent)
)
}
Image(
modifier = GlanceModifier.fillMaxSize(),
provider = ImageProvider(photoData.bitmap),
contentDescription = null
)
}
}
}
こんな感じに一覧表示されてて、

押すと一枚だけ表示されます!

戻るボタンを押すと戻れます。あと開くを押すとアプリを選択する画面が出ます!
Intent(Intent.ACTION_VIEW, photoData.uri)←この第2引数にUriを入れるやつ

ちなAndroid 14で押したら落ちた
PendingIntentの引数をFLAG_IMMUTABLEにしないといけないんだけど、現状はGlanceが内部でPendingIntentを作っているので直せない。
RemoteViewsを作って、PendingIntentを渡して、そのRemoteViewsをGlanceで使う(相互利用ができる)しかなさそう・・・
Unrecognized Action: androidx.glance.appwidget.action.StartActivityIntentAction@3cb3c84
java.lang.IllegalArgumentException: io.github.takusan23.touchphotowidget: Targeting U+ (version 34 and above) disallows creating or retrieving a PendingIntent with FLAG_MUTABLE, an implicit Intent within and without FLAG_NO_CREATE and FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT for security reasons. To retrieve an already existing PendingIntent, use FLAG_NO_CREATE, however, to create a new PendingIntent with an implicit Intent use FLAG_IMMUTABLE.
at android.os.Parcel.createExceptionOrNull(Parcel.java:3061)
at android.os.Parcel.createException(Parcel.java:3041)
at android.os.Parcel.readException(Parcel.java:3024)
at android.os.Parcel.readException(Parcel.java:2966)
at android.app.IActivityManager$Stub$Proxy.getIntentSenderWithFeature(IActivityManager.java:6568)
at android.app.PendingIntent.getActivityAsUser(PendingIntent.java:571)
at android.app.PendingIntent.getActivity(PendingIntent.java:552)
at androidx.glance.appwidget.action.ApplyActionKt.getPendingIntentForAction(ApplyAction.kt:82)
at androidx.glance.appwidget.action.ApplyActionKt.getPendingIntentForAction$default(ApplyAction.kt:73)
まずGlanceThemeが使えるように、親をGlanceThemeで囲います。
// テーマ機能
GlanceTheme(
colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GlanceTheme.colors
} else {
colors
}
) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.secondaryContainer)
) {
if (selectPhoto.value != null) {
// 選択した画像がある
PhotoDetail(
photoData = selectPhoto.value!!,
onBack = { selectPhoto.value = null }
)
} else {
// 一覧表示
PhotoGridList(
photoDataList = bitmapList.value,
onClick = { bitmap -> selectPhoto.value = bitmap }
)
}
}
}
Android 12以降はDynamic Color ( Material You )を使います。
それ以下で使うためのcolorsは、Empty Compose Project書いた時に付いてきたやつを使うことにします。
companion object {
val colors = ColorProviders(
light = LightColorScheme,
dark = DarkColorScheme
)
}
背景の色は、secondaryContainerにすると良さそうです。
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.secondaryContainer)
) { }
ボタンの色は、アイコンだけのやつだとprimaryっぽい。
塗りつぶしアイコンの場合は、塗りつぶしがprimaryで、アイコンの色がprimaryContainerっぽい(Containerが逆転!?)
あと適当にアイコンを持ってきました。Iconは多分なさそうなので、Imageを使うであってそう。
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(5.dp)
) {
// 戻るボタン
Image(
modifier = GlanceModifier
.size(40.dp)
.padding(5.dp)
.cornerRadius(10.dp)
.clickable(onBack),
provider = ImageProvider(resId = R.drawable.outline_arrow_back_24),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary)
)
Spacer(modifier = GlanceModifier.defaultWeight())
// アプリを開く
// 塗りつぶし
Image(
modifier = GlanceModifier
.size(40.dp)
.padding(5.dp)
.background(GlanceTheme.colors.primary)
.cornerRadius(10.dp)
.clickable(actionStartActivity(intent)),
provider = ImageProvider(resId = R.drawable.outline_open_in_new_24),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.primaryContainer)
)
}
こんな感じにしてみた。他のウィジェットも同じ感じの色使いしてそう!

ちなみに、primaryContainerは実際にはAndroidのカラーリソースのIDを指している(android.R.color.xxxxxみたいな)ので、以下のcolors.xmlを見ると実際のIDを見ることが出来ます。
primaryは@android:color/system_accent1_600を指定するのと同じ働きをするみたいですね!
これだと、横幅に関係なく4つ表示しているので、幅がないときは2とかにしたい!みたいなことが出来ます。
これもJetpack Glanceなら簡単にできるのでやります。(RemoteViewsだったらやりたくない
まずは大きさを定義して
companion object {
/** 小さいサイズ */
private val SMALL = DpSize(width = 100.dp, height = 100.dp)
/** 大きいサイズ */
private val LARGE = DpSize(width = 250.dp, height = 100.dp)
}
次に、GlanceAppWidgetのsizeModeをオーバーライドして、SizeMode.Responsiveを作って返してあげます。
class TouchPhotoWidget : GlanceAppWidget() {
/** ウィジェットの利用可能なサイズ。通常と横に長いサイズ */
override val sizeMode = SizeMode.Responsive(setOf(SMALL, LARGE))
あとは、LocalSizeが使えるようになるので、グリッド表示コンポーネントの横並びセル数の部分で使います。
/**
* グリッド表示で写真を表示する
*
* @param context [Context]
* @param onClick 写真を押したら呼ばれる
*/
@Composable
fun PhotoGridList(
photoDataList: List<PhotoTool.PhotoData>,
onClick: (PhotoTool.PhotoData) -> Unit
) {
// ウィジェットの大きさによって横に並べる数を変える
val gridSize = if (LocalSize.current.width >= LARGE.width) 4 else 2
LazyVerticalGrid(
modifier = GlanceModifier.fillMaxSize(),
gridCells = GridCells.Fixed(gridSize)
) {
// 以下省略...
これで、横幅が小さいときは横並びが2つになりました。
多分もっと刻むことができるはずです。いや~~楽ですねこれ

ちなみに、普通にこーゆーこともできるので、レイアウト全体を変えることも出来ます。
本当になんで動いてるか不思議だ...
val size = LocalSize.current
// サイズによってレイアウトを切り替える
if (size.width >= LARGE.width) {
LargeWidgetContent()
} else {
SmallWidgetContent()
}
class TouchPhotoWidget : GlanceAppWidget() {
/** ウィジェットの利用可能なサイズ。通常と横に長いサイズ */
override val sizeMode = SizeMode.Responsive(setOf(SMALL, LARGE))
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// 押した画像、選択していない場合は null
val selectPhoto = remember { mutableStateOf<PhotoTool.PhotoData?>(null) }
// 画像一覧
val bitmapList = remember { mutableStateOf(emptyList<PhotoTool.PhotoData>()) }
// 画像をロード
LaunchedEffect(key1 = Unit) {
bitmapList.value = PhotoTool.getLatestPhotoBitmap(context)
}
// テーマ機能
GlanceTheme(
colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GlanceTheme.colors
} else {
colors
}
) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.secondaryContainer)
) {
if (selectPhoto.value != null) {
// 選択した画像がある
PhotoDetail(
photoData = selectPhoto.value!!,
onBack = { selectPhoto.value = null }
)
} else {
// 一覧表示
PhotoGridList(
photoDataList = bitmapList.value,
onClick = { bitmap -> selectPhoto.value = bitmap }
)
}
}
}
}
}
/**
* グリッド表示で写真を表示する
*
* @param context [Context]
* @param onClick 写真を押したら呼ばれる
*/
@Composable
fun PhotoGridList(
photoDataList: List<PhotoTool.PhotoData>,
onClick: (PhotoTool.PhotoData) -> Unit
) {
// ウィジェットの大きさによって横に並べる数を変える
val gridSize = if (LocalSize.current.width >= LARGE.width) 4 else 2
LazyVerticalGrid(
modifier = GlanceModifier.fillMaxSize(),
gridCells = GridCells.Fixed(gridSize)
) {
items(photoDataList) { photoData ->
Image(
modifier = GlanceModifier
.fillMaxWidth()
.height(100.dp)
.clickable { onClick(photoData) },
provider = ImageProvider(photoData.bitmap),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
}
}
/**
* 写真の詳細画面
*
* @param photoData [PhotoTool.PhotoData]
* @param onBack 戻る押した時
*/
@Composable
fun PhotoDetail(
photoData: PhotoTool.PhotoData,
onBack: () -> Unit
) {
// 画像アプリで開くための Intent
// data に Uri を渡すことで対応しているアプリをあぶり出す
val intent = remember { Intent(Intent.ACTION_VIEW, photoData.uri) }
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(5.dp)
) {
// 戻るボタン
Image(
modifier = GlanceModifier
.size(40.dp)
.padding(5.dp)
.cornerRadius(10.dp)
.clickable(onBack),
provider = ImageProvider(resId = R.drawable.outline_arrow_back_24),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary)
)
Spacer(modifier = GlanceModifier.defaultWeight())
// アプリを開く
// 塗りつぶし
Image(
modifier = GlanceModifier
.size(40.dp)
.padding(5.dp)
.background(GlanceTheme.colors.primary)
.cornerRadius(10.dp)
.clickable(actionStartActivity(intent)),
provider = ImageProvider(resId = R.drawable.outline_open_in_new_24),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.primaryContainer)
)
}
Image(
modifier = GlanceModifier.fillMaxSize(),
provider = ImageProvider(photoData.bitmap),
contentDescription = null
)
}
}
companion object {
/** Material You が使えない用 */
val colors = ColorProviders(
light = LightColorScheme,
dark = DarkColorScheme
)
/** 小さいサイズ */
private val SMALL = DpSize(width = 100.dp, height = 100.dp)
/** 大きいサイズ */
private val LARGE = DpSize(width = 250.dp, height = 100.dp)
}
}
最後にこれ作ってたり、NewRadioSupporterのウィジェット作ってたときの知見を残します。
provideContent { }は45秒間動き続ける?
collectAsStateとかが動くのかGlanceAppWidget#provideGlanceのドキュメントを見てください。TouchPhotoWidget().update()とかTouchPhotoWidget().updateAll()とかprovideContent { }が動いているときは特に何もしなさそう
WorkManagerが動いている間?は既にウィジェット動いてるので特に何もしない?GlanceModifier.clickable { }の際、provideContent { }が起動していなかったら起動するぽい
WorkManagerが裏で動く
45秒のタイムアウトも延長するらしいRemoteViewsだから仕方ないのかもこれもiOSでウィジェットが入った影響みたいなのがあるんですかね?
もしも入らなければ、Androidもウィジェット周りの改修が入らず地獄のままだったのでしょうか・・・ありがとうiOS
ソースコード置いておきます。最新のAndroid Studioで実行できるはずです。
https://github.com/takusan23/TouchPhotoWidget
BroadcastReceiverで思い出した、レシーバーのスペル、たまにRecieverって書いちゃう
NewRadioSupporter 審査出ししました
