はじめに
タイトルと画像のあるカードのリストを作ります。
それぞれ2種類のデータ型があり、組み合わせて4種類のカードを作ります。
完成品はgithubに上げてあります
ソース1 | ソース2 | |
---|---|---|
タイトル | 文字列 | StringResource |
画像 | Url | DrawableResource |
RecyclerViewの実装
基本となるRecyclerViewを実装していきます。
まずはgradleを編集してViewBindingを有効化して、Glideも追加しておきます。
manifestを編集してインターネット権限を追加します。
plugins {id 'com.android.application'id 'org.jetbrains.kotlin.android'}android {compileSdk 32defaultConfig {applicationId "com.shirobutton.carddecks"minSdk 26targetSdk 32versionCode 1versionName "1.0"testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"}buildTypes {release {minifyEnabled falseproguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'}}compileOptions {sourceCompatibility JavaVersion.VERSION_1_8targetCompatibility JavaVersion.VERSION_1_8}kotlinOptions {jvmTarget = '1.8'}buildFeatures {viewBinding = true}}dependencies {implementation 'androidx.core:core-ktx:1.7.0'implementation 'androidx.appcompat:appcompat:1.3.0'implementation 'com.google.android.material:material:1.4.0'implementation 'androidx.constraintlayout:constraintlayout:2.0.4'implementation "com.github.bumptech.glide:glide:4.11.0"testImplementation 'junit:junit:4.13.2'androidTestImplementation 'androidx.test.ext:junit:1.1.3'androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'}
<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android"xmlns:tools="http://schemas.android.com/tools"package="com.shirobutton.carddecks"><uses-permission android:name="android.permission.INTERNET"/><applicationandroid: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.CardDecks"tools:targetApi="31"><activityandroid:name=".MainActivity"android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter></activity></application></manifest>
今回注目するのはデータクラスとViewHolderなので、
ほかは適当にRecyclerViewを実装します。
data class CardListItem(val title: String,val imageUrl: String)
class CardListViewHolder(private val binding: CardListItemViewBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(item: CardListItem) {binding.title.text = item.titleGlide.with(binding.image).load(item.imageUrl).into(binding.image)}companion object {fun create(parent: ViewGroup): CardListViewHolder {val layoutInflater = LayoutInflater.from(parent.context)val binding = CardListItemViewBinding.inflate(layoutInflater,parent,false)return CardListViewHolder(binding)}}}
<?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="wrap_content"><ImageViewandroid:id="@+id/image"android:layout_width="100dp"android:layout_height="100dp"android:layout_marginStart="8dp"android:layout_marginTop="8dp"android:layout_marginBottom="8dp"android:adjustViewBounds="true"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent"tools:srcCompat="@tools:sample/avatars" /><TextViewandroid:id="@+id/title"android:layout_width="0dp"android:layout_height="wrap_content"android:layout_marginStart="8dp"android:layout_marginTop="8dp"android:layout_marginEnd="8dp"android:layout_marginBottom="8dp"android:textSize="34sp"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toEndOf="@+id/image"app:layout_constraintTop_toTopOf="parent"tools:text="Title" /></androidx.constraintlayout.widget.ConstraintLayout>
class CardListAdapter : ListAdapter<CardListItem, CardListViewHolder>(diffCallback) {override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =CardListViewHolder.create(parent)override fun onBindViewHolder(holder: CardListViewHolder, position: Int) =holder.bind(getItem(position))companion object {private val diffCallback = object : DiffUtil.ItemCallback<CardListItem>() {override fun areItemsTheSame(oldItem: CardListItem,newItem: CardListItem): Boolean = oldItem == newItemoverride fun areContentsTheSame(oldItem: CardListItem,newItem: CardListItem): Boolean = oldItem == newItem}}}
class MainActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)val binding = ActivityMainBinding.inflate(layoutInflater)setContentView(binding.root)bindRecyclerView(binding)}private fun bindRecyclerView(binding: ActivityMainBinding) {val adapter = CardListAdapter()adapter.submitList(createData())binding.root.adapter = adapterbinding.root.layoutManager = LinearLayoutManager(this)}private fun createData() =List(3) {CardListItem(title = it.toString(),imageUrl = "https://placehold.jp/100x100.png")}}
<?xml version="1.0" encoding="utf-8"?><androidx.recyclerview.widget.RecyclerViewxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent" />
4種類のカードを実装する
現在はタイトルが文字列で画像がURLのカードを使用していますね。
なので他3種類のカードを作りたいと思います。
まずはタイトルがStringResourceのカードを作ってみます。
CardItemを異なる種類に対応するためにclassからinterfaceに変更します。
抽象であれば様々な具象に対応できるためですね。
sealed interface CardListItem {val title: Stringval imageUrl: Stringoverride fun equals(other: Any?): Boolean}data class CardTypeA(override val title: String,override val imageUrl: String) : CardListItem
private fun createData() =List(3) {CardTypeA(title = it.toString(),imageUrl = "https://placehold.jp/100x100.png")}
ではタイトルがStringResourceのカードを追加してみます。
単純にデータ型を追加して、それをViewHolderのgetTitleで利用します。
sealed interface CardListItem {val imageUrl: Stringoverride fun equals(other: Any?): Boolean}data class CardTypeA(val title: String,override val imageUrl: String) : CardListItemdata class CardTypeB(@StringRes val titleResId: Int,override val imageUrl: String) : CardListItem
class CardListViewHolder(private val binding: CardListItemViewBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(item: CardListItem) {binding.title.text = getTitle(item)Glide.with(binding.image).load(item.imageUrl).into(binding.image)}private fun getTitle(item: CardListItem) =when (item) {is CardTypeA -> item.titleis CardTypeB -> binding.root.context.getString(item.titleResId)}companion object {fun create(parent: ViewGroup): CardListViewHolder {val layoutInflater = LayoutInflater.from(parent.context)val binding = CardListItemViewBinding.inflate(layoutInflater,parent,false)return CardListViewHolder(binding)}}}
private fun createData() =List(3) {CardTypeA(title = it.toString(),imageUrl = "https://placehold.jp/100x100.png")} + CardTypeB(titleResId = R.string.title,imageUrl = "https://placehold.jp/100x100.png")}
<resources><string name="app_name">CardDecks</string><string name="title">ResourceTitle</string></resources>
この調子で画像がDrawableResourceのカードも作ります。
データクラスの名前が思いつかなかったのでちょっと適当です。
createDataがごちゃごちゃしてきたのでついでにリファクタリングもします。
sealed interface CardListItem {override fun equals(other: Any?): Boolean}data class CardTypeA(val title: String,val imageUrl: String) : CardListItemdata class CardTypeB(@StringRes val titleResId: Int,val imageUrl: String) : CardListItemdata class CardTypeC(val title: String,@DrawableRes val imageResId: Int) : CardListItemdata class CardTypeD(@StringRes val titleResId: Int,@DrawableRes val imageResId: Int) : CardListItem
class CardListViewHolder(private val binding: CardListItemViewBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(item: CardListItem) {bindTitle(item)bindImage(item)}private fun bindTitle(item: CardListItem) {val context = binding.root.contextbinding.title.text = when (item) {is CardTypeA -> item.titleis CardTypeC -> item.titleis CardTypeB -> context.getString(item.titleResId)is CardTypeD -> context.getString(item.titleResId)}}private fun bindImage(item: CardListItem) =when (item) {is CardTypeA -> loadImage(item.imageUrl)is CardTypeC -> setImageResource(item.imageResId)is CardTypeB -> loadImage(item.imageUrl)is CardTypeD -> setImageResource(item.imageResId)}private fun loadImage(imageUrl: String) {Glide.with(binding.image).load(imageUrl).into(binding.image)}private fun setImageResource(@DrawableRes drawableResId: Int) =binding.image.setImageResource(drawableResId)companion object {fun create(parent: ViewGroup): CardListViewHolder {val layoutInflater = LayoutInflater.from(parent.context)val binding = CardListItemViewBinding.inflate(layoutInflater,parent,false)return CardListViewHolder(binding)}}}
class MainActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)val binding = ActivityMainBinding.inflate(layoutInflater)setContentView(binding.root)bindRecyclerView(binding)}private fun bindRecyclerView(binding: ActivityMainBinding) {val adapter = CardListAdapter()adapter.submitList(createData())binding.root.adapter = adapterbinding.root.layoutManager = LinearLayoutManager(this)}companion object {private const val STRING_TITLE = "StringTitle"private const val TITLE_RES_ID = R.string.titleprivate const val IMAGE_URL = "https://placehold.jp/100x100.png"private const val IMAGE_RES_ID = R.drawable.ic_launcher_backgroundprivate fun createData() =listOf(CardTypeA(title = STRING_TITLE,imageUrl = IMAGE_URL),CardTypeB(titleResId = TITLE_RES_ID,imageUrl = IMAGE_URL),CardTypeC(title = STRING_TITLE,imageResId = IMAGE_RES_ID),CardTypeD(titleResId = TITLE_RES_ID,imageResId = IMAGE_RES_ID))}}
DrawableResourceのカードも
タイトルが文字列の場合とStringResourceの場合の2種類ありますね。
これで4種類のカードができました。
ソース1 | ソース2 | |
---|---|---|
タイトル | 文字列 | StringResource |
画像 | Url | DrawableResource |
プロパティをインターフェースに抽出
CardListViewHolderを見るとデータクラスで判別していてなんだか冗長的です。
同じデータ型を示しているプロパティをまとめられそうですね。
例えば文字列のタイトルであればそのままtitleをtextに入れればよくて、
反対に、StringResourceであれば、getStringを使えばいいですね。
これを区別するためのInterfaceを追加します。
とりあえずタイトルについてまとめます。
文字列であればStringTitleContainer、
StringResourceであれば、StringResourceTitleContainerを実装します。
CardListViewHolderでTitleContainerごとに処理を分けられていい感じですね。
sealed interface TitleContainersealed interface StringTitleContainer : TitleContainer {val title: String}sealed interface StringResourceTitleContainer : TitleContainer {@get:StringResval titleResId: Int}
sealed interface CardListItem : TitleContainer {override fun equals(other: Any?): Boolean}data class CardTypeA(override val title: String,val imageUrl: String) : CardListItem, StringTitleContainerdata class CardTypeB(@StringRes override val titleResId: Int,val imageUrl: String) : CardListItem, StringResourceTitleContainerdata class CardTypeC(override val title: String,@DrawableRes val imageResId: Int) : CardListItem, StringTitleContainerdata class CardTypeD(@StringRes override val titleResId: Int,@DrawableRes val imageResId: Int) : CardListItem, StringResourceTitleContainer
private fun bindTitle(item: TitleContainer) {val context = binding.root.contextbinding.title.text = when (item) {is StringTitleContainer -> item.titleis StringResourceTitleContainer -> context.getString(item.titleResId)}}
画像についても同じようにまとめます。
sealed interface ImageContainersealed interface ImageUrlContainer : ImageContainer {val imageUrl: String}sealed interface ImageResourceContainer : ImageContainer {@get:DrawableRes val imageResId: Int}
sealed interface CardListItem : TitleContainer, ImageContainer {override fun equals(other: Any?): Boolean}data class CardTypeA(override val title: String,override val imageUrl: String) : CardListItem, StringTitleContainer, ImageUrlContainerdata class CardTypeB(@StringRes override val titleResId: Int,override val imageUrl: String) : CardListItem, StringResourceTitleContainer, ImageUrlContainerdata class CardTypeC(override val title: String,@DrawableRes override val imageResId: Int) : CardListItem, StringTitleContainer, ImageResourceContainerdata class CardTypeD(@StringRes override val titleResId: Int,@DrawableRes override val imageResId: Int) : CardListItem, StringResourceTitleContainer, ImageResourceContainer
private fun bindImage(item: ImageContainer) =when (item) {is ImageUrlContainer -> loadImage(item.imageUrl)is ImageResourceContainer -> setImageResource(item.imageResId)}
これでViewHolderは冗長的ではなくなりましたね。
Cardのタイプごとに何かしら意味があればこれで十分だと思います。
それぞれのタイプでデータクラスを作成しておけば、
後々TypeAのみに追加するプロパティなどの個別の変更がしやすいです。
では、逆にタイプごとに分かれている意味はなく、
プロパティの追加見込みもないなど、
わざわざクラスに分ける意味がない場合はどうでしょうか。
Containerをプロパティにしてまとめる
クラスを分ける意味がない場合は、
冗長的なクラスを複数書くのは大変なので、一つのクラスで表現したいところです。
いくつか表現方法があります。
まずはContainerをクラスに継承させるのではなく、
Containerをクラスのプロパティにしてみます。
これなら器になるクラスを一つ用意するだけでよさそうです。
ContainerがInterfaceなのでClassにする必要がありますね。
data class CardListItem(val titleContainer: TitleContainer,val imageContainer: ImageContainer)
fun bind(item: CardListItem) {bindTitle(item.titleContainer)bindImage(item.imageContainer)}
sealed interface TitleContainerclass StringTitleContainer(val title: String) : TitleContainerclass StringResourceTitleContainer(@StringRes val titleResId: Int) : TitleContainer
sealed interface ImageContainerclass ImageUrlContainer(val imageUrl: String) : ImageContainerclass ImageResourceContainer(@DrawableRes val imageResId: Int) : ImageContainer
private fun createData() =listOf(CardListItem(StringTitleContainer(STRING_TITLE),ImageUrlContainer(IMAGE_URL)),CardListItem(StringResourceTitleContainer(TITLE_RES_ID),ImageUrlContainer(IMAGE_URL)),CardListItem(StringTitleContainer(STRING_TITLE),ImageResourceContainer(IMAGE_RES_ID)),CardListItem(StringResourceTitleContainer(TITLE_RES_ID),ImageResourceContainer(IMAGE_RES_ID)))
データクラスが一つになってすっきりしましたね。
Containerにロジックを移す
蛇足ですが、Containerにロジックを移すことで、
ViewHolder内のContainerの条件分岐を解消できます。
インターフェースを使用したポリモーフィズムの表現です。
sealed interface TitleContainer {fun getString(context: Context): String}class StringTitleContainer(private val title: String) : TitleContainer {override fun getString(context: Context) = title}class StringResourceTitleContainer(@StringRes private val titleResId: Int) : TitleContainer {override fun getString(context: Context) = context.getString(titleResId)}
sealed interface ImageContainer {fun into(imageView: ImageView)}class ImageUrlContainer(private val imageUrl: String) : ImageContainer {override fun into(imageView: ImageView) {Glide.with(imageView).load(imageUrl).into(imageView)}}class ImageResourceContainer(@DrawableRes private val imageResId: Int) : ImageContainer {override fun into(imageView: ImageView) = imageView.setImageResource(imageResId)}
class CardListViewHolder(private val binding: CardListItemViewBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(item: CardListItem) {binding.title.text = item.titleContainer.getString(binding.root.context)item.imageContainer.into(binding.image)}companion object {fun create(parent: ViewGroup): CardListViewHolder {val layoutInflater = LayoutInflater.from(parent.context)val binding = CardListItemViewBinding.inflate(layoutInflater,parent,false)return CardListViewHolder(binding)}}}
ViewHolderがとても小さくなりましたね。
ついでに委譲を使用してContainerを隠ぺいしてみましょう。
data class CardListItem(private val titleContainer: TitleContainer,private val imageContainer: ImageContainer) : TitleContainer by titleContainer, ImageContainer by imageContainer
class CardListViewHolder(private val binding: CardListItemViewBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(item: CardListItem) {binding.title.text = item.getString(binding.root.context)item.into(binding.image)}companion object {fun create(parent: ViewGroup): CardListViewHolder {val layoutInflater = LayoutInflater.from(parent.context)val binding = CardListItemViewBinding.inflate(layoutInflater,parent,false)return CardListViewHolder(binding)}}}
これでViewHolderはCardItemの中にContainerが入っていることが分からなくなりましたね。
ViewHolderはContainerごとに条件分岐する必要がなくなったので、
Containerが大量に増えてもViewHolderを編集する必要はありません。
ただ、Interfaceで変更があった場合に大量のConainterを修正する必要があります。
Containetが増える予定がなく、処理をViewHolderでのみ使うのであれば、
ViewHolderで条件分岐したほうがシンプルかもしれません。
あと、Containerという名前なのにロジックが入っているので名前を変えたほうがいいかもしれませんね。
Genericsを使用してデータをまとめる
こちらのほうがメジャーかもしれません。
複数のデータを扱うときはGenericsを使うことも多いです。
ResourceIDがただのInt型として扱われてしまうので、
value classを使用して別の型として表現します。
data class CardListItem<T, U>(val titleResource: T,val imageResource: U)
class CardListAdapter : ListAdapter<CardListItem<*, *>, CardListViewHolder>(diffCallback) {override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =CardListViewHolder.create(parent)override fun onBindViewHolder(holder: CardListViewHolder, position: Int) =holder.bind(getItem(position))companion object {private val diffCallback = object : DiffUtil.ItemCallback<CardListItem<*, *>>() {override fun areItemsTheSame(oldItem: CardListItem<*, *>,newItem: CardListItem<*, *>): Boolean = oldItem == newItemoverride fun areContentsTheSame(oldItem: CardListItem<*, *>,newItem: CardListItem<*, *>): Boolean = oldItem == newItem}}}
class CardListViewHolder(private val binding: CardListItemViewBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(item: CardListItem<*, *>) {bindTitle(item.titleResource)bindImage(item.imageResource)}private fun <T> bindTitle(titleResource: T) {val context = binding.root.contextbinding.title.text = when (titleResource) {is String -> titleResourceis StringResourceId -> context.getString(titleResource.value)else -> throw IllegalArgumentException()}}private fun <T> bindImage(imageResource: T) =when (imageResource) {is Uri -> loadImage(imageResource)is DrawableResourceId -> setImageResource(imageResource.value)else -> throw IllegalArgumentException()}private fun loadImage(imageUrl: Uri) {Glide.with(binding.image).load(imageUrl).into(binding.image)}private fun setImageResource(@DrawableRes drawableResId: Int) =binding.image.setImageResource(drawableResId)companion object {fun create(parent: ViewGroup): CardListViewHolder {val layoutInflater = LayoutInflater.from(parent.context)val binding = CardListItemViewBinding.inflate(layoutInflater,parent,false)return CardListViewHolder(binding)}}}
@JvmInlinevalue class StringResourceId(@StringRes val value: Int)fun Int.asStringResource() = StringResourceId(this)
@JvmInlinevalue class DrawableResourceId(@DrawableRes val value: Int)fun Int.asDrawableResource() = DrawableResourceId(this)
companion object {private const val STRING_TITLE = "StringTitle"private val TITLE_RES_ID = R.string.title.asStringResource()private val IMAGE_URL = "https://placehold.jp/100x100.png".toUri()private val IMAGE_RES_ID = R.drawable.ic_launcher_background.asDrawableResource()private fun createData() =listOf(CardListItem(STRING_TITLE,IMAGE_URL),CardListItem(TITLE_RES_ID,IMAGE_URL),CardListItem(STRING_TITLE,IMAGE_RES_ID),CardListItem(TITLE_RES_ID,IMAGE_RES_ID))}
これでも、DataClassは一つですみますね。
だたGenericsは上限を設定しない限り、Any?なので、
使用する側で条件分岐する必要があります。
今回は想定外の型だとエラーするようにしていますが、
プレースホルダーを表示すると丁寧ですね。
データ生成時にまとめる
今回は画像データを含むリストなので難しいですが、
テキストデータのみなどコストが低い場合は
データ生成時に一つのデータ型にまとめる方法もシンプルです。
StringResourceを使うならあらかじめgetStringするということですね。
あまり関係ないですが、あらかじめgetStringする場合は、
生成タイミングによってOSの言語設定が変わっても文字列が変わらなかったりします。
おわりに
結局のところどれがいいとかではなくて、
一長一短なのでこういう種類があるなという感じです。
個人的には委譲やポリフォーフィズムは好きですが、
デメリットももちろんありますし、
簡単な実装であればデータと処理を離すほうが主流になりつつあるのかなと思います。