20200830のAndroidに関する記事は12件です。

【Android】Retrofitについて

【Android】Retrofitについて

Retrofitとは

Retrofitとは、Http通信をする際に使われるライブラリ。
webApiを叩くとかで、けっこう使うのでは

今回は、構成とかは考えず、とりあえず最低限の動くという状態でやってます。
叩くAPIは、QiitaのWebApiです。

目次

  1. build.gradleとManifestへの記述
  2. Interfaceの作成
  3. レスポンスのmodel作成
  4. 実行

1. build.gradleとManifestへの記述

  • 依存関係の記述
build.gradle
    // retrofit
    implementation "com.squareup.retrofit2:retrofit:2.5.0"
    // 以下は、gsonというjsonをjavaで扱えるように変換してくれるライブラリ
    implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
  • Manifestへの記述
    • 通信を許可するパーミッションを記述
AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET"/>

2. Interfaceの作成

ApiService.kt
interface ApiService {
  // 記事をとってくるメソッドです。
  @GET("/api/v2/items") // => GETのリクエスト
  fun getArticle(): Call<List<Article>>
}

3. レスポンスのmodel作成

  • 先ほど、Interfaceで定義したリクエストに対するレスポンスに対応するmodelクラスを作成します。
Article.kt
data class Article (
    val id: String,
    val title: String,
    val user: User
)

data class User (
    val id: String,
)

4. 実行

  • 実行は、Activityで実行してます。
MainActivity.kt
class MainActivity : AppCompatActivity() {

    val TAG = "MainActivity"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        val retrofit = Retrofit.Builder()
            .baseUrl("https://qiita.com")
            .addConverterFactory(GsonConverterFactory.create()) // ここで、jsonをjavaで扱えるようにするGsonを使ってます。
            .build()

        // サービスを実行するクライアントの作成
        val articleClient = retrofit.create(ApiService::class.java)

        // 通信は非同期でやらないといけないので、今回はThreadで実行
         Thread().run {
           // とりあえず、レスポンスは返ってきます。あとは煮るなり焼くなり
           articleClient.getArticle()
         }
}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Jetpack Composeで頻出する"ambient"とはなにか

普通にアプリの中でも使われる概念で、Jetpack Composeのサンプルアプリのコードや、内部のコードを少しでも読むとすぐに出てくる概念で、知らないと混乱すると思うので、理解しておいたほうが良さそうです。

以下の動画でも登場しました。以下の動画を見てもらってもいいですし、このQiitaでサラッと確認したい人は以下をどうぞ。
https://youtu.be/DDd6IOlH3io?t=445

