Lesson Monday

When developing good user experiences for Android apps, it is important to not only account for a variety of screen sizes but also screen orientations.

At the moment our app looks fantastic in portrait, but not quite as nice when we change the orientation of the screen from portrait to landscape. However, to utilize our logic in multiple potential layouts (such as both portrait and landscape layouts) we'll need to refactor several of our activities into fragments.

In this lesson we will refactor activities into more flexible and reusable fragments. Then, in upcoming lessons we will take advantage of this newfound flexibility by integrating separate layouts that will automatically display when users tilt their phone into landscape mode. After that, we'll include code that will allow our new fragments to communicate seamlessly.

The end result will look something like this:

landscape-view

Let's get started!

Creating Fragments

First, let's create two fragments to house the functionality from SavedRestaurantListActivity and RestaurantListActivity. Since these areas of our app will eventually have special landscape-orientation-specific layouts, refactoring these into fragments will allow us to insert it into both landscape and portrait-orientation layouts without creating unnecessarily redundant code.

RestaurantListFragment

Let’s start by creating a blank fragment called RestaurantListFragment. Right-click on the ui sub-package, and select New > Fragment > Fragment (Blank). Make sure to check the box labeled Create layout XML? to generate the corresponding layout file, which we will use at the end of this lesson. Boxes for Include fragment factory methods? and include interface callbacks? may be un-checked, as we will not use the boilerplate code they produce.

This will create a new XML layout file, and a java file with the following code:

RestaurantListFragment.java
public class RestaurantListFragment extends Fragment {


    public RestaurantListFragment() {
        // Required empty public constructor
    }


    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_restaurant_list, container, false);
    }

}

Refactoring Activities into Fragments

Now, we'll need to move the majority of code from RestaurantListActivity to our new RestaurantListFragment. We'll do this in several pieces, pausing to explain changes each time.

First, we'll declare our necessary member variables:

RestaurantListFragment.java
public class RestaurantListFragment extends Fragment {
    @Bind(R.id.recyclerView) RecyclerView mRecyclerView;

    private RestaurantListAdapter mAdapter;
    public ArrayList<Restaurant> mRestaurants = new ArrayList<>();
    private SharedPreferences mSharedPreferences;
    private SharedPreferences.Editor mEditor;
    private String mRecentAddress;
...

Next, we'll override the onCreate() method:

RestaurantListFragment.java
public class RestaurantListFragment extends Fragment {
 @Bind(R.id.recyclerView) RecyclerView mRecyclerView;

    private RestaurantListAdapter mAdapter;
    public ArrayList<Restaurant> mRestaurants = new ArrayList<>();
    private SharedPreferences mSharedPreferences;
    private SharedPreferences.Editor mEditor;
    private String mRecentAddress;

    public RestaurantListFragment() {
        // Required empty public constructor
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
        mEditor = mSharedPreferences.edit();

        // Instructs fragment to include menu options:
        setHasOptionsMenu(true);
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_restaurant_list, container, false);
    }

}

Here, we're defining our mSharedPreferences and mEditor member variables, and instructing the fragment to include and display the menu options inherited from its parent activity. This will allow us to eventually display search menu options within RestaurantListFragment.

Next, we'll move our getRestaurants() method from RestaurantListActivity to RestaurantListFragment. Because the method will reside in a new location (and therefore a new context), slight changes will be necessary. These are indicated by comments in the code below:

RestaurantListFragment.java
...
    public void getRestaurants(String location) {
        final YelpService yelpService = new YelpService();

        yelpService.findRestaurants(location, new Callback() {

            @Override
            public void onFailure(Call call, IOException e) {
                e.printStackTrace();
            }

            @Override
            public void onResponse(Call call, Response response) {
                mRestaurants = yelpService.processResults(response);

                getActivity().runOnUiThread(new Runnable() {
                    // Line above states 'getActivity()' instead of previous 'RestaurantListActivity.this'
                    // because fragments do not have own context, and must inherit from corresponding activity.

                    @Override
                    public void run() {
                        mAdapter = new RestaurantListAdapter(getActivity(), mRestaurants);
                        // Line above states `getActivity()` instead of previous
                        // 'getApplicationContext()' because fragments do not have own context,
                        // must instead inherit it from corresponding activity.

                        mRecyclerView.setAdapter(mAdapter);
                        RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getActivity());
                        // Line above states 'new LinearLayoutManager(getActivity());' instead of previous
                        // 'new LinearLayoutManager(RestaurantListActivity.this);' when method resided
                        // in RestaurantListActivity because Fragments do not have context
                        // and must instead inherit from corresponding activity.

                        mRecyclerView.setLayoutManager(layoutManager);
                        mRecyclerView.setHasFixedSize(true);
                    }
                });
            }
        });
    }

