くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

RecyclerViewについて

今回はRecyclerViewについてです

Androidでリスト形式のデータを表示させる場合にはListViewRecyclerViewなどがあると思いますが、複雑なリストが作成できたりする点でRecyclerViewを選択することがほとんどだと思います

ただ、当たり前のように使っていて仕組みをあまり理解していない気がしないので調べたことをまとめようと思います

RecyclerViewについて

RecyclerViewはリストを表示させる際に使います。リサイクルという名前のとおり、個々の要素をリサイクルします。アイテムが画面外にスクロールされても、RecyclerViewはビューを破棄せず、画面上にスクロールされた新しいアイテムのビューを再利用します。再利用をすることによってパフォーマンスの大幅な改善がされるのがListViewとの違いです

RecyclerViewでリストを表示させるために必要なもの

今回はRecyclerViewのライブラリ追加は省きますが、前提としてbuild.gradleにRecyclerViewのバージョン等の設定を追加しないと使えないです

RecyclerViewを使用するためにはまず、AdapterViewHolderが必要になります

Adapter

Adapterについて軽く説明します。AdapterはデザインパターンのAdapterパターンを元に作られています

理解しやすくするために例えられるものでコンセントを例にしたものがあります。電気のコンセントは日本と海外では異なるので、日本のコンセントを海外で使うときは変換アダプターを使いプラグの形状を変えて使います。このように家電自体のコンセントを変えずに、別の場所でコンセントを使えるように変換するのがアダプターの役割です

Adapterパターンも変換アダプターと同じように、異なるクラス同士のインターフェースを変更せずに使用するための役割を担います

RecyclerViewのAdapterは、表示するデータをレイアウトファイルのRecyclerViewに表示させるために使います(1行分のデータを1行分のViewに設定して生成するもの)

ViewHolder

ViewHolderは1行分のView(ウィジェット)の参照を保持するものです。Adapterに指示されてセルを作ったり保持したりします

RecyclerViewやAdapterやViewHolderの関連性を簡易的な図にまとめたものは下記です

次に実際にAdapterクラスを見てみましょう

実際のAdapterクラス

下記にAdapterクラスの一例です

class SimpleRecyclerAdapter(private val titleList: List<String>): RecyclerView.Adapter<SimpleRecyclerAdapter.SimpleListViewHolder>() {
    // セルのレイアウトを読み込んでViewHolderと紐付ける (1セルごとに毎回呼び出される)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SimpleListViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        // セルのレイアウト
        val view = inflater.inflate(R.layout.simple_list, parent, false)
        return SimpleListViewHolder(view)
    }

    // セルの個数の定義
    override fun getItemCount(): Int {
        return titleList.size
    }

     // 取得したセルデータをViewHolderが参照してきたViewにセットする (1セルごとに毎回呼び出される)
     // positionは現在のセルが何番目かを定義しているもの
    override fun onBindViewHolder(holder: SimpleListViewHolder, position: Int) {
        holder.titleTextView.text = titleList[position]
    }

    class SimpleListViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var titleTextView: TextView = itemView.findViewById(R.id.simpleListTitleTextView)
    }
}

Adapterクラスを作成する場合には必ず親元のRecyclerView.Adapter<自作ViewHolder>を継承させることと次の3つのメソッドをOverrideする必要があります

  • onCreateViewHolder()
  • onBindViewHolder()
  • getItemCount()

それぞれの処理の概要は上記の一例のコードにコメントとして記載したので確認してください

今回はセルのレイアウトにsimpleListTitleTextViewというidのTextViewを設定しています

ListViewとの大きな違いとして、ListViewではgetItem()内でレイアウトファイルからインフレート・データの取得・Viewに格納を行っていますが、RecyclerViewではそれぞれが独立した処理(メソッドやクラス)として定義する必要があります。

これでAdapterクラスを作成できたのですが、RecyclerViewに渡すために紐付けを行うのとレイアウトを調整する必要があります。

LayoutManager

LayoutManagerはセル1つ分のデータのサイズなどを考慮して、レスポンシブにレイアウトを管理するクラスになります

LayoutManagerは標準で下記3パターンのレイアウトを提供しています

  • LinearLayoutManager→リストで表示する
  • GridLayoutManager→それぞれのセルの高さ(幅)がそろった格子状に表示する
  • StaggeredGridLayoutManager→格子状に表示するが、高さ(幅)がそれぞれのセルで異なる

一般的にLinearLayoutManagerを使用することが多いと思います。リストで表示する場合には縦にスクロールするか横にスクロールするかを設定することができます

実際にLayoutManagerの使い方の一例は下記になります

class SimpleRecyclerViewActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_simple_recycler)

        setupUI()
    }

    private fun setupUI() {
        setupAdapter()
    }

    private fun setupAdapter() {
        val titleList = listOf("title1", "title2", "title3", "title4", "title5", "title6", "title7", "title8")
        val adapter = SimpleRecyclerAdapter(titleList)

        //  RecyclerViewとAdapterの紐付けを行う
        simpleRecyclerView.adapter = adapter
        //  RecyclerViewの表示形式の設定
        simpleRecyclerView.layoutManager = LinearLayoutManager(this)

        // 区切り線を追加する場合には必要
        val separate = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        simpleRecyclerView.addItemDecoration(separate)
    }
}

今回はリストの縦スクロールで表示するパターンなのですが、横スクロールをする場合にはval layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)で可能です

上記のsetupAdapterメソッドの処理をActivityやFragmentなどで行うのが一般的です

区切り線を追加する場合にはDividerItemDecorationを追加する必要があります。今回は標準のクラスを使っていますが、背景色を変更する場合にはカスタマイズしたクラスを作成してRecyclerViewと紐づけることができます

ここまで行うとリストが表示されるはずです