アプリの中で共有したい色などを渡していきたいときはどうすればよいでしょうか? (この場合通常はMaterialThemeを使うことで解決できますが、このように共有したいものはアプリを作る中で出てきます。 参考: https://github.com/android/compose-samples/search?q=ambient&unscoped_q=ambient )
通常Composeのデータフローは上から下に流れていくので、一応、以下のように関数で渡していくことができます。

class NewsAppPalette(
  val primary = ...,
  val onPrimary = ...,
)

@Composable
fun NewsApp() {
  ArticleList(
    articles = ...,
      // ** 引数でcolorを渡していっている **
    colorPalette = NewsAppPalette(
      primary = ...
      onPrimary = ...
    ),
  )
}

@Composable
fun ArticleList(articles: List<Article>, colorPalette: NewsAppPalette) {
  Row() {
       articles.forEach { article ->
           Article(article, colors)
       }
  }
}

fun ArticleItem(article: Article, colorPalette: NewsAppPalette) {
  Text(
    text = article.title
    textColor = colorPalette.onPrimary
  )
}

image.png

が、こういうふうに渡していくのはけっこう大変ですよね。。??

Jetpack Composeではこれの代わりにambientを提供します。
サービスロケーターのように名前をつけたオブジェクトを作ってそれを使えるようにします。
これはJetpack Composeの MaterialTheme で使われているのと同じ仕組みです。
提供されているComposeのスコープ以下でその変数が利用可能になります。

ambientの利用方法

ambientの宣言
ambientでデフォルトで配布されるものをブロックの中に書きます。 ambientOf<NewsAppPalette>()のように 書かなくても宣言できます。

val NewsAppColors = ambientOf<NewsAppPalette> {
  LightColorPalette
}

ambientの変数の配布。
注意が必要なのが、このブロックの中とそこから呼び出されるComposable functionの中でのみ利用可能になるということです。

  Prividers(NewsAppColors privides LightColorPalette) {
    // この中でambientの要素を使える
  }

ambientの変数の使い方

NewsAppColors.current

コードから理解するambientの利用方法

コード的には以下になります。このように引数で渡していかなくても、利用することができています。

val NewsAppColors = ambientOf<NewsAppPalette> {
  LightColorPalette
}


@Composable
fun NewsApp() {
  // ** ここでambientをProvideする **
  Prividers(NewsAppColors privides LightColorPalette) {
    ArticleList(
      // ** 引数でcolorを渡していかなくて良くなっている **
      articles = ...
    )
  }
}

@Composable
fun ArticleList(articles: List<Article>) {
...
           Article(article)
...
}


fun ArticleItem(article: Article) {
  Text(
    text = article.title
    textColor = NewsAppColors.current.colors.onPrimary
  )
}

(配布されているところの色を変えてわかりやすくしています。)

image.png

配布される値の変更

Provideをもう一度することで、そのスコープ内でProvideする値を変えることができます。

(配布されているところの色を変えてわかりやすくしています。)
image.png

@Composable
fun NewsApp() {
  // ** ここでambientをProvideする **
  Prividers(NewsAppColors privides LightColorPalette) {
    ArticleList(
      articles = ...
    )
  }
}

@Composable
fun ArticleList(articles: List<Article>) {
...
  // ** ここでまた別のDarkColorPaletteをProvideする **
  Prividers(NewsAppColors privides DarkColorPalette) {
    Row() {
       articles.forEach { article ->
           Article(article, colors)
       }
    }
...
}


fun ArticleItem(article: Article) {
  Text(
    text = article.title
    textColor = NewsAppColors.current.colors.onPrimary
  )
}

少しだけ効率的に利用する

以下のように NewsAppColors.current で利用させるより、そのAmbientへのアクセスをラップオブジェクトを作ってあげるパターンがJetpack Compose内ではよく利用されるようです。

object NewsAppTheme {
  @Composable
  val colors: NewsAppPalette
     get() = NewsAppColors.current
  ...
}


fun ArticleItem(article: Article) {
  Text(
    text = article.title
    textColor = NewsAppTheme.colors.onPrimary
  )
}

まとめ

これがわかるとかなりの部分のUI周辺のJetpack Composeのコードが読めるようになるので、内部への理解も深まります!
ちょっとはずれますが、 redditでは tree-local value (ツリーローカルな値) という説明になっており、しっくりきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Jetpack Composeで頻出する"ambient"とは

普通にアプリの中でも使われる概念で、Jetpack Composeのサンプルアプリのコードや、内部のコードを少しでも読むとすぐに出てくる概念で、知らないと混乱すると思うので、理解しておいたほうが良さそうです。

以下の動画でも登場しました。以下の動画を見てもらってもいいですし、このQiitaでサラッと確認したい人は以下をどうぞ。
https://youtu.be/DDd6IOlH3io?t=445

アプリの中で共有したい色などを渡していきたいときはどうすればよいでしょうか? (この場合通常はMaterialThemeを使うことで解決できますが、このように共有したいものはアプリを作る中で出てきます。 参考: https://github.com/android/compose-samples/search?q=ambient&unscoped_q=ambient )
通常Composeのデータフローは上から下に流れていくので、一応、以下のように関数で渡していくことができます。

class NewsAppPalette(
  val primary = ...,
  val onPrimary = ...,
)

@Composable
fun NewsApp() {
  ArticleList(
    articles = ...,
      // ** 引数でcolorを渡していっている **
    colorPalette = NewsAppPalette(
      primary = ...
      onPrimary = ...
    ),
  )
}

@Composable
fun ArticleList(articles: List<Article>, colorPalette: NewsAppPalette) {
  Row() {
       articles.forEach { article ->
           Article(article, colors)
       }
  }
}

fun ArticleItem(article: Article, colorPalette: NewsAppPalette) {
  Text(
    text = article.title
    textColor = colorPalette.onPrimary
  )
}

image.png

が、こういうふうに渡していくのはけっこう大変ですよね。。??

Jetpack Composeではこれの代わりにambientを提供します。
サービスロケーターのように名前をつけたオブジェクトを作ってそれを使えるようにします。
これはJetpack Composeの MaterialTheme で使われているのと同じ仕組みです。
提供されているComposeのスコープ以下でその変数が利用可能になります。

ambientの利用方法

ambientの宣言
ambientでデフォルトで配布されるものをブロックの中に書きます。 ambientOf<NewsAppPalette>()のように 書かなくても宣言できます。

val NewsAppColors = ambientOf<NewsAppPalette> {
  LightColorPalette
}

ambientの変数の配布。
注意が必要なのが、このブロックの中とそこから呼び出されるComposable functionの中でのみ利用可能になるということです。

  Prividers(NewsAppColors privides LightColorPalette) {
    // この中でambientの要素を使える
  }

ambientの変数の使い方

NewsAppColors.current

コードから理解するambientの利用方法

コード的には以下になります。このように引数で渡していかなくても、利用することができています。

val NewsAppColors = ambientOf<NewsAppPalette> {
  LightColorPalette
}


@Composable
fun NewsApp() {
  // ** ここでambientをProvideする **
  Prividers(NewsAppColors privides LightColorPalette) {
    ArticleList(
      // ** 引数でcolorを渡していかなくて良くなっている **
      articles = ...
    )
  }
}

@Composable
fun ArticleList(articles: List<Article>) {
...
           Article(article)
...
}


fun ArticleItem(article: Article) {
  Text(
    text = article.title
    textColor = NewsAppColors.current.colors.onPrimary
  )
}

(配布されているところの色を変えてわかりやすくしています。)

image.png

配布される値の変更

Provideをもう一度することで、そのスコープ内でProvideする値を変えることができます。

(配布されているところの色を変えてわかりやすくしています。)
image.png

@Composable
fun NewsApp() {
  // ** ここでambientをProvideする **
  Prividers(NewsAppColors privides LightColorPalette) {
    ArticleList(
      articles = ...
    )
  }
}

@Composable
fun ArticleList(articles: List<Article>) {
...
  // ** ここでまた別のDarkColorPaletteをProvideする **
  Prividers(NewsAppColors privides DarkColorPalette) {
    Row() {
       articles.forEach { article ->
           Article(article, colors)
       }
    }
...
}


fun ArticleItem(article: Article) {
  Text(
    text = article.title
    textColor = NewsAppColors.current.colors.onPrimary
  )
}

少しだけ効率的に利用する

以下のように NewsAppColors.current で利用させるより、そのAmbientへのアクセスをラップオブジェクトを作ってあげるパターンがJetpack Compose内ではよく利用されるようです。

object NewsAppTheme {
  @Composable
  val colors: NewsAppPalette
     get() = NewsAppColors.current
  ...
}


fun ArticleItem(article: Article) {
  Text(
    text = article.title
    textColor = NewsAppTheme.colors.onPrimary
  )
}

まとめ

これがわかるとかなりの部分のUI周辺のJetpack Composeのコードが読めるようになるので、内部への理解も深まります!
ちょっとはずれますが、 redditでは tree-local value (ツリーローカルな値) という説明になっており、しっくりきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初学者によるViewPager2実装方法

はじめに

ViewPager2を実装する際、今までは複数のViewに対してその同数レイアウトファイルを作成していました。
今回は一つのレイアウトファイルで表示できたので紹介します。
ViewPager2実装するのが初めての方にもTabLayoutMediatorFragmentManagerを理解する助けになればと思います。

全体を見たい方向けにサンプルのリポジトリ載せときます。
https://github.com/tomoya-s-101/viewpager2

環境

OS: macOS Catalina v10.15.6
IDE: Android Studio v4.0.1

導入

TabLayoutを使用できるようにするために、build.gradleにmaterial-componentsライブラリを追加します。

build.gradle
implementation "androidx.viewpager2:viewpager2:1.1.0-alpha01"
implementation 'com.google.android.material:material:1.2.0'

実装

今回は一つのフラグメントレイアウトで画像の色とテキストの切り替えをViewPager2を使い表示します。
output.gif

レイアウト

bindingの実装をしたいので、layoutタグで囲います。
tabIndicatorGravityでインジケーターの位置を指定できます。
TabLayoutリファレンス

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
    <androidx.constraintlayout.widget.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">

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tab_layout"
            android:layout_width="match_parent"
            android:layout_height="48dp"
            app:tabIndicatorGravity="stretch"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent">
        </com.google.android.material.tabs.TabLayout>

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewpager2"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintBottom_toTopOf="@id/tab_layout"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

ViewPager2で表示するレイアウトを作ります。
stringとcolorのリソースも準備してください。

fragment_color.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/fragment_color"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/tab_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="TabTextView"
            android:textColor="@color/white"
            android:textSize="48sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Fragment

表示させたいリストのインスタンスを生むことで、一つのレイアウトで切り替えが可能になります。

ColorFragment.kt
class ColorFragment: Fragment() {
    companion object {
        private const val ARGS_POSITION = "position"
        fun newInstance (position: Int) = ColorFragment().apply {
            arguments = Bundle().apply {
                putInt(ARGS_POSITION, position)
            }
        }
    }

    private lateinit var binding: FragmentColorBinding

    private val position: Int by lazy {
        arguments?.let {
            it[ARGS_POSITION] as Int
        }?:0
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_color, container, false)
        return binding.root
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        binding.apply {
            when(position) {
                0 -> {
                    tabTextView.setText(R.string.text_red)
                    fragmentColor.setBackgroundResource(R.color.red)
                }
                1 -> {
                    tabTextView.setText(R.string.text_blue)
                    fragmentColor.setBackgroundResource(R.color.blue)
                }
                2 -> {
                    tabTextView.setText(R.string.text_green)
                    fragmentColor.setBackgroundResource(R.color.green)
                }
                else -> {
                    tabTextView.setText(R.string.text_yellow)
                    fragmentColor.setBackgroundResource(R.color.yellow)
                }
            }
        }
    }
}

Adapter

FragmentActivityを使用するため、FragmentManagerを呼び出します。
FragmentManagerリファレンス

ViewPager2Adapter.kt
class ViewPager2Adapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
    FragmentStateAdapter(fragmentManager, lifecycle) {

    private val fragments: ArrayList<Fragment> = arrayListOf(
        ColorFragment.newInstance(0),
        ColorFragment.newInstance(1),
        ColorFragment.newInstance(2),
        ColorFragment.newInstance(3)
    )

    override fun getItemCount(): Int {
        return fragments.size
    }

    override fun createFragment(position: Int): Fragment {
        return fragments[position]
    }
}

Activity

TabLayoutMediatorでTabLaoutとViewPager2を引数で渡すことで、スクロールとインジケーターがリンクします。
TabLayoutMediatorリファレンス

MainActivity.kt
class MainActivity : AppCompatActivity() {
    companion object {
        fun callingIntent(context: Context) = Intent(context, MainActivity::class.java)
    }

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView<ActivityMainBinding>(
            this, R.layout.activity_main
        ).apply {
            viewpager2.adapter = ViewPager2Adapter(supportFragmentManager, lifecycle)

            TabLayoutMediator(tabLayout, viewpager2) { tab, position ->
            }.attach()
        }
    }
}

おわりに

他にもViewPager2では、縦方向のスクロールなどアクティブな実装が簡単にできるみたいです。
ドキュメント

閲覧ありがとうございました。
2020年7月からエンジニアとして働き始めた見習いですので、拙い点ございます。
間違いやより適切な方法ありましたら、コメントお願いします。
今後も記事を投稿していくので、是非よろしくお願い致します。

参考

https://developer.android.com/reference/com/google/android/material/tabs/TabLayout
https://developer.android.com/reference/kotlin/androidx/fragment/app/FragmentManager?hl=ja
https://developer.android.com/reference/com/google/android/material/tabs/TabLayoutMediator
https://developer.android.com/training/animation/vp2-migration?hl=ja

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Repositoryから取得したLiveDataをViewModelでキャッシュしておくには?

こうだ

cached_live_data.gif

※Developer options の Don't keep activities を有効にしています

SavedStateHandle

AndroidのライフサイクルではActivityインスタンスやFragmentインスタンスはいつ破棄されるかわかりません。
これらが持つViewModelインスタンスも同様です。

破棄され、再生成されるとそれまでのメモリー上のデータは破棄されます。これはよろしくありません。

これを避けるためにデータの保存/復元を行う必要があり、そのヘルパークラスであるSavedStateHandleを使うと楽に実装できるというのは以前書かせていただきました。

ViewModelのデータを保存/復元するにはSavedStateHandleを使う

Activityが再生成されると最初から読み込みし直し

上記のケースは、EditTextの入力値のような、データがViewModel内で完結するものでした。
では、データがリポジトリー層から取得される場合はどうすればいいのでしょうか?

例を示します。

class User(...): Parcelable() {
    ...
}

class UserRepository {
    fun getUser(): LiveData<User> {
        ...
    }
}


class MainViewModel(
    private val userRepository: UserRepository
) {
    val user: LiveData<User> =  // userRepository.getUser()したいが…
}

上記のコードは UserRepository の戻り値がLiveDataになっています。
SavedStateHandleは初期値にLiveDataを取ることはできませんから、SavedStateHandleは使えません。

「何もしない」のも1つの答えです。
通常、リポジトリー層自体が永続化の役割を持っているのですから、あえてViewModelで保存/復元を行わなくてもいいというものです。

class MainViewModel(
    private val userRepository: UserRepository
) {
    val user: LiveData<User> = userRepository.getUser() // 何も考えずに代入しとけばいいんだよ!
}

しかし、 LiveDataにデータがemitされるのに時間がかかる場合はどうでしょうか?

class UserRepository {
    fun getUser(): LiveData<User> {
        liveData(GlocalContext.coroutineContext + Dispatchers.IO) {
            // サーバーとの通信やデータ整形などのとても時間のかかる処理
            val user: User = veryHeavyProcess()

            emit(user)
        }
    }
}

このような場合でも、最終的にはデータは表示されます。しかし再度読み込みが行われるため表示が完了するまで時間はかかります。ユーザーからしたらストレスになるでしょう。

// ---------- このへんから追記&修正 ----------

これを解決するためにはどこかでデータをキャッシュしておく必要があります。

リポジトリー層でキャッシュしておくこともできるでしょう。
実際、今回の例もリポジトリー層でキャッシュした方がいい雰囲気のコードです(※後で気づいたが例示として悪かった)。

しかし、リポジトリー層で取得したエンティティクラスを画面表示(プレゼンテーション)用にこねくり回している場合、こねくりまわしたデータは既にリポジトリー層の手から離れているためキャッシュ処理はViewModel層が受け持つよりほかありません。

// ---------- このへんまで ----------

したがって、 「画面にはgetUserを使いつつViewModelでgetUserを監視しキャッシュする。ViewModel再生成時はキャッシュした値を初期値として使い、遅延して読み込まれたgetUserの値を使う」 ということをする必要があります。

CachedLiveDataを作ったぞ

上記を解決するためのクラス CachedLiveData および CachedLiveData を簡単に生成できるSavedStateHandleの拡張関数を作りました。

CachedLiveData with SavedStateHandle extension

以下のように使います。

class MainViewModel(
    private val handle: SavedStateHandle,
    private val userRepository: UserRepository
) {
    val user: LiveData<User> = handle.getCachedLiveData<User>("user", userRepository.getUser()) // これだけ
}

第1引数には SavedStateHandle#getLiveData と同じくキー名を指定します。
第2引数には ソースとして使用したいLiveDataを指定します。
これだけで「画面にはgetUserを使いつつViewModelでgetUserを監視しキャッシュする。ViewModel再生成時はキャッシュした値を初期値として使い、遅延して読み込まれたgetUserの値を使う」ことをしてくれます。

結果は冒頭に貼った動画のとおりです。
Simple LiveDataが何も使わず単純に userRepository.getUser を使った結果、 CachedLiveDataが CachedLiveData を使った結果です。
両者画面表示が完了したところから動画は始まっています。
ホームボタンを押してランチャー画面を表示させ、アプリアイコンをタップしてアプリを再表示させたところ、Simple LiveDataの方はデータの再読み込みのため表示されるまでに時間がかかるのに対し、CachedLiveDataの方はデータが最初から表示されています。
これはCachedLiveDataによりデータがキャッシュされていたということです。

仕組み

CachedLiveData with SavedStateHandle extension

を見ていただければわかるとは思いますが、念のため仕組みの解説をしておきます。

CachedLiveData.kt
fun <T : Any> SavedStateHandle.getCachedLiveData(key: String, source: LiveData<T>): LiveData<T> {
    val cache = getLiveData<T>(key)
    return CachedLiveData(source, cache)
}

private class CachedLiveData<T>(
    source: LiveData<T>,
    private val cache: MutableLiveData<T>
) : MediatorLiveData<T>() {

    ...

    private var isCacheInSource = true

    ...

    init {
        addSource(source) {
            if (isCacheInSource) {
                removeSource(cache)
                isCacheInSource = false

                ...
            }
            value = it
            cache.value = it
        }
        addSource(cache) {
            value = it
        }
    }

    ...

}

引数のsourceuserRepository.getUser が、cache には handle.getLiveData("user") が代入されます。

CachedLiveData は基本的には source の中身が反映され続けます。
しかし source の中身が最初に反映されるまでの間は cache の中身が反映されます。
source の中身が反映されるようになると cache の中身を反映する処理は停止します。そして、source の中身を cache の中身として反映する動作を始めます。

上記のほか、CachedLiveDataの観測状態と cache の観測状態を一致させる処理をつけたら完成です。

おわりに

良きViewModelライフを!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

最小構成で作るARCore(kotlin)

先日Pixel 4aを買ったのでARCoreを試してみました。
公式のサンプルはJavaで書かれているのですが、サンプルの実装を確認しながら、これをkotlinに直しつつ最小構成でARCoreを動かせないか試してみました。

実装したコードはGithubに上げています。
https://github.com/r-asada-ab/SimpleARCore

公式サンプル

まず、公式のQuick Startに従ってビルドすれば簡単にARCoreの動作を確認することが出来ます。

https://developers.google.com/ar/develop/java/quickstart

ただ、中で何をしているのかとかどういうクラスが使われているのかとかいろいろ気になりますよね。

ということで、ソースコードを追いながら、それをkotlinで書いてみます。

プロジェクトの作成

新しくAndroid Projectを作ります。
Activityは何でもよいですが、ここではEmpty Activityを選択しておきます。

これをビルドすると、普通のAndroidアプリが起動します。

ここからARCoreアプリへと改造していきます。

AndoridManifest

まずAndroid Manifestから。
以下の記述が必要です。

  1. カメラのパーミッション
  2. ARCore非対応端末からダウンロードできないようにする
  3. OpenGLES2.0非対応端末からダウンロードできないようにする
  4. ARCoreのmeta-dataタグ

以下の記述をAndroidManifestに追加します。

    <uses-permission android:name="android.permission.CAMERA"/>
    <uses-feature android:name="android.hardware.camera.ar" android:required="true"/>
    <uses-feature android:glEsVersion="0x00020000" android:required="true" />

...
    <meta-data android:name="com.google.ar.core" android:value="required" />

build.gradle(app)

minSdkVersionを24以上とします。

minSdkVersion 24

ARCoreライブラリをdepenedenciesに追加します。

implementation 'com.google.ar:core:1.18.0'

実装

これで下準備は終わりです。
具体的な実装に入っていきます。

今回ActivtyはMainActivityしかない状態で実装を進めます。

レイアウトの修正

先ず、MainActivityのレイアウトには今TextViewが1つ入っている状態ですが、これをGLSurfaceViewに変えます。

Design画面からは追加できなかったと思うのでコードで以下のように追加します。
親のlayoutは何でも良いですが、今回はLinearLayoutを使いました。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <android.opengl.GLSurfaceView
        android:id="@+id/surfaceview"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_gravity="top"/>

</LinearLayout>

Rendererクラスの作成

GLSurfaceViewは描画をGLSurfaceView.Rendererインターフェースを実装したクラスで行うので、そのRenderer実装クラスを作成します。

公式サンプルではMainActivityが実装していましたが、あんまりMainActivityが長くなってもあれなので、新しくクラスを作ります。

import android.opengl.GLES20
import android.opengl.GLSurfaceView.Renderer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10

class GLRenderer: Renderer {

import android.opengl.GLES20
import android.opengl.GLSurfaceView.Renderer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10

class GLRenderer: Renderer {

    override fun onDrawFrame(unuse: GL10?) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
        GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
    }

    override fun onSurfaceChanged(unuse: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
    }

    override fun onSurfaceCreated(unuse: GL10?, p1: EGLConfig?) {
        GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
    }
}
}

ここで描画のあれこれを行っていきます。
今の段階では特に何も書かなくて良いのですが、動きを確かめるのに一応上のように書いて音きます。

これで起動したときに赤で塗りつぶされるのでとりあえず動いているのがわかってよいです。

GLSurfaceViewの初期化

MainActivityのonCreateでGLSurfaceViewの初期化をしていきます。

class MainActivity : AppCompatActivity() {

    // GLSurfaceView
    private lateinit var surfaceView: GLSurfaceView
    // Renderer
    private lateinit var glRenderer: GLRenderer

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

        glRenderer = GLRenderer()

        surfaceView = findViewById(R.id.surfaceview)
        surfaceView.preserveEGLContextOnPause = true
        surfaceView.setEGLContextClientVersion(2)
        surfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
        surfaceView.setRenderer(glRenderer)
        surfaceView.renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
        surfaceView.setWillNotDraw(false)
    }
}

