logo logo_atte 日記 随筆 何処
アンドロイド覚書
1 カメラ
1.1 フラグメントから撮影するならファイル名はaugumentsに格納しろ
アンドロイドでカメラを起動してJPEG画像として受け取る場合は以下がテンプレート。公式の通りである。
  1. private lateinit var photoFile: String
  2. private fun takePicture() {
  3. Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { picIntent ->
  4. photoFile = 画像を受け取るファイル(空でよい)のファイル名
  5. val photoURI = カメラアプリが書込可能なURIに変換
  6. picIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
  7. startActivityForResult(picIntent, リクエストコード)
  8. }
  9. }
  10. override fun onActivityResult(reqCode: Int, resCode: Int, resData: Intent?) {
  11. super.onActivityResult(reqCode, resCode, resData)
  12. if (resCode == Activity.RESULT_OK) when (reqCode) {
  13. リクエストコード -> {
  14. val photoFile = File(photoFile)
  15. 撮影された画像がphotoFileに書き込まれているのでファイル処理
  16. }
  17. }
  18. }
しかし、これはフラグメントでは正常に動作しない。原因は以下。
  1. カメラアプリを起動する
  2. カメラアプリにより起動元のアプリが隠される
  3. 非表示となった元アプリは(メモリが少なくなると)削除される
  4. カメラアプリから戻った時に元アプリのアクティビティ(フラグメントも)が再作成
  5. この時photoFileはnullになってる(16行目)のでNullPointerException
よって、photoPathをarguments(再作成で破壊されない)に格納するようにする。
  1. private fun takePicture() {
  2. Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { picIntent ->
  3. arguments?.putString("photoPath", 画像を受け取るファイル名)
  4. val photoURI = カメラアプリが書込可能なURIに変換
  5. picIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
  6. startActivityForResult(picIntent, リクエストコード)
  7. }
  8. }
  9. override fun onActivityResult(reqCode: Int, resCode: Int, resData: Intent?) {
  10. super.onActivityResult(reqCode, resCode, resData)
  11. if (resCode == Activity.RESULT_OK) when (reqCode) {
  12. リクエストコード -> arguments?.getString("photoPath")?.also {
  13. val photoFile = File(it)
  14. 撮影された画像がphotoFileに書き込まれているのでファイル処理
  15. }
  16. }
  17. }
考えてみれば当たり前。アクティビティやフラグメントのライフサイクルも、画面の再作成のことも知ってたのになぁ。再現性なし、ログなし、実機でのみ落ちる(AVDでは正常終了)の三重苦で時間がかかった。
2 Realm + RecyclerView
RealmRecyclerViewAdapterを利用してautoUpdateをtrueにしている場合、データの変更に追随してビューを自動更新してくれるので非常に便利である。しかしながら、ちょっと凝ったことをやろうとすると、自動で行なわれる便利機能(余計な機能)が邪魔になって期待する動作にならない。
2.1 Drag&Dropによる移動とSwipeによる削除
普通のRecyclerView.adapterを使うよくあるテンプレートは以下。
  1. (adapterへの参照が可能であるとする)
  2. val helper = ItemTouchHelper(object: SimpleCallback(
  3. ItemTouchHelper.UP or ItemTouchHelper.DOWN,
  4. ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
  5. ) {
  6. override fun onMove(recyclerView: RecyclerView,
  7. viewHolder: RecyclerView.ViewHolder,
  8. target: RecyclerView.ViewHolder): Boolean {
  9. val fromPos = viewHolder.adapterPosition
  10. val toPos = target.adapterPosition
  11. // アダプタに渡したデータの順序変更処理
  12. adapter.notifyItemMoved(fromPos, toPos)
  13. return true
  14. }
  15. override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
  16. // アダプタに渡したデータの削除処理
  17. adapter.notifyItemRemoved(viewHolder.adapterPosition)
  18. }
  19. })
