4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【Android】ListViewのスクロール位置を維持したままデータを更新する

Posted at

いまさらですが

聞かれましたので.
Androidへのエントリーポイントってあっちこっちにありますよね?
じゃあ同じことで今後も迷う人もいますよねって.
別プロジェクトなのでソースコード見ていませんがおそらく動いているコードをコピペしたらこうなりますよね.

原因

早速ですが回答です.
マトモに実装していたらListViewのデータを更新した際にスクロール位置は維持されます.
基本的にListView::setAdapterは一度しか呼びません.
setAdapterを呼ぶとスクロール位置は最上部にリセットされます.
データの更新にはAdapter::notifyDataSetChangedを呼ぶのが正解です.

ではソースコードで見てみましょう.
ボタンが押されるごとにリスト最後のデータを削除するプログラムを想定します.
一番最後のデータが消えるたびに画面が先頭に戻されてはたまったものではないですね.

ArrayAdapterを使った例

一番シンプルなのがArrayAdapterを使った例になります.
細かいことはともかくコードを見てみましょう.
kotlinで失礼します.

*.kt
    val array = ArrayList<String>()
    // 0 ~ 50 の計51個のリストを用意しています
    for (i in 0..50) {
      // 内容は数字の文字列です
      array.add(i.toString())
    }
    // リストのレイアウトをandroid.R.layout.simple_list_item_1としてArrayAdapterを用意しています
    val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1)
    // ArrayAdapterが持っているListに先ほど用意したリストをブチ込んでいます
    adapter.addAll(array) // adapter.array = array とは違うことに注意(そもそもそんなことできないけど)
    // リストにsetAdapterしています
    // Javaでいうところのlist.setAdapter(adapter); です
    list.adapter = adapter

    // ==== ここからがボタンを押したときの操作になります ====

    // NG setAdapterすると先頭にもどる
    btn.setOnClickListener {
      // リストの最後のデータを消しました
      array.removeAt(array.size - 1)
      // 新しいAdapterを生成しています
      val newAdapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1)
      // 再度リストにsetAdapterしています
      list.adapter = newAdapter
      // setAdapterしたらデータが更新されると既存コードから読み取った方はここで躓きます
      // Adapterを新しく生成しようがしまいが,ListViewにsetAdapterした時点でリストの先頭にスクロールするのです
      // setAdapterは基本的に一度呼べばOKなのです
    }

    // OK 操作したリストを再度Adapterに追加する
    btn.setOnClickListener {
      // リストの最後のデータを消しました
      array.removeAt(array.size - 1)
      // ArrayAdapterが持っているリストをクリアしています
      adapter.clear()
      // ArrayAdapterに再度リストをブチ込みます
      adapter.addAll(array)
      // ArrayAdapterのデータが変わりましたということを伝えます
      adapter.notifyDataSetChanged()
      // 同じリストを自分とアダプターとで別々に管理していて
      // データを変更したたびにアダプターのリストをクリアして全部ブチ込むのがベストプラクティス?
      // さすがにそんなことはありません
    }

    // ArrayAdapterのコンストラクタにリストを渡している場合はこれでOK
    // ex) val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, array)
    btn.setOnClickListener {
      // リストの最後のデータを消しました
      array.removeAt(array.size - 1)
      // アダプターのデータが変わったことを通知しました
      adapter.notifyDataSetChanged()
      // かなりシンプルになりました
      // これはArrayAdapterのコンストラクタでリストのオブジェクトを渡しているときにだけ使えます
    }

    // OK Adapterの保持しているリストを操作する
    btn.setOnClickListener {
      // ArrayAdapterがリストを保持しているのでこういうことも可能です
      // ArrayAdapterのリスト最後尾のアイテムを↓
      val item = adapter.getItem(adapter.count - 1) ?: return@setOnClickListener
      // 削除しました
      adapter.remove(item)
      // アダプターのデータが変わったことを通知しました
      adapter.notifyDataSetChanged()
      // ちなみにArrayAdapterはListのリスト操作をすべて提供してくれません
      // さらにArrayAdapterが保持しているリストを返してもくれません
      // ちょっとしたことならこれでいけますが柔軟には不可能でしょう
    }

    // WORSE なんらかの理由でAdapterを作り直さないといけない場合
    btn.setOnClickListener {
      // リストの最後のデータを消しました
      array.removeAt(array.size - 1)
      // 今画面に映っているいちばん上のViewを取得する
      val position = list.firstVisiblePosition
      // いちばん上のViewの画面からのずれを取得する
      // ※ getChildAt(0)はListView内にいま表示されている先頭のViewを返す
      val y = if (list.count > 0) list.getChildAt(0).top else 0
      // なんらかのごちゃごちゃした理由で新しいAdapterを生成しなくてはいけない…
      val newAdapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1)
      // 新しく生成したアダプターに操作後のリストをセットする
      newAdapter.addAll(array)
      // そしてその新アダプターをセットしてしまった
      list.adapter = newAdapter
      // だがしかし先ほど取得した先頭のViewのポジションとズレをセットすることでなんとかスクロール位置は維持できた
      list.setSelectionFromTop(position, y)
      // setSelectionFromTopを使えばなんとかスクロール位置は維持したまま新しいアダプターに切り替えることができました
      // が,この目的ではあまりいい実装とはいえないでしょう
      // Activityをまたいだ前後でスクロール位置を維持する場合や
      // Preferenceにスクロール位置を保存した場合などはこちらが役に立つことでしょう
    }

    // ==== 逆に先頭に戻したいとき ====
    btn.setOnClickListener {
      // 逆にデータを更新した際に先頭に戻したい場合は1つ上の例をもとにして
      // リストの最後のデータを消しました
      val item = adapter.getItem(adapter.count - 1) ?: return@setOnClickListener
      adapter.remove(item)
      // データに変更があったことを通知しました
      adapter.notifyDataSetChanged()
      // そして先頭に戻します
//      list.setSelectionFromTop(0, 0) // API 21 ~
      list.setSelection(0) // API 1 ~
      // こんな感じでいけます
    }
  }