これで下のようにひとまず真っ赤な画面が出ていれば問題ありません。

ARCoreの初期化

ここからARCoreを使う準備をしていきます。

先ず最初に次のことを確認します。

  • 端末にARCoreがインストールされているか(対応端末には自動でインストールされるので大丈夫かと思いますが)
  • カメラのパーミッション

端末にARCoreがインストールされているかは、公式のドキュメントでも太字でImportantと書かれているので確認しておきましょう。

https://developers.google.com/ar/reference/java/arcore/reference/com/google/ar/core/Session

class MainActivity : AppCompatActivity() {

    private val CAMERA_PERMISSION_CODE = 0

    private val CAMERA_PERMISSION = Manifest.permission.CAMERA

    private var installRequested = false
    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        installRequested = false
    }

    override fun onResume() {
        super.onResume()

        when (ArCoreApk.getInstance().requestInstall(this, !installRequested)) {
            InstallStatus.INSTALL_REQUESTED -> {
                installRequested = true
                return
            }
            InstallStatus.INSTALLED -> { }
        }

        if (ContextCompat.checkSelfPermission(this, CAMERA_PERMISSION)
            != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this,
                arrayOf<String>(CAMERA_PERMISSION), CAMERA_PERMISSION_CODE)
            return
        }
    }
}

