Skip to content

Latest commit

 

History

History
461 lines (361 loc) · 15.9 KB

README.md

File metadata and controls

461 lines (361 loc) · 15.9 KB

FirebaseUI for Cloud Firestore

FirebaseUI makes it simple to bind data from Cloud Firestore to your app's UI.

Before using this library, you should be familiar with the following topics:

Table of contents

  1. Data model
  2. Querying
  3. Populating a RecyclerView
    1. Choosing an adapter
    2. Using the FirestoreRecyclerAdapter
      1. Adapter lifecyle
      2. Events
    3. Using the FirestorePagingAdapter
      1. Adapter lifecyle
      2. Events

Data model

Imagine you have a chat app where each chat message is a document in the chats collection of your database. In your app, you may represent a chat message like this:

public class Chat {
    private String mName;
    private String mMessage;
    private String mUid;
    private Date mTimestamp;

    public Chat() { } // Needed for Firebase

    public Chat(String name, String message, String uid) {
        mName = name;
        mMessage = message;
        mUid = uid;
    }

    public String getName() { return mName; }

    public void setName(String name) { mName = name; }

    public String getMessage() { return mMessage; }

    public void setMessage(String message) { mMessage = message; }

    public String getUid() { return mUid; }

    public void setUid(String uid) { mUid = uid; }

    @ServerTimestamp
    public Date getTimestamp() { return mTimestamp; }

    public void setTimestamp(Date timestamp) { mTimestamp = timestamp; }
}

A few things to note about this model class:

  • The getters and setters follow the JavaBean naming pattern which allows Firestore to map the data to field names (ex: getName() provides the name field).
  • The class has an empty constructor, which is required for Firestore's automatic data mapping.

For a properly constructed model class like the Chat class above, Firestore can perform automatic serialization in DocumentReference#set() and automatic deserialization in DocumentSnapshot#toObject(). For more information on data mapping in Firestore, see the documentation on custom objects.

Querying

On the main screen of your app, you may want to show the 50 most recent chat messages. In Firestore, you would use the following query:

Query query = FirebaseFirestore.getInstance()
        .collection("chats")
        .orderBy("timestamp")
        .limit(50);

To retrieve this data without FirebaseUI, you might use addSnapshotListener to listen for live query updates:

query.addSnapshotListener(new EventListener<QuerySnapshot>() {
    @Override
    public void onEvent(@Nullable QuerySnapshot snapshot,
                        @Nullable FirebaseFirestoreException e) {
        if (e != null) {
            // Handle error
            //...
            return;
        }

        // Convert query snapshot to a list of chats
        List<Chat> chats = snapshot.toObjects(Chat.class);

        // Update UI
        // ...
    }
});

Using FirebaseUI to populate a RecyclerView

If you're displaying a list of data, you likely want to bind the Chat objects to a RecyclerView. This means implementing a custom RecyclerView.Adapter and coordinating updates with the EventListener on the Query.

Fear not, FirebaseUI does all of this for you automatically!

Choosing an adapter

FirebaseUI offers two types of RecyclerView adapters for Cloud Firestore:

  • FirestoreRecyclerAdapter — binds a Query to a RecyclerView and responds to all real-time events included items being added, removed, moved, or changed. Best used with small result sets since all results are loaded at once.
  • FirestorePagingAdapter — binds a Query to a RecyclerView by loading data in pages. Best used with large, static data sets. Real-time events are not respected by this adapter, so it will not detect new/removed items or changes to items already loaded.

Using the FirestoreRecyclerAdapter

The FirestoreRecyclerAdapter binds a Query to a RecyclerView. When documents are added, removed, or change these updates are automatically applied to your UI in real time.

First, configure the adapter by building FirestoreRecyclerOptions. In this case we will continue with our chat example:

// Configure recycler adapter options:
//  * query is the Query object defined above.
//  * Chat.class instructs the adapter to convert each DocumentSnapshot to a Chat object
FirestoreRecyclerOptions<Chat> options = new FirestoreRecyclerOptions.Builder<Chat>()
        .setQuery(query, Chat.class)
        .build();

If you need to customize how your model class is parsed, you can use a custom SnapshotParser:

...setQuery(..., new SnapshotParser<Chat>() {
    @NonNull
    @Override
    public Chat parseSnapshot(@NonNull DocumentSnapshot snapshot) {
        return ...;
    }
});

Next create the FirestoreRecyclerAdapter object. You should already have a ViewHolder subclass for displaying each item. In this case we will use a custom ChatHolder class:

FirestoreRecyclerAdapter adapter = new FirestoreRecyclerAdapter<Chat, ChatHolder>(options) {
    @Override
    public void onBindViewHolder(ChatHolder holder, int position, Chat model) {
        // Bind the Chat object to the ChatHolder
        // ...
    }

    @Override
    public ChatHolder onCreateViewHolder(ViewGroup group, int i) {
        // Create a new instance of the ViewHolder, in this case we are using a custom
        // layout called R.layout.message for each item
        View view = LayoutInflater.from(group.getContext())
                .inflate(R.layout.message, group, false);

        return new ChatHolder(view);
    }
};

Finally attach the adapter to your RecyclerView with the RecyclerView#setAdapter() method. Don't forget to also set a LayoutManager!

FirestoreRecyclerAdapter lifecycle

Start/stop listening

The FirestoreRecyclerAdapter uses a snapshot listener to monitor changes to the Firestore query. To begin listening for data, call the startListening() method. You may want to call this in your onStart() method. Make sure you have finished any authentication necessary to read the data before calling startListening() or your query will fail.

@Override
protected void onStart() {
    super.onStart();
    adapter.startListening();
}

Similarly, the stopListening() call removes the snapshot listener and all data in the adapter. Call this method when the containing Activity or Fragment stops:

@Override
protected void onStop() {
    super.onStop();
    adapter.stopListening();
}
Automatic listening

If you don't want to manually start/stop listening you can use Android Architecture Components to automatically manage the lifecycle of the FirestoreRecyclerAdapter. Pass a LifecycleOwner to FirestoreRecyclerOptions.Builder#setLifecycleOwner(...) and FirebaseUI will automatically start and stop listening in onStart() and onStop().

Data and error events

When using the FirestoreRecyclerAdapter you may want to perform some action every time data changes or when there is an error. To do this, override the onDataChanged() and onError() methods of the adapter:

FirestoreRecyclerAdapter adapter = new FirestoreRecyclerAdapter<Chat, ChatHolder>(options) {
    // ...

    @Override
    public void onDataChanged() {
        // Called each time there is a new query snapshot. You may want to use this method
        // to hide a loading spinner or check for the "no documents" state and update your UI.
        // ...
    }

    @Override
    public void onError(FirebaseFirestoreException e) {
        // Called when there is an error getting a query snapshot. You may want to update
        // your UI to display an error message to the user.
        // ...
    }
};

Using the FirestorePagingAdapter

The FirestorePagingAdapter binds a Query to a RecyclerView by loading documents in pages. This results in a time and memory efficient binding, however it gives up the real-time events afforded by the FirestoreRecyclerAdapter.

The FirestorePagingAdapter is built on top of the Android Paging 3 Library. Before using the adapter in your application, you must add a dependency on that library:

implementation 'androidx.paging:paging-runtime:3.x.x'

First, configure the adapter by building FirestorePagingOptions. Since the paging adapter is not appropriate for a chat application (it would not detect new messages), we will consider an adapter that loads a generic Item:

// The "base query" is a query with no startAt/endAt/limit clauses that the adapter can use
// to form smaller queries for each page. It should only include where() and orderBy() clauses
Query baseQuery = mItemsCollection.orderBy("value", Query.Direction.ASCENDING);

// This configuration comes from the Paging 3 Library
// https://developer.android.com/reference/kotlin/androidx/paging/PagingConfig
PagingConfig config = new PagingConfig(/* page size */ 20, /* prefetchDistance */ 10,
    /* enablePlaceHolders */ false);

// The options for the adapter combine the paging configuration with query information
// and application-specific options for lifecycle, etc.
FirestorePagingOptions<Item> options = new FirestorePagingOptions.Builder<Item>()
        .setLifecycleOwner(this)
        .setQuery(baseQuery, config, Item.class)
        .build();

If you need to customize how your model class is parsed, you can use a custom SnapshotParser:

...setQuery(..., new SnapshotParser<Item>() {
    @NonNull
    @Override
    public Item parseSnapshot(@NonNull DocumentSnapshot snapshot) {
        return ...;
    }
});

Next, create the FirestorePagingAdapter object. You should already have a ViewHolder subclass for displaying each item. In this case we will use a custom ItemViewHolder class:

FirestorePagingAdapter<Item, ItemViewHolder> adapter =
        new FirestorePagingAdapter<Item, ItemViewHolder>(options) {
            @NonNull
            @Override
            public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
                // Create the ItemViewHolder
                // ...
            }

            @Override
            protected void onBindViewHolder(@NonNull ItemViewHolder holder,
                                            int position,
                                            @NonNull Item model) {
                // Bind the item to the view holder
                // ...
            }
        };

Finally attach the adapter to your RecyclerView with the RecyclerView#setAdapter() method. Don't forget to also set a LayoutManager!

FirestorePagingAdapter lifecycle

Start/stop listening

The FirestorePagingAdapter listens for scrolling events and loads additional pages from the database only when needed.

To begin populating data, call the startListening() method. You may want to call this in your onStart() method. Make sure you have finished any authentication necessary to read the data before calling startListening() or your query will fail.

@Override
protected void onStart() {
    super.onStart();
    adapter.startListening();
}

Similarly, the stopListening() call freezes the data in the RecyclerView and prevents any future loading of data pages.

Call this method when the containing Activity or Fragment stops:

@Override
protected void onStop() {
    super.onStop();
    adapter.stopListening();
}
Automatic listening

If you don't want to manually start/stop listening you can use Android Architecture Components to automatically manage the lifecycle of the FirestorePagingAdapter. Pass a LifecycleOwner to FirestorePagingOptions.Builder#setLifecycleOwner(...) and FirebaseUI will automatically start and stop listening in onStart() and onStop().

Paging events

When using the FirestorePagingAdapter, you may want to perform some action every time data changes or when there is an error. To do this:

In Java

Use the addLoadStateListener method from the adapter:

adapter.addLoadStateListener(new Function1<CombinedLoadStates, Unit>() {
            @Override
            public Unit invoke(CombinedLoadStates states) {
                LoadState refresh = states.getRefresh();
                LoadState append = states.getAppend();

                if (refresh instanceof LoadState.Error || append instanceof LoadState.Error) {
                    // The previous load (either initial or additional) failed. Call
                    // the retry() method in order to retry the load operation.
                    // ...
                }

                if (refresh instanceof LoadState.Loading) {
                    // The initial Load has begun
                    // ...
                }

                if (append instanceof LoadState.Loading) {
                    // The adapter has started to load an additional page
                    // ...
                }

                if (append instanceof LoadState.NotLoading) {
                    LoadState.NotLoading notLoading = (LoadState.NotLoading) append;
                    if (notLoading.getEndOfPaginationReached()) {
                        // The adapter has finished loading all of the data set
                        // ...
                        return null;
                    }

                    if (refresh instanceof LoadState.NotLoading) {
                        // The previous load (either initial or additional) completed
                        // ...
                        return null;
                    }
                }
                return null;
            }
        });

In Kotlin

Use the loadStateFlow exposed by the adapter, in a Coroutine Scope:

// Activities can use lifecycleScope directly, but Fragments should instead use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
    pagingAdapter.loadStateFlow.collectLatest { loadStates ->
        when (loadStates.refresh) {
            is LoadState.Error -> {
                // The initial load failed. Call the retry() method
                // in order to retry the load operation.
                // ...
            }
            is LoadState.Loading -> {
                // The initial Load has begun
                // ...
            }
        }

        when (loadStates.append) {
            is LoadState.Error -> {
                // The additional load failed. Call the retry() method
                // in order to retry the load operation.
                // ...
            }
            is LoadState.Loading -> {
                // The adapter has started to load an additional page
                // ...
            }
            is LoadState.NotLoading -> {
                if (loadStates.append.endOfPaginationReached) {
                    // The adapter has finished loading all of the data set
                    // ...
                }
                if (loadStates.refresh is LoadState.NotLoading) {
                    // The previous load (either initial or additional) completed
                    // ...
                }
            }
        }
  }
}