複数のAdapterを使う場合

場合によっては複数のAdapterを使う場合があると思います。例えばセルのレイアウトが異なるリストを作成する場合などです

そんなときはConcatAdapterという機能を使えます。ConcatAdapterはRecyclerviewの1.2以降で使える機能なので古いバージョンを使っている方はバージョンをあげて利用できるようにする必要があります

下記のようにConcatAdapterにaddAdapterすることでセルのレイアウトが異なる場合でも簡単に表示できるようになります

class SimpleRecyclerViewActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_simple_recycler)

        setupUI()
    }

    private fun setupUI() {
        setupAdapter()
    }

    private fun setupAdapter() {
        val titleList = listOf("title1", "title2", "title3", "title4", "title5", "title6", "title7", "title8")
        val headerAdapter = HeaderAdapter(titleList)
        val contentsAdapter = ContentsAdapter()

        val concatAdapter = ConcatAdapter().apply {
            addAdapter(headerAdapter)
            addAdapter(contentsAdapter)
        }

        simpleRecyclerView.adapter = concatAdapter
        simpleRecyclerView.layoutManager = LinearLayoutManager(this)
    }
}

スクロール位置の取得・復元

スクロール位置の取得と復元ができます

// 保持
val state: Parcelable = recyclerView.layoutManager?.onSaveInstanceState()

// 復元
recyclerView.layoutManager?.onRestoreInstanceState(state)

リストの先頭や末尾かどうかを調べる

あまり使うことはないかもしれませんが、リストが先頭や末尾にいるかどうかを判定する方法です。 次で判定することができます

// 先頭かどうか
recyclerView.canScrollVertically(-1).not()

// 末尾かどうか
recyclerView.canScrollVertically(1).not()

主に使うのはonScrolledで判定するケースだと思いますが、大体以下のような実装になるかと思います

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            if (recyclerView.canScrollVertically(-1).not()) {
                // リストの先頭に来た時の処理
            }
            if (recyclerView.canScrollVertically(1).not()) {
                // リストの末尾に来た時の処理
            }
        }
    })

参照

developer.android.com

qiita.com

developer.android.com

qiita.com

build.gradleとは?

今回はbuild.gradleについてです

build.gradleファイルに息をするかのごとくライブラリを追加したり、設定を追加したりすると思いますが、よくよく考えるとbuild.gradleってなんだろうとふと思いまとめてみることにしました

build.gradleとは?

まず、AndroidアプリケーションのビルドにはGradleが採用されています。Google が Gradle用のAndroid プラグインを提供しており、このプラグインを各Android プロジェクトの Gradleスクリプトから利用することでAndroidモジュールのビルドを行えるようになります。build.graldeは「Gradleスクリプト」にあたるものになります

有名なビルドツールにMavenがありますが、GradleはMavenのようにXMLで設定定義するのではなくGroovyという言語を利用して、ビルド定義ができます

Gradle自身もJavaとGroovyで作成されGradleによりビルドされています

また、JVM上で動作するため、基本的にどのような環境でも動作させることができます

build.gradleの種類

Android Studioのプロジェクトを見るとProjectとModuleの2種類のbuild.gradleがあると思います

それぞれどんな役割のために存在しているのでしょうか?

共通の依存関係とか設定とかをProjectのbuild.gradleに記載して、プロジェクト毎の設定はModuleのbuild.gradleに記載します

プロジェクトごとは例えばモジュールを分割した場合などで、分割したモジュールごとにModuleのbuild.gradleが存在してモジュールごとに設定を追加することになります

