炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

(Android)動的に変化する TextView にもっと読むボタンを設置する

アクトインディ Advent Calendar 2016 14日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

今回は TextView の行数によって、もっと読むボタンを表示する機能の紹介をしたいと思います。API を利用してコンテンツを取得する際、長文の場合は 3 行ぐらい表示して「もっと読む」ボタンを置いて、ボタンをタップしたら開閉したい場合があるかと思います。固定されたテキストであれば xml ファイルに maxLines を指定して、ボタンを押したら maxLines に大きな値をセットすれば良いので実装はとても簡単なんですが、API から取得する場合は、 3 行未満のケースも考慮が必要な為、一工夫必要になります。実装したい内容は以下です。サンプルは GitHub で公開しています andridReadMoreSample

f:id:t-namikata:20161212235932g:plain

  1. コンテンツが 3 行を超える場合は、末尾を「...」で省略して、「もっと読む」ボタンを追加する
  2. 「もっと読む」ボタンをタップすると、コンテンツが全文表示されて、「もっと読む」ボタンは消える
  3. コンテンツが 3 行以下の場合は、「もっと読む」ボタンは表示せず「...」の表示もない

1. コンテンツが 3 行を超える場合は、末尾を「...」で省略して、「もっと読む」ボタンを追加する

Android には maxLines に指定した行数を超えると、末尾に「...」を表示する便利なプロパティがあるので、それを xml で指定してあげるだけで、簡単に実装することができます。

  • maxLines で行数を指定
  • maxLines を超えた場合は ellipsize="end" で末尾に ... を表示
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    tools:context="test.readmoresample.MainActivity"

    ... 省略 ...

    <TextView
        android:id="@+id/content"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:maxLines="3"
        android:text="@string/long_content" />

    <Button
        android:id="@+id/read_more"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="もっと読む" />

    ... 省略 ...

</LinearLayout>

2. 「もっと読む」ボタンをタップすると、コンテンツが全文表示されて、「もっと読む」ボタンは消える

maxLines に Integer.MAX_VALUE をセットしてあげることで content の全文を表示され ellipsize の ... も表示されなくなります。

class MainActivity : AppCompatActivity() {
    val contentTextView: TextView by bindView(R.id.content)
    val readMoreButton: Button by bindView(R.id.read_more)

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

        this.setUpContentTextView()

        this.readMoreButton.setOnClickListener {
            this.contentTextView.maxLines = Integer.MAX_VALUE
            this.readMoreButton.visibility = View.GONE
        }
    }
}

3. コンテンツが 3 行以下の場合は、「もっと読む」ボタンは表示せず「...」の表示もない

コンテンツの内容が動的に変更される場合は、コードで maxLines を上手い具合にセットしてあげる必要があり、条件は以下の 3 つに分類されます。

  1. contentTextView.lineCount > 3 である場合は maxLines に 3 をセットして「もっと読む」ボタンを表示する
  2. contentTextView.lineCount <= 3 であり、実際のコンテンツも 3 行以下の場合は「もっと読む」ボタンを表示しない
  3. contentTextView.lineCount <= 3 であるが maxLines = 3 の設定により折りたたまれていて、実際のコンテンツは 3 行以上の場合は「もっと読む」ボタンを表示する
1. contentTextView.lineCount > 3 である場合は maxLines に 3 をセットして「もっと読む」ボタンを表示する

こちらのチェックは簡単です。 this.contentTextView.post の中で lineCount を行わないと onCreate メソッドなどで実行してしまうと View の状態がまだ確定していない為 lineCount は常に 0 が返ってきてしまうので注意しましょう。

private fun setUpContentTextView() {
    val MAX_LINES = 3

    this.contentTextView.post {
        val needTruncate = (contentTextView.lineCount > MAX_LINES)
        if (needTruncate) {
            this.contentTextView.maxLines = MAX_LINES
            this.readMoreButton.visibility = View.VISIBLE
            return@post
        }
    }
}
2. contentTextView.lineCount <= 3 であり、実際のコンテンツも 3 行以下の場合は「もっと読む」ボタンを表示しない
3. contentTextView.lineCount <= 3 であるが maxLines = 3 の設定により折りたたまれていて、実際のコンテンツは 3 行以上の場合は「もっと読む」ボタンを表示する

