[Android] ポケモン一覧アプリを作成する

なぜか今更ポケモンGOをやり始めました。
しかしポケモン達の名前が覚えられない!
というわけで、ポケモン一覧を表示してくれる自分専用のAndroidアプリを作ってみました。

ちなみに

  • 初めてのAndroid開発
  • 初めてのKotlin(javaも経験なし)
    という人間が書いています。
  • 開発環境 : Android Studio 3.4.1
  • 使用言語 : Kotlin 1.3.40
  • 実機環境 : Zenfone 5 (ZE620KL)

このような環境でやっていきます。

成果物

自分が想定していた物以上のものが出来ました。(もっとグダグダな物が出来上がるかと思っていた)
Androidの開発環境インストールからテスト実行までの早さ、IDEの使い勝手の良さ、技術記事の多さなどなど先人の方々に感謝。

リスト表示
属性等でのフィルタリング

詳細表示
名前でフィルタリング

元データ

ポケモンの名前、進化先、属性などと、デフォルメ画像を使いました。ライセンス的にどうなっているか不明でしたが、個人で作って公開もしない予定なので使います。

ちなみにポケモンデータはJSON。デフォルメ画像はPNGで、すべてリソースファイルとして入れました。

JSONファイル
  {
    "no": 1,
    "name": "フシギダネ",
    "form": "",
    "isMegaEvolution": false,
    "evolutions": [2],
    "types": ["くさ", "どく"],
    "abilities": ["しんりょく"],
    "hiddenAbilities": ["ようりょくそ"],
    "stats": {
      "hp": 45,
      "attack": 49,
      "defence": 49,
      "spAttack": 65,
      "spDefence": 65,
      "speed": 45
    },
  }
デフォルメ画像
かわいい

やった事

リソースファイルの置き場所

JSONファイルはRawフォルダ。デフォルメ画像はAssetsフォルダに入れました。

フォルダ構成はこのような感じになりました。

JSONからdata classを自動生成する

参考リンクを見ながらプラグインを入れます。プラグインを使用してdata classを生成します。

data classの定義はカーソルの場所に挿入されるのでカーソルを移動しておきます。

生成されたdata class
// 自動生成したクラス
data class PokemonData(
    val abilities: List<String>,
    val evolutions: List<Int>,
    val form: String,
    val hiddenAbilities: List<String>,
    val isMegaEvolution: Boolean,
    val name: String,
    val no: Int,
    val stats: Stats,
    val types: List<String>
): Serializable

data class Stats(
    val attack: Int,
    val defence: Int,
    val hp: Int,
    val spAttack: Int,
    val spDefence: Int,
    val speed: Int
): Serializable

JSONをパースするGSONライブラリを使用する

参考にしたサイト

build.gradleファイルに下記の1行を追加しましょう。
この記事を書いている際の最新バージョンは2.8.5でした。

dependencies {
    implementation 'com.google.code.gson:gson:2.8.5'
}

Singletonでデータを保持する

作るアプリは巨大ではないのでデータはSingletonで保持します。
(とりあえず全部グローバル変数化してしまえの精神)

RecycerViewでリストを表示する

Androidでリスト表示するにはRecycerViewを使うのが良さそうです。
使用するとこのような感じに。

これだけで作れた感が出ますね

画面遷移とデータの受け渡し

別のActivityへデータを渡す際はIntent.putExtraメソッドを使います。
さらに自前のdata classにjava.io.Serializableを付けておけばデータをそのまま指定できます。

Serializable を付けた data class
import java.io.Serializable

// ポケモン情報全てを表すデータクラス
data class Pokemons(
    var list: List<PokemonDataOnKey>
): Serializable

// 識別できるキーを持ったクラス
data class PokemonDataOnKey(
    val index: Int,     // この項目を識別キーとします
    var data: PokemonData
): Serializable

// 自動生成したクラス
data class PokemonData(
    val abilities: List<String>,
    val evolutions: List<Int>,
    val form: String,
    val hiddenAbilities: List<String>,
    val isMegaEvolution: Boolean,
    val name: String,
    val no: Int,
    val stats: Stats,
    val types: List<String>
): Serializable
    private fun onClickListItem(tappedView: View, position: Int) {
        Repository.Instance().pokemonListScrollState = recyclerView.layoutManager?.onSaveInstanceState()

        val intent = Intent(this, MonsterDetailContainerActivity::class.java)
        intent.putExtra(MonsterDetailContainerActivity.FILTERED_DATA_KEY, Repository.Instance().filteredPokemons!!)
        intent.putExtra(MonsterDetailContainerActivity.ORIGINAL_DATA_KEY, Repository.Instance().originalPokemons!!)
        intent.putExtra(MonsterDetailContainerActivity.POSITION_KEY, position)
        intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
        startActivity(intent)
    }

各種クリック処理

クリックされた際のOnClickListenerをどのように書けば綺麗なのか判らず。自分は下記のように記述しました。

fun hogehoge() {
  // ...
  filterButtonDragon.setOnClickListener{view -> onClickAttributeButton(view as ImageButton, PokemonAttributeTypes.Dragon)}
  filterButtonDark.setOnClickListener{view -> onClickAttributeButton(view as ImageButton, PokemonAttributeTypes.Dark)}
  filterButtonFairy.setOnClickListener{view -> onClickAttributeButton(view as ImageButton, PokemonAttributeTypes.Fairy)}
  filterAllOnOffButton.setOnClickListener{onClickAllAttributeButton()}
  evolutionMegaCheckBox.setOnClickListener{onClickMegaCheckButton()}
  evolutionAllRadioButtom.setOnClickListener{view -> onClickEvolutionRatioButton(view, EvolutionTypes.All)}
  evolutionOffRadioButtom.setOnClickListener{view -> onClickEvolutionRatioButton(view, EvolutionTypes.OffOnly)}
  evolutionOnRadioButtom.setOnClickListener{view -> onClickEvolutionRatioButton(view, EvolutionTypes.OnOnly)}
  // ...
}