3つめの例でArrayAdapterのコンストラクタにリストを渡しました.
ほかにArrayAdapterのリストに外からリストを渡すすべはなく,addなりaddAllなりする必要があります.
最初にaddやaddAllした場合,クラスで管理しているリストとは別オブジェクトになるため,自分で保持しているリスト(コード中のarray)を更新したからと言ってArrayAdapterのリストには変化はありませんので,再度クリアしてarrayからaddなりaddAllをしたのちnotifyDataSetChangedしないと描画は更新されません.

BaseAdapterを拡張した例

BaseAdapterを拡張したカスタムアダプターでも同じです.
こういうカスタムアダプターがあるとします.

*.kt
  // BaseAdapterを継承したカスタムアダプター
  inner class CustomAdapter(private val c: Context, private val array: ArrayList<String>) : BaseAdapter() {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
      // v = (convertView != null && convertView instanceOf TextView) ? convertView : new TextView(c);
      val v = if (convertView != null && convertView is TextView) {
        convertView
      } else {
        TextView(c)
      }
      // TextView に文字列をセット
      v.text = array[position]
      return v
    }

    override fun getItem(position: Int): Any {
      return array[position]
    }

    override fun getItemId(position: Int): Long {
      return 0
    }

    override fun getCount(): Int {
      return array.size
    }
  }

コンストラクタで与えられたarrayを参照してTextViewとして描画するしょっぱいAdapterです.

*.kt
    val array = ArrayList<String>()
    // 0 ~ 50 の計51個のリストを用意しています
    for (i in 0..50) {
      // 内容は数字の文字列です
      array.add(i.toString())
    }
    // カスタムアダプターにリストを渡して生成
    val adapter = CustomAdapter(this, array)
    // ListViewにアダプターをセット
    list.adapter = adapter

    // ボタンが押されたとき
    btn.setOnClickListener {
      // 最後のデータを削除
      array.removeAt(array.size - 1)
      // データに変更があったことを通知
      adapter.notifyDataSetChanged()
    }