次に、すでに maxLines = 3 の指定により、コンテンツが折りたたまれているかどうかを判別して、処理を分岐します。

private fun setUpContentTextView() {
    val MAX_LINES = 3

    this.contentTextView.post {
        ... 省略 ...
    }

    if (contentTextView.isTextTruncated()) {
        this.readMoreButton.visibility = View.VISIBLE
    } else {
        this.readMoreButton.visibility = View.GONE
    }
}

今回は TextView に Extension で実装を追加しました。 getEllipsisCount メソッドにより textView が maxLines を超えているかどうかを判別することができます。

fun TextView.isTextTruncated(): Boolean {
    val layout = this.layout ?: return false
    val lines = layout.lineCount
    if (lines < 1) return false
    val ellipsisCount = layout.getEllipsisCount(lines - 1)
    return ellipsisCount > 0
}

これで contentTextView にセットされた文の長さによって、表示の切り替えが行えるようになったので、あとは API で結果を取得して TextView に値を反映したタイミングで this.setUpContentTextView() を呼び出せば contentTextView にセットされた文の長さによって、コンテンツの省略ともっと読むボタンが表示されるようになります。

今回は「長文をセット」「短文をセット」ボタンにより、表示の切り替えサンプルを作ってみたので、最後にそのコードを紹介したいと思います。

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

    ... 省略 ...

    <TextView
        android:id="@+id/content"

    <Button
        android:id="@+id/read_more"

    ... 省略 ...

    <LinearLayout
        android:layout_marginTop="@dimen/activity_vertical_margin"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:id="@+id/long_content"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="長文をセット" />

        <Button
            android:id="@+id/short_content"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="短文をセット" />
    </LinearLayout>

</LinearLayout>
class MainActivity : AppCompatActivity() {
    val contentTextView: TextView by bindView(R.id.content)
    val readMoreButton: Button by bindView(R.id.read_more)
    val setLongContentButton: Button by bindView(R.id.long_content)
    val setShortContentButton: Button by bindView(R.id.short_content)

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

        this.setUpContentTextView()

        this.readMoreButton.setOnClickListener {
            this.contentTextView.maxLines = Integer.MAX_VALUE
            this.readMoreButton.visibility = View.GONE
        }

        this.setLongContentButton.setOnClickListener {
            this.contentTextView.text = this.getString(R.string.long_content)
            this.setUpContentTextView()
        }

        this.setShortContentButton.setOnClickListener {
            this.contentTextView.text = this.getString(R.string.short_content)
            this.setUpContentTextView()
        }
    }

    private fun setUpContentTextView() {
        val MAX_LINES = 3

        this.contentTextView.post {
            val needTruncate = (contentTextView.lineCount > MAX_LINES)
            if (needTruncate) {
                this.contentTextView.maxLines = MAX_LINES
                this.readMoreButton.visibility = View.VISIBLE
                return@post
            }

            if (contentTextView.isTextTruncated()) {
                this.readMoreButton.visibility = View.VISIBLE
            } else {
                this.readMoreButton.visibility = View.GONE
            }
        }
    }
}

メソッド数調査、エミュレーターのファイルダウンロードなどを楽にする Android プロジェクト用のシェルスクリプト

アクトインディ Advent Calendar 2016 13日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

今回の記事は Android プロジェクト用に利用しているシェルスクリプトについて書きたいと思います。一つ一つは簡単なコマンドですが、頻繁に利用しないとすぐコマンド忘れてしまうので、覚えなくても良いコマンドはシェルスクリプトに書くようにしています。

現在のメソッド数を確認する

ライブラリを導入する際は、導入前、導入後でメソッド数を計測し、導入すべきか検討するようにしているので、開発初期は結構使います。./gradlew clean assembleDebug はいらないかもしれません。

dex.sh

./gradlew clean assembleDebug
~/tools/dex-method-counts/dex-method-counts app/build/outputs/apk/app-debug.apk
  • dex-method-counts をインストールする場所は任意です
  • プロジェクト直下に dex.sh を置いていることを想定した path です

