たくさんの自由帳
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
審査出ししました