Realmを採用している場合、アダプタが適切に(余計に)ビューを更新してくれるため、ビューにデータの変更を通知するメソッドが正しく動作しない。アドホックに見つけた解決方法は次の通り。
  1. 項目移動は後ろへの移動の場合のみnotifyItemMovedを呼ぶ
  2. 項目削除は削除する項目以降を指定してnotifyItemRangeChangedを呼ぶ
これでどうやら正しく動作しているようだ。プログラムの変更点は以下。
  1. open class Model(
  2. @PrimaryKey
  3. var id: Long = 0L,
  4. var ord: Long = 0L, // 順序用
  5. var text: String = ""
  6. ): RealmObject() {
  7. // Realmデータを処理するメソッド
  8. }
Model
  1. (realmへの参照が可能であるとする)
  2. (adapterへの参照が可能であるとする)
  3. val helper = ItemTouchHelper(object: SimpleCallback(
  4. ItemTouchHelper.UP or ItemTouchHelper.DOWN,
  5. ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
  6. ) {
  7. override fun onMove(recyclerView: RecyclerView,
  8. viewHolder: RecyclerView.ViewHolder,
  9. target: RecyclerView.ViewHolder): Boolean {
  10. val fromPos = viewHolder.adapterPosition
  11. val toPos = target.adapterPosition
  12. val fromPage = adapter.getItem(fromPos) ?: error("no item")
  13. val toPage = adapter.getItem(toPos) ?: error("no item")
  14. if (fromPos < toPos) adapter.notifyItemMoved(fromPos, toPos)
  15. realm.executeTransaction {
  16. fromPage.ord = toPage.ord.also { toPage.ord = fromPage.ord } // 順序を交換
  17. }
  18. return true
  19. }
  20. override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
  21. val i = viewHolder.adapterPosition
  22. val n = adapter.itemCount
  23. adapter.notifyItemRangeChanged(i, n)
  24. realm.executeTransaction {
  25. adapter.getItem(i)?.deleteFromRealm()
  26. }
  27. }
  28. })
Helper
ただし、あまりに速い移動には対応できない。順序の交換ではなく、順序をずらす処理が必要になる。 Swipe削除の際、notifyItemRangeChangedをしないと、削除した項目以降の遷移先がずれる問題は、このページが参考になった。最後に、ソースを読んだわけではないので、なぜこれでうまくいくのかは不明。 Realm+List表示でタッチ動作したいなら、素直にautoUpdateをfalseにしておけと言う気もする。
2.1.1 追記 2020-08-19
いつのまにやら
notifyItemMoved(fromPos, toPos)
がなくても動くようになっていた。いずれかのバージョンアップで訂正されたようだ。 (左記、勘違いであった。) ついでに有用な情報を発見。まぁ、そういうことだわなぁ。これも、これも、これも参考まで。
2.1.2 追記 2020-12-07
autoUpdateをfalseにして、自前でnotifyItemChangedを呼ぶようにしたがダメであった。ひとつだけ入れ変わる場合が問題になるので、上記サンプル14行目は次のように変更した方が良いだろう。
  1. if (1 == toPos - fromPos) {
  2. adapter.notifyItemChanged(toPos)
  3. adapter.notifyItemMoved(fromPos, toPos)
  4. }
