2016年10月25日火曜日

Fragment に Toolbar を持たせるのはやめなさい

NavigationDrawer や BottomNavigation パターンを実現するために、各画面を Fragment で実装することがあります。 Fragment によって ActionBar に持たせる機能が違うからか、Fragment のレイアウトに Toolbar を持たせて、Fragment で ((AppCompatActivity) getActivity()).setSupportActionBar(toolbar); のような処理をさせているコードを見かけることがあります。

やめなさい

(Toolbar をただの View として使って、ActionBar としては使わない(setSupportActionBar()しない)というのであればまだ許容できるが、それならそもそも Toolbar を使う必要がない)

ViewPager のように複数の Fragment を一度に attach する場合、これでは予期しない動作になることがありえます。ちゃんと Fragment に用意されている機能を使ってください。 Fragment で setHasOptionsMenu(true) を呼ぶと onCreateOptionsMenu() が呼ばれるので、Fragment 用の Menu を inflate します。

ViewPager はこの機能を適切に処理しており、現在のページの Fragment の Menu だけ inflate されるようになっています。 また、FragmentTransaction の show() / hide() で Fragment の表示・非表示を切り替える際も OptionsMenu であれば一緒に適切に処理されます。


Fragment にこのような機能があることを知っているにもかかわらず、上記のようなひどいコードを実装してしまう要因として、Menu ではなく View を置きたいという状況があります。
よくあるのが ActionBar に検索用の入力フィールド(EditText)を持たせたい場合です。
Menu ではなく View を置きたいのだから OptionsMenu の機能は使えないと思ってしまうのでしょうか。

OptionsMenu にはこのような用途のために ActionView という機能があります。MenuItem に独自のレイアウト/Viewを設定できる機能です。 Menu リソースの item で android:actionLayout(app:actionLayout)を使ってレイアウトを指定することもできます。
また、android:actionViewClass(app:actionViewClass) で View クラスを指定することもできます。

この ActionView 用に用意されているクラスとして SearchView があります。 SearchView は ActionBar に検索用の入力フィールド(EditText)を持たせてくれるそのものずばりの機能です。文字が入力されているときにクリアボタン(xボタン)が出る機能も実装されています。 これを利用せずにわざわざ自分で実装する意味はあまりないと思いますが、独自でやりたいのであればそれ用のViewクラスを自分で用意して android:actionViewClass で指定すればよいのです。

まとめると、
  • Fragment のレイアウトに toolbar を持たせない
  • Fragment 独自の機能を ActionBar に入れたいときは OptionsMenu の機能を使う
  • OptionsMenu の機能なら ViewPager で適切に処理される
  • OptionsMenu の機能なら FragmentTransaction の show() / hide() で適切に処理される
  • OptionsMenu には独自の View を配置できる ActionView 機能がある


追記1

Toolbar の中に複雑な View を入れること自体をダメだと言っているわけではありません。 Activityのレイアウトで <android.support.v7.widget.Toolbar> に子ビュー持たせるのは別にいいと思います。 このエントリはあくまで Fragment に toolbar を持たせることについての話です。

2016年10月21日金曜日

BehaviorSubject を使って Activity と Fragment のデータの読み込みを待ち合わせる

画面構成は
  • MainActivity
    • TabLayout + ViewPager
    • ViewPagerの各ページは MainFragment
  • MainFragment
    • RecyclerView

やりたいことは
  • MainActivity
    • 各ページで共通のデータ(以後 CommonData)をサーバーから取得する
  • MainFragment
    • ページ特有のデータ(以後 SpecificData)をサーバーから取得する
    • MainActivity から CommonData をもらう
    • CommonData と SpecificData 両方の読み込みが終わったら RecyclerView にデータを追加する

キモになるのが、CommonData と SpecificData 両方の読み込みが終わるのを待ち合わせたいというところです。
CommonData の読み込みが終わる前に生成された MainFragment なら両方の読み込みを待ち合わせるし、 CommonData の読み込みが終わった後に生成された MainFragment なら SpecificData の読み込みだけ待てばいいわけです。
でも、この2つの状態をわけて処理すると煩雑になってしまいます。

そこで、BehaviorSubject を使って、CommonData の値を MainFragment 側に渡せるようにします。 BehaviorSubject は onNext() が呼ばれたときにその値を通知し、さらにその値をキャッシュします。新しく subscribe されると、最新のキャッシュした値があればその時点で通知します。

public class MainActivity extends AppCompatActivity implements MainFragment.MainFragmentListener { private final BehaviorSubject<CommonData> commonDataBehaviorSubject = BehaviorSubject.create(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ... loadCommonData(); } private void loadCommonData() { subscription = DataRetriever.getInstance().getCommonData() .onErrorReturn(new Func1<Throwable, CommonData>() { @Override public CommonData call(Throwable throwable) { // エラーのときはデータがないものとして扱う return CommonData.empty(); } }) .subscribe(new Action1<CommonData>() { @Override public void call(CommonData commonData) { commonDataBehaviorSubject.onNext(commonData); } }); } @NonNull @Override public Observable<CommonData> getCommonDataObservable() { return commonDataBehaviorSubject; } } public class MainFragment extends Fragment { public interface MainFragmentListener { @NonNull Observable<CommonData> getCommonDataObservable(); } @Nullable private MainFragmentListener listener; ... @Override public void onAttach(Context context) { super.onAttach(context); if (context instanceof MainFragmentListener) { listener = (MainFragmentListener) context; } } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); if (adapter == null) { adapter = new DataAdapter(); load(); } recyclerView.setAdapter(adapter); } void load() { recyclerView.setVisibility(View.GONE); progressView.setVisibility(View.VISIBLE); // 共通データとタブ独自のデータ両方揃うまで待ち合わせ subscription = Observable .combineLatest( getCommonDataObservable(), getSpecificDataObservable(), new Func2<CommonData, SpecificData, Pair<CommonData, SpecificData>>() { @Override public Pair<CommonData, SpecificData> call(CommonData commonData, SpecificData specificData) { return new Pair<>(commonData, specificData); } }) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Subscriber<Pair<CommonData, SpecificData>>() { @Override public void onCompleted() { } @Override public void onError(Throwable e) { recyclerView.setVisibility(View.VISIBLE); progressView.setVisibility(View.GONE); } @Override public void onNext(Pair<CommonData, SpecificData> combinedData) { recyclerView.setVisibility(View.VISIBLE); progressView.setVisibility(View.GONE); final List<String> list = new ArrayList<>(); final CommonData commonData = combinedData.first; list.add("CommonData : " + (commonData.isEmpty() ? "empty" : commonData.getData())); final SpecificData specificData = combinedData.second; list.addAll(specificData.getData()); adapter.addAll(list); } }); } /** * 共通のデータを取得 */ private Observable<CommonData> getCommonDataObservable() { return listener != null // first() を介して onComplete()が呼ばれるようにしている ? listener.getCommonDataObservable().first() : Observable.just(CommonData.empty()); } /** * このタブ独自のデータを取得 */ private Observable<SpecificData> getSpecificDataObservable() { final int position = getArguments() == null ? -1 : getArguments().getInt(ARGS_POSITION); return DataRetriever.getInstance().getSpecificData(position); } ... }

さらに、MainActivity に SwipeRefreshLayout を追加して、PullToRefresh で共通データを取り直し、各 MainFragment にもデータを取り直させる処理を追加したサンプルが
https://github.com/yanzm/BehaviorSubjectSample
です。