たまたま1行で書けています。
リスナーの書き方はいろいろな人のコードを参考にするしかないですね。

ツールバーが自動で隠れるように設定する

たまたまRecycerViewを使用していたのでツールバーの「app:layout_scrollFlags」に「scroll|enterAlways」を指定しただけで対応できました。

    <com.google.android.material.appbar.AppBarLayout
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:theme="@style/AppTheme.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="?attr/colorPrimary"
                app:popupTheme="@style/AppTheme.PopupOverlay"
                app:layout_scrollFlags="scroll|enterAlways"/>

    </com.google.android.material.appbar.AppBarLayout>
これはこれでいいと思う
ツールバーが自動で消えてくれる

Snackbarを表示する

リストの最後まで移動したら表示しているポケモンの数を表示する為にSnackbarという機能を使用しました。

いい感じ

MainActivityのonCreate内で下記コードを記述。
Snackbarは実質3行で出来ている。
(build.gradle等は触らなくても大丈夫でした。参考にしたサイトにはいろいろ設定が必要だよ的な事が書いてありましたが。)

        // リストの最後表示時に表示個数を表示
        recyclerView.addOnScrollListener(object: RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy);
                if (!recyclerView.canScrollVertically(1)) {
                    var sb = Snackbar.make(recyclerView, Repository.Instance().filteredPokemons?.list?.size.toString() + "体の表示", 800)
                    sb.view.setBackgroundColor(Color.argb( 127, 0, 0, 0))
                    sb.show()
                }
            }
        })

オンライン音声認識を使う

検索に音声認識出来ると楽かな?と思い途中で追加しました。
実装はすごい簡単でした。さすがGoogle先生。

ただGoogleさんから返ってくる結果には漢字や文章化されたものが含まれます。
このアプリでは「ポケモンの名前 = 全てカタカナ」がほしいでの全角カタカナだけの結果を使用するようにしました。

全角カタカナ判定

Kotlinには拡張関数というものがあります。(知人に教えてもらいました)
こう記述しておくとstr.isFullKana()のように記述出来る。ふむふむ。

// 全角カタカナかどうかを取得します。
fun String.isFullKana(): Boolean {
    return java.util.regex.Pattern.matches("^[ァ-ヶー]*$", this)
}
音声認識開始コード

REQUEST_CODEは戻ってきた際の判定用番号として使用。

    private val REQUEST_CODE = 12345

    private fun onRecognitionSearch(): Boolean {
        var intent: Intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
        intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.JAPANESE.toString())
        intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 10)
        intent.putExtra(RecognizerIntent.EXTRA_PROMPT, "話してください")
        startActivityForResult(intent, REQUEST_CODE)
        return true
    }
音声認識結果を受ける

onActivityResultで受けて、結果の中から全角カタカナの結果を探して、検索入力欄に表示。
ツールバーにある検索入力の処理がおまじないレベル。この部分は理解せず。

    private fun findFullKana(list: ArrayList<String>): String {
        for(s in list) {
            if( s.isFullKana() ) {
                return s
            }
        }
        return ""
    }

    private fun pickupRecognizerText(data: Intent?): String {
        var result = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
        if(result != null) {
            when(result.isEmpty()) {
                true -> return ""
                false -> return findFullKana(result)
            }
        }
        return ""
    }

    fun onRecognitionResult(data: Intent?) {
        var inputText: String = pickupRecognizerText(data)
        var searchView = toolbar.menu.findItem(R.id.menu_search).actionView as SearchView
        searchView.setQuery(inputText, false)
        if(inputText.isNotEmpty()) {
            searchView.requestFocusFromTouch()
            searchView.isIconified = false
        }
        Repository.Instance().filterData.searchText = inputText
        onChangedFilterCondition()
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if(requestCode == REQUEST_CODE && resultCode == RESULT_OK) {
            onRecognitionResult(data)
        }
    }

苦労した点 / はまった点

Activityの復元

画面遷移して戻ってきた際に、Activity内のコントロールの状態を復元する手間がかかりました。
(スクロール位置を保持したり、押されたボタンの状態を復元したり、検索入力を復元したり)

KotlinでもIcePickが使いたい!
ライブラリがあるようでしたが初心者が手を出すとハマるだけ、と思いスルーしました。

ConstraintLayout

レイアウトファイルを初めて触る、かつ試行錯誤しながらだと相性が最悪でした。
(現在は多少レイアウトの事を理解したので最悪ではなく楽かも?になりました)
「このコントロールはこっちがいいな。マウスでツーっと移動♪」なんてやると他のコントロールが、あれ?ズレてる? みたいな事が。

開発する前にレイアウト作成は少し練習した方が効率が良かったかもです。

さいごに

初心者に対してAndroid開発環境はかなり優しいと感じました。
Kotlinという言語も2019/7時点で情報が多く存在する為、コーディングでのつまづきは皆無でした。(ほとんどの時間をxml (レイアウト) に吸われた気がする)

公開できるようなアプリケーションを作るにはGoogle Playの規約や、さまざまな状態への対応、各種機種対応、バージョン対応などあると思いますが、すぐに自分のスマホで動くのは単純に楽しかったです。


希木小鳥

Diablo1でハクスラの世界に。今はBorderlands2をプレイ中。ぬるゲーマー。

あわせて読みたい