in Helper
notifyItemMovedの前に、notifyItemChangedを呼んでおかないと、なぜか「positionが取得できない」とかの例外で落ちる。謎!
3 ファイル共有
3.1 Intentで起動されたアクティビティがバックボタンで終了後もタスクに残る
他アプリから画像を受け取ったアクティビティが、処理終了後にfinish()の呼び出しやバックボタンで元のアプリに戻っても、タスク一覧に起動されたアクティビティが残り続けてしまう。 finish()の代わりにfinishAndRemoveTask()を使えば解決する。こちらの情報が参考になった。が、バックボタンで終了するとダメ。どうやら、バックボタン処理のなかでもfinish()を呼んでいるようだ。こちらの情報が参考になった。う〜む。関係するメソッドをオーバーライドするのも嫌だなぁ。標準と動作が変ってしまうし。ついでに言うと、呼び出し元のアプリによってバックボタンで正しく終了できたりできなかったりする。なんだこりゃ。 Manifestで<activity>属性に android:autoRemoveFromRecents="true"を加えることで解決。こちらの情報が参考になった。要は、バックボタンで終了できるのは、呼び出し元アプリがFLAG_ACTIVITY_RETAIN_IN_RECENTSを設定していない場合、バックボタンで終了できないのは、呼び出し元アプリがFLAG_ACTIVITY_RETAIN_IN_RECENTSを設定している場合のようだ。
4 ラジオボタン
4.1 isCheckedでの判定には要注意
ラジオボタンで選択項目が変更された場合、新しい選択項目のisCheckedがtrueになった後に、以前の選択項目のisCheckedがfalseに変更されるようだ。よって、以下の例ではAからBへ選択を変更してもprocessBは実効されない。
  1. ...
  2. radioButtonA.setOnCheckedChangeListener { dialog, isChecked ->
  3. process()
  4. }
  5. radioButtonB.setOnCheckedChangeListener { dialog, isChecked ->
  6. process()
  7. }
  8. ...
  9. fun process () {
  10. if (radioButtonA.isChecked) {
  11. processA
  12. ...
  13. } else if (radioButtonB.isChecked) {
  14. processB
  15. ...
  16. }
  17. }
sample
Bがtrueに変更されてlistenerが実行された時でも、まだAがfalseになっていない為、 (radioButtonAの判定が先であるので)processAが実行されてしまう。
5 RecyclerView
5.1 Multiple ViewHolder
Sealed Classを利用して複数のViewHolderを切り変える場合の実装。
  1. sealed class ViewHolder(cell: View) : RecyclerView.ViewHolder(cell) {
  2. class TextViewHolder(cell: View) : ViewHolder(cell) {
  3. val content: TextView = cell.findViewById(R.id.scrapContent)
  4. val thumbnail: ImageView = cell.findViewById(R.id.scrapThumbnail)
  5. }
  6. class EditViewHolder(cell: View) : ViewHolder(cell) {
  7. val content: EditText = cell.findViewById(R.id.scrapContent)
  8. val thumbnail: ImageView = cell.findViewById(R.id.scrapThumbnail)
  9. }
  10. }
  11. override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
  12. val inflater = LayoutInflater.from(parent.context)
  13. if (viewType == VIEW_TYPE_TEXT) {
  14. val view = inflater.inflate(R.layout.item_scrap, parent, false)
  15. return ViewHolder.TextViewHolder(view)
  16. } else if (viewType == VIEW_TYPE_EDIT) {
  17. val view = inflater.inflate(R.layout.item_scrap_edit, parent, false)
  18. return ViewHolder.EditViewHolder(view)
  19. }
  20. ...
  21. }
  22. override fun onBindViewHolder(holder: ViewHolder, position: Int) {
  23. if (holder is ViewHolder.TextViewHolder) {
  24. ...
  25. } else if (holder is ViewHolder.EditViewHolder) {
  26. ...
  27. }
  28. }
Sample
5.2 Animation
Itemが画面外に出るとanimation設定はクリアされるようである。よって、以下はうまくいかない。最初の表示時にはアニメーションしているが、一旦、画面外に追い出し、すぐに戻す(リサイクルされる前に戻す)とアニメーションが解除されている。
  1. (in onBindViewHolder)
  2. holder.something.animation = anim
  3. or
  4. holder.something.startAnimation(anim)
Bad Sample
面倒であるが、Itemが画面に表示された時、非表示になった時に、 Listenerでアニメーションを再設定してやる必要がある。
  1. (in onBindViewHolder)
  2. holder.something.addOnAttachStateChangeListener(
  3. object: View.OnAttachStateChangeListener {
  4. override fun onViewAttachedToWindow(p0: View?) {
  5. p0.startAnimation(anim)
  6. }
  7. override fun onViewDetachedFromWindow(p0: View?) {
  8. p0.clearAnimation()
  9. }
  10. })
Good Sample
onViewDetachedFromWindowの処理は必要ないだろう(前述、自動で解除されるため)。