まえがき
前回大まかなロジック部分の実装はできたものの、クイズ画面で画面を表示させられなかったり、スコア表示は後回しにしている状態でした。今回で、残った実装を終わらせにかかります。
前回はこちら
目次
概要
この記事では、ライブラリによるURLからの画像表示と、不正解画面での正解数を表示させる実装をする上で作業した内容をまとめる。
ライブラリの設定
前回はURLから画像を表示させるライブラリとして、スタンダードらしいGlideを採用しようと考えていましたが、調べてみるとGlideはSVGのURLに対応していないようでした。そこで、SVGのURLから画像を表示させられるAndroidSVGを使うことにしました。ライブラリを用いる上で、いくつか設定をしておく必要があります。
GlideでもSVGに対応させる方法もWeb上にいくつか見られましたが、難しそうだったので断念しました。
AndroidManifest.xml
以下コードを、AndroidManifest.xmlに追記します。
<uses-permission android:name="android.permission.INTERNET" />
これはアプリがインターネットを使う権限を設定している部分になります。URLから画像を表示させるので、これが必要になります。
build.gradle
モジュール用のbuild.gradleのdependenciesセクションに、以下コードを追記します。
implementation(libs.androidsvg)
この部分では、使うライブラリ(今回はAndoidSVG)の登録を行っています。
以上で、Kotlinファイルでライブラリをimportする準備が整いました。
Activityの実装
QuizActivity.kt
package com.example.flagquizapp
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import kotlin.random.Random
import android.graphics.drawable.PictureDrawable
import com.caverock.androidsvg.SVG
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URL
class QuizActivity : AppCompatActivity() {
// 国旗のURLと国名を格納する配列を宣言
private var correctAnswerCount = 0
private lateinit var flagUrls: Array<String>
private lateinit var countryNames: Array<String>
private lateinit var correctFlagUrl: String
private lateinit var options: MutableList<String>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_quiz)
// 前のクイズ画面から戻ったときに正解した国のセットを受け取る
correctCountries = intent.getStringArrayListExtra("correct_countries")?.toMutableSet() ?: mutableSetOf()
// リソースから国旗のURLと国名を読み込む
flagUrls = resources.getStringArray(R.array.flag_urls)
countryNames = resources.getStringArray(R.array.country_names)
// 保存された状態がある場合は国旗URLと選択肢を復元
if (savedInstanceState != null) {
correctFlagUrl = savedInstanceState.getString("flag_url") ?: flagUrls[0] // デフォルトのURLを指定
options = savedInstanceState.getStringArrayList("options")?.toMutableList() ?: generateOptions(0).toMutableList()
} else {
// ランダムに国旗と選択肢を生成
val correctIndex = Random.nextInt(flagUrls.size)
correctFlagUrl = flagUrls[correctIndex]
options = generateOptions(correctIndex).toMutableList()
}
// 国旗画像を表示する ImageView
val flagImage: ImageView = findViewById(R.id.flagImage)
// コルーチンを使ってSVG画像を読み込み、表示
CoroutineScope(Dispatchers.Main).launch {
val svgDrawable = loadSvgFromUrl(correctFlagUrl)
svgDrawable?.let {
flagImage.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
flagImage.setImageDrawable(it)
}
}
// ボタンに選択肢をセット
val option1: Button = findViewById(R.id.option1)
val option2: Button = findViewById(R.id.option2)
val option3: Button = findViewById(R.id.option3)
val option4: Button = findViewById(R.id.option4)
option1.text = options[0]
option2.text = options[1]
option3.text = options[2]
option4.text = options[3]
// ボタンがクリックされたときの処理
option1.setOnClickListener { checkAnswer(options[0], countryNames[flagUrls.indexOf(correctFlagUrl)]) }
option2.setOnClickListener { checkAnswer(options[1], countryNames[flagUrls.indexOf(correctFlagUrl)]) }
option3.setOnClickListener { checkAnswer(options[2], countryNames[flagUrls.indexOf(correctFlagUrl)]) }
option4.setOnClickListener { checkAnswer(options[3], countryNames[flagUrls.indexOf(correctFlagUrl)]) }
}
// 状態保存
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("flag_url", correctFlagUrl)
outState.putStringArrayList("options", ArrayList(options)) // 選択肢を保存
}
// SVG画像をURLから読み込む関数
private suspend fun loadSvgFromUrl(url: String): PictureDrawable? {
return withContext(Dispatchers.IO) {
try {
val connection = URL(url).openConnection()
connection.connect()
val inputStream = connection.getInputStream()
val svg = SVG.getFromInputStream(inputStream)
val picture = svg.renderToPicture()
PictureDrawable(picture)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
// 正解1つとランダムな不正解3つを含む選択肢を生成する関数
private fun generateOptions(correctIndex: Int): List<String> {
// 長いので省略
}
// 回答が正解かどうかを判定する関数
private fun checkAnswer(selectedAnswer: String, correctAnswer: String) {
if (selectedAnswer == correctAnswer) {
// 新しい国なら正解セットに追加
correctCountries.add(correctAnswer)
val intent = Intent(this, QuizActivity::class.java)
intent.putStringArrayListExtra("correct_countries", ArrayList(correctCountries))
startActivity(intent)
} else {
// 不正解なら不正解画面に遷移し、正解国セットを渡す
val intent = Intent(this, IncorrectActivity::class.java)
intent.putStringArrayListExtra("correct_countries", ArrayList(correctCountries))
startActivity(intent)
}
}
}
AndroidSVGによる画像表示
loadSvgFromUrl関数で、画像URLを読み込みます。
// SVG画像をURLから読み込む関数
private suspend fun loadSvgFromUrl(url: String): PictureDrawable? {
return withContext(Dispatchers.IO) {
try {
val connection = URL(url).openConnection()
connection.connect()
val inputStream = connection.getInputStream()
val svg = SVG.getFromInputStream(inputStream)
val picture = svg.renderToPicture()
PictureDrawable(picture)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
suspendとありますが、これはコルーチン処理のなかで呼び出される関数であることを意味し、処理を非同期的に行うことができます。
画像表示はコルーチン処理によって書かれており、画面表示が完了せずとも選択肢が表示されるため、画像表示でノロノロなどのユーザビリティの低下を防ぎます。
// コルーチンを使ってSVG画像を読み込み、表示
CoroutineScope(Dispatchers.Main).launch {
val svgDrawable = loadSvgFromUrl(correctFlagUrl)
svgDrawable?.let {
flagImage.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
flagImage.setImageDrawable(it)
}
}
デバイスの向きによって国旗画像と選択肢が更新されてしまう問題の解消
途中で、国旗画像URLと選択肢を保存させ、
// 状態保存
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("flag_url", correctFlagUrl)
outState.putStringArrayList("options", ArrayList(options)) // 選択肢を保存
}
再度読み込まれた際(OnCreate()が実行された際)、保存された状態のものの有無で、URLと選択肢を更新します。
// 保存された状態がある場合は国旗URLと選択肢を復元
if (savedInstanceState != null) {
correctFlagUrl = savedInstanceState.getString("flag_url") ?: flagUrls[0] // デフォルトのURLを指定
options = savedInstanceState.getStringArrayList("options")?.toMutableList() ?: generateOptions(0).toMutableList()
} else {
// ランダムに国旗と選択肢を生成
val correctIndex = Random.nextInt(flagUrls.size)
correctFlagUrl = flagUrls[correctIndex]
options = generateOptions(correctIndex).toMutableList()
}
正解数カウントのための実装
checkAnswer関数が、正解数をカウントするための関数となっています。
// 回答が正解かどうかを判定する関数
private fun checkAnswer(selectedAnswer: String, correctAnswer: String) {
if (selectedAnswer == correctAnswer) {
// 新しい国なら正解セットに追加
correctCountries.add(correctAnswer)
val intent = Intent(this, QuizActivity::class.java)
intent.putStringArrayListExtra("correct_countries", ArrayList(correctCountries))
startActivity(intent)
} else {
// 不正解なら不正解画面に遷移し、正解国セットを渡す
val intent = Intent(this, IncorrectActivity::class.java)
intent.putStringArrayListExtra("correct_countries", ArrayList(correctCountries))
startActivity(intent)
}
}
Activityクラスにはintentというプロパティが用意されており、これによってActivity間で値の受け渡しを行います。正解した国数を表示させるため、ユニークな国の配列を作成します。
IncorrectActivity.kt
package com.example.flagquizapp
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class IncorrectActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_incorrect)
// Intentから正解した国のリストを受け取る
val correctCountries = intent.getStringArrayListExtra("correct_countries") ?: arrayListOf()
// 正解した国の数を取得
val uniqueCorrectCountryCount = correctCountries.size
// TextViewに正解数を表示
val correctCountTextView: TextView = findViewById(R.id.correctCountTextView)
correctCountTextView.text = getString(R.string.score, uniqueCorrectCountryCount)
// タイトル画面に戻るボタン
val returnToTitleButton: Button = findViewById(R.id.returnToTitleButton)
returnToTitleButton.setOnClickListener {
val intent = Intent(this, TitleActivity::class.java)
startActivity(intent)
}
}
}
先述したintentによって受け取った正解数を、画面にも表示させます。
実装後の画面
色々な仮想デバイスを試しながら、レイアウトのXMLの値は調整しました。
また、クイズ画面で横画面表示の際に長い国名が2行になるのが気になったので2×2から4×1にし、白が多い国旗が見えやすくなるよう背景の色を少しいじりました。
クイズ画面
不正解画面
196ヵ国全ての国旗を覚えている人がプレイするかもしれないことを考慮し、文言を「残念!」から「終了!」としました。
いくつかの仮想デバイスで動作を確認し、タブレットのような形状でも不都合なく表示できることを確認しました。
終わりに
今回でアプリの実装フェーズが終わったので、今後はリリースに向けた作業を行っていきたいと思います。
現在リリースのための作業を進めていますが、リリースにはAndroid実機が必要であることが判明しました(2024年初頭から、リリースの要件になったようです:参考)。そして筆者は実機を所持していません…。実機を所持している知人いないかな…。
次回はこちら