実行結果

Read in 49829 method IDs.
<root>: 49829
    <default>: 1
    a: 22
    android: 14348
        accounts: 3

    ...

Overall method count: 49829

dex-method-counts の導入方法

github に公開されているソースを適当なディレクトリにダウンロードするだけです

今回の例では ~/tools にインストールすることにします

cd ~/tools
git clone https://github.com/mihaip/dex-method-counts.git

Realm ファイルを adb でダウンロードする

Realm に格納されているデータを見たい時に便利です。RealmBrowser を入れている場合は、実行後に open default.realm で RealmBrowser を開くことができます。

fetch_realm.sh

adb kill-server
adb root
adb pull /data/data/com.example.sample/files/default.realm

実行結果

シェルを実行すると以下のファイルがダウンロードされます。

  • default.realm
  • default.realm.lock
  • default.realm.management/

keyhash の取得

Firebase や Line SDK を導入する際に必要になるのですが、利用するシーンが少なく、すぐ忘れてしまうので。Debugビルド用です。

key-hash.sh

keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore -list -v -storepass android

実行結果

別名: androiddebugkey
作成日: 2017/01/01
エントリ・タイプ: PrivateKeyEntry
証明書チェーンの長さ: 1
証明書[1]:
所有者: C=US, O=Android, CN=Android Debug
発行者: C=US, O=Android, CN=Android Debug
シリアル番号: 1
有効期間の開始日: Mon Sep 30 10:00:00 JST 2017終了日: Wed Sep 23 10:00:00 JST 2100
証明書のフィンガプリント:
   MD5:  ************
   SHA1: ************
   SHA256: ************
   署名アルゴリズム名: ******
   バージョン: 1

Git のブランチ整理

プルリクエストが master にマージされた後などに良く実行しています。master ブランチを最新にして、既に master ブランチにマージされたローカルの作業ブランチを削除してくれます。

pull_and_fetch.sh

git checkout master && git pull origin master && git fetch -p origin && git branch --merged | grep -vE '^\*|master$|develop$' | xargs -I % git branch -d %

その他

プロジェクトの git の管理対象にならないように .git/info/exclude に以下のように設定して git 管理対象外になるようにして利用しています。

pull_and_fetch.sh
fetch_realm.sh
default.realm
default.realm.lock
default.realm.management/
dex.sh
key-hash.sh

Realm InMemory を使って Activity 間でのデータ同期をスムーズに行う

アクトインディ Advent Calendar 2016 9日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

Kotlin 1.0.5
Realm 2.2.2

一覧と詳細画面があって、詳細画面でいいね!して、戻ったら一覧画面にも値を反映させたい。といった事はアプリを作っていたら良くある事だと思います。今回の記事では Realm のオートリフレッシュとリスナーを使って、異なる Activity 間でもデータの変更を反映する方法を書きたいと思います。せっかく Realm を使うので、API で取得した結果をアプリが終了するまでキャッシュして利用するようにしたいと思います。

以下を想定して書きたいと思います。

f:id:t-namikata:20161208001656g:plain

  • 一覧と詳細のデータは API を利用したサーバーから取得する
  • 取得した結果は Realm に保存し、アプリケーションが終了するタイミングで破棄する
  • 詳細で「いいね」したら、戻るボタンで一覧に戻るといいねした結果が反映されている
  • 詳細で「削除」したら、戻るボタンで一覧に戻ると削除したアイテムが消えている

Realm を利用すると Activity をまたいだデータの同期や、ListView の item の増減が簡単に行える利点があります。

ポイントを先にまとめると以下になります。

  1. アプリケーションが終了したらデータを破棄するには Realm InMemory を利用する
  2. List の Adapter を作成する際には RealmBaseAdapter を継承する
  3. Realm から取得した情報はマネージドオブジェクトになり、オートリフレッシュや値の変更を検知できることを知っておく
  4. オートリフレッシュでオブジェクトの値が自動で更新されても、TextView にセットした text は自動で更新されないことを知っておく

それでは、実際にソースを書いていきたいと思います。完成版は github に公開しています。 https://github.com/takanamishi/AndroidRealmSample