We can no longer call RestaurantListActivity.this within this method, simply because it is no longer located within RestaurantListActivity. Remember, fragments do not have their own context and must instead inherit it from their parent activity. We may instead access the parent context by calling getActivity().

Next, we'll add code to the existing onCreateView() method:

RestaurantListFragment.java
...
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_restaurant_list, container, false);
        ButterKnife.bind(this, view);

        mRecentAddress = mSharedPreferences.getString(Constants.PREFERENCES_LOCATION_KEY, null);

        if (mRecentAddress != null) {
            getRestaurants(mRecentAddress);
        }

        return view;
    }
...

While activities instantiate their views directly inonCreate(), fragments require multiple steps to do this:

  • onCreate() will only create the fragment itself. It is called before onCreateView() and offers an opportunity to assign variables, get Intent extras, and anything else that doesn't involve the view hierarchy.
  • onCreateView() is called after onCreate(), and is used to assign View variables, and handle any graphical initializations. It must be called to render the fragment's views, as detailed in the Lifecycle of Android Fragments.

In the code above, we simply inflate and bind the corresponding layout, fetch the user's last-searched zip code (mRecentAddress) from shared preferences, and, if it exists, we call getRestaurants() to return restaurants in that area.

Next, we'll need to move the existing onCreateOptionsMenu() and onOptionsItemSelected() methods from RestaurantListActivity into our new RestaurantListFragment. These methods will require several changes to operate from their new location, as detailed below:

RestaurantListFragment.java
import android.support.v7.widget.SearchView;
...
    @Override
    // Method is now void, menu inflater is now passed in as argument:
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {

        // Call super to inherit method from parent:
        super.onCreateOptionsMenu(menu, inflater);

        inflater.inflate(R.menu.menu_search, menu);

        MenuItem menuItem = menu.findItem(R.id.action_search);
        SearchView searchView = (SearchView) MenuItemCompat.getActionView(menuItem);

        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {

            @Override
            public boolean onQueryTextSubmit(String query) {
                addToSharedPreferences(query);
                getRestaurants(query);
                return false;
            }

            @Override
            public boolean onQueryTextChange(String newText) {
                return false;
            }
        });
    }

   @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        return super.onOptionsItemSelected(item);
    }
}

We'll also need to move the addToSharedPreferences() method in RestaurantListActivity to RestaurantListFragment:

RestaurantListFragment.java
...
    private void addToSharedPreferences(String location) {
        mEditor.putString(Constants.PREFERENCES_LOCATION_KEY, location).apply();
    }
...

The completed RestaurantListFragment should look like this:

RestaurantListFragment.java
public class RestaurantListFragment extends Fragment {
    @Bind(R.id.recyclerView) RecyclerView mRecyclerView;

    private RestaurantListAdapter mAdapter;
    public ArrayList<Restaurant> mRestaurants = new ArrayList<>();
    private SharedPreferences mSharedPreferences;
    private SharedPreferences.Editor mEditor;
    private String mRecentAddress;

    public RestaurantListFragment() {
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
        mEditor = mSharedPreferences.edit();

        setHasOptionsMenu(true);
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_restaurant_list, container, false);
        ButterKnife.bind(this, view);
        mRecentAddress = mSharedPreferences.getString(Constants.PREFERENCES_LOCATION_KEY, null);

        if (mRecentAddress != null) {
            getRestaurants(mRecentAddress);
        }

        return view;
    }

    @Override
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
        super.onCreateOptionsMenu(menu, inflater);
        inflater.inflate(R.menu.menu_search, menu);

        MenuItem menuItem = menu.findItem(R.id.action_search);
        SearchView searchView = (SearchView) MenuItemCompat.getActionView(menuItem);

        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {

            @Override
            public boolean onQueryTextSubmit(String query) {
                addToSharedPreferences(query);
                getRestaurants(query);
                return false;
            }

            @Override
            public boolean onQueryTextChange(String newText) {
                return false;
            }
        });
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        return super.onOptionsItemSelected(item);
    }

    public void getRestaurants(String location) {
        final YelpService yelpService = new YelpService();

        yelpService.findRestaurants(location, new Callback() {

            @Override
            public void onFailure(Call call, IOException e) {
                e.printStackTrace();
            }

            @Override
            public void onResponse(Call call, Response response) {
                mRestaurants = yelpService.processResults(response);

                getActivity().runOnUiThread(new Runnable() {

                    @Override
                    public void run() {
                        mAdapter = new RestaurantListAdapter(getActivity(), mRestaurants);
                        mRecyclerView.setAdapter(mAdapter);
                        RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getActivity());
                        mRecyclerView.setLayoutManager(layoutManager);
                        mRecyclerView.setHasFixedSize(true);
                    }
                });
            }
        });
    }

    private void addToSharedPreferences(String location) {
        mEditor.putString(Constants.PREFERENCES_LOCATION_KEY, location).apply();
    }

}