次いでこれらが大丈夫だった時、Sessionを作成します。

Sessionの作成

Sessionの作成はそこまで複雑でありません。Depthが有効かを見ています(Pixel 4aはまだ対応していていませんがすぐ使えるようになると信じています)。

ただし、このコンストラクタではいくつか例外が投げられる可能性があるので捕捉しておきましょう。

https://developers.google.com/ar/reference/java/arcore/reference/com/google/ar/core/Session

        try {
            session = Session(this)
            val config: Config = session!!.getConfig()
            if (session!!.isDepthModeSupported(Config.DepthMode.AUTOMATIC)) {
                config.depthMode = Config.DepthMode.AUTOMATIC
            } else {
                config.depthMode = Config.DepthMode.DISABLED
            }
            session!!.configure(config)
        } catch (e: UnavailableArcoreNotInstalledException) {
            Log.e(TAG, "Please install ARCore")
        } catch (e: UnavailableUserDeclinedInstallationException) {
            Log.e(TAG, "Please install ARCore")
        } catch (e: UnavailableApkTooOldException) {
            Log.e(TAG, "Please update ARCore")
        } catch (e: UnavailableSdkTooOldException) {
            Log.e(TAG, "Please update this app")
        } catch (e: UnavailableDeviceNotCompatibleException) {
            Log.e(TAG, "This device does not support AR")
        } catch (e: Exception) {
            Log.e(TAG, "Failed to create AR session")
        }