下記がProjectのbuild.gradleです。最初のコメントにあるようにサブモジュールやサブプロジェクトでも共通で追加する設定を定義します

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext.kotlin_version = '1.3.71'
    repositories {
        google()
        jcenter()
        
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.6.3'
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

build.gradleで正しく設定するために

最後にbuild.gradleで正しく定義できるようにファイルに記載されている内容を理解しましょう

まずはProjectのbuild.gradleです。先程のテンプレートを再度記載します

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext.kotlin_version = '1.3.71'
    repositories {
        google()
        jcenter()
        
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.6.3'
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

buildscriptブロックではビルド環境の設定を行います。 テンプレートでは、Mavenリポジトリとしてメジャーな JCenterを使用することや、ビルド用にどのようなプラグインが必要であるかを指定しています

また、上記テンプレートでは、Android アプリケーションをビルドするために必要な Android プラグイン (for Gradle) を使用することを示しています

また、kotlinのバージョンの設定やgradleのバージョン設定などプロジェクト全てに共通する設定を定義します(モジュールを分割しても適用される設定になります)

dependenciesではコメントにある通りアプリケーションごとの設定はモジュールごとのbuild.gradleに定義することになります(後述します)

allprojectsブロックを利用すると、すべてのプロジェクト(マルチプロジェクト構成の場合)に対して共通で適用する設定を記述しておくことができます

buildscriptブロックとallprojectsブロックの両方にrepositoryが含まれていますが、buildscriptブロックではgradleスクリプトを実行するために必要な依存関係を追加する、allprojectsブロックではプロジェクトを作成するために必要な依存関係を追加するという違いがあります

そして、task cleanは起動したらbuildフォルダを削除するタスクになります。このタスクは、Gradleの設定ファイルを変更した場合、キャッシュされている設定をきれいにする(リセット)ために行います

次にModuleのbuild.gradleのテンプレートを記載します

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.3"

    defaultConfig {
        applicationId "com.example.test"
        minSdkVersion 28
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }

}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'androidx.core:core-ktx:1.5.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    implementation 'androidx.viewpager2:viewpager2:1.0.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.navigation:navigation-fragment:2.3.5'
    implementation 'androidx.navigation:navigation-ui:2.3.5'
    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
    implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
    implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

androidブロックでtargetSdkVersionやapplicationIdなどアプリの設定を行います。モジュールごとに別々の設定になる可能性もあるのでModuleのbuild.gradleで定義します

dependenciesブロックでは使用するライブラリを定義してインポートできるようにします

apply pluginでは対象アプリのビルドするために必要なプラグインを定義しています。一例を挙げると、kotlin-androidはkotlinで書かれたAndroidのモジュールをビルドするために必要です

このようにbuild.gradleが2種類あるのは設定を適用させる対象が明確に分かれているためなのです

最後に

今回説明したものはほんのさわりの部分で細かいカスタマイズをするためにはさらに設定を追加したりタスクを追加したりする必要があると思います。最後の参照に記法や設定に関するドキュメントのリンクを載せているので参照してください

また、setting.gradleなど他にもファイルがあるため別の記事で説明したいと思います

それにしても奥が深い!!

参照

qiita.com

docs.gradle.org

developer.android.com

Androidでプッシュ通知(FCM)を使う

今回はAndroidでプッシュ通知を使う方法です。プッシュ通知はFCM(Firebase Cloud Messaging)を使います。

FCMの設定に関しては今回は割愛しますが大まかにいうとFirebaseのプロジェクトを作成してパッケージ名を設定すると、google-service.jsonがダウンロードできるようになっているのでAndroid Studioのプロジェクトに追加します

ここまでを前提としてこれからプッシュ通知を受け取るための実装や受け取った後の実装について記載しようと思います。

SDKの設定

まずはFirebase Cloud Messagingを利用できるようにライブラリの追加を行います。ルートレベルのbuild.gradleに下記設定を追加します

buildscript {
    dependencies {
        classpath 'com.google.gms:google-services:4.3.10'
    }
}

そしてモジュールレベルのbuild.gradleに下記を追加します(Firebaseのバージョンをbomで管理しています)

dependencies {
    implementation platform('com.google.firebase:firebase-bom:28.3.1')
    implementation 'com.google.firebase:firebase-messaging'
}

サービスクラスの作成

次にFirebaseMessagingServiceを継承したサービスを作成します。これは、バックグラウンド時にアプリで通知を受け取る処理よりも高度なメッセージ処理を行う場合に必要になります。 例えば、フォアグラウンド時のアプリで通知を受け取る、プロジェクトとして独自のペイロードを受信するなどの場合はサービスを継承します。

class PushNotificationService : FirebaseMessagingService() {
    override fun onNewToken(p0: String) {
        super.onNewToken(p0)
    }

    override fun onMessageReceived(p0: RemoteMessage) {
        super.onMessageReceived(p0)
    }
}

サービスクラスの処理について説明は後述します。そして作成したクラスをマニフェストファイルに追加します

<application>
        <service
            android:name=".service.PushNotificationService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT"/>
            </intent-filter>
        </service>
</application>

channelの作成

Android 8.0以降、通知はすべてチャネルに割り当てる必要があります。 チャネルごとに、そのチャネルのすべての通知に適用される表示と音声の動作を設定することができます。 チャネルはユーザーがアプリのどの通知チャネルを実際に表示するか、または不要とするかを決めることができます

class PushNotificationService : FirebaseMessagingService() {
    override fun onNewToken(p0: String) {
        super.onNewToken(p0)
    }

    override fun onMessageReceived(p0: RemoteMessage) {
        super.onMessageReceived(p0)

        sendNotification(p0)
    }

    private fun sendNotification(remoteMessage: RemoteMessage) {
        // 受け取った後の遷移先の設定
        val intent = Intent(this, MainActivity::class.java)
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

        // Android12でクラッシュするのでPendingIntent.FLAG_IMMUTABLEを使う
        val pendingIntent = PendingIntent.getActivity(this, requestCode, intent,
            PendingIntent.FLAG_IMMUTABLE)

        // channelIdを設定(変更可能)
        val channelId = getString(R.string.channel_id)
        // チャネル名を設定(変更可能)
        val channelName = getString(R.string.channel_name)
        val notificationBuilder = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_launcher)
            .setColor(resources.getColor(R.color.appTransparent, null))
            .setContentTitle(remoteMessage.notification?.title)
            .setContentText(remoteMessage.notification?.body)
            .setShowWhen(true)
            .setWhen(System.currentTimeMillis())
            .setAutoCancel(true)
            .setDefaults(NotificationCompat.DEFAULT_ALL)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .setContentIntent(pendingIntent)

        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        // Android8以降設定が必要
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
            channel.setShowBadge(true)
            channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
            notificationManager.createNotificationChannel(channel)
        }

        // 通知処理(PendingIntent.getActivityで指定したリクエストコードを通知に設定する)
        notificationManager.notify(requestCode, notificationBuilder.build())
    }

    companion object {
        val requestCode = 0
    }
}

少し補足します。override fun onNewToken(p0: String)ではデバイス登録トークンが登録されたときに呼ばれます。override fun onMessageReceived(p0: RemoteMessage)はプッシュ通知が届いた時に呼ばれます

チャネルの設定については公式ドキュメントに細かく記載されています

intentは遷移先になるので、ペイロードのデータによって遷移先を変えたい場合には適時変更してください

もし、自分で設定した覚えのない「その他」(Miscellaneous)というChannelに割り振られていて、スマホで追加したChannelのOn/Offを切り替えても通知が届かない場合にはAndroidManifest.xmlに以下のようにmeta-dataを追加して自分でChannelIdを指定してください

また、通知でアイコンが表示されない場合にはStackOverFlowに方法が載っていたので参考にしてみてください

<application>
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="@string/channel_name"/>
</application>

ペイロードを受け取る

最後にペイロードを受け取る処理についてです

ペイロードが含まれている時はoverride fun onMessageReceived(p0: RemoteMessage)の引数のp0に含まれています