1. アプリケーションが終了したらデータを破棄するには Realm InMemory を利用する

まずは InMemory な Realm のセットアップです。公式ドキュメントに書いてある通りです。アプリケーションのサイクルを管理するアプリケーションクラスを継承したサブクラスを作成します。 Realm.setDefaultConfiguration(config) することで Realm.getDefaultInstance() で取得される Realm が config でセットしたものになるので InMemory と Disk に保存する通常の Realm とで書き方は変わりません。

class RealmSampleApplication : Application() {
    /**
     * キャッシュデータ用のRealmオブジェクト
     * インスタンスを保持しつづけていないとRealmの仕様でキャッシュデータが消えてしまうため
     * アプリケーションクラスで保持する
     */
    lateinit var inMemoryRealm: Realm

    companion object {
        lateinit var instance: RealmSampleApplication
    }

    init {
        instance = this
    }

    override fun onCreate() {
        super.onCreate()

        // InMemory な Realm の config を設定する
        Realm.init(this)
        val config: RealmConfiguration = RealmConfiguration.Builder()
                .name("inMemory.realm")
                .inMemory()
                .build()
        this.inMemoryRealm = Realm.getInstance(config)
        Realm.setDefaultConfiguration(config)
    }
}

2. List の Adapter を作成する際には RealmBaseAdapter を継承する

リストの要素となる Adapter を作成します。Realm の公式ドキュメントでも説明があるように、ListView を利用する際には、Realm が用意してくれている RealmBaseAdapter を継承するようにします。RealmBaseAdapter を継承することで、リストに変更があった場合に、変更を反映するコードを書くことなく、リストが更新されます。

ここでのポイントは RealmResults 型のインスタンスを引数で受け取ることです。initialize で指定した realmResult は adapterData に格納されるので、要素を参照したい時は adapterData を参照します。他の部分は通常の ListView と同様です。

class ItemAdapter(context: Context, realmResult: RealmResults<Item>): RealmBaseAdapter<Item>(context, realmResult), ListAdapter {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.item, parent, false).apply {
            this.tag = ViewHolder(this)
        }
        val holder = view.tag as ViewHolder
        val data = this.adapterData ?: return view
        val item = data[position]
        holder.setUp(item = item)

        return view
    }

    private class ViewHolder(view: View) {
        private val nameTextView = view.findViewById(R.id.name) as TextView
        private val likesCountTextView = view.findViewById(R.id.likesCount) as TextView

        fun setUp(item: Item) {
            this.nameTextView.text = item.name
            this.likesCountTextView.text = item.likesCount.toString()
        }
    }
}

Realm から取得した情報はマネージドオブジェクトになり、オートリフレッシュや値の変更を検知できることを知っておく

Adapter ができたので、MainActivity でリストを表示します。ここでのポイントは 1 点だけで、リスト情報( items )は、Realm データベースから取得するといった点です。Realm に対して where で取得した結果は、マネージドオブジェクトとなり、値に変更があった際のオートリフレッシュや変更の検知を行うことができるようになります。private val items: RealmResults<Item> = realm.where(Item::class.java).findAll()と、プロパティ宣言時に Realm から値を取得しているのもその為です。findAll() メソッドは、Realm にデータがなくても RealmResults 型の空配列を返してくれるので Item が Realm に追加、削除、変更が加わった際に items の値は自動で更新されるようになります。

今回の例では、プロパティ宣言時にprivate val items: RealmResults<Item> = realm.where(Item::class.java).findAll() では空の RealmResults が items に代入されますが saveRealmData() で Realm に Item を保存するだけで、画面にリストが表示されます。

setUpView() メソッドを呼んだ時点では items の中身は RealmResults 型の空配列になっていますが setUpRealmInitData で item に変更が加わると items が更新され、 RealmBaseAdapter を継承した ItemAdapter が変更を検知して、リストを更新してくれるという訳です。便利ですね。

class MainActivity : AppCompatActivity() {
    private val realm = Realm.getDefaultInstance()
    private val items: RealmResults<Item> = realm.where(Item::class.java).findAll()
    private val listView: ListView by bindView(R.id.list_view)

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