この状態で起動してもまだ赤いままです。
肝心の描画の部分を書いていきます。

カメラ画像の描画

カメラから得られた画像をGLSurfaceViewに書くためのShaderを作成します。
Shaderは公式サンプルだと〇〇RendererクラスがShaderを使ってdrawコールを処理するように設計されていましたが、Shaderの基底クラスを作成して、用途に応じて継承していってもよいかと思います(お好みで)。

いつもクラス内定数としてShaderを書いていたのですが、公式サンプルを見るとAssetsにいい感じにまとめてあって便利だったので今回はこちらの方法で記載しました。

頂点シェーダーはこんな感じでテクスチャを1枚ぺらっと張る感じです。

attribute vec4 a_Position;
attribute vec2 a_TexCoord;

varying vec2 v_TexCoord;

void main() {
   gl_Position = a_Position;
   v_TexCoord = a_TexCoord;
}

フラグメントシェーダーも単純ですがこんな感じです。

#extension GL_OES_EGL_image_external : require

precision mediump float;
varying vec2 v_TexCoord;
uniform samplerExternalOES sTexture;


void main() {
    gl_FragColor = texture2D(sTexture, v_TexCoord);
}

後は良きことこれをロードして、シェーダープログラムを作ってdrawコールします。

Shaderの作成

Shaderは先に記載した文字列を次のようにしてglShaderSourceに渡せばShaderを返してくれます。

    fun loadShader(type: Int, shaderCode: String): Int {
        return GLES20.glCreateShader(type).also { shader ->
            GLES20.glShaderSource(shader, shaderCode)
            GLES20.glCompileShader(shader)
        }
    }

コンパイルのチェックなどが後の記述に増えるのですが、長くなりますのでGithubにコードを上げているのでそちらを参考にされるか、公式のShaderUtilクラスを参考にしてください。

シェーダープログラムの作成

シェーダーが出来たらそれをもとにシェーダープログラムを作ります。
ここでは公式と同じように〇〇Rendererクラスを用意してその中で描画の準備と実際の描画を行うようにします。

まず、メンバ変数ですがざっくりとこれくらい必要です。
textureIDやattribute変数のハンドラーを引き回すので、少し多めになります。

FLOAT_SIZEとかはハードコーディングでもいいかもですがマジックナンバーを減らすためにも定数化しておくとよいかもです。

class BackgroundRenderer {

    private val QUAD_COORDS = floatArrayOf(
        -1.0f, -1.0f,
        1.0f, -1.0f, 
        -1.0f, 1.0f,
        1.0f, 1.0f
    )

    private val CAMERA_VERTEX_SHADER_NAME = "shaders/screenquad.vert"
    private val CAMERA_FRAGMENT_SHADER_NAME = "shaders/screenquad.frag"
    private val program = 0
    private val COORDS_PER_VERTEX = 2
    private val TEXCOORDS_PER_VERTEX = 2
    private val FLOAT_SIZE = 4

    private val quadCoords: FloatBuffer? = null
    private val quadTexCoords: FloatBuffer? = null

    private val cameraPositionAttrib = 0
    private val cameraTexCoordAttrib = 0
    private val cameraTextureUniform = 0
    private val cameraTextureId = -1

    // コンストラクタ
    constructor() {

    }
}

次にコンストラクタの中で必要な初期化処理を入れていきます。

まずはテクスチャを作成しておきます。
MIN、MAGフィルターなどは目的とする実装に合わせてでよいかと思いますので、今回は公式通りに設定しておきます(たまに思わぬバグの原因になったりしますが。。。)。

        val textures = IntArray(1)
        GLES20.glGenTextures(1, textures, 0)
        cameraTextureId = textures[0]
        val textureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES
        GLES20.glBindTexture(textureTarget, cameraTextureId)
        GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
        GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)

描画用の座標を準備します。
今回はテクスチャを描画するのでテクスチャ座標も必要です。

        val numVertices = 4
        val bbCoords =
            ByteBuffer.allocateDirect(QUAD_COORDS.size * FLOAT_SIZE)
        bbCoords.order(ByteOrder.nativeOrder())
        quadCoords = bbCoords.asFloatBuffer().apply {
            put(QUAD_COORDS)
            position(0)
        }

        val bbTexCoordsTransformed =
            ByteBuffer.allocateDirect(numVertices * TEXCOORDS_PER_VERTEX * FLOAT_SIZE)
        bbTexCoordsTransformed.order(ByteOrder.nativeOrder())
        quadTexCoords = bbTexCoordsTransformed.asFloatBuffer()

最後にシェーダープログラムを作成します。

        val vertexShader: Int = Shader.loadGLShader(TAG, context,
            GLES20.GL_VERTEX_SHADER, CAMERA_VERTEX_SHADER_NAME)

        val fragmentShader: Int = Shader.loadGLShader(TAG, context,
            GLES20.GL_FRAGMENT_SHADER, CAMERA_FRAGMENT_SHADER_NAME)

        program = GLES20.glCreateProgram()
        GLES20.glAttachShader(program, vertexShader)
        GLES20.glAttachShader(program, fragmentShader)
        GLES20.glLinkProgram(program)
        GLES20.glUseProgram(program)
        cameraPositionAttrib = GLES20.glGetAttribLocation(program, "a_Position")
        cameraTexCoordAttrib = GLES20.glGetAttribLocation(program, "a_TexCoord")
        Shader.checkGLError(TAG, "Program creation")
        cameraTextureUniform = GLES20.glGetUniformLocation(program, "sTexture")
        Shader.checkGLError(TAG, "Program parameters")

描画部分の実装