例えば下記のようなペイロードがあるとします

{
  "to":"deviceid","priority":"high",
  "notification":{
    "title": "通知タイトル",
    "body": "通知メッセージ"
  },
  "data":{
    "title": "title",
    "body": "message",
    "type": "type1"
  }
}

dataのtypeを取得する場合には下記のようにして取得します

  if (remoteMessage.data.size > 0) { 
    for( key in remoteMessage.data.keys ) {
      // typeのデータを取得
      if( key == "type" ) {
      }
    }
  }

プッシュ通知はほとんどのアプリで使う機能だと思うので、参考にしてみてください!!

※補足

ペイロードを受け取る際にアプリがフォアグラウンド状態か起動していないかで受け取るクラスが異なるため注意してください。

フォアグラウンドでプッシュ通知をタップした場合にはonMessageReceived(p0: RemoteMessage)のp0で受け取ります。 プッシュ通知をタップしてアプリを起動したりバックグラウンドの場合にはAndroidManifest.xml<action android:name="android.intent.action.MAIN" />の設定のあるクラスで intent.getStringExtra("")などのintentの処理でペイロードを取得できます。 プッシュ通知をタップしてアプリを起動する場合にはFirebaseMessagingService()を継承したクラスは通らないためご注意ください!

参照

firebase.google.com

developers.goalist.co.jp

qiita.com

trueman-developer.blogspot.com

UIのインスタンス化について

Xcodeには、Interface Builderでコードを書かずにデザインを構築することが多いと思います

Auto Layoutも比較的付けやすいので、使ったことがない人はいないくらい使われると思っています

今回は、Interface Builderソースコードを紐付ける方法をまとめます。(主に初期化などです)

ViewController

まずはViewControllerについてです。

ViewControllerの作り方についてですが、 New File... から新しいViewControllerのクラスを作ります。

次に、利用するStoryboardに新しいViewControllerを置き、 Storyboard IDに適当な文字列を指定します。

また、ソースコードでViewControllerを作った際にはClassに対応するクラスを指定しましょう。

インスタンス化する場合には下記のように行います。

let vc = storyboard?.instantiateViewController(identifier: "( Storyboard IDで指定した名前)") as? ViewController(該当のViewController)

今まで説明したものは1つのStroryBoardで管理する場合の方法ですが、1つのViewControllerを1つのStroryBoardで管理する場合には下記のようの行います。

let sb = UIStoryboard(name: "( Storyboardの名前)", bundle: nil)
let vc = storyboard?.instantiateViewController() as? ViewController(該当のViewController)

UIStoryboardのイニシャライザの引数について、 name には.storyboardファイルの拡張子を除いた部分の文字列を、 bundle には指定した.storyboardファイルとそれに関連するリソースが含まれているbundleを渡します。

上のコードのようにbundleにnilを渡すと、このメソッドはmain bundleを見にいきます。

XibでのカスタムView

ViewControllerをXibで定義するパターンもありますが、ここではカスタムViewをxibで作成するパターンについて説明します

カスタムViewを作成(初期化)する方法は大まかに2種類あるので、それぞれ説明します

①File’s Ownerで設定する場合

下記のようにカスタムViewをXibで作成する場合にFile’s Ownerでクラス指定する場合です。

生成方法は、イニシャライザでxibファイルを読み込み、自身に addSubview() します。

CustomViewクラスを継承したRoot viewの上に通常のUIViewを一枚挟んでから、Interfce Builder上で配置した部品が載ることになるのでパフォーマンスに影響を与える可能性があります(微々たるものではないので影響はほぼなさそう)

class LoginForm: UIView {
    @IBOutlet private weak var loginButton: UIButton!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        loadNib()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        loadNib()
    }
    
    private func loadNib() {
        let view = UINib(nibName: "LoginForm", bundle: nil).instantiate(withOwner: self, options: nil).first as! UIView
        view.frame = self.bounds
        addSubview(view)
    }
}

②Custom Classで設定する場合

File’s Ownerとは別の方法でCustom Classで設定する方法もあります。

Custom Classで設定する場合には、File’s Ownerで設定する場合とは違い、無駄なUIViewを挟む必要はありません。 しかし、コードでinit(frame:)が呼ばれてしまうと、その時点でCustomViewがインスタンス化されてしまうため、xibと結びつけることができません。 コードから呼び出す場合には別途 static関数などを用意し、そちらを利用する必要があります。 仮にload() の処理をrequired init?(coder: NSCoder)で実行した場合には初期化されているのにinstantiateでサイド初期化しようとして無限ループに陥ります。

class LoginForm: UIView {
    @IBOutlet private weak var loginButton: UIButton!
    
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    // パラメータを渡す場合にもここで引数を設定してViewController側で呼び出す
    static func load() {
        let view = UINib(nibName: "LoginForm", bundle: nil).instantiate(withOwner: nil, options: nil).first as! Self
      
        addSubview(view)
    }
}

File’s OwnerとCustom Classで設定する方法がありますが、File’s Ownerで設定する方がUIViewを扱う時と同じ方法で使えるのでおすすめです。

まとめ

ここまでUIのインスタンス化について記載しました。 どのパターンを使うのかは実装方法やインスタンス化のコードを踏まえ、自分のプロジェクトにあったものを採用するのがいいでしょう。

参照

qiita.com

developer.apple.com

medium.com

shiba1014.medium.com

Realmを使ってみよう(Android編)

今回はRealmをAndroidで使うやり方について書こうと思います

Realmの大まかな説明などは前回の記事をみてください

Realmの導入

まずはRealmを使えるようにします

build.gradle(app)で下記設定を追加します

plugin {
    id `realm-android`
}

そして、build.gradle(Project)で下記設定を追加します

