ナカザンドットネット

それって私の感想ですよね

Support Package版ListFragmentにカスタムビューを適用するとsetListShownできなくなる問題とその対策

4ヶ月前にこんなツイートをしたまま放置してましたごめんなさい。書きます。
※ツイートした当時に認識していたものよりも問題が大きかったので、タイトルは少し違ったものになりました

はじめに

これからはFragmentの時代や! と誰が言ったか定かではありませんが、Fragmentの概念が登場してから1年半の月日が経ちそうです。みなさん、Fragment使ってますか?
僕はお仕事でゴリゴリFragment使ってます。ときどき使い方間違っててTLの皆様から失笑を買ったりしておりますがめげません(´;ω;`)ブワッ
で、アプリの中でListFragmentを使う機会というのは割と多いと思うんですが、やはりViewのカスタマイズが必要になってくる場面があると思います。そんな要望に応えるのがこちらの記事。

ListFragment 内で表示する View をカスタマイズするには、
onCreateView() 内で、差し替えたい View をインフレートします。

ListFragment の View をカスタマイズする方法 - adakoda

実は、これをそのまま使うと、ListFragment#setListShown(boolean)が使えなくなってしまいます。
ということで、今回はこの問題への対策となります。

※この問題自体はSupport Package版・3.x Higher版のListFragmentの両方で起こりますが、後者の対応方法は分からないので誰かよろしく

ver.0 元の動作

ソース

MainActivity.java
public class MainActivity extends FragmentActivity implements OnClickListener {

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);
   
    Button btnSetAdapter = (Button) findViewById(R.id.btn_set_adapter);
    btnSetAdapter.setOnClickListener(this);

    Button btnToggleListShown = (Button) findViewById(R.id.btn_toggle_listshown);
    btnToggleListShown.setOnClickListener(this);
  }

  @Override
  public void onClick(View v) {

    // リストの中身
    String[] items = { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "20" };
    // ListFragment
    SampleListFragment fragment = (SampleListFragment) getSupportFragmentManager().findFragmentById(R.id.f_list);

    switch (v.getId()) {
    case R.id.btn_set_adapter:
      // ListAdapterをセット(初期表示のProgressBarはここで消える)
      fragment.setListAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, items));
      break;
    case R.id.btn_toggle_listshown:
      // リストを消してProgressBarを表示する
      fragment.setListShown(isListShown); 
      isListShown = !isListShown;
      break;
    }
  }
}
SimpleListFragment.java
public class SampleListFragment extends ListFragment {

}
main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent" >

  <fragment
    android:id="@+id/f_list"
    android:name="net.nkzn.android.sample.v4.customlist.SampleListFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_above="@+id/btn_toggle_listshown" />

  <Button
    android:id="@+id/btn_set_adapter"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_alignParentBottom="true"
    android:layout_alignParentLeft="true"
    android:text="set adapter" />

  <Button
    android:id="@+id/btn_toggle_listshown"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_above="@+id/btn_set_adapter"
    android:layout_alignParentLeft="true"
    android:text="toggle ListShown" />

</RelativeLayout>

画面

起動時

f:id:Nkzn:20120614210424p:image:w320

set adapterボタンを押下

f:id:Nkzn:20120614210442p:image:w320

toggle ListShownボタンを押下

f:id:Nkzn:20120614210424p:image:w120 f:id:Nkzn:20120614210442p:image:w120

解説

初期表示時からListAdapterをセットするまでの間、ProgressBarが自動でぐるぐる回っててくれます。
一度Adapterをセットしたあとは、ListFragment#setListShown(boolean)で以下の動作を切り替えられます。

false リストを隠してProgressBarを出す
true リストを出してProgressBarを隠す

そして余談ですが何も書かなくても動くListFragmentさんある意味すげえ。

ver.1 Viewをカスタマイズ

あだこだ先生の言うとおりに、以下のコードを足したり変更したりしました。
※背景画像やピンはヒバナさんからいただきました。

ソース

SampleListFragment.java
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
  View view = inflater.inflate(R.layout.custom_list_layout, container, false);
  return view;
}
custom_list_layout.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:background="@drawable/tile_bg" >

  <!-- タイトル付けてみた -->
  <TextView
    android:id="@+id/tv_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp"
    android:drawableLeft="@drawable/pin"
    android:drawablePadding="8dp"
    android:gravity="center_vertical"
    android:text="カスタマイズサンプル"
    android:textAppearance="?android:attr/textAppearanceMedium" />

  <!-- ListView用(idは定義済みのandroid:list)-->
  <ListView android:id="@id/android:list"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="16dp"
    android:paddingRight="16dp"
    android:layout_below="@+id/tv_title"
    android:drawSelectorOnTop="false" />
    
  <!-- アイテムが空の場合に使用するテキスト(idは定義済みのandroid:empty)-->
  <TextView android:id="@id/android:empty"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_below="@id/android:list"
    android:text="No data" />

</RelativeLayout>

画面

起動時

f:id:Nkzn:20120614211324p:image:w320

set adapterボタンを押下

f:id:Nkzn:20120614211444p:image:w320

toggle ListShownボタンを押下

f:id:Nkzn:20120614211601p:image:w320

解説

哀しいことその1:ListAdapterのセット前にぐるぐるしてくれません。
哀しいことその2:setListShownすると落ちます。

ver.1で何が起きたのか

エラーメッセージ

FATAL EXCEPTION: main
java.lang.IllegalStateException: Can't be used with a custom content view
  at android.support.v4.app.ListFragment.setListShown(ListFragment.java:282)
  at android.support.v4.app.ListFragment.setListShown(ListFragment.java:258)
  at net.nkzn.android.sample.v4.customlist.MainActivity.onClick(MainActivity.java:38)
  at android.view.View.performClick(View.java:2604)
  at android.view.View$PerformClick.run(View.java:9314)
  at android.os.Handler.handleCallback(Handler.java:587)
  at android.os.Handler.dispatchMessage(Handler.java:92)
  at android.os.Looper.loop(Looper.java:130)
  at android.app.ActivityThread.main(ActivityThread.java:3691)
  at java.lang.reflect.Method.invokeNative(Native Method)
  at java.lang.reflect.Method.invoke(Method.java:507)
  at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:912)
  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:670)
  at dalvik.system.NativeStart.main(Native Method)

Can't be used with a custom content view

ListFragment#setListShownはカスタマイズしたViewでは使えませんよ」と。

なんか無理らしい

じゃあ諦めようか。

やっぱAndroidといえばコードリーディングっしょ!

ソースコード探訪

無理って言われたからって諦めてたらプログラマーやってられないんで、無理を道理でひっくり返している前例を探しに行きましょう。つまり、「本来のListFragmentはsetListShownできてるんだから、ListFragmentだけがやってる方法があるはずだ!」というわけです。
Support Packageの中にある本家ListFragmentをさくっと参照しちゃいましょう。

$ANDROID_SDK/extras/android/support/v4/src/java/android/support/v4/app/ListFragment

なんかonCreateViewの中身が色々とアレな様子が御覧いただけましたでしょうか。INTERNAL_HOGEHOGE_IDさんたちがキモのようです。

ver.2 既に出来上がったものがこちらになります。

ソース

SampleListFragment.java
public class SampleListFragment extends ListFragment {

  /** おまじないの材料1 */
  private static final int INTERNAL_PROGRESS_CONTAINER_ID = 0x00ff0002;
  /** おまじないの材料2 */
  private static final int INTERNAL_LIST_CONTAINER_ID = 0x00ff0003;
	
  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    View view = inflater.inflate(R.layout.custom_list_layout, container, false);

    // ----------おまじないここから----------
    ProgressBar pBar = (ProgressBar) view.findViewById(android.R.id.progress);
    LinearLayout pframe = (LinearLayout) pBar.getParent();
    pframe.setId(INTERNAL_PROGRESS_CONTAINER_ID);

    ListView listView = (ListView) view.findViewById(android.R.id.list);
    listView.setItemsCanFocus(false);
    FrameLayout lFrame = (FrameLayout) listView.getParent();
    lFrame.setId(INTERNAL_LIST_CONTAINER_ID);
    // ----------おまじないここまで----------
	
    return view;
  }	
}
custom_list_layout.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical"
  android:background="@drawable/tile_bg" >

  <TextView
    android:id="@+id/tv_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp"
    android:drawableLeft="@drawable/pin"
    android:drawablePadding="8dp"
    android:gravity="center_vertical"
    android:text="カスタマイズサンプル"
    android:textAppearance="?android:attr/textAppearanceMedium" />

  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content" >
    <ListView android:id="@id/android:list"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:paddingLeft="16dp"
      android:paddingRight="16dp"
      android:drawSelectorOnTop="false" />
  </FrameLayout>
  
  <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:visibility="gone" >
    <ProgressBar
        android:id="@android:id/progress"
        style="?android:attr/progressBarStyleLarge"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
  </LinearLayout>
    
  <!-- アイテムが空の場合に使用するテキスト(idは定義済みのandroid:empty)-->
  <TextView android:id="@id/android:empty"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:text="No data" />

</LinearLayout>

画面

起動時

f:id:Nkzn:20120614215720p:image:w320

set adapterボタンを押下

f:id:Nkzn:20120614215757p:image:w320

toggle ListShownボタンを押下

f:id:Nkzn:20120614215720p:image:w120 f:id:Nkzn:20120614215757p:image:w120

解説

(*´ω`*)むふー。
要点を挙げます。

  • カスタムレイアウトxml
    1. ListViewをFrameLayoutで囲む(width/height重要)
    2. ProgressBarをLinearLayoutで囲む(width/height重要)
  • ListFragment#onCreateView
    1. ListViewを囲んだFrameLayoutに0x00ff0002というIDを振る
    2. ProgressBarを囲んだLinearLayoutに0x00ff0003というIDを振る

これだけで、ListFragment#setListShownがカスタムレイアウトでも使えるようになります。

まとめ

ListFragmentさんが本来扱いたいIDやレイアウトを配置・設定しておく、というのが今回の肝でした。
人間側としてはかなり釈然としませんが、まあ動くので仕方ない。あとemptyさんがちゃんと動くか微妙。
しかし3.x Higherではどうすればいいんだろうなあ。