        this.title = "将来なりたい職業"
        this.setUpView()

        // 一覧を取得する API をコールする
        // 結果が取得できたら Realm に保存する
        // 今回は説明を簡単にする為 API 経由で情報の取得が完了したとして、データをセットします。
        this.saveRealmData()
    }

    override fun onDestroy() {
        super.onDestroy()
        this.realm.close()
    }

    private fun setUpView() {
        val adapter = ItemAdapter(context = this, realmResult = items)
        this.listView.adapter = adapter
        this.listView.setOnItemClickListener { adapterView, view, position, id ->
            val item = this.items[position]
            val intent = Intent(this, ItemActivity::class.java)
            intent.putExtra("itemId", item.id)
            this.startActivity(intent)
        }
    }

    private fun saveRealmData() {
        val pilot = Item(id = 1, name = "パイロット", content = "空を飛びたい", likesCount = 7)
        val carpenter = Item(id = 2, name = "大工", content = "自分の家を建てたい", likesCount = 4)
        val programer = Item(id = 3, name = "プログラマー", content = "キーボードが好き", likesCount = 9999)

        this.realm.executeTransaction {
            realm.copyToRealmOrUpdate(pilot)
            realm.copyToRealmOrUpdate(carpenter)
            realm.copyToRealmOrUpdate(programer)
        }
    }
}

オートリフレッシュでオブジェクトの値が自動で更新されても、TextView にセットした text は自動で更新されないことを知っておく

最後に、リストをタップした際に表示される ItemActivity を作成して完成です。ここでのポイントは、オートリフレッシュで値が更新されても textView にセットした値は更新されないといった点です。今回の例では ItemActivity のいいねボタンが押されると item.likesCount を +1 して Realm に保存しています。item は realm.where(Item::class.java).equalTo("id", itemId).findFirst() で Realm からデータを取得しているのでマネージドオブジェクトとなり、オートリフレッシュの対象となります。this.likesCountTextView.text = item.likesCount.toString() と textView にいいね数を反映していますが item.likesCount がインクリメントされても textView の値は変更されません。このようなケースでは item の変更を addChangeListerner で検知して textView に値を再セットしてあげる必要があります。

class ItemActivity : AppCompatActivity() {
    private val realm = Realm.getDefaultInstance()
    private val contentTextView: TextView by bindView(R.id.content)
    private val likesCountTextView: TextView by bindView(R.id.likes_count)
    private val likeButton: Button by bindView(R.id.like)
    private val deleteButton: Button by bindView(R.id.delete)
    private lateinit var item: Item

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

        val itemId = this.intent.getIntExtra("itemId", 0)
        this.item = this.realm.where(Item::class.java).equalTo("id", itemId).findFirst() ?: return

        item.addChangeListener<Item> {
            this.likesCountTextView.text = item.likesCount.toString()
        }

        this.setUpViews(item = item)

        this.likeButton.setOnClickListener {
            this.realm.executeTransaction {
                item.likesCount += 1
            }
        }

        this.deleteButton.setOnClickListener {
            this.realm.executeTransaction {
                item.deleteFromRealm()
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        this.item.removeChangeListeners()
        this.realm.close()
    }

    private fun setUpViews(item: Item) {
        this.contentTextView.text = item.name
        this.likesCountTextView.text = item.likesCount.toString()
    }
}

まとめ

Realm を採用していいなと思った点

  • Realm のオートリフレッシュの機能により、リストを更新するコードを書く必要がなく、コードの見通しが良くなる。
  • Realm の監視を解除するのが onDestory なので Activity が生きている限り、フロントにいない Activity でも、値の変更を検知して画面を更新することができる
  • Realm をキャッシュとして利用することで API のコールを減らせる

Realm の使いづらいなと思った点