buildscript {
    dependencies {
        classpath `io.realm:realm-gradle-plugin:10.9.0`
    }
}

設定を追加したらsyncを行うと使えるはずです。バージョンはGithubを確認してください

Modelの作成

プロジェクトで支えようになったら、まずはModelの作成からです

今回も前回と同じように仮でユーザーを表すクラスを作成します

open class User: RealmObject() {
    @PrimaryKey
    var id: Int = 0
    var name: String? = null
    var age: Int? = null
    @Ignore
    var height: Int? = null
}

RealmObjectを継承することでRealmで使えるModelになります

アノテーション@PrimaryKeyを指定しているプロパティはプライマリーキー扱いにでき、@Ignoreを指定しているプロパティはRealmには登録されないカラムとなります

Realmのデータベースはテーブルにアクセスするために中でModelクラスを継承する必要があります

しかし、KotlinのデフォルトではJavaでいうfinalクラスで定義され、継承ができなくなってしまうので、open修飾子をつけています

Realmの初期化(起動)

Modelを初期化した後にはRealmインスタンスを初期化してDBを起動します

var relalm: Realm? = null

Realm.init(this)
realm = Realm.getDefaultInstance()

上記の処理をActivityやRealmを管理するシングルトンのクラスなどに追加します

ちなみにRealmを削除する場合にはcloseメソッドで行えます

RealmのCRUD

次にRealmの登録・読み取り・更新・削除についてです

読み取り

まずは読み取りです

先ほど初期化したRealmを使って読み取りを行います

// ここでのinitRealmは初期化した際のRealm
initRealm.use { realm ->
    val result = relam.where<User>().findAll()
}

findAll()を使うことで、Realmに登録されているUserクラスのデータを全件取得できます

条件を指定する場合には下記のように条件を追加することで特定の条件のデータを取得できます

// 名前が「テスト」のデータのみ取得
val realmFilterNameData = relam.where<User>().equalTo("name", "テスト").findAll()

// 名前が登録されているデータのみ取得
val realmNameNotNullData = relam.where<User>().isNotNull("name").findAll()

上記の場合だとAND条件になるので、OR条件にする場合にはgroupを指定する必要があります

relam.where<User>()
    .beginGroup()
        .equalTo("name", "テスト1")
        .or()
        .equalTo("name", "テスト2")
    .endGroup()
    .findAll()

単純に取得したデータはRealmResult型のためListなどに変換する場合には下記のようにConvertする必要があります

initRealm.use { realm ->
    val result = relam.where<User>().findAll()
    val listResult = realm.copyFromRealm(result)
}
登録・更新

登録と更新は同じ処理でできますのでまとめます

val user = User()
user.id = 1
user.name = "テスト"
user.age = 2

initRealm.use { realm ->
    realm.executeTransaction {
        it.insertOrUpdate(user)
    }
}

読み取りのときと違って、realm.executeTransactionというトランザクションの中で行わないと登録されません

insertOrUpdateを使うことでprimaryKeyが一致しているデータがRealmに登録されている場合には更新処理が行われ、なければ登録処理が行われます

先ほどModelを作成したときにprimaryKeyを設定したのはここに理由があります。Realmに登録されているデータと一致しているかの判断基準で使われるのでprimaryKeyは設定しましょう!

また、読み取りと組み合わせて下記のように取得したデータのプロパティの更新も可能です

val results = relam.where<User>().findAll()

for result in results {
    realm.executeTransaction {
       result.name = "テスト1"
    }
}
削除

削除はrealm.deleteAllFromRealm()でできます

val user = User()
user.id = 1
user.name = "テスト"
user.age = 2

initRealm.use { realm ->
    realm.executeTransaction {
        it.deleteAllFromRealm(user)
    }
}

基本的な操作はこれだけです

Realmのデータの確認方法

実機もしくはエミュレーターで対象のアプリを起動します

AndroidStudioのView→Tool Windows→Device File Exploreを選択します。

f:id:kumaskun:20220206064208p:plain

すると上記のようにdata/data/{パッケージ名}/files配下にrealmファイルが存在するので右クリックで別の場所に保存してリネームすればRealmファイルになります

ただし本番環境ではファイルが確認ができないためデバッグ可能になるように下記設定をbuild.gradleに追加しないとファイルを参照できないので注意が必要です

buildTypes {
        release {
            debuggable true
        }
}

初期データを追加する

Realmファイルを追加して初期データとして扱うことができます

下記のように起動時にRealmファイルが存在しない場合には初期データをコピーしてrealmファイルとするようにできます

val congfig = RealmConfiguration.Builder()
    .assetFile("default.realm") // assetフォルダ配下に設定しているRealm初期データの名前指定
    .name("rename.realm") // 取得できるRealmファイルの名前を変えたい場合には指定
    .allowWritesOnUiThread(true)
    .allowQueriesOnUiThread(true)
    .schemeVersion(1L)
    .build()

Realm.getInstance(config)

realmを初期化する際に設定値を変更できるのでそのタイミングでassetフォルダに格納している初期データを見るように変更します

マイグレーションについて

テーブルを追加する場合には自動的にマイグレーションされますが、変数名を変更したり、カラムを削除したりする場合には不整合のエラーが発生します

ローカルで開発している場合にはアプリを削除して新規でアプリを入れ直せばいいのですが、リリースしているアプリではそうはいきません

リリース済みのアプリではマイグレーションが必要になります

マイグレーションの処理としては下記のようなマイグレーションのクラスを作成してRealm初期化の際の設定値に追加します

