In this series of blog posts, we are working through a sample app called Github User Search. Parts 1 – 4 covered why we should test, getting set up with testing, creating API calls and creating a presenter. Take a look at the previous posts as part 5 is a continuation of the series.
In part 5, we will take a look at interacting with the Presenter created in part 4 and we will create the UI to display the list of search results.
Creating the UI
For the User Interface we want a simple list that displays the avatar, name and other user information in the list.
In part 4, we defined a View contract which the Activity should implement. This is where the Android specific code will be located (things such as visibility changes or any UI changes will be located here). To refresh your memory, this is the View contract we created in this last post:
interface UserSearchContract { interface View extends MvpView { void showSearchResults(List<User> githubUserList); void showError(String message); void showLoading(); void hideLoading(); } interface Presenter extends MvpPresenter<View> { void search(String term); } }
Let’s implement the View!
- Create a class called UserSearchActivity. This class will implement the UserSearchContract.View contract and extend AppCompatActivity. Define a variable called userSearchPresenter of type UserSearchContract.Presenter. This is the object that we will interact with in order to perform our network calls.
public class UserSearchActivity extends AppCompatActivity implements UserSearchContract.View { private UserSearchContract.Presenter userSearchPresenter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_user_search); userSearchPresenter = new UserSearchPresenter(Injection.provideUserRepo(), Schedulers.io(), AndroidSchedulers.mainThread()); userSearchPresenter.attachView(this); } @Override protected void onDestroy() { super.onDestroy(); userSearchPresenter.detachView(); } @Override public void showSearchResults(List<User> githubUserList) { } @Override public void showError(String message) { } @Override public void showLoading() { } @Override public void hideLoading() { } }
In onCreate(), create the presenter object. Provide it with the User repo defined in the Injection class. Pass the io() scheduler and the AndroidSchedulers.mainThread() scheduler so that the RxJava subscriptions know which threads they should perform their work on.
On the next line, you can see I call userSearchPresenter.attachView(this) . This attaches the view to the presenter, so that the presenter can notify the view of any changes. Because the presenter isn’t aware of the activity’s lifecycle, in onDestroy() we need to inform the presenter that the view is no longer in existence, so we should then call userSearchPresenter.detachView(). This will unregister any RxJava subscriptions and prevent memory leaks from occurring.
- Create activity_user_search.xml in the layout folder. This will contain a RecyclerView, a ProgressBar, an error TextView and a Toolbar. I am using ConstraintLayout to design my screen, so I won’t go into too much detail as it is mostly drag and drop. (If you want to read more about ConstraintLayout check out my blog post about it here)
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activity_user_search" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="za.co.riggaroo.gus.presentation.search.UserSearchActivity"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="0dp" android:layout_height="wrap_content" android:background="?attr/colorPrimary" android:minHeight="?attr/actionBarSize" android:theme="?attr/actionBarTheme" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintLeft_toLeftOf="@+id/activity_user_search" app:layout_constraintRight_toRightOf="@+id/activity_user_search" app:layout_constraintTop_toTopOf="@+id/activity_user_search"> </android.support.v7.widget.Toolbar> <android.support.v7.widget.RecyclerView android:id="@+id/recycler_view_users" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginBottom="16dp" android:clipToPadding="false" android:scrollbars="vertical" app:layoutManager="android.support.v7.widget.LinearLayoutManager" app:layout_constraintBottom_toBottomOf="@+id/activity_user_search" app:layout_constraintLeft_toLeftOf="@+id/activity_user_search" app:layout_constraintRight_toRightOf="@+id/activity_user_search" app:layout_constraintTop_toBottomOf="@+id/toolbar" tools:listitem="@layout/list_item_user"> </android.support.v7.widget.RecyclerView> <TextView android:id="@+id/text_view_error_msg" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/search_for_some_users" android:visibility="visible" app:layout_constraintBottom_toBottomOf="@+id/recycler_view_users" app:layout_constraintLeft_toLeftOf="@+id/toolbar" app:layout_constraintRight_toRightOf="@+id/recycler_view_users" app:layout_constraintTop_toBottomOf="@+id/toolbar" tools:text="No Data has loaded"/> <ProgressBar android:id="@+id/progress_bar" style="@style/Widget.AppCompat.ProgressBar" android:layout_width="60dp" android:layout_height="60dp" android:layout_marginBottom="16dp" android:layout_marginTop="16dp" android:visibility="gone" app:layout_constraintBottom_toBottomOf="@+id/activity_user_search" app:layout_constraintLeft_toLeftOf="@+id/recycler_view_users" app:layout_constraintRight_toRightOf="@+id/recycler_view_users" app:layout_constraintTop_toBottomOf="@+id/toolbar" tools:visibility="visible"/> </android.support.constraint.ConstraintLayout>
- We also need to add a SearchView to the toolbar so we have somewhere to type. Add a menu_user_search.xml file to the menu resource folder. Inside it, you will need to add a SearchView:
<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item android:id="@+id/menu_search" android:icon="@drawable/ic_search" app:showAsAction="always|collapseActionView" android:title="@string/search_icon_title" app:actionViewClass="android.support.v7.widget.SearchView"/> </menu>
- We need to create a layout that will be used for each item in the RecyclerView. Create a file named list_item_user.xml in the layout folder. I used ConstraintLayout with one ImageView for the avatar and two TextViews.
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/constraintLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <ImageView android:id="@+id/imageview_userprofilepic" android:layout_width="50dp" android:layout_height="50dp" android:layout_marginLeft="16dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" app:layout_constraintLeft_toLeftOf="@+id/constraintLayout" app:layout_constraintTop_toTopOf="@+id/constraintLayout" app:srcCompat="@mipmap/ic_launcher"/> <TextView android:id="@+id/textview_username" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintLeft_toRightOf="@+id/imageview_userprofilepic" app:layout_constraintTop_toTopOf="@+id/constraintLayout" tools:text="Rebecca Franks"/> <TextView android:id="@+id/textview_user_profile_info" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginLeft="16dp" android:layout_marginRight="16dp" android:layout_marginStart="16dp" android:textAppearance="@style/TextAppearance.AppCompat.Caption" app:layout_constraintBottom_toBottomOf="@+id/constraintLayout" app:layout_constraintLeft_toRightOf="@+id/imageview_userprofilepic" app:layout_constraintRight_toRightOf="@+id/constraintLayout" app:layout_constraintTop_toBottomOf="@+id/textview_username" tools:text="JHB, South Africa. Lots of code, lots and lots and lots of code."/> </android.support.constraint.ConstraintLayout>
- Now that we have all the layouts we need, let’s tie the XML to the Activity. First, in onCreate() we will get references to the views we need.
private UsersAdapter usersAdapter; private SearchView searchView; private Toolbar toolbar; private ProgressBar progressBar; private RecyclerView recyclerViewUsers; private TextView textViewErrorMessage; @Override protected void onCreate(Bundle savedInstanceState) { ... toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); progressBar = (ProgressBar) findViewById(R.id.progress_bar); textViewErrorMessage = (TextView) findViewById(R.id.text_view_error_msg); recyclerViewUsers = (RecyclerView) findViewById(R.id.recycler_view_users); usersAdapter = new UsersAdapter(null, this); recyclerViewUsers.setAdapter(usersAdapter); }
- We need to hook the SearchView up into our activity to make it trigger the presenters search() method. In the onCreateOptionsMenu(), add the following code:
@Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu); getMenuInflater().inflate(R.menu.menu_user_search, menu); final MenuItem searchActionMenuItem = menu.findItem(R.id.menu_search); searchView = (SearchView) searchActionMenuItem.getActionView(); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { if (!searchView.isIconified()) { searchView.setIconified(true); } userSearchPresenter.search(query); toolbar.setTitle(query); searchActionMenuItem.collapseActionView(); return false; } @Override public boolean onQueryTextChange(String s) { return false; } }); searchActionMenuItem.expandActionView(); return true; }
This will inflate the correct menu, find the search view and set a query text listener. In this case, only when someone presses submit on the keyboard, we will respond by calling the search presenter with the query. We could do it in onQueryTextChange too but due to rate limiting on the Github API I will stick to onQueryTextSubmit. By default, the item will be expanded.
- Next, we will implement the callbacks that the presenter will call when the items are finished loading.
@Override public void showSearchResults(List<User> githubUserList) { recyclerViewUsers.setVisibility(View.VISIBLE); textViewErrorMessage.setVisibility(View.GONE); usersAdapter.setItems(githubUserList); } @Override public void showError(String message) { textViewErrorMessage.setVisibility(View.VISIBLE); recyclerViewUsers.setVisibility(View.GONE); textViewErrorMessage.setText(message); } @Override public void showLoading() { progressBar.setVisibility(View.VISIBLE); recyclerViewUsers.setVisibility(View.GONE); textViewErrorMessage.setVisibility(View.GONE); } @Override public void hideLoading() { progressBar.setVisibility(View.GONE); recyclerViewUsers.setVisibility(View.VISIBLE); textViewErrorMessage.setVisibility(View.GONE); }
We are basically just toggling visibility of views here and setting the usersAdapter to the new items that the service returned.
- For completeness, here is the UserSearchAdapter class which is used for the RecyclerView on the activity:
class UsersAdapter extends RecyclerView.Adapter<UserViewHolder> { private final Context context; private List<User> items; UsersAdapter(List<User> items, Context context) { this.items = items; this.context = context; } @Override public UserViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_user, parent, false); return new UserViewHolder(v); } @Override public void onBindViewHolder(UserViewHolder holder, int position) { User item = items.get(position); holder.textViewBio.setText(item.getBio()); if (item.getName() != null) { holder.textViewName.setText(item.getLogin() + " - " + item.getName()); } else { holder.textViewName.setText(item.getLogin()); } Picasso.with(context).load(item.getAvatarUrl()).into(holder.imageViewAvatar); } @Override public int getItemCount() { if (items == null) { return 0; } return items.size(); } void setItems(List<User> githubUserList) { this.items = githubUserList; notifyDataSetChanged(); } } class UserViewHolder extends RecyclerView.ViewHolder { final TextView textViewBio; final TextView textViewName; final ImageView imageViewAvatar; UserViewHolder(View v) { super(v); imageViewAvatar = (ImageView) v.findViewById(R.id.imageview_userprofilepic); textViewName = (TextView) v.findViewById(R.id.textview_username); textViewBio = (TextView) v.findViewById(R.id.textview_user_profile_info); } }
Injection class
public class Injection { private static final String BASE_URL = "https://api.github.com"; private static OkHttpClient okHttpClient; private static GithubUserRestService userRestService; private static Retrofit retrofitInstance; public static UserRepository provideUserRepo() { return new UserRepositoryImpl(provideGithubUserRestService()); } static GithubUserRestService provideGithubUserRestService() { if (userRestService == null) { userRestService = getRetrofitInstance().create(GithubUserRestService.class); } return userRestService; } static OkHttpClient getOkHttpClient() { if (okHttpClient == null) { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BASIC); okHttpClient = new OkHttpClient.Builder().addInterceptor(logging).build(); } return okHttpClient; } static Retrofit getRetrofitInstance() { if (retrofitInstance == null) { Retrofit.Builder retrofit = new Retrofit.Builder().client(Injection.getOkHttpClient()).baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create()); retrofitInstance = retrofit.build(); } return retrofitInstance; } }
- Now if you run the app, you should be able to search for a username on Github and see results.
Yay! We have a working app. Code for this post can be found here. In the next part, we will look at writing UI tests for the app. Make sure you subscribe so you don’t miss a post!
If you enjoyed this blog post, please consider buying me a cupcake to support future blog posts.
[buy_cupcake]
Leave a Reply
You must be logged in to post a comment.