  • realm.close 面倒。close 忘れが怖い。
  • メインスレッド以外で Realm にアクセスするのが辛い
  • Model の定義で var と val を間違えた時や、初期値を設定していない時、open をつけ忘れた時など、以下のエラーになって気づきにくい
Error:Execution failed for task ':app:compileDebugJavaWithJavac'.
> Compilation failed; see the compiler error output for details.

Kotlin で Realm を操作する時に気をつけていること

アクトインディ Advent Calendar 2016 4日目の記事になります。「若者の結婚願望 子供時代、体験多いと強く 」といった記事が日本経済新聞に書かれていました。

www.nikkei.com

子ども時代に家族で沢山思い出を作っていると、将来、結婚して家庭を持ちたいという気持ちが強くなるといった結果が出たそうです。アクトインディでは、子どもとのお出かけ先を提案するサービスを運営していますが、家族の思い出作りに少しでもお役に立てるサービスにもっと良くしていこうと思える嬉しい記事でした。子どもとのお出かけ先に困っている方は、是非いこーよアプリを使ってみてください ^^

iko-yo.net

4日目の記事では kotlin で Realm を操作する時に気をつけていることについて書きたいと思います。kotlin は null安全の言語ですが、Realm は java で書かれている為、 Realm から要素を取得する際 kotlin の型推論を使って以下のように書くと、要素がない場合に NullPointerExceptionが発生します。

open class User(
    @PrimaryKey open var id: Int = 0,
    open var name: String = ""
)
val realm = Realm.getDefaultInstance()
val user = realm.where(User::class.java).findFirst()
Log.d("*****", user.id.toString()) // user は null の為 NullPointerException
realm.close()

realm の findFirst() メソッドの定義が public E findFirst() { ... } となっているので、 val user の部分は型推論が行われ、実際には val user: User = realm.where(User::class.java).findFirst() と宣言していることになります。

kotlin でも安全に使う為には、型推論を行わず以下のように書くことで null が返却されることを明示することができますが

val realm = Realm.getDefaultInstance()
val user: User? = realm.where(User::class.java).findFirst()
Log.d("*****", user?.id.toString()) // null と Log に表示される
realm.close()

val user: User と書くことができてしまう為、少しでも安全に Realm を操作できるように Realm の Extension を定義して、極力 Extension で定義したメソッドを利用するようにしています。

RealmExtension.kt

// nullable な要素を返却するようにメソッドを定義する
fun <T : RealmObject> Realm.findFirst(type: Class<T>): T? {
    return this.where(type).findFirst()
}

fun <T : RealmObject> Realm.findId(type: Class<T>, id: Int): T? {
    return this.where(type).equalTo("id", id).findFirst()
}

Extension で定義したメソッドを利用するには以下のように書きます

val realm = Realm.getDefaultInstance()
val user = realm.findFirst(User::class.java)
Log.d("*****", user?.id.toString()) // null と Log に表示される
realm.close()

PrimaryKey に良く指定する id で要素を検索することが多いので findId といったメソッドも定義しています。Realm には要素を全て取得する findAll メソッドがありますが、こちらは要素が1件もなかった場合は空の配列で返却されるので、特にメソッドを定義する必要はないかと思います。

RecyclerView で Section を表示する

アクトインディ Advent Calendar 2016 2日目の記事になります。2015年の11月にアクトインディに入社して 1 年が経ちました。 入社してからは iOS アプリの開発に携わらせてもらい、現在は鋭意 Android アプリの開発をしています。

子どもとのお出かけ先に困っている方は、是非いこーよアプリを使ってみてください ^^

iko-yo.net

アドベントカレンダーの本題ですが、今回は Android の RecyclerView で Section をどうやって表現するかの話を書きたいと思います。アプリの設定画面など、表示する項目が決まっていて、セクションで区切られたリストを表示したいケースがあると思います。iOS では TableView に Section が用意されているので容易に実装できますが Android では List にセクションといった要素がない為、セクション用のレイアウトとリストアイテム用のレイアウトファイルを項目で切り替えて実装する必要が出てきます。今回は Enum を使った実装を紹介したいと思います。実装のサンプルは Kotlin 1.0.5 になります。

f:id:t-namikata:20161201184934p:plain

やっていることは簡単で ヘッダーには setOnClickListener を設定せず、リストアイテムの場合に setOnClickListener を設定するといった事をしています。

1. リストで表示する Enum クラスを作成する

enum class ListItem(val title: String) {
    HeaderFruits(title = "果物"),
    Apple(title = "りんご"),
    Orange(title = "オレンジ"),
    HeaderVegetables(title = "野菜"),
    Carrot(title = "人参"),
    Onion(title = "玉ねぎ"),
    HeaderDrinks(title = "飲み物"),
    Milk(title = "牛乳"),
    Water(title = "水")
}

2. Adapter を作成する

Adapter の雛形は以下になります。コメントアウトの箇所をそれぞれ実装していきたいと思います。

class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val items = ListItem.values()