// RealmMigrationクラスを継承
class Migration: RealmMigration {
    override fun migrate(realm: DynamicRealm?, oldVersion: Long, newVersion: Long) {
        val scheme = realm?.schema ?: return

        if (olfVersion == 0L) {
            scheme.create("Prefecture")
                 // 第1引数がカラム名、第2引数がカラムの型、第3引数がぷりプライマリーキーであることの明示
                .addField("id", Int::class.java, FieldAttribute.PRIMARY_KEY)
                .addField("sortOrder", Int::class.java)
                // null許容であることの明示(型がIntでNullがあり得る場合には追加する必要がある)
                .setNullable("name", true)

            oldVersion++
        }
    }
}

iOSとは違い、テーブル追加は自動でマイグレーションしないため追加する必要があります

scheme.createがテーブル追加、addFieldがカラム追加です

上記Migrationクラスを設定に追加します

val congfig = RealmConfiguration.Builder()
    .assetFile("default.realm")
    .name("rename.realm")
    .migration(Migration()) // 追加
    .allowWritesOnUiThread(true)
    .allowQueriesOnUiThread(true)
    .schemeVersion(1L)
    .build()

Realm.getInstance(config)

今回はスキーマのバージョンを1にアップした状態で起動時にスキーマのバージョンが0の人はマイグレーションの処理が行われます(oldVersionがアプリ起動時のスキーマバージョンになります)

AndroidiOSと同様に学習コストが低いので使いやすいと思います。是非試してみてください!!

参照

docs.mongodb.com

qiita.com

Realmを使ってみよう(iOS編)

だいぶ久しぶりの更新になりました(なかなか忙しくて...)

今年初の更新がこのタイミングになってしまいましたが、これからは定期的に更新できればと思います

今回はRealmに関してまとめます。今回はiOS編です

Realmとは?

まずはRealmについて軽く説明しようと思います

RealmのGithubには下記のように説明があります

Realm is a mobile database that runs directly inside phones, tablets or wearables.

簡単にいうと、Realmというのは端末に保存するDBのようなものです

一般的にはデータを保存する場合にはAPIを使ってサーバー側のDBに保存しますが、その必要なく、端末のデータを保存して端末からデータを取得できるようになります

また特徴は下記のように羅列されています

Intuitive to Developers: Realm’s object-oriented data model is simple to learn, doesn’t need an ORM, and lets you write less code.

Designed for Offline Use: Realm’s local database persists data on-disk, so apps work as well offline as they do online.

Built for Mobile: Realm is fully-featured, lightweight, and efficiently uses memory, disk space, and battery life.

大まかな特徴としては学習コストが低い、オフラインでも使える、パフォーマンスがいいなどが挙げられています

パフォーマンスは実際に使ってもらえるとわかると思うですが、インディケーター(処理中のグルグル)が要らないのではないかというくらいすぐデータを取得・登録できます

次からはRealmSwiftに見ていきましょう

RealmSwiftの導入

iOSでRealmを使う場合にはrealmSwiftというオープンソースのライブラリがあるので使っていきます

podで導入する場合には下記podファイルに追加してインストールするという他のライブラリと同じ方法で導入できます。この辺りの詳しい説明は書略します

pod 'RealmSwift'

Realmを使ってみる

ここから使い方を説明しようと思います。まずはModelの作成からです

Modelの作成

ModelはDBでいうところのテーブルやカラムにあたります

今回は仮でユーザーを表すクラスを作成します

import Foundation
import RealmSwift

class User: Object {
    @persisted(primaryKey:true)  var id: Int = 0
    @persisted  var name: String?
    @persisted  var age: Int?
}

まず、モデル定義をするにはObjectを継承したクラスを作成する必要があります

今回はUserクラスを作成しました。ここでUserというテーブルが作成されるイメージです

さらに、変数の前に@persistedを追加することでカラムとして扱われます。逆にいうと、@persistedがない変数に関してはRealmには登録されないので、うまく使い分けることができます

idに(primaryKey:true)とつけているのはidをプライマリーキーとして扱うようにするためです。プライマリーキーがなぜ必要かというのは後述します

Realmの初期化(起動)

Modelを初期化した後にはRealmインスタンスを初期化してDBを起動します

let realm = try! Realm()

これがよくみるRealm起動の処理だと思います

初期化についてはもう少し詳しくあとで説明したいと思いますが、Realmを起動する場合にはこの処理でできます

RealmのCRUD

次にRealmの登録・読み取り・更新・削除についてです

読み取り

まずは読み取りです

先ほど初期化したRealmを使って読み取りを行います

val realmRegistedData = realm.objects(User.self)

これでRealmに登録されているUserクラスのデータを全件取得できます

条件を指定する場合にはwhereを追加すれば特定の条件のデータを取得できます

// 名前が「テスト」のデータのみ取得
let realmFilterNameData = realm.objects(User.self).where { $0.name = "テスト"}

上記のrealmFilterNameDataResult型のためArrayなどに変換する場合には下記のようにConvertする必要があります

var userConverted = [User]()

val realmFilterNameData = realm.objects(User.self).where { $0.name = "テスト"}

for data in realmFilterNameData {
    userConverted.append(User(value: data))
}
登録・更新

登録と更新は同じ処理でできますのでまとめます

let user = User()
user.id = 1
user.name = "テスト"
user.age = 2

try? realm.write {
    realm.add(user, update: .modified)
}

登録したいUserのインスタンスを作成してaddするだけです

重要なのは読み取りと違って、realm.writeというトランザクションの中で行わないと登録されません

更新に関してはrealm.add(user, update: .modified)update: .modifiedの部分で更新処理を行います

どういうことかというとprimaryKeyが一致しているデータがRealmに登録されている場合には更新処理が行われ、なければ登録処理が行われます

先ほどModelを作成したときにprimaryKeyを設定したのはここに理由があります。Realmに登録されているデータと一致しているかの判断基準で使われるのでprimaryKeyは設定しましょう!

また、読み取りと組み合わせて下記のように取得したデータのプロパティの更新も可能です