これで準備が出来たので描画部分を書いていきます。
GLRendererのonDrawFrameが呼ばれたときに行う描画処理を書いていきます。

    public fun draw(frame: Frame) {
        if (frame.hasDisplayGeometryChanged()) {
            frame.transformCoordinates2d(
                Coordinates2d.OPENGL_NORMALIZED_DEVICE_COORDINATES,
                quadCoords,
                Coordinates2d.TEXTURE_NORMALIZED,
                quadTexCoords
            )
        }

        if (frame.timestamp == 0L) {
            return
        }


        quadTexCoords!!.position(0)
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
        GLES20.glUseProgram(program)
        GLES20.glUniform1i(cameraTextureUniform, 0)
        GLES20.glVertexAttribPointer(
            cameraPositionAttrib,
            COORDS_PER_VERTEX,
            GLES20.GL_FLOAT,
            false,
            0,
            quadCoords
        )
        GLES20.glVertexAttribPointer(
            cameraTexCoordAttrib,
            TEXCOORDS_PER_VERTEX,
            GLES20.GL_FLOAT,
            false,
            0,
            quadTexCoords
        )
        GLES20.glEnableVertexAttribArray(cameraPositionAttrib)
        GLES20.glEnableVertexAttribArray(cameraTexCoordAttrib)

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

        GLES20.glDisableVertexAttribArray(cameraPositionAttrib)
        GLES20.glDisableVertexAttribArray(cameraTexCoordAttrib)
    }

これをGLRender.onDrawFrameから呼び出すように書いて仕上げとなります。

    override fun onDrawFrame(unuse: GL10?) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)

        if (session == null) {
            return
        }

        if (viewportChanged) {
            val windowManager = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
            val display = windowManager.defaultDisplay
            val displayRotation = display.getRotation()
            session!!.setDisplayGeometry(displayRotation, viewportWidth, viewportHeight)
            viewportChanged = false
        }

        session!!.setCameraTextureName(backgroundRenderer.getTextureId())
        val frame = session!!.update()
        backgroundRenderer.draw(frame)
    }

    override fun onSurfaceChanged(unuse: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
        viewportWidth = width
        viewportHeight = height
        viewportChanged = true
    }

session.setDisplayGeometry()ですが、onSurfaceChangedで幅や高さが変更になったとき(初めてonSurfaceChangedが呼ばれる時も含めて)必須で必要になります。

以上で、ARCoreを利用した最小構成のアプリができるかと思います。
アプリを起動するとカメラの映像が反映されているのを確認できます。

さらにARCoreで検出した平面を描画したいとか、特徴点を点で描画したいとかであれば、同様にしてシェーダーを追加で書いていくとよいかと思います。

まとめ

最初構成と言いながら結構ありますがまとめると次の設定をしておけばよいですね。

  • AndroidManifestの設定
    1. カメラのパーミッション
    2. ARCore非対応端末からダウンロードできないようにする
    3. OpenGLES2.0非対応端末からダウンロードできないようにする
    4. ARCoreのmeta-dataタグ
  • build.gradle(app)の設定
    1. minSdkVersionを24以上にする
    2. ARCoreライブラリを追加する
  • GLSurfaceViewの実装
    1. GLSurfaceViewをレイアウトファイルに追加する
    2. GLSurfaceView.Rendererインターフェースを実装したクラスを作る
    3. GLSurfaceViewを初期化する
  • ARCoreの初期化
    1. ARCoreが端末にインストールされているか確認する
    2. カメラのパーミッションを確認する
    3. Sessionを初期化する
  • Shaderの作成
    1. フラグメントシェーダーを書く
    2. 頂点シェーダーを書く
    3. シェーダーをロードする
    4. シェーダープログラムを作る
    5. シェーダーで描画する
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewPagerにある異なるFragment上のListViewのスクロールを同期させる

実現すること

タイトルの通り、ViewPagerのページをめくったときに遷移先のページと元のページのListViewのスクロール量が同期するようにします。
時刻表を作るときなどに活用できます。

前提

以下のような環境をもとに、実装を進めていきます。

表示するリスト

Data.kt
object Data {
    val list = (1..100).toList()
}

Avtivityの実装

MainActivity.kt
class MainActivity : AppCompatActivity() {

    lateinit var viewPager: ViewPager

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

        viewPager = findViewById(R.id.view_pager)
        viewPager.apply {
            adapter = ViewPagerAdapter(supportFragmentManager, viewPager)
        }
    }
}

MainActivityのレイアウト

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.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">

    <androidx.viewpager.widget.ViewPager android:layout_height="match_parent" 
        android:layout_width="match_parent" 
        android:id="@+id/view_pager"/>

</androidx.constraintlayout.widget.ConstraintLayout>

ViewPagerのアダプター

ViewPagerAdapter.kt
class ViewPagerAdapter(fragmentManager: FragmentManager, private val container: ViewPager)
 : FragmentStatePagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT){

    override fun getCount(): Int {
        return 5 //ページ数:5
    }

    override fun getItem(position: Int): Fragment {
        return PageFragment.newInstance(position).apply {
            adapter = this@ViewPagerAdapter
        }
    }
}

ViewPagerに充てるFragmentの実装

kotlinPageFragment.kt
class PageFragment : Fragment() {

    //自身が何ページ目か
    private var page = 0

    private lateinit var listView: ListView

    //自身が属すViewPager
    private var viewPagerAdapter: ViewPagerAdapter? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            page = it.getInt(ARG_PAGE)
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_page, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        listView = requireView().findViewById(R.id.list_view)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        listView.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, Data.list)

    }

    companion object {
        const val ARG_PAGE = "Page"
        @JvmStatic
        fun newInstance(page: Int) =
            PageFragment().apply {
                arguments = Bundle().apply {
                    putInt(ARG_PAGE, page)
                }
            }
    }
}

PageFragmentのレイアウト

fragment_page.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".PageFragment">

    <ListView android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/list_view" />

</FrameLayout>

処理の流れ

  1. listViewがスクロールを感知する
  2. viewPagerAdapterに通知
  3. viewPagerAdapterが左右1ページずつのPageFragmentに通知
  4. スクロールを更新する

注意点

  • OnScrollListenerはユーザーがスワイプする前(生成時など)にも反応してしまうため、ユーザーがlistViewを操作できるようになって初めて反応するように設定しないと、ページを繰るたびにスクロール量が0になってしまいます。
  • ページをめくって、そこでlistViewをスワイプせずにまたページをめくるとOnScrollListenerが起動しないまま次のページに進んでしまい、次のページのlistViewが初期位置のままになってしまいます。

以上2点を解決できるように実装しないといけません。

要点

なぜviewPagerAdapterを経由して通知するのか

viewPagerAdapterを通さないと、隣のフラグメントを取得できないからです。
具体的にはviewPagerAdapter内でinstantiateItem(container: ViewGroup, position: Int)を呼び出すことで取得できます。containerはこのアダプタが属すViewPagerを渡せばよいです。

あるフラグメントにページが遷移したことを知る方法

ViewPagerAdapterの振る舞いをBEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENTとして設定しているので、そのフラグメントにページが遷移したときにのみonResumeが実行されます。そのため、lifecycle.currentState == Lifecycle.State.RESUMEDのとき、自身のページが表示されていること、Lifecycle.State.STARTEDのとき今表示されているページの隣にあることが分かります。

実装

上で提示した処理の流れの逆順に実装していきます。

PageFragmentviewPagerAdapterから通知を受け、スクロールを更新するメソッド