    override fun getItemViewType(position: Int): Int {
        // 1. ヘッダーかリストアイテムかで異なる viewType を返すように実装する
    }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
        // 2. viewType によって読み込む layout ファイルを指定する
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) {
        // 3. text のセットとアイテムクリック時のイベントをセットする
    }

    override fun getItemCount(): Int {
        return this.items.count()
    }
}
1. ヘッダーかリストアイテムかで異なる viewType を返すように実装する

ヘッダー用かリストアイテム用のレイアウトを返すか判別する為の viewType を返却するように設定します。

class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val items = ListItem.values()
    private val headerType = 0
    private val itemType = 1

    override fun getItemViewType(position: Int): Int {
        val item = this.items[position]
        when (item) {
            ListItem.HeaderFruits, ListItem.HeaderVegetables, ListItem.HeaderDrinks -> return headerType
            else -> return itemType
        }
    }
2. viewType によって読み込む layout ファイルを指定する

設定した viewTyep を利用してヘッダー用とリストアイテム用の Header を指定した ViewHolder を返却するようにします。

class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    ... 省略 ...

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(context)
        when (viewType) {
            headerType -> return ViewHolderItem(inflater.inflate(ViewHolderItem.headerLayoutId, parent, false))
            itemType -> return ViewHolderItem(inflater.inflate(ViewHolderItem.itemLayoutId, parent, false))
            else -> return ViewHolderItem(inflater.inflate(ViewHolderItem.itemLayoutId, parent, false))
        }
    }

    ... 省略 ...

    class ViewHolderItem(view: View) : RecyclerView.ViewHolder(view) {
        companion object {
            val headerLayoutId = R.layout.header
            val itemLayoutId = R.layout.item
        }
    }
3. text のセットとアイテムクリック時のイベントをセットする

あとは Enum で定義した各アイテム毎に行いたいアクションを定義します。

class ItemsAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    ... 省略 ...

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) {
        holder ?: return
        val item = this.items[position]
        val textView = holder.itemView.findViewById(R.id.item_text_view) as TextView
        textView.text = item.title

        holder.itemView.setOnClickListener {
            this.action(item = item)
        }
    }

    ... 省略 ...

    private fun action(item: ListItem) {
        when (item) {
            ListItem.Apple -> {
                Toast.makeText(context, "りんご", Toast.LENGTH_LONG).show()
            }
            ListItem.Orange -> {
                Toast.makeText(context, "オレンジ", Toast.LENGTH_SHORT).show()
            }
            ListItem.Carrot -> {
                Toast.makeText(context, "人参", Toast.LENGTH_SHORT).show()
            }

        // ...

            else -> {}
        }
    }

adapter をセットする

最後に activity で adapter をセットします。

class MainActivity : AppCompatActivity() {
    private val recyclerView: RecyclerView by bindView(R.id.recycler_view)

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

        this.recyclerView.setHasFixedSize(true)
        this.recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)

        val settingAdapter = ItemsAdapter(context = this)
        recyclerView.adapter = settingAdapter
    }
}

Enum で定義した順番がリストに表示される順番という気持ち悪さはありますが、ある程度はアイテムが増減しても、そこまで改修が大きくなることはない実装かと思います。各レイアウトファイルは以下のような感じです。

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="test.sectionrecyclerview.MainActivity">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</RelativeLayout>

item.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="wrap_content">

    <TextView
        android:id="@+id/item_text_view"
        android:padding="16dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</FrameLayout>

header.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="wrap_content">

    <TextView
        android:background="@android:color/darker_gray"
        android:id="@+id/item_text_view"
        android:padding="16dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</FrameLayout>