Adding a Persistent Annotation Sidebar

Illustration: Adding a Persistent Annotation Sidebar

PSPDFKit for Android provides a wide array of APIs that let you accomplish almost any PDF-related task. Today, we’ll take a look at how to use those APIs to build a persistent sidebar that always shows you the annotations in the currently loaded document.

The Plan

We want to create a split UI with the list of annotations in the current document on the left and the PDF on the right. The list should always reflect the state of the content, so we need to keep it in sync with operations on the document.

The Layout

Let’s start by creating the main layout for our new activity. We’ll be using the PdfUiFragment, so all we need is a container for our fragment and a RecyclerView to display our annotations in. Let’s create a new layout, call it activity_persistent_sidebar.xml, and put the following in:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    android:orientation="horizontal">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/annotationList"
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:paddingTop="8dp"
        android:paddingBottom="8dp" />

    <FrameLayout
        android:id="@+id/fragmentContainer"
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:layout_weight="2" />

</LinearLayout>

That should be all we need layout-wise. Next, let’s add our PdfUiFragment to the fragmentContainer.

Displaying the PDF

First things first, let’s create a new activity, which we will call PersistentAnnotationSidebarActivity:

PersistentAnnotationSidebarActivity.kt
1
2
3
4
@SuppressLint("pspdfkit-experimental")
class PersistentAnnotationSidebarActivity : AppCompatActivity() {

}

The only special thing about this is the @SuppressLint("pspdfkit-experimental") statement. We added this because we are planning on using a PdfUiFragment, which is still experimental. However, that’s fine for us, so we acknowledge this by suppressing the warning.

Next up, let’s add a new method that will create our PdfUiFragment:

Copy
PersistentAnnotationSidebarActivity.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/** The currently displayed `PdfUiFragment`. */
private lateinit var pdfUiFragment: PdfUiFragment

private fun obtainPdfFragment() {
    // We either grab the existing fragment or add a new one.
    pdfUiFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as? PdfUiFragment
        // There is no existing fragment, so we create a new one.
        // For the sake of this example, we hardcode the path.
        ?: PdfUiFragmentBuilder.fromUri(this, Uri.fromFile(File("/sdcard/my-document.pdf")))
            .build()
            .apply {
                // After creation, we actually add the fragment to the fragment manager.
                supportFragmentManager.beginTransaction().add(R.id.fragmentContainer, this, FRAGMENT_TAG).commit()
            }
}

companion object {
    /** The tag we give to our `PdfUiFragment`. */
    const val FRAGMENT_TAG = "PersistentAnnotationSidebarActivity.Fragment"
}

The above code is pretty basic stuff: We first check if the support fragment manager already contains a PdfUiFragment, and if not, we create a new one. The PdfUiFragmentBuilder has many more configuration options, but for our example, we can keep it simple. All that’s left for this part is for us to call the fragment from onCreate:

Copy
PersistentAnnotationSidebarActivity.kt
1
2
3
4
5
6
7
8
9
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // We need to load our layout first.
    setContentView(R.layout.activity_persistent_sidebar)

    // Then we can set up the PDF fragment.
    obtainPdfFragment()
}

There’s nothing special here either, so we load the layout and we call our previously defined method. If we start our activity, now it should look something like what’s shown below.

The activity without any annotations listed

Our PDF is displayed, but our RecyclerView is empty. So let’s move on to extracting the annotations from the document.

Getting the Annotations

There are actually two parts to getting the annotations for this example. One part is grabbing the initial set of annotations, and the other is keeping the list up-to-date as the user is making changes.

Fetching the Annotations

This next part, fetching the annotations, is pretty straightforward when using the AnnotationProvider. We just load the annotations contained on each page and save them in a map.

Now let’s make this part of the adapter we use for our RecyclerView. First, let’s create the AnnotationRecyclerAdapter and add some properties we’ll need:

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class AnnotationRecyclerAdapter(private val context: Context) : RecyclerView.Adapter<AnnotationRecyclerAdapterViewHolder>() {
    /** We need a reference to the current document to load annotations. */
    var currentDocument: PdfDocument? = null

    /** We keep a list of all annotations we display for easy access. */
    private val displayedItems = mutableListOf<Annotation>()

    /** We keep a list of annotations per page so we can update single pages easily. */
    private val annotationsPerPage = mutableMapOf<Int, List<Annotation>>()

    /** It's good practice to keep track of running RxJava operations so they can be disposed of when exiting the activity. */
    private val loadingDisposables = mutableMapOf<Int, Disposable>()

    /** The types of annotations we want to list. */
    private val listedAnnotationTypes = EnumSet.allOf(AnnotationType::class.java).apply {
        // We don't want to clutter the list with widget or link annotations.
        remove(AnnotationType.WIDGET)
        remove(AnnotationType.LINK)
    }

    /** Removes all currently loaded annotations and clears the state. */
    fun clear() {
        displayedItems.clear()
        annotationsPerPage.clear()
        for (disposable in loadingDisposables.values) {
            disposable.dispose()
        }
        loadingDisposables.clear()
        currentDocument = null
    }
}

Most of this is pretty straightforward. The only special thing about it is that we store the annotations (and disposables) per page. This will come in handy when we add the code to reflect updates to the document in our list. Next, we’ll add a method that fetches all annotations for a page and stores them in our annotationsPerPage map:

Copy
AnnotationRecyclerAdapter.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/** Reloads the list of annotations for the given page. */
fun refreshAnnotationsForPage(pageIndex: Int) {
    // If no document is set, we don't do anything.
    val document = currentDocument ?: return

    // Cancel any already-running loading operation for this page.
    loadingDisposables[pageIndex]?.dispose()

    // We grab the annotations for the current page index.
    // This operates on a background scheduler, so we have to explicitly observe it on the main thread.
    loadingDisposables[pageIndex] = document.annotationProvider.getAllAnnotationsOfTypeAsync(listedAnnotationTypes, pageIndex, 1)
        .toList()
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe { annotations ->
            // Now that we have the annotations, we need to store them.
            annotationsPerPage[pageIndex] = annotations
            // Afterward, we update our final list for displaying.
            refreshDisplayedItems()
        }
}

What we do here is create the list of annotation types we want to load and then load them into our annotationsPerPage. Finally, we call refreshDisplayedItems. This method will build our list of displayedItems based on the annotations we already loaded:

Copy
AnnotationRecyclerAdapter.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
private fun refreshDisplayedItems() {
    val document = currentDocument ?: return
    displayedItems.clear()
    for (pageIndex in 0 until document.pageCount) {
        // We add all pages we already loaded here.
        val items = annotationsPerPage[pageIndex]
        if (items != null) {
            displayedItems.addAll(items)
        }
    }
    notifyDataSetChanged()
}

With all of this set up, we now need to load our initial set of annotations. For that, we go back to our PersistentAnnotationSidebarActivity and set everything up so we can get the current document and start loading annotations:

Copy
PersistentAnnotationSidebarActivity.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** The adapter we use for our recycler view. */
private val annotationRecyclerAdapter = AnnotationRecyclerAdapter(this)

override fun onStart() {
    super.onStart()
    // We need to be notified when the document is loaded.
    pdfUiFragment.pdfFragment?.addDocumentListener(object : SimpleDocumentListener() {
        override fun onDocumentLoaded(document: PdfDocument) {
            // When the document is loaded, clear the previous annotations.
            annotationRecyclerAdapter.clear()

            // We need to set the current document so we can load the annotations.
            annotationRecyclerAdapter.currentDocument = document

            // Then we trigger loading of all annotations in the document.
            for (pageIndex in 0 until document.pageCount) {
                annotationRecyclerAdapter.refreshAnnotationsForPage(pageIndex)
            }
        }
    })
}

override fun onDestroy() {
    super.onDestroy()
    // This will cancel all running operations and remove the loaded annotations.
    annotationRecyclerAdapter.clear()
}

We simply call refreshAnnotationsForPage for all pages of the document. This will populate the displayedItems with all annotations in the document at the time of opening. One thing to keep in mind here is that in large documents (those with more than 2,000 pages), we shouldn’t load all annotations — but for our small example, we simply ignore this. We also make sure that running operations are canceled when leaving the activity by calling clear() on our annotationRecyclerAdapter in onDestroy().

Next up, we need to ensure that we get notified when the annotations in the document change. To do that, we’ll be using an OnAnnotationUpdatedListener:

Copy
PersistentAnnotationSidebarActivity.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...

// We need to set the current document so we can load the annotations.
annotationRecyclerAdapter.currentDocument = document

// We need to be aware of any change to the annotations so we can keep our list updated.
document.annotationProvider.addOnAnnotationUpdatedListener(object : AnnotationProvider.OnAnnotationUpdatedListener {
    override fun onAnnotationCreated(annotation: Annotation) {
        annotationRecyclerAdapter.refreshAnnotationsForPage(annotation.pageIndex)
    }

    override fun onAnnotationUpdated(annotation: Annotation) {
        annotationRecyclerAdapter.refreshAnnotationsForPage(annotation.pageIndex)
    }

    override fun onAnnotationRemoved(annotation: Annotation) {
        annotationRecyclerAdapter.refreshAnnotationsForPage(annotation.pageIndex)
    }

    override fun onAnnotationZOrderChanged(pageIndex: Int, oldOrder: MutableList<Annotation>, newOrder: MutableList<Annotation>) {
        annotationRecyclerAdapter.refreshAnnotationsForPage(pageIndex)
    }
})

...

Now, whenever something changes in the document, we reload the annotations on the affected pages.

With that, all that’s really left to do is fill in the actual RecyclerView.Adapter implementation:

Copy
1
2
3
4
5
6
7
8
9
10
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnotationRecyclerAdapterViewHolder {
    // Inflate your layout and return your view holder.
}

// `displayedItems` is always the source of truth for the size of this adapter.
override fun getItemCount(): Int = displayedItems.size

override fun onBindViewHolder(holder: AnnotationRecyclerAdapterViewHolder, position: Int) {
    // Present the annotations in any way you choose.
}

We’ll leave onCreateViewHolder and onBindViewHolder blank here, since their bodies will highly depend on what your intention is, but you can find a complete implementation of all of this in our Catalog.

The activity with annotations listed

And with that, we’re done. Our activity will now always show all annotations in the current document and keep them in sync as the user works on the document.

Conclusion

We had a look at how to use the PdfUiFragment in combination with our AnnotationProvider to create a custom annotation list UI that will always stay visible. This approach can be used for other UI customizations as well; one example would be to build a sidebar view of page thumbnails to replace the built-in thumbnail bar. However, anything you want to always have visible while working on your documents can work.

PSPDFKit for Android

Download the free 60-day trial and add it to your app today.