val realmFilterNameData = realm.objects(User.self).where { $0.name = "テスト"}

for data in realmFilterNameData {
    try? realm.write {
        data.name = "テスト更新"
    }
}
削除

削除はrealm.deleteでできます

let user = User()
user.id = 1
user.name = "テスト"
user.age = 2


try? realm.write {
    realm.delete(user)
}

もしくは検索結果から削除することも可能です

try? realm.write {
    realm.delete(realm.delete(User.self))
}

基本的な操作はこれだけです。学習コストが低いのがわかると思います

あとは少し補足をしようかと思います

Realmのデータの確認方法

実際のRealmのデータがどうなっているか確認したい時があると思います。先ほど大まかにRealmは端末に保存されるDBと記載しましたが、Realmデータはファイルとして端末に保存されています

実機もしくはシミュレーターで対象のアプリを起動します

XcodeのWindow→Devices and Simulatorsを選択します。対象のアプリを選択して歯車マークを選択して「Download Container」を選択します

f:id:kumaskun:20220206005750p:plain

ダウンロードしたファイルを右クリックで「パッケージの内容を表示」を選択してAppData→Documents配下に「default.realm」が存在していると思います

「default.realm」がRealmのデータになるので、Realm Studioなどでファイルを開くとデータを確認できます

初期データを追加する

Realmファイルを追加して初期データとして扱うことができます

下記のように起動時にRealmファイルが存在しない場合には初期データをコピーしてrealmファイルとするようにできます

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

  // アプリで使用するdefault.realmのパスを取得
  let defaultRealmPath = Realm.Configuration.defaultConfiguration.fileURL!

  // 初期データが入ったRealmファイルのパスを取得
  let bundleRealmPath = Bundle.main.url(forResource: 「ファイル名を書く」, withExtension: "realm"(拡張子))      

  // アプリで使用するRealmファイルが存在しない(= 初回利用)場合は、初期データをコピーする
  if !FileManager.default.fileExists(atPath: defaultRealmPath.path) {
    do {
      try FileManager.default.copyItem(at: bundleRealmPath!, to: defaultRealmPath)
    } catch let error {
      }
  }        

  return true
}    

マイグレーションについて

テーブルを追加する場合には自動的にマイグレーションされますが、変数名を変更したり、カラムを削除したりする場合には不整合のエラーが発生します

ローカルで開発している場合にはアプリを削除して新規でアプリを入れ直せばいいのですが、リリースしているアプリではそうはいきません

リリース済みのアプリではマイグレーションが必要になります

マイグレーションの処理は以下のようになります

Realm.Configuration.defaultConfiguration = Realm.Configuration(
        schemaVersion: 1,
        migrationBlock: { migration, oldSchemaVersion in
            if(oldSchemaVersion < 1) {
                // スキーマ名をnameからfullNameに変更
                migration.renameProperty(onType: User.className(), from: "name", to: "fullName") 
    // ageをStringに変更
                migration.emurateObjects(ofType: User.className()) { oldObject, newObject in
                    if let age = oldObject["age"] as? Int {
                        newObject?["age"] = String(age)
                    }
                }
            }
       }
    })

schemaVersionスキーマのバージョンを指定します。最初は0になります

今回はスキーマのバージョンを1にアップした状態で起動時にスキーマのバージョンが0の人はマイグレーションの処理が行われます(oldSchemaVersionがアプリ起動時のスキーマバージョンになります)

deleteRealmMigrationNeededについて

Realmの初期化でRealmの初期化(起動)について書きましたが、もしRealmの初期化や起動に失敗した場合はエラーが発生し続けます(let realm = try! Realm()でforce unwrapしていると失敗する可能性がある)

そこで失敗した場合にはdeleteRealmMigrationNeededという失敗した場合には初期化するという設定値があります

そこで下記のように失敗した場合には初期化することでクラッシュを防ぐことができます

また、Realmを起動する前にデータを更新したり取得しようとするとエラーが発生するので、realmにlazyをつけるなので解消する方法があります

do {
    let realm = try Realm()
} catch let error {
    var config = Realm.Configuration.defaultConfiguration
    config.deleteRealmMigrationNeeded = true
    try? Realm(configuration: config)
}

最後に

基本的にドキュメントを確認すると基本的な動作の実装については書かれています

下記参照にリンクを記載しているので確認してみてください

次回はAndroid編です

参照

docs.mongodb.com

kita-note.com

naoya-ono.com

Androidのツール属性について

今回はAndroidのレイアウト作成時に使うツール属性について書いていこうと思います

ツール属性は主にレイアウトのプレビュー確認で使う名前空間です

<TextView
    android:id="@+id/cardTextView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:text="TextView"
    android:textAlignment="center"
    android:textSize="18sp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

例えば上記のレイアウトが存在するとして、android:から始まるものは実行時にどう表示するかの設定を行い、tools:から始まるものはプレビューで確認するときの設定を行います

ツール属性の設定

toolsを使えるようにするためにはxmlファイルのルート要素(1番上の要素)に下記を追加します。

設定はこれだけです。次にどんなことができるのかを見ていきましょう!

xmlns:tools="http://schemas.android.com/tools"

ツール属性の種類

ここからはツール属性の種類を見ていきましょう

tools:text

まずは個人的によく使うtools:textです

これは固定文言ではなく、場合によって文言が変更する場合に設定した文言をプレビューでどう表示されるか確認するために使います

tools:text="TextView"

上記を例えば、TextViewに設定するとプレビューでTextViewと表示されます。実際にアプリを起動した場合にはTextViewとは表示されないのであくまでも確認のために使われます

tools:ignore

レイアウトの警告を無視したい場合に使います

本当に無視していいものなのかは確認して無視するべきものだけに使うべきだと個人的に思っているので、ほとんど使わないと思います