ArrayAdapterと同じですね.
今回作成したCustomAdapterはコンストラクタで与えられたarray(クラスで管理しているarray)を参照しているので
クラスで管理しているarrayを更新ののちnotifyDataSetChangedで描画が更新されます.

RecyclerViewの場合

最後にRecyclerViewの場合も確認します.
RecyclerViewの場合も同じ…でもいけるのですが,正しくは少し違います.

*.kt
  // ViewHolder 実際はTextViewを保持しているだけ
  inner class ViewHolder(internal val view: TextView) : RecyclerView.ViewHolder(view)

  // RecyclerViewなのでViewHolder(TextViewを保持しているだけだけど)を使いまわすよ
  inner class RecyclerAdapter(private val c: Context, private val array: ArrayList<String>) : RecyclerView.Adapter<ViewHolder>() {
    override fun onCreateViewHolder(p0: ViewGroup, p1: Int): ViewHolder {
      // ViewHodlerにTextViewだけを渡す
      val v = TextView(c)
      return ViewHolder(v)
    }

    // リストはどんだけ用意すればいいの?
    override fun getItemCount(): Int {
      // array のリスト分だけだよ
      return array.size
    }

    // リスト表示するときに何をどう表示したらいいの
    override fun onBindViewHolder(vh: ViewHolder, pos: Int) {
      // ViewHolderが持っているTextViewにその位置のテキストをセットしてね
      vh.view.text = array[pos]
    }

    // これは継承したメソッドじゃないよ The 後述
    internal fun remove(position: Int) {
      // 指定位置のデータを削除して
      array.removeAt(position)
      // その位置のデータが削除されたことを通知
      notifyItemRemoved(position)
    }
  }

ViewHolder作るだけ面倒なTextViewだけを持ったViewなのですがRecyclerViewなので仕方がない.
この最後のRecyclerAdapter::removeはオマケです.
このRecyclerViewに対して---
あ,recyclerっていう変数はRecyclerViewです.kotlinって便利.

*.kt
    val array = ArrayList<String>()
    // 0 ~ 50 の計51個のリストを用意しています
    for (i in 0..50) {
      // 内容は数字の文字列です
      array.add(i.toString())
    }
    // アダプターにリストを渡して生成
    val adapter = RecyclerAdapter(this, array)
    recycler.setHasFixedSize(true)
    recycler.layoutManager = LinearLayoutManager(this)
    // RecyclerViewにアダプターをセット
    recycler.adapter = adapter

    // 先までと同じように
    btn.setOnClickListener {
      // リストの最後のデータを削除しました
      array.removeAt(array.size - 1)
      // データに変更があったことを通知しました
      adapter.notifyDataSetChanged()
      // これでもOKなのですが,RecyclerViewのアダプターには
      // 変更があったデータ,削除されたデータ,追加されたデータ,に関してのみ変更を伝えることができます
      // 見た目の動き(アニメーション)も変わるので↓のほうがスマートでしょう
    }

    btn.setOnClickListener {
      // 最後のデータを
      val index = array.size - 1
      // 削除しました
      array.removeAt(index)
      // そのデータが削除されたということを通知しました
      adapter.notifyItemRemoved(index)
      // こうすることにより,消されたデータがリストから消えたのちにリスト自体の高さが縮小するアニメーションが表示されます
      // 特に必須とは考えませんが,Android標準のUIとみるとこちらのほうが正しい実装かと思います
    }

    btn.setOnClickListener {
      // 最後です RecyclerView.Adapterを継承したRecyclerAdapterにremoveという関数を追加していました
      // そちらのほうで削除して変更通知を実施しているのでデータをいじる部分ではこまごまと書かなくて済みますね
      adapter.remove(array.size - 1)
    }

最後に

JavaをやったことがあるってだけでAndroidに投入された,それはまだいいほうでC#とかいうJavaっぽかったものが触れるからAndroidに投げ込まれたって例も少なくないと思います.
同じJavaといってもプラットフォーム部分は全然違いますからね?C#.net?もはや別物.
少しでノウハウになればと思って書き始めました.

kotlinで書いてるんですが.

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?