Refactoring RestaurantListActivity

If you haven't done so already, we can remove all of the methods we've just placed in our new fragment from RestaurantListActivity. After doing so, it should look like this:

RestaurantListActivity.java
public class RestaurantListActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_restaurants);
    }

}

Note: You won't be able to launch the application and see anything in our newly-refactored "Find Restaurants" area quite yet; we still need to create and update necessary layouts, which we'll do at the end of this lesson.

SavedRestaurantListFragment

Next we’ll create the fragment to house functionality currently residing in SavedRestaurantListActivity. We'll call this new fragment SavedRestaurantListFragment

Again, make sure to select the option to create the corresponding XML layout. We'll use this layout at the end of the lesson. Boxes for Include fragment factory methods? and include interface callbacks? may be un-checked, as we will not use the boilerplate code they produce.

First, we'll move all declarations and bindings from SavedRestaurantListActivity to our new SavedRestaurantListFragment, and implement OnStartDragListener :

SavedRestaurantListFragment.java
public class SavedRestaurantListFragment extends Fragment implements OnStartDragListener {
    @Bind(R.id.recyclerView) RecyclerView mRecyclerView;

    private FirebaseRestaurantListAdapter mFirebaseAdapter;
    private ItemTouchHelper mItemTouchHelper;
...

Next, we'll add code to the fragment's existing onCreateView() method:

SavedRestaurantListFragment.java
public class SavedRestaurantListFragment extends Fragment implements OnStartDragListener {
...

 @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_saved_restaurant_list, container, false);
        ButterKnife.bind(this, view);
        setUpFirebaseAdapter();
        return view;
    }

...

onCreateView() is the fragment lifecycle method in which the fragment's view is instantiated. Therefore, we'll call setupFirebaseAdapter() here in order to act as the bridge between our back-end data and our front-end view.

Next, we'll simply move the setUpFirebaseAdapter(), onDestroy(), and onStartDrag() methods from SavedRestaurantListActivity to SavedRestaurantListFragment. We only need to make three small changes to account for moving this method, as described in the comments below:

SavedRestaurantListFragment.java
public class SavedRestaurantListFragment extends Fragment implements OnStartDragListener {
    private FirebaseRestaurantListAdapter mFirebaseAdapter;
    private ItemTouchHelper mItemTouchHelper;

    @Bind(R.id.recyclerView) RecyclerView mRecyclerView;

    public SavedRestaurantListFragment() {
        // Required empty public constructor
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_saved_restaurant_list, container, false);
        ButterKnife.bind(this, view);
        setUpFirebaseAdapter();
        return view;
    }

    private void setUpFirebaseAdapter() {
        FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser();
        String uid = user.getUid();

        Query query = FirebaseDatabase.getInstance()
                .getReference(Constants.FIREBASE_CHILD_RESTAURANTS)
                .child(uid)
                .orderByChild(Constants.FIREBASE_QUERY_INDEX);

        //  In line below, we change 6th parameter 'this' to 'getActivity()' 
        //  because fragments do not have own context:
        mFirebaseAdapter = new FirebaseRestaurantListAdapter(Restaurant.class,
                R.layout.restaurant_list_item_drag, FirebaseRestaurantViewHolder.class,
                query, this, getActivity());

        mRecyclerView.setHasFixedSize(true);

        //In line below, we change 'this' to 'getActivity()' because fragments do not have own context:
        mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
        mRecyclerView.setAdapter(mFirebaseAdapter);

        ItemTouchHelper.Callback callback = new SimpleItemTouchHelperCallback(mFirebaseAdapter);
        mItemTouchHelper = new ItemTouchHelper(callback);
        mItemTouchHelper.attachToRecyclerView(mRecyclerView);
    }

    @Override
    public void onStartDrag(RecyclerView.ViewHolder viewHolder) {
        mItemTouchHelper.startDrag(viewHolder);
    }

    @Override
    //method is now public
    public void onDestroy() {
        super.onDestroy();
        mFirebaseAdapter.cleanup();
    }
}