PageFragmentのライフサイクル状態がSTARTEDのとき、つまりViewが作成されてからViewPager上でこのフラグメントにページが変わるまでの間、スクロールの更新を受け付けます。

PageFragment
fun updateScroll(position: Int, y: Int){
    if(lifecycle.currentState == Lifecycle.State.STARTED){
        listView.setSelectionFromTop(position, y)
    }
}

ViewPagerAdapterlistViewから通知を受け、左右1ページずつのPageFragmentに更新を通知するメソッド

ViewPagerAdapter
fun updateScroll(page: Int, listPosition: Int, listY: Int){
    var previous: PageFragment? = null
    if(page > 0) previous = instantiateItem(container, page - 1) as PageFragment
    var next: PageFragment? = null
    if(page < this.count - 1) next = instantiateItem(container, page + 1) as PageFragment
    previous?.updateScroll(listPosition, listY)
    next?.updateScroll(listPosition, listY)
}

listViewがスクロールを感知し、ViewPagerAdapterに通知する部分

ライフサイクル状態がRESUMEDであること、つまり、このフラグメントが表示されていてユーザーが操作可能であることを確認し、1つ目の注意点を解決しました。

PageFragment.onActivityCreated
listView.setOnScrollListener(object: AbsListView.OnScrollListener{
    override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) { }

    override fun onScroll(view: AbsListView?, firstVisibleItem: Int,
        visibleItemCount: Int, totalItemCount: Int) {
        if(lifecycle.currentState == Lifecycle.State.RESUMED){
             viewPagerAdapter?.updateScroll(page, listView.firstVisiblePosition, listView.getChildAt(0).top)
        }
    }
})

2つ目の問題点はPageFragmentに以下のようなonResumeを実装することで解決できます。
ページが自分に遷移した瞬間に両隣のスクロール量を自らに合わせるという処理をしています。

PageFragment
override fun onResume() {
    super.onResume()
    viewPagerAdapter?.updateScroll(page, listView.firstVisiblePosition, listView.getChildAt(0)?.top ?: 0)
}

おわりに

この投稿は初投稿です。
これを解決するのに丸一日掛かったので、備忘録として残しておこうと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ESP-WROOM-02(ESP8266)の基本回路

毎回、ブレッドボードを組むときに構成を調べないで済むようにするための忘備録です。

接続

説明

PIN 説明
3V3 電源のプラスライン(+)に接続
GND 電源のマイナスライン(-)に接続
EN プルアップ抵抗(10KΩ)を通してプラスライン(+)に接続
IO0 プルアップ抵抗(10KΩ)を通してプラスライン(+)に接続
併せてFLASHスイッチを経由してマイナスラインにも接続
IO2 プルアップ抵抗(10KΩ)を通してプラスライン(+)に接続
IO15 プルダウン抵抗(10KΩ)を通してマイナスライン(-)に接続
RST RESETスイッチを経由してマイナスライン(-)に接続
TXD UARTコントローラのRXDと接続
RXD UARTコントローラのTXDと接続

回路図

ESP8266基本回路.png

RESETスイッチの使い方

本稿のRESETスイッチはPUSHで通電するPUSH型のタクトスイッチを想定している。

モード 操作
プログラム実行モード FLASHスイッチをOFF(押さない)状態でRESETスイッチをON(押す)/OFF(離す)する。
RESETスイッチ離した瞬間にリセットがかかり、プログラムが実行される。
プログラム書込モード FLASHスイッチをON(押す)状態でRESETスイッチをON(押す)/OFF(離す)する。
FLASHスイッチがONのままRESETスイッチ離した瞬間にプログラム書き込みモードでリセットがかかる。
プログラムはシリアルインタフェイス(UART)経由で流し込む
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Android 9.0 Pie Java】RecyclerView 長押しで要素毎に内容の変わるコンテキストメニューを表示する

RecyclerViewで要素を長押しして、要素毎に内容の変わるコンテキストメニューを表示する方法の記事が見当たらなかったのでメモしてみます!

↓完成イメージ↓
7c30d4aa9804094e0e83ddb1b9247cb8.gif

実装方法

コンテキストメニューの表示

Activity or Fragmentに

View.OnCreateContextMenuListener

をimplementsします。

SampleFragment.java
public class SampleFragment extends Fragment
        implements View.OnCreateContextMenuListener {

他にもViewHolderにimplementsするパターンもありますが、
コンテキストメニューを要素毎に内容を出し分けしたくて、都合のよかったFragmentにimplementsしました。

次に、RecyclerViewを生成をしているonCreatedViewメソッドで

SampleFragment.java
RecyclerView rv = view.findViewById(R.id.recyclerView);

// Adapter等のアタッチは省略します

registerForContextMenu(rv); // 追記

を追記します。
registerForContextMenuはView.OnCreateContextMenuListenerをimplementsすることで呼び出せるようになります。
この記述でコンテキストメニューを認識させることができます。

続いて、FragmentにonCreateContextMenuを実装します。

SampleFragment.java
    @Override
    public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
        // 長押しされたメッセージ位置を取得し、
        // RecyclerViewで表示させているListのindexとして扱い、
        // ユーザーIDを取得してメニューの出し分けを実現
        if (list.get(adapter.getPosition()).getUserId() == loginUserId) {
            menu.add(Menu.NONE, 1, Menu.NONE, "コピー");
            menu.add(Menu.NONE, 2, Menu.NONE, "編集");
            menu.add(Menu.NONE, 3, Menu.NONE, "削除");
        } else {
            menu.add(Menu.NONE, 1, Menu.NONE, "コピー");
        }
    }

ContextMenuオブジェクトに追加したい内容をaddする形で簡単に項目を追加できます。
addメソッドの第二引数に数値をセットしていますが、これは押された際のリスナーのハンドリングで使用します。
参考程度ですが、Adapterに以下の要領でgetPosition()メソッドを用意して、長押しされた要素の位置を取得できるようにしています。

Adapter.java
    @Override
    public void onBindViewHolder(final ChatViewHolder holder, int position) {

        // 省略

        holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                setPosition(holder.getPosition());
                return false;
            }
        });
    }

    // 長押しされた要素の位置を取得する
    public int getPosition() {
        return position;
    }

    // 長押しされた要素の位置をセットする
    public void setPosition(int position) {
        this.position = position;
    }

ここまでの実装でコンテキストメニューは表示できます。

コンテキストメニューのイベントリスナー

以下のように、onContextItemSelectedを実装し、中でswitch文を用いてハンドリングします。
MenuItem.getItemId()で先ほど登録した各要素の判別用の数値が取得できますので、
switch文で振り分ける感じですね。

SampleFragment.java
    @Override
    public boolean onContextItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case 1:
                // TODO メッセージをクリップボードにコピー
                return true;
            case 2:
                // TODO メッセージを編集
                return true;
            case 3:
                // TODO メッセージを削除
                return true;
            default:
                return false;
        }
    }

以上です。

どなたかの参考になりましたら幸いです!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

adb connectでmissing port in specificationとなる場合

はじめに

adbのバージョンによるのか、adb connnectで接続できない場合があったため備忘録