ある要素に設定するとその要素と全ての子要素に適用されるので、レイアウトファイルの全てに適用したい場合はトップの要素に記述します

<ImageView
    android:layout_width="200dp"
    android:layout_height="200dp"
    tools:igonre= "contentDescription"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:src="@tools:sample/backgrounds/scenic" />

tools:src

tools:textに似ていますが、レイアウトの画像を確認したい場合に使います

<ImageView
    android:layout_width="200dp"
    android:layout_height="200dp"
    tools:igonre= "contentDescription"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:src="@tools:sample/backgrounds/scenic" />

tools:context

tools:contextはレイアウトファイルのトップ要素に追加してどのActivityやFragemtをプレビューで表示させるかを設定するものです。

<?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=".tutorial.MainActivity">

</androidx.constraintlayout.widget.ConstraintLayout>

tools:layout

tools:layoutはFragementで使うもので、プレビューでフラグメント内に描画されるレイアウトを宣言できます

下記のように設定することでフラグメント内に描画されるレイアウトをプレビューで確認できるようになります

<fragment android:name="com.example.main.ItemListFragment"
    tools:layout="@layout/list_content" />

tools:layout

tools:layoutはFragementで使うもので、プレビューでフラグメント内に描画されるレイアウトを宣言できます

下記のように設定することでフラグメント内に描画されるレイアウトをプレビューで確認できるようになります

<fragment android:name="com.example.main.ItemListFragment"
    tools:layout="@layout/list_content" />

tools:itemCount

tools:itemCountはRecyclerViewで使うもので、指定した個数分のアイテムをプレビューで表示させます

実際のデータとは異なるので注意です

<androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:itemCount="3" />

開発時にビルドせずプレビューで確認できるので、便利な機能です

ここまで個人的に使うものを羅列しましたが、ツール属性はまだまだあります

公式ドキュメントのリンクを参照に載せておくので、一度見てみてください

参照

developer.android.com

ViewPager2とCardViewでカルーセル表示を行う

QiitaのAndroid Advent Calendar 2021の23日目の記事です

他の方も素晴らしい記事を投稿しているので、ぜひこちらからご覧ください!

今回はCardViewのカルーセル表示についてです

ルーセル表示とは横にスライドして次のデータを表示させるものです

下記のようなイメージのものを作成します

f:id:kumaskun:20211031195830p:plain

1. レイアウト作成

まずはレイアウトを作成します

大まかにはViewPager2のレイアウトとCardViewのレイアウトを作成します

<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=".dashboard.DashboardFragment">

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/cardPager"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_marginTop="16dp"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

まずはViewPager2からです

色々設定していますが、重要なのはandroid:orientation="horizontal" の部分です。ここを設定することで横にスライドします。逆にverticalを設定すると縦にスライドするようになります

次にCardViewのレイアウトです

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginStart="36dp"  // 横に見せる範囲とカードのマージンを足した値
    android:layout_marginEnd="36dp"   // 横に見せる範囲とカードのマージンを足した値
    app:cardBackgroundColor="#E91E63"
    app:cardCornerRadius="16dp"
    app:cardElevation="7dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="200dp">

        <TextView
            android:id="@+id/cardTextView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:text="TextView"
            android:textAlignment="center"
            android:textSize="18sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

重要なポイントは2つあります

1つ目はCardViewのandroid:layout_marginStart="36dp"android:layout_marginEnd="36dp" の部分です

ここでは横のアイテムを表示させるためにマージンを設定しています

どういう値かというと下記を見てもらうとわかると思います

f:id:kumaskun:20211031201915p:plain

ここではマージンを次のアイテムとの距離、オフセットを次のアイテムをどれくらい表示させるかの値とします

CardViewで設定するmarginStartとmarginEndはマージンとオフセットの合計にします

2つ目はCardViewのheightとwidthはmatch_parentに設定しないとExceptionが発生します

java.lang.IllegalStateException: Pages must fill the whole ViewPager2 (use match_parent)のエラーです

あとは自分の好みでカスタマイズしてみてください

2. ソースコードでの設定

レイアウトを先ほど作成しましたが、それだけでカルーセル表示にはなりません。ソースコードで設定が必要になりますので見ていきましょう!!

CardViewを使うクラスで下記設定が必要になります

cardPager.adapter = CardSlideAdapter(listOf(1, 2, 3, 4))

val margin = view.context.resources.getDimension(R.dimen.card_margin)
val offset = view.context.resources.getDimension(R.dimen.card_offset)

cardPager.offscreenPageLimit = 2
cardPager.setPageTransformer { page, position ->
    val offset = position * (2 * offset + margin)
    page.translationX = -offset
}

1つづつ説明します

まず、CardSlideAdapterの部分はカードのデータをAdapterで定義しています。細かくは割愛します

marginとoffsetはdimensファイルで定義しています。直接数字指定すると表示がおかしくなる可能性が高いので、dimensファイルで定義するか数値をdpに変換する処理を行なってください

<resources>
    <!-- Default screen margins, per the Android Design guidelines. -->
    <dimen name="card_offset">16dp</dimen>
    <dimen name="card_margin"> 20dp</dimen>
</resources>

cardPager.offscreenPageLimit = 2の部分は左右のアイテムを描画するために必要です。これがないと左右のアイテムが表示されないです

そして、setPageTransformerはページが変わった場合の処理を行います。アニメーションを変えたり設定値を変更したりします

今回は左右の表示させる値とマージン分移動を打ち消しています。(page.translationX = -offsetの部分)

そうしないと1ページで1つ分のカードしか表示できなくなります

今回紹介した箇所以外にsetPageTransformerで色々なカスタマイズができると思います。それは試してみたら追記したいと思います

参照

developer.android.com

satoshun.github.io