Refactoring SavedRestaurantListActivity

Now that we've moved these methods into our new fragment, we may remove them from SavedRestaurantListActivity. After doing so, this file should look like this:

SavedRestaurantListActivity.java
public class SavedRestaurantListActivity extends AppCompatActivity implements OnStartDragListener {  
  @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_restaurants);
    }
}

Note: You won't be able to launch the application and see anything in our newly-refactored "Find Restaurants" area quite yet; we still need to create and update necessary layouts, which we'll do next.

Creating & Refactoring Layouts

Next, let’s remove the RecyclerViews from our list activity layouts and add them to the corresponding fragment layouts:

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

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

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

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

</LinearLayout>

Now, we may add the fragments to our list activity layout. The updated activity_restaurants.xml layout should appear as follows:

activity_restaurants.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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.RestaurantListActivity">

    <fragment
        android:id="@+id/fragmentRestaurantList"
        android:name="com.epicodus.myrestaurants.ui.RestaurantListFragment"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tools:layout="@layout/fragment_restaurant_list" />

</RelativeLayout>

We’ll also need to create a new layout for our SavedRestaurantListActivity that specifically uses our new SavedRestaurantListFragment. We'll also add its corresponding SavedRestaurantListFragment to its layout:

activity_saved_restaurant_list.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:layout_width="match_parent"
                android:layout_height="match_parent"
                tools:context=".ui.RestaurantListActivity">

    <fragment
        android:id="@+id/fragmentRestaurantList"
        android:name="com.epicodus.myrestaurants.ui.SavedRestaurantListFragment"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tools:layout="@layout/fragment_restaurant_list" />

</RelativeLayout>

Next, we'll need to inflate this new layout in SavedRestaurantListActivity instead of the activity_restaurants layout:

SavedRestaurantListActivity.java
public class SavedRestaurantListActivity extends AppCompatActivity implements OnStartDragListener {
    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_saved_restaurant_list);
        ...

Observers

Currently, we should be able to launch our application and everything should work fairly similarly. However, if we select "Saved Restaurants" you'll notice our restaurants aren't immediately loading! Yet, if we go back, and re- enter the "Saved Restaurants" area of our application, they suddenly show up! Thankfully, we can easily address this issue with something called an observer.

RecyclerViews have a dedicated observer called AdapterDataObserver. These observers watch for changes, and notify our adapter when they occur. We can easily implement one with just a few lines of code:

SavedRestaurantListFragment.java
...
    private void setUpFirebaseAdapter() {
        ...   
        mFirebaseAdapter = new FirebaseRestaurantListAdapter(Restaurant.class,
                R.layout.restaurant_list_item_drag, FirebaseRestaurantViewHolder.class,
                query, this, getActivity());

        mRecyclerView.setHasFixedSize(true);
        mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
        mRecyclerView.setAdapter(mFirebaseAdapter);

        mFirebaseAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
            @Override
            public void onItemRangeInserted(int positionStart, int itemCount) {
                super.onItemRangeInserted(positionStart, itemCount);
                mFirebaseAdapter.notifyDataSetChanged();
            }
        });
        ...  
    }
...

Here, we call registerAdapterDataObserver() on our mFirebaseAdapter to begin the process of associating an observer. Then, we construct a new AdapterDataObserver object, and override its onItemRangeInserted() method, which is automatically called whenever a new item is added to the adapter's range of data. We include the required line super.onItemRangeInserted(positionStart, itemCount);, referring to any onItemRangeInserted() methods of a parent class. Then, we can call mFirebaseAdapter.notifyDataSetChanged();, which will trigger our observer to notify our adapter of any new changes.

If we run our app, everything should not only appear the same as it did before we made these changes, but our "Saved Restaurants" should load on the first try instead of having to exit and re-enter! These changes have laid the groundwork to implement multiple layouts based on device orientation, as we'll do in tomorrow's lessons. Great work!


Example GitHub Repo for MyRestaurants

Tips


  • Fragments do not have their own context and must instead inherit it from their parent activity. We may instead access the parent context by calling getActivity().

  • While activities instantiate their views directly inonCreate(), fragments require multiple steps to do this:

    • onCreate() will only create the fragment itself. It is called before onCreateView() and offers an opportunity to assign variables, get Intent extras, and anything else that doesn't involve the view hierarchy.
    • onCreateView() is called after onCreate(), and is used to assign View variables, and handle any graphical initializations. It must be called to render the fragment's views. ,

Examples


Additional Resources