コマンド

C:\Users\hoge>adb tcpip 5555
restarting in TCP mode port: 5555

rem 接続に失敗する
C:\Users\hoge>adb connect 192.168.XXX.XXX
missing port in specification: tcp:192.168.XXX.XXX

rem ポート番号を末尾に追加すると接続できる
C:\Users\hoge>adb connect 192.168.XXX.XXX:5555
connected to 192.168.XXX.XXX:5555

C:\Users\hoge>adb devices
List of devices attached
192.168.XXX.XXX:5555       device

確認したバージョン

C:\Users\hoge>adb version
Android Debug Bridge version 1.0.41
Version 29.0.1-5644136

参考サイト

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewModelのデータを保存/復元するにはSavedStateHandleを使う

こうだ

saved_state_handle.gif

Developer optionsDon't keep activities を有効にしています

Androidとメモリー上のデータ

Androidはすぐメモリー上のデータが飛びます。
画面を回転したら飛ぶ、システムがアクティビティを破棄したら飛ぶ、システムがプロセスをキルったら飛ぶ。

これらはAndroidのライフサイクルのせいで、上記のタイミングでActivityやFragmentのインスタンスが都度作り直しになるためです。
そのために開発者はActivityやFragmentの onSaveInstanceState関数で飛ばしたくないデータを保存し、 onCreate 関数などで savedInstanceState からデータを復元する処理を書かなくてはいけませんでした。

ViewModelの登場

AndroidでViewModelクラスが登場して以降、開発者は様々な恩恵を受けられるようになりました。

その一つにメモリー上のデータが飛びにくくなったことがあります。
ViewModelは画面を回転させてもデータは飛びません! 自動で復元されます!
やったね!……?

ViewModelの問題

しかしViewModelといえど、システムがアクティビティを破棄した時、システムがプロセスをキルした時はやはり飛びます。No, やったね!。やってない。

結局、ViewModle内の状態も savedInstanceState に都度保存・から都度復元しなければならないのでしょうか?

SavedStateHandle

いいえ、そんなことはありません。ViewModelには SavedStateHandle という便利なものが用意されています。

SavedStateHandle はViewModel内のプロパティの保存/復元を行うためのヘルパークラスです。
SavedStateHandle の内部でプロパティの実体を保持しており、ライフサイクルを勝手に観測していい感じに保存・復元をしてくれます。

ここで例を見てみましょう。
保存/復元を行いたいのは EditText とデータバインディングされた val userTextInput: MutableLiveData<String> プロパティであるとします。
保存/復元を考えない場合、 userTextInputval userTextInput = MutableLiveData<String>("") のように宣言されていたことでしょう。
これをSavedStateHandleを使って保存/復元するには以下のように書きます。

MainFragment.kt
class MainFragment : Fragment() {
    ...

    // SavedStateHandleを使っても使わなくてもViewModelの初期化方法は同じ。
    private val viewModel: MainViewModel by viewModels()

    ...
}
MainViewModel.kt
class MainViewModel(
    // ViewModelの第1引数にSavedStateHandleを指定します
    handle: SavedStateHandle
) : ViewModel() {
    // SavedStateHandleからキー"USER_TEXT_INPUT"にひも付くMutableLiveDataを探して引っ張ってくる。
    // 存在しない場合は新しく作る(そしてSavedStateHandle内でキー"USER_TEXT_INPUT"に対してひも付け、抱えておく)。
    // 初期値は空文字。
    val userTextInput: MutableLiveData<String> = handle.getLiveData<String>("USER_TEXT_INPUT", "")

普通にMutableLiveDataのコンストラクタから宣言していたのを handle の関数から取得するようにしただけです。
これだけでシステムがアクティビティを破棄する時、システムがプロセスをキルする時はその時のuserTextInputの値を勝手に保存してくれますし、復帰する時は勝手に復元してくれます。

val userTextInput = MutableLiveData<String>("") (Simple LiveData)方式と val userTextInput: MutableLiveData<String> = handle.getLiveData<String>("USER_TEXT_INPUT", "")
(SavedStateHandle#getLiveData)方式の振る舞いを比較したのが冒頭の動画になります。
EditText に文字を入力したあとホームボタンを押してランチャー画面を表示させ、再度アプリアイコンをタップしてアプリを起動させたところ、Simple LiveDataの方は入力したデータが飛んでしまっていますが、SavedStateHandle#getLiveDataの方はきちんとデータが残っています。これは保存/復元が行われたということです。今度こそやったね!

なお、handle.getLiveData<T>(...)T になれるのは、

  • 基本型
    • Boolean
    • Short
    • Int
    • Long
    • Float
    • Double
    • String
    • Byte
    • Char
    • CharSequence
  • Parcelable
  • Array<基本型 または Parcelable>
  • Serializable
  • Binder
  • Bundle
  • Size
  • SizeF
  • ArrayList<Tになれるどれか>
  • SparseArray<Tになれるどれか>

のみです。
T がエンティティクラスの場合はParcelableを継承し実装するのがよいでしょう。

ちなみに

SavedStateHandleget という関数も持っています。
これは MutableLiveData<T> ではなく生の値(T)を返す関数です。
使う機会は多くないかもしれませんが覚えておくといいでしょう。

おわりに

Androidのライフサイクルに泣きたくなるのをこらえて頑張りましょう!

追記

続きのような立ち位置の記事を書きました。合わせてどうぞ。

Repositoryから取得したLiveDataをViewModelでキャッシュしておくには?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ImageViewを角丸にする方法

概要

Androidには、画像リソースを表示するためにImageViewクラスが存在します。これを使用することで、UIに画像を表示することが可能になります。しかしながら、下の画像のように丸いアイコンのような画像を表示させる属性は、存在しません。(2020/08/29現在)

なので今回は、ImageViewを角丸にする方法を紹介します。サードパーティのライブラリも複数存在しますが、今回はそのよなライブラリに頼らずAndroid標準のライブラリのみ使用して実装する方法を紹介します。

hojge.png

実装方法

実装のコードは下記のようになります。
ポイントはCardViewを使用することです。

<androidx.cardview.widget.CardView
    android:id="@+id/icon"
    android:layout_width="40dp"
    android:layout_height="40dp"
    app:cardBackgroundColor="@color/colorAccent"
    app:cardCornerRadius="20dp"
    app:cardElevation="0dp"

    <ImageView
        android:id="@+id/image_view"
        android:layout_height="wrap_content"
        android:layout_width="wrap_content"
        android:src="@drawable/test_jpg"
        android:scaleType="centerCrop"
        android:layout_gravity="center"
        />
</androidx.cardview.widget.CardView>

ざっくり実装方針を説明すると、CardViewの子としてImageViewを配置し、CardViewで角丸を作成しています。

CardViewには、app:cardCornerRadiusという属性が存在し、角の半径を指定することができます。この属性により角丸が実現できます。

まとめ

今回はImageViewを角丸にする方法を紹介しました。
サードパーティのライブラリを使用しなくても、簡単に実装できますので、ぜひ使用してみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む